Compare commits
51 Commits
v0.3.3-bet
...
v0.4.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb3d35756a | ||
|
|
0830e64ae6 | ||
|
|
9a218b1904 | ||
|
|
399b845e14 | ||
|
|
436d5c6149 | ||
|
|
5a9edc076e | ||
|
|
5bdc7a6429 | ||
|
|
5a0480bae0 | ||
|
|
80de4dd523 | ||
|
|
de52f4ebd6 | ||
|
|
98ff0dd578 | ||
|
|
9a1ff6e8dc | ||
|
|
034b9ee0e4 | ||
|
|
c1b1fc653f | ||
|
|
50ca75180c | ||
|
|
b8aa935bec | ||
|
|
5627ddd2ce | ||
|
|
d27872572a | ||
|
|
7d0f807fb0 | ||
|
|
cbf623b98b | ||
|
|
b85ebb8e54 | ||
|
|
7cc206dc20 | ||
|
|
bf8c0fd380 | ||
|
|
08dc1fd53b | ||
|
|
13e937a11b | ||
|
|
3cf701b002 | ||
|
|
3a09e0e0c2 | ||
|
|
47fa2e01bb | ||
|
|
401292ec5b | ||
|
|
199a7e409a | ||
|
|
c91931f42f | ||
|
|
cbbb224725 | ||
|
|
8d10d2182e | ||
|
|
e9696ef82b | ||
|
|
1edd4f053a | ||
|
|
92f943c3e6 | ||
|
|
1704b196cf | ||
|
|
40ec493bae | ||
|
|
233368c954 | ||
|
|
00118f0803 | ||
|
|
167ab82978 | ||
|
|
a23c0c5b94 | ||
|
|
24b31b0b47 | ||
|
|
7ae4017672 | ||
|
|
8c540eba93 | ||
|
|
1074b019d3 | ||
|
|
2da0cf9421 | ||
|
|
9987a586e2 | ||
|
|
2827acfe96 | ||
|
|
afb6e77c7f | ||
|
|
84be22661b |
@@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
- name: Commit changelog
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
git config user.name "CI Bot"
|
||||
git config user.email "ci@legion-muyue.fr"
|
||||
@@ -181,30 +181,45 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -ex
|
||||
if [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "Warning: GITEATOKEN not set, skipping release"
|
||||
exit 0
|
||||
echo "Error: GITEA_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
BODY=$(cat /tmp/stable_changelog.md)
|
||||
RESPONSE=$(curl -s -X POST "${API}" \
|
||||
echo "Creating release ${VERSION} at ${API}"
|
||||
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" "${API}/tags/${VERSION}" || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
echo "Release ${VERSION} already exists (ID: ${EXISTING_ID}), deleting..."
|
||||
curl -sf -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/${EXISTING_ID}" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
BODY=$(python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" < /tmp/stable_changelog.md)
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\":\"${VERSION}\",
|
||||
\"target_commitish\":\"main\",
|
||||
\"name\":\"muyue ${VERSION}\",
|
||||
\"body\":$(echo "$BODY" | jq -Rs .),
|
||||
\"body\":${BODY},
|
||||
\"draft\":false,
|
||||
\"prerelease\":false
|
||||
}")
|
||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
RESPONSE_BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
echo "HTTP Status: ${HTTP_CODE}"
|
||||
echo "Response: ${RESPONSE_BODY}"
|
||||
RELEASE_ID=$(echo "$RESPONSE_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "")
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "Failed to create release:"
|
||||
echo "$RESPONSE"
|
||||
echo "Failed to create release"
|
||||
exit 1
|
||||
fi
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
@@ -212,8 +227,12 @@ jobs:
|
||||
for file in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
|
||||
filename=$(basename "$file")
|
||||
echo "Uploading ${filename}..."
|
||||
curl -s -X POST "${UPLOAD_URL}" \
|
||||
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" -X POST "${UPLOAD_URL}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file};filename=${filename}" > /dev/null
|
||||
-F "attachment=@${file};filename=${filename}")
|
||||
UPLOAD_CODE=$(echo "$UPLOAD_RESP" | tail -1)
|
||||
if [ "$UPLOAD_CODE" != "201" ]; then
|
||||
echo "Upload failed with status ${UPLOAD_CODE}"
|
||||
fi
|
||||
done
|
||||
echo "Stable release ${VERSION} published!"
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,6 +15,13 @@ type TerminalParams struct {
|
||||
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"`
|
||||
}
|
||||
|
||||
type TerminalResponse struct {
|
||||
Content string `json:"content"`
|
||||
IsError bool `json:"is_error"`
|
||||
SudoBlocked bool `json:"sudo_blocked,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
func NewTerminalTool() (*ToolDefinition, error) {
|
||||
return NewTool("terminal",
|
||||
"Execute a shell command on the local system and return the output. Use for running builds, tests, git operations, package management, system info, or any CLI task. Commands run in the user's home directory by default. Long-running commands are auto-terminated.",
|
||||
@@ -22,6 +30,18 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
||||
return TextErrorResponse("command is required"), nil
|
||||
}
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
trimmed := strings.TrimSpace(p.Command)
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ") {
|
||||
return ToolResponse{
|
||||
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). The current user is not root. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, strings.Fields(trimmed)[0]),
|
||||
IsError: true,
|
||||
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
timeout := time.Duration(p.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 60 * time.Second
|
||||
|
||||
@@ -2,6 +2,16 @@ Tu es l'assistant IA de **Muyue Studio**, le centre de commandement de l'environ
|
||||
|
||||
Tu es intégré dans Muyue, un gestionnaire d'environnement de développement de bureau. Ton rôle est d'aider l'utilisateur à configurer, gérer et optimiser son environnement dev.
|
||||
|
||||
<critical_rules>
|
||||
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils immédiatement. Ne dis pas "je pourrais faire X" — fais-le.
|
||||
2. **SOIS AUTONOME** — Ne pose pas de questions si tu peux chercher, lire, déduire. Essaie plusieurs approches avant de bloquer. Ne t'arrête que pour les erreurs bloquantes réelles (credentials manquants, permissions, etc.).
|
||||
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule ("Voici...", "Je vais..."), pas de postambule ("N'hésitez pas...", "J'espère que..."). Réponse directe. Un mot quand c'est suffisant.
|
||||
4. **GÈRE LES ERREURS** — Si un outil échoue, essaie 2-3 approches alternatives avant de rapporter l'échec. Lis le message d'erreur complet, isole la cause racine.
|
||||
5. **NE DEVINE PAS** — Lis les fichiers avant d'éditer. Utilise les outils pour obtenir les informations manquantes (lire, chercher, grep).
|
||||
6. **CONFIDENTIALITÉ** — Ne révèle jamais les clés API, mots de passe, tokens ou informations sensibles.
|
||||
7. **LANGUE** — Réponds dans la même langue que l'utilisateur.
|
||||
</critical_rules>
|
||||
|
||||
## Environnement
|
||||
|
||||
Muyue gère :
|
||||
@@ -13,32 +23,70 @@ Muyue gère :
|
||||
|
||||
## Outils disponibles
|
||||
|
||||
Tu as accès à des outils. Utilise-les concrètement, ne décris pas ce que tu ferais — fais-le.
|
||||
| Outil | Usage |
|
||||
|-------|-------|
|
||||
| **terminal** | Exécuter des commandes shell (builds, tests, git, etc.) |
|
||||
| **crush_run** | Déléguer une tâche complexe à Crush (édition de fichiers, refactoring, debug) — préfère cet outil pour les tâches multi-fichiers ou l'écriture de code |
|
||||
| **read_file** | Lire le contenu d'un fichier |
|
||||
| **list_files** | Lister les fichiers d'un répertoire |
|
||||
| **search_files** | Chercher des fichiers par motif (glob) |
|
||||
| **grep_content** | Chercher du texte dans les fichiers |
|
||||
| **get_config** | Lire la configuration Muyue |
|
||||
| **set_provider** | Configurer un fournisseur IA |
|
||||
| **manage_ssh** | Gérer les connexions SSH |
|
||||
| **web_fetch** | Récupérer le contenu d'une URL |
|
||||
|
||||
- **terminal** : Exécuter des commandes shell (builds, tests, git, etc.)
|
||||
- **crush_run** : Déléguer une tâche complexe à l'agent Crush (édition de fichiers, refactoring, debug)
|
||||
- **read_file** : Lire le contenu d'un fichier
|
||||
- **list_files** : Lister les fichiers d'un répertoire
|
||||
- **search_files** : Chercher des fichiers par motif (glob)
|
||||
- **grep_content** : Chercher du texte dans le contenu des fichiers
|
||||
- **get_config** : Lire la configuration Muyue
|
||||
- **set_provider** : Configurer un fournisseur IA
|
||||
- **manage_ssh** : Gérer les connexions SSH
|
||||
- **web_fetch** : Récupérer le contenu d'une URL
|
||||
<tool_strategy>
|
||||
- **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
|
||||
- **Délégation intelligente** — Pour les tâches complexes (refactoring, création de fichiers, debug multi-fichiers), utilise `crush_run` au lieu d'enchaîner des commandes terminal
|
||||
- **Parallélisme** — Lance plusieurs appels d'outils en parallèle quand les opérations sont indépendantes
|
||||
- **Troncature** — Si un résultat d'outil dépasse 2000 caractères, résume les points clés au lieu de tout afficher
|
||||
- **Une chose à la fois** — Sauf si les opérations sont indépendantes, exécute séquentiellement
|
||||
</tool_strategy>
|
||||
|
||||
## Règles
|
||||
<decision_making>
|
||||
- Décide par toi-même : cherche, lis, déduis, agis
|
||||
- Ne demande confirmation que pour : actions destructrices (suppression, overwrite), plusieurs approches valides avec des trade-offs importants
|
||||
- Si bloqué : documente (a) ce que tu as essayé, (b) pourquoi tu es bloqué, (c) l'action minimale requise
|
||||
- Ne t'arrête jamais pour : tâche trop grosse (découpe), trop de fichiers (change-les), complexité (gère-la)
|
||||
</decision_making>
|
||||
|
||||
1. **AGIS, ne décris pas** — Si l'utilisateur demande de faire quelque chose, utilise les outils pour le faire. Ne dis pas "je pourrais faire X" — fais-le.
|
||||
2. **Sois concis** — Pas de préambule, pas de blabla. Réponse directe.
|
||||
3. **Une chose à la fois** — N'appelle pas plusieurs outils simultanément sauf si c'est nécessaire.
|
||||
4. **Gère les erreurs** — Si un outil échoue, essaie une approche différente avant de le dire à l'utilisateur.
|
||||
5. **Ne devine pas** — Si tu n'as pas assez d'informations, utilise les outils pour les obtenir (lire un fichier, chercher, etc.)
|
||||
6. **Confidentialité** — Ne révèle jamais les clés API, mots de passe ou informations sensibles dans tes réponses.
|
||||
7. **Langue** — Réponds dans la même langue que l'utilisateur.
|
||||
<error_recovery>
|
||||
1. Lis le message d'erreur complet
|
||||
2. Comprends la cause racine
|
||||
3. Essaie une approche différente (pas la même)
|
||||
4. Cherche du code similaire qui fonctionne
|
||||
5. Applique un correctif ciblé
|
||||
6. Vérifie que ça marche
|
||||
7. Pour chaque erreur, essaie au moins 2-3 stratégies avant de conclure que c'est bloquant
|
||||
</error_recovery>
|
||||
|
||||
## Format des réponses
|
||||
|
||||
- Code : utilise des blocs markdown
|
||||
- Résultats d'outils : résume les points clés, ne colle pas des milliers de lignes
|
||||
- Erreurs : explique clairement et propose une solution
|
||||
- Succès : confirme brièvement ce qui a été fait
|
||||
- **Code** : blocs markdown avec le langage spécifié
|
||||
- **Résultats d'outils** : résume les points clés, max 2000 caractères, ne copie pas des milliers de lignes
|
||||
- **Erreurs** : explique clairement la cause et propose une solution concrète
|
||||
- **Succès** : confirme brièvement ce qui a été fait (1 ligne)
|
||||
- **Multi-fichiers** : liste les fichiers modifiés avec `fichier:ligne` pour les références
|
||||
|
||||
## Diagrammes Mermaid
|
||||
|
||||
Tu peux utiliser des diagrammes Mermaid pour visualiser des architectures, flux, séquences, etc.
|
||||
Utilise un bloc code avec le langage `mermaid` :
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Début] --> B{Décision}
|
||||
B -->|Oui| C[Action]
|
||||
B -->|Non| D[Fin]
|
||||
```
|
||||
|
||||
Types utiles :
|
||||
- `graph TD/LR` — Architecture, flux de données
|
||||
- `sequenceDiagram` — Interactions entre composants
|
||||
- `flowchart` — Processus et décisions
|
||||
- `classDiagram` — Structures de données
|
||||
- `erDiagram` — Schémas de base de données
|
||||
- `gantt` — Planning et timelines
|
||||
|
||||
Utilise Mermaid quand ça apporte de la clarté : architecture complexe, flux multi-étapes, relations entre entités. Ne l'utilise pas pour du texte simple.
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
@@ -22,6 +21,7 @@ type ChatEngine struct {
|
||||
tools json.RawMessage
|
||||
onChunk func(map[string]interface{})
|
||||
stream bool
|
||||
TotalTokens int
|
||||
}
|
||||
|
||||
// NewChatEngine creates a new ChatEngine instance.
|
||||
@@ -72,16 +72,16 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
||||
return finalContent, allToolCalls, allToolResults, err
|
||||
}
|
||||
|
||||
if resp.Usage.TotalTokens > 0 {
|
||||
ce.TotalTokens += resp.Usage.TotalTokens
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
content := cleanThinkingTags(choice.Message.Content)
|
||||
|
||||
if content != "" {
|
||||
words := strings.Fields(content)
|
||||
for _, w := range words {
|
||||
chunk := w
|
||||
if ce.onChunk != nil {
|
||||
ce.onChunk(map[string]interface{}{"content": chunk})
|
||||
}
|
||||
ce.onChunk(map[string]interface{}{"content": content})
|
||||
}
|
||||
finalContent = content
|
||||
}
|
||||
@@ -128,6 +128,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
||||
"content": result.Content,
|
||||
"is_error": result.IsError,
|
||||
}
|
||||
if result.Meta != nil {
|
||||
for k, v := range result.Meta {
|
||||
resultData[k] = v
|
||||
}
|
||||
}
|
||||
allToolResults = append(allToolResults, map[string]interface{}{
|
||||
"tool_call_id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
@@ -154,6 +159,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
||||
return finalContent, allToolCalls, allToolResults, nil
|
||||
}
|
||||
|
||||
// ProviderName returns the name of the active provider used by the engine.
|
||||
func (ce *ChatEngine) ProviderName() string {
|
||||
return ce.orchestrator.ProviderName()
|
||||
}
|
||||
|
||||
// RunNonStream executes chat without streaming content to client.
|
||||
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
|
||||
var finalContent string
|
||||
@@ -164,6 +174,10 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
||||
return finalContent, err
|
||||
}
|
||||
|
||||
if resp.Usage.TotalTokens > 0 {
|
||||
ce.TotalTokens += resp.Usage.TotalTokens
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
content := cleanThinkingTags(choice.Message.Content)
|
||||
|
||||
|
||||
127
internal/api/consumption.go
Normal file
127
internal/api/consumption.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
)
|
||||
|
||||
type consumptionEntry struct {
|
||||
Date string `json:"date"`
|
||||
Tokens int `json:"tokens"`
|
||||
Requests int `json:"requests"`
|
||||
}
|
||||
|
||||
type providerConsumption struct {
|
||||
Name string `json:"name"`
|
||||
Daily []consumptionEntry `json:"daily"`
|
||||
Total int `json:"total_tokens"`
|
||||
Requests int `json:"total_requests"`
|
||||
}
|
||||
|
||||
type consumptionStore struct {
|
||||
mu sync.Mutex
|
||||
providers map[string]*providerConsumption
|
||||
}
|
||||
|
||||
func newConsumptionStore() *consumptionStore {
|
||||
cs := &consumptionStore{
|
||||
providers: make(map[string]*providerConsumption),
|
||||
}
|
||||
cs.load()
|
||||
cs.prune()
|
||||
return cs
|
||||
}
|
||||
|
||||
func (cs *consumptionStore) Record(providerName string, tokens int) {
|
||||
if tokens <= 0 || providerName == "" {
|
||||
return
|
||||
}
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
p, ok := cs.providers[providerName]
|
||||
if !ok {
|
||||
p = &providerConsumption{Name: providerName}
|
||||
cs.providers[providerName] = p
|
||||
}
|
||||
|
||||
p.Total += tokens
|
||||
p.Requests++
|
||||
|
||||
if len(p.Daily) > 0 && p.Daily[len(p.Daily)-1].Date == today {
|
||||
p.Daily[len(p.Daily)-1].Tokens += tokens
|
||||
p.Daily[len(p.Daily)-1].Requests++
|
||||
} else {
|
||||
p.Daily = append(p.Daily, consumptionEntry{
|
||||
Date: today,
|
||||
Tokens: tokens,
|
||||
Requests: 1,
|
||||
})
|
||||
}
|
||||
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *consumptionStore) GetAll() map[string]*providerConsumption {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
result := make(map[string]*providerConsumption)
|
||||
for k, v := range cs.providers {
|
||||
pc := *v
|
||||
daily := make([]consumptionEntry, len(v.Daily))
|
||||
copy(daily, v.Daily)
|
||||
pc.Daily = daily
|
||||
result[k] = &pc
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (cs *consumptionStore) prune() {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
|
||||
for _, p := range cs.providers {
|
||||
filtered := make([]consumptionEntry, 0)
|
||||
for _, d := range p.Daily {
|
||||
if d.Date >= cutoff {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
p.Daily = filtered
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *consumptionStore) filePath() string {
|
||||
dir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, "consumption.json")
|
||||
}
|
||||
|
||||
func (cs *consumptionStore) load() {
|
||||
fp := cs.filePath()
|
||||
if fp == "" {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(fp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
json.Unmarshal(data, &cs.providers)
|
||||
}
|
||||
|
||||
func (cs *consumptionStore) save() {
|
||||
fp := cs.filePath()
|
||||
if fp == "" {
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(cs.providers)
|
||||
os.WriteFile(fp, data, 0644)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ type ConversationStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
conv *Conversation
|
||||
realTokens int
|
||||
}
|
||||
|
||||
type TokenCount struct {
|
||||
@@ -133,6 +134,7 @@ func (cs *ConversationStore) Clear() {
|
||||
cs.conv.Summary = ""
|
||||
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
cs.realTokens = 0
|
||||
cs.save()
|
||||
}
|
||||
|
||||
@@ -154,9 +156,22 @@ func (cs *ConversationStore) TrimOld(keepCount int) {
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ApproxTokenCount() int {
|
||||
if cs.realTokens > 0 {
|
||||
return cs.realTokens
|
||||
}
|
||||
return cs.ApproxTokenCountDetailed().total
|
||||
}
|
||||
|
||||
// AddRealTokens accumulates actual token counts from the API response.
|
||||
func (cs *ConversationStore) AddRealTokens(tokens int) {
|
||||
if tokens <= 0 {
|
||||
return
|
||||
}
|
||||
cs.mu.Lock()
|
||||
cs.realTokens += tokens
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
@@ -3,9 +3,12 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
@@ -42,7 +45,14 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
||||
var studioPrompt strings.Builder
|
||||
studioPrompt.WriteString(agent.StudioSystemPrompt())
|
||||
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05")))
|
||||
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", os.Geteuid() == 0))
|
||||
if os.Geteuid() != 0 {
|
||||
studioPrompt.WriteString("⚠️ Session utilisateur standard — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
||||
}
|
||||
orb.SetSystemPrompt(studioPrompt.String())
|
||||
orb.SetTools(s.agentToolsJSON)
|
||||
|
||||
if body.Stream {
|
||||
@@ -91,6 +101,9 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
||||
storeContent = string(storeJSON)
|
||||
}
|
||||
s.convStore.Add("assistant", storeContent)
|
||||
s.convStore.AddRealTokens(engine.TotalTokens)
|
||||
|
||||
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||
|
||||
sseWriter.Write(map[string]interface{}{"done": "true"})
|
||||
}
|
||||
@@ -107,6 +120,10 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
|
||||
}
|
||||
|
||||
s.convStore.Add("assistant", finalContent)
|
||||
s.convStore.AddRealTokens(engine.TotalTokens)
|
||||
|
||||
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||
|
||||
writeJSON(w, map[string]string{"content": finalContent})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,21 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
|
||||
const summarizePrompt = `Résume cette conversation de manière ultra-concise et structurée.
|
||||
|
||||
CONSERVE :
|
||||
- Les décisions techniques prises et leur rationale
|
||||
- Les configurations modifiées (noms exacts, valeurs)
|
||||
- Les fichiers/chemins manipulés
|
||||
- Les erreurs rencontrées et leurs résolutions
|
||||
- Le contexte nécessaire pour continuer
|
||||
|
||||
ÉLIMINE :
|
||||
- Les échanges de politesse
|
||||
- Les tentatives infructueuses (sauf si la solution n'a pas été trouvée)
|
||||
- Les sorties d'outils brutes (garde seulement les conclusions)
|
||||
|
||||
FORMAT : Markdown structuré avec sections. Max 500 mots. Pas de méta-commentaire.`
|
||||
|
||||
func writeJSON(w http.ResponseWriter, data interface{}) {
|
||||
json.NewEncoder(w).Encode(data)
|
||||
|
||||
@@ -187,6 +187,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
|
||||
switch body.Name {
|
||||
case "minimax":
|
||||
baseURL = "https://api.minimax.io/v1"
|
||||
case "mimo":
|
||||
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
|
||||
case "openai":
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
case "anthropic":
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -477,9 +478,96 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
case "zai":
|
||||
// Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe
|
||||
if p.APIKey == "" {
|
||||
q.Error = "no API key"
|
||||
results = append(results, q)
|
||||
continue
|
||||
}
|
||||
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
q.Error = err.Error()
|
||||
} else {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
var data map[string]interface{}
|
||||
if json.Unmarshal(body, &data) == nil {
|
||||
if d, ok := data["data"].(map[string]interface{}); ok {
|
||||
if limits, ok := d["limits"].([]interface{}); ok {
|
||||
models := make([]map[string]interface{}, 0)
|
||||
for _, l := range limits {
|
||||
if lm, ok := l.(map[string]interface{}); ok {
|
||||
name := "Z.AI"
|
||||
if model, ok := lm["model"].(string); ok && model != "" {
|
||||
name = model
|
||||
} else if t, ok := lm["type"].(string); ok && t != "TIME_LIMIT" {
|
||||
name = t
|
||||
}
|
||||
usage, _ := lm["usage"].(float64)
|
||||
remaining, _ := lm["remaining"].(float64)
|
||||
limitVal, hasLimit := lm["limit"].(float64)
|
||||
total := usage + remaining
|
||||
if hasLimit && limitVal > 0 {
|
||||
total = limitVal
|
||||
}
|
||||
if total > 0 {
|
||||
models = append(models, map[string]interface{}{
|
||||
"model": name,
|
||||
"used": usage,
|
||||
"total": total,
|
||||
"remaining": remaining,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(models) > 0 {
|
||||
q.Data = map[string]interface{}{"models": models}
|
||||
q.Healthy = true
|
||||
q.Data = map[string]interface{}{"note": "crush-only"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "mimo":
|
||||
q.Healthy = p.APIKey != ""
|
||||
if p.APIKey == "" {
|
||||
q.Error = "no API key"
|
||||
results = append(results, q)
|
||||
continue
|
||||
}
|
||||
mimoBase := p.BaseURL
|
||||
if mimoBase == "" {
|
||||
mimoBase = "https://token-plan-ams.xiaomimimo.com/v1"
|
||||
}
|
||||
req, _ := http.NewRequest("GET", strings.TrimRight(mimoBase, "/")+"/models", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
q.Error = err.Error()
|
||||
} else {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
var data map[string]interface{}
|
||||
if json.Unmarshal(body, &data) == nil {
|
||||
if modelList, ok := data["data"].([]interface{}); ok {
|
||||
models := make([]map[string]interface{}, 0)
|
||||
for _, m := range modelList {
|
||||
if mm, ok := m.(map[string]interface{}); ok {
|
||||
id, _ := mm["id"].(string)
|
||||
if id != "" {
|
||||
models = append(models, map[string]interface{}{
|
||||
"model": id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
q.Data = map[string]interface{}{"models": models, "available": len(models)}
|
||||
q.Healthy = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case "claude", "anthropic":
|
||||
// Claude Code n'a pas d'API externe, vérifier l'installation
|
||||
claudePath := "/usr/bin/claude"
|
||||
@@ -496,6 +584,15 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{"providers": results})
|
||||
}
|
||||
|
||||
func (s *Server) handleProvidersConsumption(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
data := s.consumption.GetAll()
|
||||
writeJSON(w, map[string]interface{}{"providers": data})
|
||||
}
|
||||
|
||||
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
||||
home, _ := os.UserHomeDir()
|
||||
type cmdEntry struct {
|
||||
@@ -516,10 +613,11 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
||||
shell = "zsh"
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
start := len(lines) - 25
|
||||
start := len(lines) - 50
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
for i := len(lines) - 1; i >= start; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
@@ -536,6 +634,15 @@ func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
base := strings.Fields(line)[0]
|
||||
if len(base) < 2 {
|
||||
continue
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-zA-Z@./]`).MatchString(base) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
@@ -51,81 +53,89 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
|
||||
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
||||
orb.SetTools(s.shellAgentToolsJSON)
|
||||
|
||||
if req.Stream {
|
||||
s.handleShellChatStreamV2(w, orb)
|
||||
s.handleShellChatStream(w, orb)
|
||||
} else {
|
||||
s.handleShellChatNonStreamV2(w, orb)
|
||||
s.handleShellChatNonStream(w, orb)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
|
||||
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
|
||||
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
|
||||
|
||||
RÈGLES STRICTES:
|
||||
- Tu ne peux JAMAIS exécuter de commande ou de code
|
||||
- Tu ne peux que analyser, expliquer, et proposer des solutions
|
||||
- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié
|
||||
- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons
|
||||
|
||||
`)
|
||||
sb.WriteString(shellSystemPromptBase)
|
||||
|
||||
analysis := LoadSystemAnalysis()
|
||||
if analysis != "" {
|
||||
sb.WriteString("=== ANALYSE SYSTÈME ACTUELLE ===\n")
|
||||
sb.WriteString("<system_context>\n")
|
||||
sb.WriteString(analysis)
|
||||
sb.WriteString("\n=== FIN DE L'ANALYSE ===\n\n")
|
||||
sb.WriteString("\n</system_context>\n\n")
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
sb.WriteString("Hostname: " + hostname + "\n")
|
||||
}
|
||||
if user := os.Getenv("USER"); user != "" {
|
||||
sb.WriteString("User: " + user + "\n")
|
||||
}
|
||||
|
||||
isRoot := os.Geteuid() == 0
|
||||
sb.WriteString(fmt.Sprintf("Root: %t\n", isRoot))
|
||||
if isRoot {
|
||||
sb.WriteString("⚠️ Session en root — toutes les commandes ont les privilèges administrateur.\n")
|
||||
} else {
|
||||
sb.WriteString("⚠️ Session utilisateur standard — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
sb.WriteString(fmt.Sprintf("Date: %s\nHeure: %s\n", now.Format("02/01/2006"), now.Format("15:04:05")))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||
SetupSSEHeaders(w)
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
sseWriter := NewSSEWriter(w)
|
||||
|
||||
// Rebuild history into orchestrator
|
||||
history := s.shellConvStore.Get()
|
||||
for _, m := range history[:len(history)-1] { // all except last user msg
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
// Pre-load orchestrator history
|
||||
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
ctx := context.Background()
|
||||
messages := s.buildShellContextMessages()
|
||||
|
||||
lastUserMsg := history[len(history)-1].Content
|
||||
|
||||
var finalContent string
|
||||
result, err := orb.SendStream(lastUserMsg, func(chunk string) {
|
||||
finalContent = chunk
|
||||
sseWriter.Write(map[string]interface{}{"content": chunk})
|
||||
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||
engine.OnChunk(func(data map[string]interface{}) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
sseWriter.Write(data)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
})
|
||||
|
||||
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
|
||||
if err != nil {
|
||||
sseWriter.Write(map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
content := result
|
||||
if content == "" {
|
||||
content = finalContent
|
||||
storeContent := finalContent
|
||||
if len(allToolCalls) > 0 {
|
||||
storeObj := map[string]interface{}{
|
||||
"content": storeContent,
|
||||
"tool_calls": allToolCalls,
|
||||
"tool_results": allToolResults,
|
||||
}
|
||||
storeJSON, _ := json.Marshal(storeObj)
|
||||
storeContent = string(storeJSON)
|
||||
}
|
||||
s.shellConvStore.Add("assistant", storeContent)
|
||||
s.shellConvStore.AddRealTokens(engine.TotalTokens)
|
||||
|
||||
s.shellConvStore.Add("assistant", cleanThinkingTags(content))
|
||||
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||
|
||||
sseWriter.Write(map[string]interface{}{
|
||||
"done": "true",
|
||||
@@ -133,30 +143,66 @@ func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrato
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||
history := s.shellConvStore.Get()
|
||||
for _, m := range history[:len(history)-1] {
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
||||
ctx := context.Background()
|
||||
messages := s.buildShellContextMessages()
|
||||
|
||||
lastUserMsg := history[len(history)-1].Content
|
||||
|
||||
result, err := orb.Send(lastUserMsg)
|
||||
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.shellConvStore.Add("assistant", cleanThinkingTags(result))
|
||||
s.shellConvStore.Add("assistant", finalContent)
|
||||
s.shellConvStore.AddRealTokens(engine.TotalTokens)
|
||||
|
||||
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"content": result,
|
||||
"content": finalContent,
|
||||
"tokens": s.shellConvStore.ApproxTokens(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
||||
history := s.shellConvStore.Get()
|
||||
start := 0
|
||||
const shellContextWindow = 20
|
||||
if len(history) > shellContextWindow {
|
||||
start = len(history) - shellContextWindow
|
||||
}
|
||||
|
||||
messages := make([]orchestrator.Message, 0, len(history[start:]))
|
||||
|
||||
for _, m := range history[start:] {
|
||||
content := m.Content
|
||||
if m.Role == "assistant" {
|
||||
var parsed struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
|
||||
content = parsed.Content
|
||||
}
|
||||
}
|
||||
role := m.Role
|
||||
if role == "system" {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, orchestrator.Message{
|
||||
Role: role,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
@@ -252,15 +298,33 @@ func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
||||
|
||||
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes sur le système de l'utilisateur.
|
||||
Génère un rapport d'analyse concis et structuré en markdown qui inclut:
|
||||
1. Un résumé de l'état du système
|
||||
2. Les points d'attention (performance, sécurité, configuration)
|
||||
3. Des recommandations spécifiques d'optimisation
|
||||
4. Les outils manquants qui pourraient être utiles
|
||||
5. L'état du réseau et des connexions
|
||||
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes et génère un rapport structuré en markdown.
|
||||
|
||||
Sois concret et technique. Le rapport sera utilisé comme contexte pour un assistant terminal.
|
||||
STRUCTURE REQUISE :
|
||||
|
||||
## État du système
|
||||
- Résumé en 2-3 phrases de l'état général (OK/Attention/Critique)
|
||||
|
||||
## Points d'attention
|
||||
Liste les problèmes détectés par priorité :
|
||||
- **CRITIQUE** : problèmes de sécurité, espace disque < 10%, mémoire < 10%
|
||||
- **ATTENTION** : CPU élevé, services en échec, config non-optimale
|
||||
- **INFO** : améliorations possibles, mises à jour disponibles
|
||||
|
||||
## Recommandations
|
||||
Pour chaque point d'attention, donne UNE commande ou action corrective concrète.
|
||||
|
||||
## Outils manquants
|
||||
Liste les outils utiles non installés avec la commande d'installation.
|
||||
|
||||
## Réseau
|
||||
- Interfaces actives, ports en écoute, connectivité
|
||||
|
||||
RÈGLES :
|
||||
- Pas de blabla générique — sois spécifique à CE système
|
||||
- Inclus les valeurs numériques réelles (%, Go, MHz)
|
||||
- Max 1500 mots
|
||||
- Le rapport sert de contexte persistant pour un assistant terminal
|
||||
|
||||
` + sysInfo.String()
|
||||
|
||||
@@ -277,3 +341,16 @@ Sois concret et technique. Le rapport sera utilisé comme contexte pour un assis
|
||||
"analysis": result,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleShellAnalysisGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
analysis := LoadSystemAnalysis()
|
||||
if analysis == "" {
|
||||
writeJSON(w, map[string]interface{}{"analysis": nil})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"analysis": analysis})
|
||||
}
|
||||
|
||||
@@ -18,8 +18,11 @@ type Server struct {
|
||||
mux *http.ServeMux
|
||||
convStore *ConversationStore
|
||||
shellConvStore *ShellConvStore
|
||||
consumption *consumptionStore
|
||||
agentRegistry *agent.Registry
|
||||
agentToolsJSON json.RawMessage
|
||||
shellAgentRegistry *agent.Registry
|
||||
shellAgentToolsJSON json.RawMessage
|
||||
workflowEngine *workflow.Engine
|
||||
}
|
||||
|
||||
@@ -48,10 +51,19 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
s.scanResult = scanner.ScanSystem()
|
||||
s.convStore = NewConversationStore()
|
||||
s.shellConvStore = NewShellConvStore()
|
||||
s.consumption = newConsumptionStore()
|
||||
s.agentRegistry = agent.DefaultRegistry()
|
||||
tools := s.agentRegistry.OpenAITools()
|
||||
toolsJSON, _ := json.Marshal(tools)
|
||||
s.agentToolsJSON = json.RawMessage(toolsJSON)
|
||||
|
||||
s.shellAgentRegistry = agent.NewRegistry()
|
||||
terminalTool, _ := agent.NewTerminalTool()
|
||||
s.shellAgentRegistry.Register(terminalTool)
|
||||
shellTools := s.shellAgentRegistry.OpenAITools()
|
||||
shellToolsJSON, _ := json.Marshal(shellTools)
|
||||
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
|
||||
|
||||
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
||||
s.routes()
|
||||
return s
|
||||
@@ -94,6 +106,7 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
|
||||
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
|
||||
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
|
||||
s.mux.HandleFunc("/api/shell/analysis", s.handleShellAnalysisGet)
|
||||
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
|
||||
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
|
||||
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)
|
||||
@@ -120,6 +133,7 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
|
||||
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
|
||||
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
|
||||
s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption)
|
||||
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
|
||||
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
|
||||
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
|
||||
|
||||
@@ -14,6 +14,63 @@ import (
|
||||
const shellMaxTokens = 100000
|
||||
const shellCharsPerToken = 4
|
||||
|
||||
const shellSystemPromptBase = `Tu es l'**Analyste Système** de Muyue. Tu es un expert en administration système, DevOps et développement.
|
||||
|
||||
<critical_rules>
|
||||
1. **AGIS, ne décris pas** — Utilise l'outil terminal pour exécuter, ne te contente pas de proposer des commandes.
|
||||
2. **SOIS AUTONOME** — Cherche les infos manquantes via des commandes avant de demander à l'utilisateur. Essaie plusieurs approches avant de bloquer.
|
||||
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule. Réponse directe et technique.
|
||||
4. **GÈRE LES ERREURS** — Si une commande échoue, lis l'erreur, comprends la cause, essaie une approche alternative. 2-3 tentatives avant de rapporter.
|
||||
5. **SÉCURITÉ** — Ne révèle jamais de credentials. Demande confirmation avant les commandes destructrices (rm -rf, format, etc.).
|
||||
6. **LANGUE** — Réponds dans la même langue que l'utilisateur.
|
||||
</critical_rules>
|
||||
|
||||
<tool_usage>
|
||||
Outil disponible : **terminal** — Exécute des commandes shell sur le système local.
|
||||
|
||||
Stratégies :
|
||||
- **Diagnostique** — Enchaîne les commandes de diagnostic (ps, df, free, top, journalctl, dmesg, netstat, ss, etc.)
|
||||
- **Parallélisme** — Combine les commandes avec && ou ; quand elles sont indépendantes
|
||||
- **Filtrage** — Utilise grep, awk, sort, head pour extraire l'essentiel des sorties volumineuses
|
||||
- **Non-interactif** — Préfère les commandes non-interactives (apt install -y, non pas apt install)
|
||||
- **Troncature** — Si le résultat dépasse 2000 caractères, résume les points clés au lieu de tout afficher
|
||||
</tool_usage>
|
||||
|
||||
<decision_making>
|
||||
- Décide par toi-même : exécute des commandes pour comprendre l'état du système
|
||||
- Ne demande confirmation que pour les actions destructrices
|
||||
- Si tu ne connais pas la commande exacte, exécute la commande avec --help pour la trouver
|
||||
- Si bloqué : documente ce que tu as essayé, pourquoi, et l'action minimale requise
|
||||
- Ne t'arrête jamais pour une tâche complexe — découpe en étapes et exécute-les
|
||||
</decision_making>
|
||||
|
||||
<error_recovery>
|
||||
1. Lis le message d'erreur complet (stderr + stdout)
|
||||
2. Identifie la cause racine (permissions, paquet manquant, config, service)
|
||||
3. Essaie : vérifier le service, vérifier les logs, chercher le paquet, tester la connexion
|
||||
4. Propose une solution concrète, pas générique
|
||||
</error_recovery>
|
||||
|
||||
<response_format>
|
||||
- **Commandes** : blocs markdown avec le langage (bash, sh, etc.)
|
||||
- **Résultats** : résume les métriques clés, pas de dump complet
|
||||
- **Erreurs** : cause + solution en 1-2 lignes
|
||||
- **Succès** : confirmation en 1 ligne
|
||||
- **Analyses** : markdown structuré avec sections si nécessaire
|
||||
</response_format>
|
||||
|
||||
<mermaid>
|
||||
Tu peux utiliser des diagrammes Mermaid pour visualiser :
|
||||
- Architecture système (graph TD/LR)
|
||||
- Flux réseau (sequenceDiagram)
|
||||
- Processus (flowchart)
|
||||
- Timeline (gantt)
|
||||
|
||||
Utilise un bloc de code avec le langage mermaid quand ça clarifie l'explication. Pas pour du texte simple.
|
||||
</mermaid>
|
||||
|
||||
`
|
||||
|
||||
type ShellMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
@@ -25,6 +82,7 @@ type ShellConvStore struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
msgs []ShellMessage
|
||||
realTokens int
|
||||
}
|
||||
|
||||
func NewShellConvStore() *ShellConvStore {
|
||||
@@ -82,19 +140,37 @@ func (s *ShellConvStore) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.msgs = []ShellMessage{}
|
||||
s.realTokens = 0
|
||||
s.save()
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) ApproxTokens() int {
|
||||
if s.realTokens > 0 {
|
||||
return s.realTokens
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
total := 0
|
||||
for _, m := range s.msgs {
|
||||
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
|
||||
}
|
||||
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
|
||||
if analysis := LoadSystemAnalysis(); analysis != "" {
|
||||
total += utf8.RuneCountInString(analysis) / shellCharsPerToken
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// AddRealTokens accumulates actual token counts from the API response.
|
||||
func (s *ShellConvStore) AddRealTokens(tokens int) {
|
||||
if tokens <= 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.realTokens += tokens
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *ShellConvStore) AtLimit() bool {
|
||||
return s.ApproxTokens() >= shellMaxTokens
|
||||
}
|
||||
|
||||
@@ -146,13 +146,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: pty started successfully")
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
@@ -164,6 +157,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
})
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
@@ -171,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := ptmx.Read(buf)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
@@ -234,7 +226,6 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
|
||||
@@ -128,6 +128,22 @@ var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{
|
||||
},
|
||||
}
|
||||
|
||||
func migrateProviders(cfg *MuyueConfig) {
|
||||
defaults := Default().AI.Providers
|
||||
for _, dp := range defaults {
|
||||
found := false
|
||||
for _, p := range cfg.AI.Providers {
|
||||
if p.Name == dp.Name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
cfg.AI.Providers = append(cfg.AI.Providers, dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetTerminalTheme(name string) TerminalTheme {
|
||||
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
|
||||
return theme
|
||||
@@ -206,6 +222,8 @@ func Load() (*MuyueConfig, error) {
|
||||
}
|
||||
}
|
||||
|
||||
migrateProviders(&cfg)
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -269,6 +287,12 @@ func Default() *MuyueConfig {
|
||||
BaseURL: "https://api.minimax.io/v1",
|
||||
Active: true,
|
||||
},
|
||||
{
|
||||
Name: "mimo",
|
||||
Model: "mimo-v2.5-pro",
|
||||
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
|
||||
Active: false,
|
||||
},
|
||||
{
|
||||
Name: "zai",
|
||||
Model: "glm",
|
||||
@@ -297,6 +321,7 @@ func Default() *MuyueConfig {
|
||||
|
||||
cfg.Terminal.CustomPrompt = true
|
||||
cfg.Terminal.PromptTheme = "zerotwo"
|
||||
cfg.Terminal.FontSize = 14
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -476,6 +476,8 @@ func getProviderBaseURL(name string) string {
|
||||
return "https://api.openai.com/v1"
|
||||
case "zai":
|
||||
return "https://api.z.ai/v1"
|
||||
case "mimo":
|
||||
return "https://token-plan-ams.xiaomimimo.com/v1"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -503,11 +505,19 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
||||
if o.provider != nil {
|
||||
providerOrder = append(providerOrder, o.provider)
|
||||
}
|
||||
var zaiProvider *config.AIProvider
|
||||
for _, p := range providers {
|
||||
if o.provider == nil || p.Name != o.provider.Name {
|
||||
if p.Name == "zai" {
|
||||
zaiProvider = p
|
||||
} else {
|
||||
providerOrder = append(providerOrder, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if zaiProvider != nil {
|
||||
providerOrder = append(providerOrder, zaiProvider)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var triedProviders []string
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
const (
|
||||
Name = "muyue"
|
||||
Version = "0.3.3"
|
||||
Version = "0.4.0"
|
||||
Author = "La Légion de Muyue"
|
||||
)
|
||||
|
||||
|
||||
@@ -159,14 +159,18 @@ func parsePlanResponse(content string) ([]Step, error) {
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
const plannerSystemPrompt = `Tu es un assistant de planification de workflows pour Muyue. Tu génères des plans d'exécution sous forme de JSON. Chaque plan est une séquence d'étapes (steps) représentant des appels d'outils.
|
||||
const plannerSystemPrompt = `Tu es un planificateur de workflows pour Muyue. Tu génères des plans d'exécution sous forme de tableaux JSON.
|
||||
|
||||
Pour générer un plan:
|
||||
1. Comprends l'objectif de l'utilisateur
|
||||
2. Identifie les outils nécessaires
|
||||
3. Décompose en étapes logiques
|
||||
4. Spécifie les paramètres de chaque outil
|
||||
RÈGLES :
|
||||
1. Analyse l'objectif → identifie les outils → décompose en étapes
|
||||
2. Chaque étape : {"name": string, "tool": string, "args": object}
|
||||
3. Max 10 étapes par plan
|
||||
4. Ordonne par dépendances (les lectures avant les écritures)
|
||||
5. Préfère les commandes non-interactives
|
||||
6. Utilise crush_run pour les tâches complexes multi-fichiers
|
||||
|
||||
Réponds toujours en JSON valide, sans texte additionnel.`
|
||||
Outils : terminal, crush_run, read_file, list_files, search_files, grep_content, get_config, set_provider, manage_ssh, web_fetch
|
||||
|
||||
var _ = plannerSystemPrompt
|
||||
Réponds UNIQUEMENT en JSON valide, sans texte avant/après.`
|
||||
|
||||
const _ = plannerSystemPrompt
|
||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
1301
web/package-lock.json
generated
1301
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-image": "^0.10.0-beta.203",
|
||||
"@xterm/addon-search": "^0.17.0-beta.203",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.203",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xterm/addon-webgl": "^0.20.0-beta.202",
|
||||
"@xterm/xterm": "^6.1.0-beta.203",
|
||||
"lucide-react": "^1.8.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ const api = {
|
||||
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
|
||||
getDashboardStatus: () => request('/dashboard/status'),
|
||||
getProvidersQuota: () => request('/providers/quota'),
|
||||
getProvidersConsumption: () => request('/providers/consumption'),
|
||||
getRecentCommands: () => request('/recent-commands'),
|
||||
getRunningProcesses: () => request('/running-processes'),
|
||||
getSystemMetrics: () => request('/system/metrics'),
|
||||
@@ -60,6 +61,7 @@ const api = {
|
||||
getShellChatHistory: () => request('/shell/chat/history'),
|
||||
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
|
||||
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
|
||||
getShellAnalysis: () => request('/shell/analysis'),
|
||||
sendChat: (message, stream = true, onChunk, signal) => {
|
||||
if (!stream) {
|
||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||
@@ -104,7 +106,7 @@ const api = {
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
sendShellChat: (message, context = {}, stream = true, onChunk) => {
|
||||
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
|
||||
const payload = {
|
||||
message,
|
||||
cwd: context.cwd || '',
|
||||
@@ -119,6 +121,7 @@ const api = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
@@ -139,7 +142,11 @@ const api = {
|
||||
if (data.error) { reject(new Error(data.error)); return }
|
||||
if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
|
||||
if (data.content) {
|
||||
full = data.content
|
||||
full += data.content
|
||||
if (onChunk) onChunk(full, data)
|
||||
} else if (data.tool_call || data.tool_result) {
|
||||
if (onChunk) onChunk(full, data)
|
||||
} else if (data.thinking !== undefined || data.thinking_end) {
|
||||
if (onChunk) onChunk(full, data)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -76,6 +76,12 @@ export default function App() {
|
||||
|
||||
const switchTab = useCallback((tabId) => setActiveTab(tabId), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setActiveTab('shell')
|
||||
window.addEventListener('navigate-to-shell', handler)
|
||||
return () => window.removeEventListener('navigate-to-shell', handler)
|
||||
}, [])
|
||||
|
||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
||||
const installed = tools.filter(tool => tool.installed).length
|
||||
|
||||
@@ -86,6 +92,10 @@ export default function App() {
|
||||
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
|
||||
],
|
||||
shell: [
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
|
||||
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+F`, desc: t('statusbar.search') },
|
||||
{ keys: `${layout.keys.ctrl}++/${layout.keys.ctrl}+−`, desc: t('statusbar.zoom') },
|
||||
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
|
||||
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
|
||||
],
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react'
|
||||
import { useI18n, LANGUAGES } from '../i18n'
|
||||
import { getLayoutList } from '../i18n/keyboards'
|
||||
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const PANELS = [
|
||||
{ id: 'profile', icon: User },
|
||||
{ id: 'providers', icon: Brain },
|
||||
{ id: 'updates', icon: RefreshCw },
|
||||
{ id: 'locale', icon: Globe },
|
||||
{ id: 'skills', icon: Wrench },
|
||||
{ id: 'system', icon: Monitor },
|
||||
]
|
||||
@@ -29,8 +27,6 @@ export default function Config({ api }) {
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
|
||||
const layouts = getLayoutList()
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
api.getConfig().then(d => {
|
||||
setConfig(d)
|
||||
@@ -65,28 +61,15 @@ export default function Config({ api }) {
|
||||
setChecking(false)
|
||||
}
|
||||
|
||||
const handleUpdateTool = async (tool) => {
|
||||
setUpdating(tool)
|
||||
try {
|
||||
await api.runUpdate(tool)
|
||||
await handleCheckUpdates()
|
||||
showToast(`${tool} ✓`)
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setUpdating(null)
|
||||
const handleUpdateTool = (tool) => {
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } }))
|
||||
}
|
||||
|
||||
const handleUpdateAll = async () => {
|
||||
setUpdating('__all__')
|
||||
try {
|
||||
await api.runUpdate('')
|
||||
await handleCheckUpdates()
|
||||
showToast(t('config.saved'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setUpdating(null)
|
||||
const handleUpdateAll = () => {
|
||||
const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool)
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } }))
|
||||
}
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
@@ -118,9 +101,9 @@ export default function Config({ api }) {
|
||||
...prev,
|
||||
[p.name]: {
|
||||
name: p.name,
|
||||
api_key: p.apiKey || '',
|
||||
api_key: p.api_key || '',
|
||||
model: p.model || '',
|
||||
base_url: p.baseURL || '',
|
||||
base_url: p.base_url || '',
|
||||
},
|
||||
}))
|
||||
setEditProvider(p.name)
|
||||
@@ -181,13 +164,6 @@ export default function Config({ api }) {
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'locale' && (
|
||||
<PanelLocale
|
||||
language={language} keyboard={keyboard} layouts={layouts}
|
||||
api={api}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{activePanel === 'skills' && (
|
||||
<PanelSkills skillList={skillList} t={t} />
|
||||
)}
|
||||
@@ -333,39 +309,57 @@ function getFieldLabel(key, t) {
|
||||
|
||||
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
|
||||
const [validating, setValidating] = useState(null)
|
||||
const [validationStatus, setValidationStatus] = useState(null)
|
||||
const [keyStatus, setKeyStatus] = useState({})
|
||||
|
||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||
setValidating(name)
|
||||
setValidationStatus(null)
|
||||
const validateKey = async (p) => {
|
||||
setValidating(p.name)
|
||||
try {
|
||||
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
||||
setValidationStatus({ provider: name, valid: true })
|
||||
await api.validateProvider({ name: p.name, api_key: p.api_key, model: p.model, base_url: p.base_url || '' })
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
|
||||
} catch (err) {
|
||||
const msg = err.message || ''
|
||||
if (msg.includes('invalid_api_key')) {
|
||||
setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') })
|
||||
} else {
|
||||
setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` })
|
||||
}
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||
}
|
||||
setValidating(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
providers.forEach(p => {
|
||||
if (p.api_key && !keyStatus[p.name]) {
|
||||
validateKey(p)
|
||||
} else if (!p.api_key) {
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||
}
|
||||
})
|
||||
}, [providers])
|
||||
|
||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||
setValidating(name)
|
||||
try {
|
||||
await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl })
|
||||
setKeyStatus(prev => ({ ...prev, [name]: { valid: true, checked: true } }))
|
||||
} catch (err) {
|
||||
setKeyStatus(prev => ({ ...prev, [name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
|
||||
}
|
||||
setValidating(null)
|
||||
}
|
||||
|
||||
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'mimo')
|
||||
|
||||
return (
|
||||
<div className="config-providers-list">
|
||||
<div className="provider-setup-hint">{t('config.setupDescription')}</div>
|
||||
{providers.map((p, i) => {
|
||||
{displayed.map((p, i) => {
|
||||
const isEditing = editProvider === p.name
|
||||
const isValidationTarget = validationStatus?.provider === p.name
|
||||
const currentModel = providerForm[p.name]?.model || p.model
|
||||
const status = keyStatus[p.name]
|
||||
|
||||
return (
|
||||
<div key={i} className="config-card provider-card-v2">
|
||||
<div className="provider-card-top">
|
||||
<div className="provider-card-identity">
|
||||
<span className="provider-card-name">{p.name}</span>
|
||||
<span className="provider-card-name">{p.name.toUpperCase()}</span>
|
||||
{p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
|
||||
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
|
||||
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
|
||||
{status?.checked && status?.valid && <span className="badge ok">✓ {t('config.keyValid')}</span>}
|
||||
{status?.checked && !status?.valid && <span className="badge error">✗ {status.error || t('config.keyInvalid')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -376,7 +370,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
<input
|
||||
className="config-form-input"
|
||||
type="password"
|
||||
placeholder={t('config.tokenPlaceholder')}
|
||||
placeholder={p.api_key ? '••••••••' : t('config.tokenPlaceholder')}
|
||||
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
|
||||
onChange={e => {
|
||||
if (!isEditing) openProviderEdit(p)
|
||||
@@ -391,18 +385,18 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
<button
|
||||
className="sm primary"
|
||||
disabled={validating === p.name || !providerForm[p.name]?.api_key}
|
||||
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)}
|
||||
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
|
||||
>
|
||||
{validating === p.name ? t('config.validating') : t('config.validateKey')}
|
||||
</button>
|
||||
{isValidationTarget && validationStatus?.valid && (
|
||||
{isEditing && (
|
||||
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="provider-card-meta" style={{ marginTop: 8 }}>
|
||||
{p.active && <span className="badge ok" style={{ marginRight: 6 }}>active</span>}
|
||||
{p.model && p.model !== p.name && <span className="mono">{p.model}</span>}
|
||||
<div className="provider-card-model">
|
||||
<span className="provider-card-model-label">{t('config.model')}</span>
|
||||
<span className="provider-card-model-value">{p.model || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -412,7 +406,14 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
)
|
||||
}
|
||||
|
||||
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
||||
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
|
||||
const handleInstallTool = (tool) => {
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
|
||||
}
|
||||
|
||||
const missingTools = tools.filter(tool => !tool.installed)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="config-card">
|
||||
@@ -435,6 +436,30 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{missingTools.length > 0 && (
|
||||
<>
|
||||
<div className="section-title" style={{ marginTop: 12, marginBottom: 4 }}>{t('config.missing') || 'Modules manquants'}</div>
|
||||
<div className="config-update-list">
|
||||
{missingTools.map((tool, i) => (
|
||||
<div key={`miss-${i}`} className="config-update-row">
|
||||
<div className="config-update-info">
|
||||
<span className="config-update-name">{tool.name}</span>
|
||||
<span className="config-update-versions">
|
||||
<span style={{ color: 'var(--danger)' }}>{t('config.notInstalled') || 'Non installé'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="sm primary"
|
||||
onClick={() => handleInstallTool(tool.name)}
|
||||
>
|
||||
{t('config.install') || 'Installer'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{updates.length === 0 ? (
|
||||
<div className="config-card">
|
||||
<div className="empty-state">{t('config.noUpdates')}</div>
|
||||
@@ -470,98 +495,7 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
|
||||
)
|
||||
}
|
||||
|
||||
function PanelLocale({ language, keyboard, layouts, api, t }) {
|
||||
const { setLanguage, setKeyboard } = useI18n()
|
||||
const [editLocale, setEditLocale] = useState(false)
|
||||
const [draftLang, setDraftLang] = useState(language)
|
||||
const [draftKbd, setDraftKbd] = useState(keyboard)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 2500)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.savePreferences({ language: draftLang, keyboard_layout: draftKbd })
|
||||
setLanguage(draftLang)
|
||||
setKeyboard(draftKbd)
|
||||
setEditLocale(false)
|
||||
showToast(t('config.saved'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const currentLang = LANGUAGES.find(l => l.id === language)
|
||||
const currentKbd = layouts.find(l => l.id === keyboard)
|
||||
|
||||
return (
|
||||
<div className="config-profile-center">
|
||||
{toast && <div className="config-toast">{toast}</div>}
|
||||
<div className="config-card">
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.language')}</span>
|
||||
<span className="config-card-value">{currentLang?.name || language}</span>
|
||||
</div>
|
||||
<div className="config-card-row">
|
||||
<span className="config-card-label">{t('config.keyboardLayout')}</span>
|
||||
<span className="config-card-value">{currentKbd?.name || keyboard}</span>
|
||||
</div>
|
||||
</div>
|
||||
{editLocale && (
|
||||
<div className="config-card">
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.language')}</span>
|
||||
<div className="chip-row">
|
||||
{LANGUAGES.map(lang => (
|
||||
<div
|
||||
key={lang.id}
|
||||
className={`chip ${draftLang === lang.id ? 'active' : ''}`}
|
||||
onClick={() => setDraftLang(lang.id)}
|
||||
>
|
||||
{lang.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-card-group">
|
||||
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
|
||||
<div className="chip-row">
|
||||
{layouts.map(l => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`chip ${draftKbd === l.id ? 'active' : ''}`}
|
||||
onClick={() => setDraftKbd(l.id)}
|
||||
>
|
||||
{l.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="config-card">
|
||||
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
|
||||
{editLocale ? (
|
||||
<>
|
||||
<button className="primary sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? t('config.saving') : t('config.save')}
|
||||
</button>
|
||||
<button className="ghost sm" onClick={() => setEditLocale(false)}>{t('config.cancel')}</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="primary sm" onClick={() => { setDraftLang(language); setDraftKbd(keyboard); setEditLocale(true) }}>{t('config.editProfile')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelSkills({ skillList, t }) {
|
||||
const [selected, setSelected] = useState(null)
|
||||
@@ -644,7 +578,7 @@ function PanelSkills({ skillList, t }) {
|
||||
}
|
||||
|
||||
function PanelSystem({ api, t }) {
|
||||
const [resetConfirm, setResetConfirm] = useState(false)
|
||||
const [showResetModal, setShowResetModal] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
const showToast = (msg) => {
|
||||
@@ -655,7 +589,7 @@ function PanelSystem({ api, t }) {
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await api.resetConfig()
|
||||
setResetConfirm(false)
|
||||
setShowResetModal(false)
|
||||
showToast(t('config.resetDone'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
@@ -663,49 +597,66 @@ function PanelSystem({ api, t }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyStarship = async () => {
|
||||
try {
|
||||
await api.applyStarshipTheme('charm')
|
||||
showToast(t('config.starshipApplied'))
|
||||
} catch (err) {
|
||||
showToast(`${t('config.error')}: ${err.message}`)
|
||||
}
|
||||
const handleApplyStarship = () => {
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-shell', {}))
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } }))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{toast && <div className="config-toast">{toast}</div>}
|
||||
|
||||
<div className="section-title" style={{ marginBottom: 8 }}>Configuration Système</div>
|
||||
<div className="config-card">
|
||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.applyStarship')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12 }}>
|
||||
{t('config.starshipApplied')}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||
Vérifie l'installation de starship et configure le thème charm via l'IA.
|
||||
</div>
|
||||
<button className="sm primary" onClick={handleApplyStarship}>
|
||||
{t('config.applyStarship')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="config-card" style={{ marginTop: 12 }}>
|
||||
|
||||
<div className="section-title" style={{ marginTop: 20, marginBottom: 8, color: 'var(--danger)' }}>
|
||||
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
|
||||
Zone Rouge
|
||||
</div>
|
||||
<div className="config-card" style={{ borderColor: 'var(--danger)', borderWidth: 1, borderStyle: 'solid' }}>
|
||||
<div className="config-card-row" style={{ marginBottom: 16 }}>
|
||||
<span className="config-card-label" style={{ fontWeight: 600 }}>{t('config.resetConfig')}</span>
|
||||
<span className="config-card-label" style={{ fontWeight: 600, color: 'var(--danger)' }}>{t('config.resetConfig')}</span>
|
||||
</div>
|
||||
{resetConfirm ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 12 }}>
|
||||
{t('config.resetConfirm')}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||
Cette action supprimera toute votre configuration et relancera l'application.
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="sm" onClick={() => setResetConfirm(false)}>{t('config.cancel')}</button>
|
||||
<button className="sm danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="sm ghost danger" onClick={() => setResetConfirm(true)}>
|
||||
<button className="sm ghost danger" onClick={() => setShowResetModal(true)}>
|
||||
{t('config.resetConfig')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showResetModal && (
|
||||
<div className="shell-modal-overlay" onClick={() => setShowResetModal(false)}>
|
||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shell-modal-header" style={{ color: 'var(--danger)' }}>
|
||||
<AlertTriangle size={16} style={{ verticalAlign: 'middle', marginRight: 8 }} />
|
||||
{t('config.resetConfig')}
|
||||
</div>
|
||||
<div className="shell-modal-body">
|
||||
<p style={{ color: 'var(--warning)', fontSize: 13, marginBottom: 12 }}>
|
||||
{t('config.resetConfirm')}
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>
|
||||
Cette action est irréversible. Toute votre configuration (profil, clés API, préférences) sera supprimée.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shell-modal-footer">
|
||||
<button className="ghost" onClick={() => setShowResetModal(false)}>{t('config.cancel')}</button>
|
||||
<button className="danger" onClick={handleReset}>{t('config.resetConfig')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ const MAX_POINTS = 30
|
||||
const POLL_INTERVAL = 5000
|
||||
const MAX_IDLE_POLLS = 3
|
||||
|
||||
function formatTokens(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function MiniGraph({ data, max, color, label, unit }) {
|
||||
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
||||
const m = max || Math.max(...data, 1)
|
||||
@@ -37,12 +43,32 @@ function MiniGraph({ data, max, color, label, unit }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BarChart({ data, max, color }) {
|
||||
if (!data || data.length === 0) return null
|
||||
const barW = 100 / 7
|
||||
const m = max || Math.max(...data.map(d => d.tokens), 1)
|
||||
return (
|
||||
<svg viewBox="0 0 100 40" className="dash-graph-svg" preserveAspectRatio="none">
|
||||
{data.map((d, i) => {
|
||||
const h = Math.max(1, (d.tokens / m) * 36)
|
||||
const x = i * barW + barW * 0.15
|
||||
const w = barW * 0.7
|
||||
return (
|
||||
<rect key={i} x={x} y={40 - h} width={w} height={h} rx="1.5" fill={color} opacity={0.85} />
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Dashboard({ api, refreshRef }) {
|
||||
const { t } = useI18n()
|
||||
const [quota, setQuota] = useState(null)
|
||||
const [consumption, setConsumption] = useState(null)
|
||||
const [recentCmds, setRecentCmds] = useState([])
|
||||
const [processes, setProcesses] = useState([])
|
||||
const [metrics, setMetrics] = useState(null)
|
||||
const [copiedSet, setCopiedSet] = useState(new Set())
|
||||
const cpuRef = useRef([])
|
||||
const memRef = useRef([])
|
||||
const netRxRef = useRef([])
|
||||
@@ -50,13 +76,15 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [quotaData, cmdData, procData, metricsData] = await Promise.all([
|
||||
const [quotaData, consumData, cmdData, procData, metricsData] = await Promise.all([
|
||||
api.getProvidersQuota().catch(() => null),
|
||||
api.getProvidersConsumption().catch(() => null),
|
||||
api.getRecentCommands().catch(() => ({ commands: [] })),
|
||||
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
||||
api.getSystemMetrics().catch(() => null),
|
||||
])
|
||||
setQuota(quotaData?.providers || [])
|
||||
setConsumption(consumData?.providers || {})
|
||||
setRecentCmds(cmdData.commands || [])
|
||||
setProcesses(procData.processes || [])
|
||||
if (metricsData) {
|
||||
@@ -90,7 +118,54 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
}, [loadData, refreshRef])
|
||||
|
||||
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||||
const zai = (quota || []).find(p => p.name === 'zai')
|
||||
|
||||
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
|
||||
|
||||
const topCmds = (() => {
|
||||
const counts = {}
|
||||
for (const c of recentCmds) {
|
||||
const base = c.cmd.split(/\s+/)[0]
|
||||
if (!base || base.length < 2 || EXCLUDE_CMDS.includes(base)) continue
|
||||
if (!/^[a-zA-Z@.\/]/.test(base)) continue
|
||||
counts[base] = (counts[base] || 0) + 1
|
||||
}
|
||||
return Object.entries(counts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([cmd, count]) => ({ cmd, count }))
|
||||
})()
|
||||
|
||||
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
|
||||
|
||||
const copyCmd = (cmd, key) => {
|
||||
navigator.clipboard.writeText(cmd)
|
||||
setCopiedSet(prev => new Set(prev).add(key))
|
||||
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
|
||||
}
|
||||
|
||||
const relativeTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
|
||||
if (diff < 60) return `${diff}s`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||
return `${Math.floor(diff / 86400)}d`
|
||||
}
|
||||
|
||||
const recentUnique = (() => {
|
||||
const seen = new Set()
|
||||
return recentCmds.filter(c => {
|
||||
if (seen.has(c.cmd)) return false
|
||||
seen.add(c.cmd)
|
||||
return true
|
||||
})
|
||||
})()
|
||||
|
||||
const providerEntries = consumption ? Object.entries(consumption) : []
|
||||
const colors = ['var(--accent)', '#34d399', '#a78bfa', '#f59e0b', '#f472b6']
|
||||
const maxDaily = providerEntries.length > 0
|
||||
? Math.max(...providerEntries.map(([, p]) => Math.max(...(p.daily || []).map(d => d.tokens), 0)), 1)
|
||||
: 1
|
||||
|
||||
return (
|
||||
<div className="dash-grid">
|
||||
@@ -122,34 +197,36 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
||||
</div>
|
||||
|
||||
{/* API Quota */}
|
||||
{/* Consommation */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">API Quota</span>
|
||||
<span className="dash-label">Consommation</span>
|
||||
<span className="dash-count">7j</span>
|
||||
</div>
|
||||
<div className="dash-quota-list">
|
||||
{minimax && minimax.data?.models?.map((m, i) => (
|
||||
<div key={i} className="dash-quota-row">
|
||||
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
|
||||
<div className="dash-bar">
|
||||
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
|
||||
<div className="dash-consumption-list">
|
||||
{providerEntries.length === 0 && (
|
||||
<span className="dash-empty">Aucune donnée</span>
|
||||
)}
|
||||
{providerEntries.map(([name, p], pi) => (
|
||||
<div key={name} className="dash-consumption-provider">
|
||||
<div className="dash-consumption-head">
|
||||
<span className="dash-consumption-name" style={{ color: colors[pi % colors.length] }}>
|
||||
{name.toUpperCase()}
|
||||
</span>
|
||||
<span className="dash-consumption-total">
|
||||
{formatTokens(p.total_tokens)} tokens · {p.total_requests} req
|
||||
</span>
|
||||
</div>
|
||||
<BarChart data={p.daily || []} max={maxDaily} color={colors[pi % colors.length]} />
|
||||
<div className="dash-consumption-days">
|
||||
{(p.daily || []).map((d, i) => (
|
||||
<span key={i} className="dash-consumption-day">
|
||||
{d.date.slice(5)} <strong>{formatTokens(d.tokens)}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="dash-quota-val">{m.used}/{m.total}</span>
|
||||
</div>
|
||||
))}
|
||||
{minimax && minimax.data?.models?.length === 0 && (
|
||||
<div className="dash-quota-row">
|
||||
<span className="dash-quota-name">MiniMax</span>
|
||||
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
|
||||
</div>
|
||||
)}
|
||||
{zai && (
|
||||
<div className="dash-quota-row">
|
||||
<span className="dash-quota-name">Z.AI</span>
|
||||
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
|
||||
</div>
|
||||
)}
|
||||
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,16 +248,34 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
</div>
|
||||
|
||||
{/* Recent Commands */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card dash-cmd-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">Recent Commands</span>
|
||||
<span className="dash-count">{recentUnique.length}</span>
|
||||
</div>
|
||||
{topCmds.length > 0 && (
|
||||
<div className="dash-cmd-freq">
|
||||
<span className="dash-cmd-freq-title">Most used</span>
|
||||
{topCmds.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
|
||||
<span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
|
||||
<div className="dash-cmd-freq-bar-wrap">
|
||||
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
|
||||
</div>
|
||||
<span className="dash-cmd-freq-count">{c.count}×</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="dash-cmd-list">
|
||||
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
||||
{recentCmds.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-row" title={c.cmd}>
|
||||
<span className="dash-cmd-shell">{c.shell}</span>
|
||||
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
|
||||
{recentUnique.length === 0 && <span className="dash-empty">No history</span>}
|
||||
{recentUnique.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
|
||||
<div className="dash-cmd-left">
|
||||
<span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
|
||||
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
|
||||
</div>
|
||||
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
|
||||
|
||||
const RANKS = {
|
||||
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
|
||||
@@ -47,17 +50,33 @@ function renderContent(text) {
|
||||
lastIndex = match.index + full.length
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex) })
|
||||
const remaining = text.slice(lastIndex)
|
||||
const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/)
|
||||
if (openBlock) {
|
||||
if (openBlock.index > 0) {
|
||||
parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) })
|
||||
}
|
||||
parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' })
|
||||
} else {
|
||||
parts.push({ type: 'text', content: remaining })
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function formatText(text) {
|
||||
// First escape HTML entities
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
// Apply markdown transformations (now with escaped brackets)
|
||||
html = html.replace(/^(\|.+\|)\n(\|[\s\-:|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, sepRow, bodyRows) => {
|
||||
const headers = headerRow.split('|').filter(c => c.trim() !== '').map(c => `<th>${c.trim()}</th>`).join('')
|
||||
const rows = bodyRows.trim().split('\n').map(row => {
|
||||
const cells = row.split('|').filter(c => c.trim() !== '').map(c => `<td>${c.trim()}</td>`).join('')
|
||||
return `<tr>${cells}</tr>`
|
||||
}).join('')
|
||||
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`
|
||||
})
|
||||
|
||||
html = html
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
@@ -66,10 +85,13 @@ function formatText(text) {
|
||||
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
|
||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
|
||||
.replace(/\n/g, '<br/>')
|
||||
|
||||
// Sanitize: remove event handlers and dangerous protocols
|
||||
html = html
|
||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '') // Remove on* event handlers
|
||||
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
||||
.replace(/<br\/>\s*(<h[234]|<div class="msg-|<table)/g, '$1')
|
||||
.replace(/(<\/h[234]|<\/div>|<\/table>)\s*<br\/>/g, '$1')
|
||||
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/data:/gi, '')
|
||||
|
||||
@@ -158,10 +180,69 @@ function ToolCallBlock({ call, result }) {
|
||||
)
|
||||
}
|
||||
|
||||
let mermaidIdCounter = 0
|
||||
|
||||
function MermaidBlock({ code }) {
|
||||
const ref = useRef(null)
|
||||
const [svg, setSvg] = useState('')
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const id = `studio-mermaid-${++mermaidIdCounter}`
|
||||
mermaid.render(id, code).then(({ svg }) => {
|
||||
if (!cancelled) setSvg(svg)
|
||||
}).catch(() => {
|
||||
if (!cancelled) setError(true)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [code])
|
||||
|
||||
if (error) return <pre className="studio-mermaid-error">{code}</pre>
|
||||
if (!svg) return <div className="studio-mermaid-loading">Chargement...</div>
|
||||
return <div className="studio-mermaid-container" ref={ref} dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
}
|
||||
|
||||
function CodeBlockWithCopy({ part, index, copiedIdx, setCopiedIdx }) {
|
||||
if (part.lang === 'mermaid') {
|
||||
return (
|
||||
<div className="studio-code-block">
|
||||
<div className="studio-code-header">
|
||||
<span className="studio-code-lang">mermaid</span>
|
||||
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
|
||||
navigator.clipboard.writeText(part.content)
|
||||
setCopiedIdx(index)
|
||||
setTimeout(() => setCopiedIdx(null), 1500)
|
||||
}}>
|
||||
{copiedIdx === index ? 'Copie!' : 'Copier'}
|
||||
</button>
|
||||
</div>
|
||||
<MermaidBlock code={part.content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="studio-code-block">
|
||||
<div className="studio-code-header">
|
||||
{part.lang && <span className="studio-code-lang">{part.lang}</span>}
|
||||
<button className={`studio-copy-btn ${copiedIdx === index ? 'copied' : ''}`} onClick={() => {
|
||||
navigator.clipboard.writeText(part.content)
|
||||
setCopiedIdx(index)
|
||||
setTimeout(() => setCopiedIdx(null), 1500)
|
||||
}}>
|
||||
{copiedIdx === index ? 'Copie!' : 'Copier'}
|
||||
</button>
|
||||
</div>
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedItem({ msg }) {
|
||||
const isUser = msg.role === 'user'
|
||||
const isSystem = msg.role === 'system'
|
||||
const rank = getRank(msg.role)
|
||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||
|
||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
|
||||
@@ -187,7 +268,7 @@ function FeedItem({ msg }) {
|
||||
)
|
||||
}
|
||||
|
||||
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
let cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
@@ -216,10 +297,7 @@ function FeedItem({ msg }) {
|
||||
<div className="feed-content">
|
||||
{renderContent(cleanContent).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
@@ -235,6 +313,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
||||
const rank = RANKS.general
|
||||
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
const hasToolCalls = toolCalls && toolCalls.length > 0
|
||||
const [copiedIdx, setCopiedIdx] = useState(null)
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
if (!cleanContent) return []
|
||||
@@ -271,10 +350,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
|
||||
<div className="feed-content">
|
||||
{renderedContent.map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<div key={i} className="studio-code-block">
|
||||
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
|
||||
<pre><code>{part.content}</code></pre>
|
||||
</div>
|
||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
@@ -299,7 +375,9 @@ export default function Studio({ api }) {
|
||||
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 })
|
||||
const [contextCollapsed, setContextCollapsed] = useState(false)
|
||||
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
|
||||
const [sudoModal, setSudoModal] = useState(null)
|
||||
const messagesEnd = useRef(null)
|
||||
const feedRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
|
||||
@@ -330,6 +408,20 @@ export default function Studio({ api }) {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streaming, streamThinking, streamToolCalls])
|
||||
|
||||
useEffect(() => {
|
||||
const onTab = (e) => {
|
||||
if (e.key !== 'Tab') return
|
||||
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return
|
||||
const feed = document.querySelector('.studio-feed-layout')
|
||||
if (!feed?.closest('.tab-hidden')) {
|
||||
e.preventDefault()
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onTab)
|
||||
return () => window.removeEventListener('keydown', onTab)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
@@ -379,6 +471,14 @@ export default function Studio({ api }) {
|
||||
const text = input.trim()
|
||||
setInput('')
|
||||
|
||||
const isSlashCommand = (t) => /^\/(clear|help|summarize|model(?:\s+\S+)?)$/.test(t)
|
||||
|
||||
if (text.startsWith('/') && !isSlashCommand(text)) {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }])
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Commande inconnue. Tapez `/help` pour la liste des commandes.', time: new Date().toISOString() }])
|
||||
return
|
||||
}
|
||||
|
||||
if (text === '/clear') {
|
||||
handleClear()
|
||||
return
|
||||
@@ -391,18 +491,8 @@ export default function Studio({ api }) {
|
||||
'- `/clear` - Effacer la conversation',
|
||||
'- `/summarize` - Résumer la conversation précédente',
|
||||
'- `/help` - Afficher cette aide',
|
||||
'- `/plan <objectif>` - Demander un plan structuré',
|
||||
'- `/export` - Exporter la conversation en Markdown',
|
||||
'- `/model` - Afficher le provider et modèle actifs',
|
||||
'',
|
||||
'## Tools disponibles',
|
||||
'- Terminal - Exécuter des commandes',
|
||||
'- read_file - Lire des fichiers',
|
||||
'- list_files - Lister des fichiers',
|
||||
'- search_files - Rechercher des fichiers',
|
||||
'- grep_content - Rechercher dans le contenu',
|
||||
'- get_config - Lire la configuration',
|
||||
'- web_fetch - Récupérer une page web',
|
||||
'- `/model change` - Basculer entre MiniMax et MiMo',
|
||||
].join('\n')
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }])
|
||||
return
|
||||
@@ -413,39 +503,37 @@ export default function Studio({ api }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (text === '/model') {
|
||||
if (text === '/model' || text === '/model change') {
|
||||
if (text === '/model change') {
|
||||
api.getProviders().then(data => {
|
||||
const providers = data.providers || []
|
||||
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
|
||||
const mimo = providers.find(p => p.name.toUpperCase() === 'MIMO')
|
||||
if (!minimax || !mimo) {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et MiMo doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
|
||||
return
|
||||
}
|
||||
const active = providers.find(p => p.active)
|
||||
const activeName = active ? active.name.toUpperCase() : ''
|
||||
const switchTo = activeName === 'MINIMAX' ? 'MIMO' : 'MINIMAX'
|
||||
const target = switchTo === 'MINIMAX' ? minimax : mimo
|
||||
api.saveProvider({ name: target.name, active: true }).then(() => {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
|
||||
}).catch(() => {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur lors du changement de provider.', time: new Date().toISOString() }])
|
||||
})
|
||||
}).catch(() => {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
||||
})
|
||||
} else {
|
||||
api.getProviders().then(data => {
|
||||
const active = data.providers?.find(p => p.active)
|
||||
const modelMsg = active ? active.name : 'Aucun provider actif configuré'
|
||||
const modelMsg = active ? `**${active.name}** — ${active.model}` : 'Aucun provider actif configuré'
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
|
||||
}).catch(() => {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (text.startsWith('/plan ')) {
|
||||
const objective = text.slice(6).trim()
|
||||
if (!objective) {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Usage: `/plan <objectif>`\nEx: `/plan créer un fichier de test`', time: new Date().toISOString() }])
|
||||
return
|
||||
}
|
||||
setInput(`Crée un plan structuré en étapes numérotées pour: ${objective}. Chaque étape devrait avoir une estimation de complexité et de temps.`)
|
||||
handleSend()
|
||||
return
|
||||
}
|
||||
|
||||
if (text === '/export') {
|
||||
api.getChatHistory().then(data => {
|
||||
let markdown = '# Conversation Export\n\n'
|
||||
data.messages?.forEach((msg, i) => {
|
||||
const roleLabel = msg.role === 'user' ? '👤' : (msg.role === 'assistant' ? '🤖' : '⚙️')
|
||||
markdown += `## [${i + 1}] ${roleLabel} ${msg.role}\n${msg.content}\n\n---\n\n`
|
||||
})
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Conversation exportée:\n```markdown\n' + markdown + '```', time: new Date().toISOString() }])
|
||||
}).catch(() => {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible d\'exporter la conversation', time: new Date().toISOString() }])
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -475,9 +563,14 @@ export default function Studio({ api }) {
|
||||
if (event && event.tool_call) {
|
||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
||||
setStreamToolCalls([...toolCalls])
|
||||
accumulated = ''
|
||||
setStreaming('')
|
||||
return
|
||||
}
|
||||
if (event && event.tool_result) {
|
||||
if (event.tool_result.sudo_blocked) {
|
||||
setSudoModal({ command: event.tool_result.command || event.tool_result.content })
|
||||
}
|
||||
const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
|
||||
if (idx >= 0) {
|
||||
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
|
||||
@@ -501,6 +594,11 @@ export default function Studio({ api }) {
|
||||
aiMsg.content = JSON.stringify({
|
||||
content: finalContent,
|
||||
tool_calls: toolCalls.map(tc => tc.call),
|
||||
tool_results: toolCalls.map(tc => ({
|
||||
tool_call_id: tc.call?.tool_call_id,
|
||||
result: tc.result?.content || '',
|
||||
is_error: tc.result?.is_error || false,
|
||||
})),
|
||||
})
|
||||
}
|
||||
setMessages(prev => [...prev, aiMsg])
|
||||
@@ -538,10 +636,38 @@ export default function Studio({ api }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const ta = textareaRef.current
|
||||
if (!ta) return
|
||||
if (document.activeElement !== ta) {
|
||||
ta.focus()
|
||||
return
|
||||
}
|
||||
const val = ta.value
|
||||
const pos = ta.selectionStart
|
||||
const before = val.slice(0, pos)
|
||||
const afterSlash = before.match(/\/[\w ]*$/)
|
||||
if (afterSlash) {
|
||||
const partial = afterSlash[0]
|
||||
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
|
||||
if (matches.length === 1) {
|
||||
const completed = matches[0] + ' '
|
||||
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
|
||||
setInput(newText)
|
||||
requestAnimationFrame(() => {
|
||||
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,13 +713,23 @@ export default function Studio({ api }) {
|
||||
|
||||
return (
|
||||
<div className="studio-feed-layout">
|
||||
<div className="studio-feed">
|
||||
<div className="studio-feed-scroll-wrap">
|
||||
<div className="studio-feed" ref={feedRef}>
|
||||
{renderMessages()}
|
||||
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
|
||||
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
|
||||
)}
|
||||
<div ref={messagesEnd} style={{ height: '24px' }} />
|
||||
</div>
|
||||
<div className="studio-scroll-btns">
|
||||
<button className="studio-scroll-btn" onClick={() => feedRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} title="Remonter">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6"/></svg>
|
||||
</button>
|
||||
<button className="studio-scroll-btn" onClick={() => messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })} title="Descendre">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="studio-input-area">
|
||||
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
|
||||
@@ -642,9 +778,25 @@ export default function Studio({ api }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="studio-input-hint">
|
||||
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model
|
||||
{t('studio.inputHint')} · /clear /summarize /help /model
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sudoModal && (
|
||||
<div className="shell-modal-overlay" onClick={() => setSudoModal(null)}>
|
||||
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shell-modal-header">Commande bloquée</div>
|
||||
<div className="shell-modal-body">
|
||||
<p style={{ color: 'var(--accent-bright)', fontWeight: 600, marginBottom: 8 }}>L'IA a tenté d'exécuter une commande nécessitant des privilèges administrateur :</p>
|
||||
<pre style={{ background: 'var(--bg)', padding: '10px 12px', borderRadius: 'var(--radius)', fontSize: 12, overflow: 'auto', fontFamily: 'var(--font-mono)' }}>{sudoModal.command}</pre>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 12 }}>La commande a été bloquée. L'IA en a été informée et cherchera une alternative.</p>
|
||||
</div>
|
||||
<div className="shell-modal-footer">
|
||||
<button className="primary" onClick={() => setSudoModal(null)}>Compris</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ const en = {
|
||||
switchWindow: 'Switch window',
|
||||
sendMessage: 'Send message',
|
||||
newLine: 'New line',
|
||||
copy: 'Copy',
|
||||
paste: 'Paste',
|
||||
search: 'Search',
|
||||
zoom: 'Zoom +/−',
|
||||
switchTab: 'Switch tab',
|
||||
nextTab: 'Next tab',
|
||||
runCommand: 'Run command',
|
||||
commandHistory: 'Command history',
|
||||
},
|
||||
|
||||
@@ -16,6 +16,12 @@ const fr = {
|
||||
switchWindow: 'Changer de fen\u00eatre',
|
||||
sendMessage: 'Envoyer le message',
|
||||
newLine: 'Nouvelle ligne',
|
||||
copy: 'Copier',
|
||||
paste: 'Coller',
|
||||
search: 'Rechercher',
|
||||
zoom: 'Zoom +/\u2212',
|
||||
switchTab: 'Changer d\u2019onglet',
|
||||
nextTab: 'Onglet suivant',
|
||||
runCommand: 'Ex\u00e9cuter',
|
||||
commandHistory: 'Historique',
|
||||
},
|
||||
|
||||
@@ -155,6 +155,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
|
||||
|
||||
.content { flex: 1; overflow: hidden; position: relative; }
|
||||
.content > div { position: absolute; inset: 0; overflow: hidden; }
|
||||
.tab-hidden { display: none; }
|
||||
|
||||
.statusbar {
|
||||
@@ -275,8 +276,8 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
|
||||
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
|
||||
.shell-layout { display: flex; height: 100%; }
|
||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.shell-layout { display: flex; height: 100%; overflow: hidden; }
|
||||
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
|
||||
|
||||
.shell-tabs-bar {
|
||||
display: flex; align-items: center; background: var(--bg-surface);
|
||||
@@ -328,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
|
||||
|
||||
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
|
||||
.shell-zoom-badge {
|
||||
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
|
||||
color: var(--accent); background: var(--accent-bg);
|
||||
padding: 2px 6px; border-radius: 3px;
|
||||
border: 1px solid var(--accent-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shell-new-tab-wrapper { position: relative; }
|
||||
.shell-new-tab-btn {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
@@ -381,18 +390,57 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||
|
||||
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
||||
.shell-xterm-instance {
|
||||
position: absolute; inset: 0; padding: 4px;
|
||||
display: block !important;
|
||||
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
|
||||
|
||||
.shell-search-bar {
|
||||
position: absolute; top: 8px; right: 12px; z-index: 20;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 4px 6px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
||||
.shell-search-icon { color: var(--text-tertiary); flex-shrink: 0; }
|
||||
.shell-search-input {
|
||||
width: 200px; font-size: 12px; padding: 3px 6px; border-radius: 4px;
|
||||
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
|
||||
font-family: var(--font-mono); outline: none;
|
||||
}
|
||||
.shell-search-input:focus { border-color: var(--accent); }
|
||||
.shell-search-nav {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: 4px;
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
color: var(--text-tertiary); cursor: pointer; font-size: 12px;
|
||||
padding: 0; transition: all 0.1s;
|
||||
}
|
||||
.shell-search-nav:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-dark); }
|
||||
.shell-search-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: 4px;
|
||||
background: transparent; border: none;
|
||||
color: var(--text-disabled); cursor: pointer; padding: 0;
|
||||
}
|
||||
.shell-search-close:hover { color: var(--accent); }
|
||||
.shell-xterm-instance {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.shell-xterm-instance.active {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.shell-xterm-instance .xterm { height: 100%; }
|
||||
|
||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
.connection-dot.off { background: var(--error); }
|
||||
|
||||
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.shell-tab.ai-tab .shell-tab-name { color: var(--accent); }
|
||||
.shell-tab.ai-tab { border-bottom-color: var(--accent); }
|
||||
|
||||
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
|
||||
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
|
||||
.shell-analyze-btn {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
@@ -410,6 +458,8 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.shell-ai-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
|
||||
.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; }
|
||||
.ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); }
|
||||
.ai-message.user.analysis { border-left-color: var(--info); background: color-mix(in srgb, var(--info) 8%, var(--bg-elevated)); }
|
||||
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
.ai-message.system { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); font-size: 12px; }
|
||||
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
|
||||
@@ -444,6 +494,38 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.shell-code-actions button:last-child { border-right: none; }
|
||||
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
|
||||
.shell-code-actions button.copied { background: var(--accent-bg); color: var(--accent); animation: copy-flash 0.3s ease; }
|
||||
|
||||
.shell-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
|
||||
.shell-mermaid-container svg { max-width: 100%; height: auto; }
|
||||
.shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||
.shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||
|
||||
.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; }
|
||||
.ai-message th { background: var(--bg-surface); padding: 6px 10px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||
.ai-message td { padding: 5px 10px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||
.ai-message tr:nth-child(even) td { background: var(--bg-surface); }
|
||||
|
||||
@keyframes copy-flash {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.shell-analysis-modal {
|
||||
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); width: 720px; max-width: 90vw; max-height: 80vh;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.shell-analysis-modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||
font-weight: 700; font-size: 15px; color: var(--accent);
|
||||
}
|
||||
.shell-analysis-modal-body {
|
||||
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
|
||||
color: var(--text-primary); word-break: break-word;
|
||||
}
|
||||
|
||||
.shell-modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
@@ -521,6 +603,9 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
|
||||
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
|
||||
.provider-card-model { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||
.provider-card-model-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.provider-card-model-value { font-size: 14px; font-weight: 600; font-family: var(--font-mono); color: var(--accent); }
|
||||
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
|
||||
.provider-setup-hint {
|
||||
@@ -594,6 +679,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
@@ -603,7 +689,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
position: relative;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 14px 16px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
display: flex; flex-direction: column; justify-content: center; gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -653,6 +739,25 @@ input::placeholder { color: var(--text-disabled); }
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Consumption */
|
||||
.dash-consumption-list { display: flex; flex-direction: column; gap: 10px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-consumption-provider { display: flex; flex-direction: column; gap: 4px; }
|
||||
.dash-consumption-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.dash-consumption-name {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||
}
|
||||
.dash-consumption-total {
|
||||
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||
}
|
||||
.dash-consumption-days {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
}
|
||||
.dash-consumption-day {
|
||||
font-size: 9px; font-family: var(--font-mono); color: var(--text-tertiary);
|
||||
background: var(--bg-input); padding: 1px 5px; border-radius: 4px;
|
||||
}
|
||||
.dash-consumption-day strong { color: var(--text-secondary); }
|
||||
|
||||
/* Processes */
|
||||
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-proc-row {
|
||||
@@ -668,21 +773,38 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
|
||||
/* Commands */
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-cmd-card .dash-cmd-list { max-height: 220px; }
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
|
||||
.dash-cmd-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 3px 0; overflow: hidden;
|
||||
}
|
||||
.dash-cmd-shell {
|
||||
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
|
||||
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
|
||||
text-transform: uppercase; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface); cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dash-cmd-row:hover { background: var(--accent-bg); }
|
||||
.dash-cmd-left { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.dash-cmd-text {
|
||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
|
||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-primary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.dash-cmd-time { font-size: 9px; color: var(--text-disabled); }
|
||||
.dash-cmd-copy { font-size: 13px; color: var(--text-disabled); flex-shrink: 0; }
|
||||
.dash-cmd-row:hover .dash-cmd-copy { color: var(--accent); }
|
||||
|
||||
.dash-cmd-freq { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
||||
.dash-cmd-freq-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-disabled); letter-spacing: 0.05em; margin-bottom: 2px; }
|
||||
.dash-cmd-freq-row {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
padding: 3px 4px; border-radius: var(--radius-sm);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dash-cmd-freq-row:hover { background: var(--accent-bg); }
|
||||
.dash-cmd-freq-name { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-primary); width: 100px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-cmd-freq-bar-wrap { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
|
||||
.dash-cmd-freq-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
|
||||
.dash-cmd-freq-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); width: 28px; text-align: right; flex-shrink: 0; }
|
||||
|
||||
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
|
||||
/* Services */
|
||||
.dash-services { display: flex; flex-direction: column; gap: 6px; }
|
||||
@@ -763,7 +885,17 @@ input::placeholder { color: var(--text-disabled); }
|
||||
|
||||
/* ── Studio Feed ── */
|
||||
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.studio-feed-scroll-wrap { flex: 1; position: relative; overflow: hidden; }
|
||||
.studio-feed { height: 100%; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.studio-scroll-btns { position: absolute; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 4px; z-index: 10; }
|
||||
.studio-scroll-btn {
|
||||
width: 32px; height: 32px; border-radius: 50%; padding: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.studio-scroll-btn:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); opacity: 1; }
|
||||
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
|
||||
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
|
||||
.feed-item:hover { background: var(--bg-card); }
|
||||
@@ -785,7 +917,7 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
|
||||
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
|
||||
.feed-content { font-size: 14px; line-height: 1.5; color: var(--text-primary); word-break: break-word; }
|
||||
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
|
||||
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
|
||||
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
|
||||
@@ -839,17 +971,39 @@ input::placeholder { color: var(--text-disabled); }
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: hidden; margin: 8px 0;
|
||||
}
|
||||
.studio-code-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.studio-code-block pre { padding: 12px 16px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; overflow-x: auto; color: var(--text-primary); margin: 0; }
|
||||
.studio-code-lang {
|
||||
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary);
|
||||
background: var(--bg-surface); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
background: var(--bg-surface); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.studio-copy-btn {
|
||||
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
|
||||
background: transparent; border: none; border-left: 1px solid var(--border);
|
||||
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.studio-copy-btn:hover { background: var(--accent-bg); color: var(--accent); }
|
||||
.studio-copy-btn.copied { background: var(--accent-bg); color: var(--accent); }
|
||||
|
||||
.studio-mermaid-container { padding: 12px; background: var(--bg); overflow-x: auto; display: flex; justify-content: center; }
|
||||
.studio-mermaid-container svg { max-width: 100%; height: auto; }
|
||||
.studio-mermaid-loading { padding: 12px; text-align: center; color: var(--text-tertiary); font-size: 12px; }
|
||||
.studio-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; }
|
||||
|
||||
.feed-content table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||
.feed-content th { background: var(--bg-surface); padding: 6px 12px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
|
||||
.feed-content td { padding: 5px 12px; border: 1px solid var(--border); color: var(--text-primary); }
|
||||
.feed-content tr:nth-child(even) td { background: var(--bg-surface); }
|
||||
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
|
||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 16px 0 8px; display: block; }
|
||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 6px; display: block; }
|
||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 2px 0; }
|
||||
.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; }
|
||||
.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; }
|
||||
.msg-bullet { display: block; padding-left: 16px; position: relative; margin: 1px 0; }
|
||||
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
|
||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 3px 0; }
|
||||
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; }
|
||||
.msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; }
|
||||
.studio-cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); margin-left: 2px; vertical-align: text-bottom; animation: blink 0.8s step-end infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
@@ -1002,3 +1156,76 @@ input::placeholder { color: var(--text-disabled); }
|
||||
word-break: break-word;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* === XTerm Custom Styling === */
|
||||
/* Styles for xterm.js integrated with Muyue theme */
|
||||
.shell-xterm-instance .xterm {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-viewport {
|
||||
background-color: var(--bg-base) !important;
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-screen {
|
||||
background-color: var(--bg-base);
|
||||
}
|
||||
|
||||
/* Scrollbar styling for xterm */
|
||||
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-dim);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
|
||||
/* Selection styling */
|
||||
.shell-xterm-instance .xterm-selection {
|
||||
background: var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
/* Focus ring styling */
|
||||
.shell-xterm-instance .xterm:focus .xterm-helper-text-container {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Ensure consistent font rendering */
|
||||
.shell-xterm-instance .xterm .xterm-char-measure-element {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
/* Bell animation styling */
|
||||
.shell-xterm-instance .xterm-bell {
|
||||
animation: xterm-bell-flash 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes xterm-bell-flash {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Cursor styling */
|
||||
.shell-xterm-instance .xterm-cursor {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Link styling for web links addon */
|
||||
.shell-xterm-instance .xterm-link {
|
||||
color: var(--accent-light) !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.shell-xterm-instance .xterm-link:hover {
|
||||
color: var(--accent-muted) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user