Compare commits

...

17 Commits

Author SHA1 Message Date
Augustin
e8a289ccf3 feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection
All checks were successful
Beta Release / beta (push) Successful in 1m6s
Replace message-count context windows with token-budget based ones for both
studio and shell. Add /api/ai/task endpoint for background tool
check/install/update. Enhance sudo blocking to catch piped/chained elevation
commands. Add SSH password support via sshpass and connection editing UI.
Remove realTokens persistence in favor of consumption tracking. Bump to 0.4.1.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-26 20:06:20 +02:00
Augustin
12000e523c fix: token persistence, context windows, CSS tables/bullets/hr, image attachments
All checks were successful
Beta Release / beta (push) Successful in 1m1s
- Fix token count reset on app restart: persist realTokens in conversation.json
- Fix token/context window values: Studio 150K (summarize at 120K), Terminal 100K
- Fix table rendering in terminal tab: correct thead/tbody display model
- Fix copy button always top-right in Studio code blocks
- Add markdown horizontal rule (---) support in Studio and Terminal
- Fix bullet list double dot: remove CSS ::before duplicate bullet point
- Add image attachments support (VLM description, file mentions @file.ext)
- Add sudo detection with cache (sync.Once)
- Fix message content serialization (TextContent wrapper)
- Guide AI to use read_file instead of cat in studio prompt

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-26 15:19:26 +02:00
Augustin
cb3d35756a feat: terminal sudo blocking, token tracking, mermaid & consumption UI
All checks were successful
Beta Release / beta (push) Successful in 1m3s
- Block sudo/doas commands when not running as root
- Add real token counting from API responses
- Track and display consumption by provider/day
- Add Mermaid diagram rendering in Shell and Studio
- Add copy-to-clipboard buttons for code blocks
- Support tables in AI message rendering
- Update system prompt with context (date, time, root status)

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-26 12:43:15 +02:00
Augustin
0830e64ae6 fix(shell,config): terminal font size, AI tools, provider keys
All checks were successful
Beta Release / beta (push) Successful in 56s
- Fix terminal default fontSize from 6px to 14px across all references
- Add terminal tool to shell AI via ChatEngine with tool_call streaming
- Fix provider key detection (apiKey → api_key, baseURL → base_url)
- Add mimo provider migration and validation endpoint
- Bump version to 0.4.0

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-25 22:03:35 +02:00
Augustin
9a218b1904 fix(shell): set default terminal fontSize to 6px
All checks were successful
Beta Release / beta (push) Successful in 49s
All fallbacks were still using 12px. User confirmed 6px is the
correct baseline on their display.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:41:47 +02:00
Augustin
399b845e14 fix(shell): default fontSize 10px and init new tabs immediately
All checks were successful
Beta Release / beta (push) Successful in 48s
- Base font size reduced from 12px to 10px
- New tabs now initialize directly when added (was waiting for
  tab switch because the MutationObserver only fired on visibility
  changes, not on tab additions)
- Zoom level applied to newly created terminals

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:33:49 +02:00
Augustin
436d5c6149 feat(shell): add Ctrl+/- zoom and display all shortcuts in footer
All checks were successful
Beta Release / beta (push) Successful in 48s
- Ctrl+/Ctrl-/Ctrl+0 to zoom in/out/reset terminal font size
- Zoom badge indicator in tab bar
- All shell shortcuts now shown in statusbar footer
- Added i18n labels for search, zoom, switch tab, next tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:28:15 +02:00
Augustin
5a9edc076e fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility
All checks were successful
Beta Release / beta (push) Successful in 49s
The addon-web-links registerApcHandler API requires xterm >= 6.1.0.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:19:12 +02:00
Augustin
5bdc7a6429 fix(shell): enable allowProposedApi for Unicode11 addon
All checks were successful
Beta Release / beta (push) Successful in 48s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:16:23 +02:00
Augustin
5a0480bae0 fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution
All checks were successful
Beta Release / beta (push) Successful in 49s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:12:05 +02:00
Augustin
80de4dd523 feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image)
Some checks failed
Beta Release / beta (push) Failing after 20s
Add xterm addons from Vercel Hyper terminal: WebGL renderer with DOM
fallback, search bar (Ctrl+Shift+F), Unicode 11 grapheme support, and
inline image protocol. All existing functionality preserved.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:10:15 +02:00
Augustin
de52f4ebd6 fix(shell): restore all missing imports, constants, and utility functions
All checks were successful
Beta Release / beta (push) Successful in 49s
- Restore xterm imports (Terminal, FitAddon, WebLinksAddon)
- Restore all lucide-react icons (Globe, X, Plus, ChevronDown, etc.)
- Restore module-level constants (AI_TAB_ID, MAX_TABS, SHELL_MAX_TOKENS,
  TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY)
- Restore renderContent() and formatText() utility functions
- Add @xterm/xterm CSS import
- Remove duplicate constants from inside Shell component

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:02:36 +02:00
Augustin
98ff0dd578 fix(shell): add missing Monitor import from lucide-react
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:56:32 +02:00
Augustin
9a1ff6e8dc fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants
All checks were successful
Beta Release / beta (push) Successful in 48s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:53:38 +02:00
Augustin
034b9ee0e4 fix(shell): add missing useI18n import
All checks were successful
Beta Release / beta (push) Successful in 45s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:51:54 +02:00
Augustin
c1b1fc653f fix(shell): remove stray 'impo' typo causing ReferenceError
All checks were successful
Beta Release / beta (push) Successful in 44s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:50:12 +02:00
Augustin
50ca75180c fix(terminal): improve dimensions handling and add system theme for xterm
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 21:43:10 +02:00
32 changed files with 4804 additions and 361 deletions

1073
CRUSH_ARCHITECTURE_REPORT.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,48 @@ package agent
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
) )
var (
sudoCache bool
sudoCacheSet bool
sudoCacheOnce sync.Once
)
func NeedsSudoPassword() bool {
sudoCacheOnce.Do(func() {
if os.Geteuid() == 0 {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := exec.CommandContext(ctx, "sudo", "-n", "true").Run()
sudoCacheSet = true
sudoCache = err != nil
} else {
sudoCache = true
sudoCacheSet = true
}
})
return sudoCache
}
type TerminalParams struct { type TerminalParams struct {
Command string `json:"command" description:"The shell command to execute"` Command string `json:"command" description:"The shell command to execute"`
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds (default 60, max 300)"` 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) { func NewTerminalTool() (*ToolDefinition, error) {
return NewTool("terminal", 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.", "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 +53,39 @@ func NewTerminalTool() (*ToolDefinition, error) {
return TextErrorResponse("command is required"), nil return TextErrorResponse("command is required"), nil
} }
if NeedsSudoPassword() {
trimmed := strings.TrimSpace(p.Command)
lower := strings.ToLower(trimmed)
prefixBlocked := strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ")
anywhereBlocked := false
blockedCmd := ""
if !prefixBlocked {
for _, kw := range []string{"sudo", "doas", "run0", "pkexec"} {
for _, pattern := range []string{" " + kw + " ", "|" + kw + " ", ";" + kw + " ", "&&" + kw + " ", "||" + kw + " ", "`" + kw + " ", "$(" + kw + " "} {
if strings.Contains(lower, pattern) {
anywhereBlocked = true
blockedCmd = kw
break
}
}
if anywhereBlocked {
break
}
}
}
if prefixBlocked || anywhereBlocked {
elevCmd := blockedCmd
if prefixBlocked {
elevCmd = strings.Fields(trimmed)[0]
}
return ToolResponse{
Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). Passwordless sudo is not available. 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, elevCmd),
IsError: true,
Meta: map[string]string{"sudo_blocked": "true", "command": trimmed},
}, nil
}
}
timeout := time.Duration(p.Timeout) * time.Second timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 { if timeout == 0 {
timeout = 60 * time.Second timeout = 60 * time.Second

View File

@@ -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. 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 ## Environnement
Muyue gère : Muyue gère :
@@ -13,32 +23,71 @@ Muyue gère :
## Outils disponibles ## 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.) <tool_strategy>
- **crush_run** : Déléguer une tâche complexe à l'agent Crush (édition de fichiers, refactoring, debug) - **Recherche avant action** — Utilise `search_files`, `grep_content`, `read_file` avant de supposer quoi que ce soit sur l'état du système
- **read_file** : Lire le contenu d'un fichier - **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
- **list_files** : Lister les fichiers d'un répertoire - **Lecture de fichiers** — Utilise TOUJOURS `read_file` pour lire le contenu d'un fichier. N'utilise PAS `terminal` avec `cat` pour lire des fichiers — `read_file` est plus rapide, plus précis, et consomme moins de tokens
- **search_files** : Chercher des fichiers par motif (glob) - **Parallélisme** — Lance plusieurs appels d'outils en parallèle quand les opérations sont indépendantes
- **grep_content** : Chercher du texte dans le contenu des fichiers - **Troncature** — Si un résultat d'outil dépasse 2000 caractères, résume les points clés au lieu de tout afficher
- **get_config** : Lire la configuration Muyue - **Une chose à la fois** — Sauf si les opérations sont indépendantes, exécute séquentiellement
- **set_provider** : Configurer un fournisseur IA </tool_strategy>
- **manage_ssh** : Gérer les connexions SSH
- **web_fetch** : Récupérer le contenu d'une URL
## 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. <error_recovery>
2. **Sois concis** — Pas de préambule, pas de blabla. Réponse directe. 1. Lis le message d'erreur complet
3. **Une chose à la fois** — N'appelle pas plusieurs outils simultanément sauf si c'est nécessaire. 2. Comprends la cause racine
4. **Gère les erreurs** — Si un outil échoue, essaie une approche différente avant de le dire à l'utilisateur. 3. Essaie une approche différente (pas la même)
5. **Ne devine pas** — Si tu n'as pas assez d'informations, utilise les outils pour les obtenir (lire un fichier, chercher, etc.) 4. Cherche du code similaire qui fonctionne
6. **Confidentialité** — Ne révèle jamais les clés API, mots de passe ou informations sensibles dans tes réponses. 5. Applique un correctif ciblé
7. **Langue** — Réponds dans la même langue que l'utilisateur. 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 ## Format des réponses
- Code : utilise des blocs markdown - **Code** : blocs markdown avec le langage spécifié
- Résultats d'outils : résume les points clés, ne colle pas des milliers de lignes - **Résultats d'outils** : résume les points clés, max 2000 caractères, ne copie pas des milliers de lignes
- Erreurs : explique clairement et propose une solution - **Erreurs** : explique clairement la cause et propose une solution concrète
- Succès : confirme brièvement ce qui a été fait - **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.

View File

@@ -21,6 +21,7 @@ type ChatEngine struct {
tools json.RawMessage tools json.RawMessage
onChunk func(map[string]interface{}) onChunk func(map[string]interface{})
stream bool stream bool
TotalTokens int
} }
// NewChatEngine creates a new ChatEngine instance. // NewChatEngine creates a new ChatEngine instance.
@@ -71,6 +72,10 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
return finalContent, allToolCalls, allToolResults, err return finalContent, allToolCalls, allToolResults, err
} }
if resp.Usage.TotalTokens > 0 {
ce.TotalTokens += resp.Usage.TotalTokens
}
choice := resp.Choices[0] choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content) content := cleanThinkingTags(choice.Message.Content)
@@ -87,7 +92,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
assistantMsg := orchestrator.Message{ assistantMsg := orchestrator.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: orchestrator.TextContent(content),
ToolCalls: choice.Message.ToolCalls, ToolCalls: choice.Message.ToolCalls,
} }
messages = append(messages, assistantMsg) messages = append(messages, assistantMsg)
@@ -123,6 +128,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
"content": result.Content, "content": result.Content,
"is_error": result.IsError, "is_error": result.IsError,
} }
if result.Meta != nil {
for k, v := range result.Meta {
resultData[k] = v
}
}
allToolResults = append(allToolResults, map[string]interface{}{ allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID, "tool_call_id": tc.ID,
"name": tc.Function.Name, "name": tc.Function.Name,
@@ -137,7 +147,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
messages = append(messages, orchestrator.Message{ messages = append(messages, orchestrator.Message{
Role: "tool", Role: "tool",
Content: result.Content, Content: orchestrator.TextContent(result.Content),
ToolCallID: tc.ID, ToolCallID: tc.ID,
Name: tc.Function.Name, Name: tc.Function.Name,
}) })
@@ -149,6 +159,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
return finalContent, allToolCalls, allToolResults, nil 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. // RunNonStream executes chat without streaming content to client.
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) { func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
var finalContent string var finalContent string
@@ -159,6 +174,10 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
return finalContent, err return finalContent, err
} }
if resp.Usage.TotalTokens > 0 {
ce.TotalTokens += resp.Usage.TotalTokens
}
choice := resp.Choices[0] choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content) content := cleanThinkingTags(choice.Message.Content)
@@ -172,7 +191,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
assistantMsg := orchestrator.Message{ assistantMsg := orchestrator.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: orchestrator.TextContent(content),
ToolCalls: choice.Message.ToolCalls, ToolCalls: choice.Message.ToolCalls,
} }
messages = append(messages, assistantMsg) messages = append(messages, assistantMsg)
@@ -194,7 +213,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
messages = append(messages, orchestrator.Message{ messages = append(messages, orchestrator.Message{
Role: "tool", Role: "tool",
Content: result.Content, Content: orchestrator.TextContent(result.Content),
ToolCallID: tc.ID, ToolCallID: tc.ID,
Name: tc.Function.Name, Name: tc.Function.Name,
}) })

127
internal/api/consumption.go Normal file
View 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)
}

View File

@@ -13,8 +13,8 @@ import (
"github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/config"
) )
const maxTokensApprox = 100000 const contextWindowTokens = 150000
const summarizeThreshold = 80000 const summarizeRatio = 0.80
const charsPerToken = 4 const charsPerToken = 4
type FeedMessage struct { type FeedMessage struct {
@@ -22,6 +22,7 @@ type FeedMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Time string `json:"time"` Time string `json:"time"`
Images []string `json:"images,omitempty"`
} }
type Conversation struct { type Conversation struct {
@@ -126,14 +127,38 @@ func (cs *ConversationStore) Add(role, content string) FeedMessage {
return msg return msg
} }
func (cs *ConversationStore) AddWithImages(role, content string, imageIDs []string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
Images: imageIDs,
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) Clear() { func (cs *ConversationStore) Clear() {
cs.mu.Lock() cs.mu.Lock()
defer cs.mu.Unlock() defer cs.mu.Unlock()
var imageIDs []string
for _, m := range cs.conv.Messages {
imageIDs = append(imageIDs, m.Images...)
}
cs.conv.Messages = []FeedMessage{} cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = "" cs.conv.Summary = ""
cs.conv.CreatedAt = time.Now().Format(time.RFC3339) cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339) cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.save() cs.save()
go cleanupImages(imageIDs)
} }
func (cs *ConversationStore) SetSummary(summary string) { func (cs *ConversationStore) SetSummary(summary string) {
@@ -181,7 +206,7 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
} }
func (cs *ConversationStore) NeedsSummarization() bool { func (cs *ConversationStore) NeedsSummarization() bool {
return cs.ApproxTokenCount() > summarizeThreshold return cs.ApproxTokenCount() > int(float64(contextWindowTokens)*summarizeRatio)
} }
func (cs *ConversationStore) Search(query string) []SearchResult { func (cs *ConversationStore) Search(query string) []SearchResult {

View File

@@ -0,0 +1,172 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"runtime"
"strings"
"time"
"github.com/muyue/muyue/internal/orchestrator"
)
func (s *Server) handleAITask(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Task string `json:"task"`
Tool string `json:"tool,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Task == "" {
writeError(w, "task is required", http.StatusBadRequest)
return
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, "AI not available: "+err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(buildAITaskSystemPrompt())
orb.SetTools(s.shellAgentToolsJSON)
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
messages := []orchestrator.Message{
{Role: "user", Content: orchestrator.TextContent(buildAITaskPrompt(body.Task, body.Tool))},
}
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, "AI task failed: "+err.Error(), http.StatusInternalServerError)
return
}
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
parsed := parseAIJSONResponse(finalContent)
writeJSON(w, map[string]interface{}{
"status": "ok",
"raw": finalContent,
"result": parsed,
"tokens": engine.TotalTokens,
})
}
func buildAITaskSystemPrompt() string {
return fmt.Sprintf(`You are a system administration assistant. You have access to a terminal tool to run commands on the host system.
IMPORTANT RULES:
- You MUST respond ONLY with valid JSON. No markdown, no code fences, no extra text.
- Always run the actual commands needed to complete the task.
- Be thorough: check versions, verify installations, compare with latest releases.
OS: %s/%s
Date: %s
`, runtime.GOOS, runtime.GOARCH, time.Now().Format("2006-01-02"))
}
func buildAITaskPrompt(task, tool string) string {
switch task {
case "check_tools":
return `Check the following tools on this system. For each tool, determine:
1. Is it installed? Run "which <tool>" or "<tool> --version"
2. If installed, what is the current version?
3. What is the latest available version? Check GitHub releases API or official sources.
Tools to check: crush, claude, git, node, npm, pnpm, python3, pip3, uv, go, docker, gh, starship, npx
Run the commands needed, then respond with ONLY this JSON structure (no markdown fences):
{
"tools": [
{"name": "tool_name", "installed": true/false, "version": "x.y.z", "latest": "a.b.c", "needs_update": true/false, "category": "ai|runtime|vcs|devops|prompt"}
]
}`
case "install_tool":
return fmt.Sprintf(`Install the tool "%s" on this system.
Steps:
1. Check if it's already installed: run "which %s" and "%s --version"
2. If not installed, determine the best installation method for this OS
3. Run the installation command
4. Verify the installation succeeded
Respond with ONLY this JSON (no markdown fences):
{
"tool": "%s",
"installed": true/false,
"version": "installed version or empty",
"message": "what was done",
"error": "error message or empty"
}`, tool, tool, tool, tool)
case "update_tool":
return fmt.Sprintf(`Update the tool "%s" to its latest version on this system.
Steps:
1. Check current version: run "%s --version"
2. Find the latest version available
3. Run the update/upgrade command
4. Verify the new version
Respond with ONLY this JSON (no markdown fences):
{
"tool": "%s",
"previous_version": "old version",
"version": "new version",
"updated": true/false,
"message": "what was done",
"error": "error message or empty"
}`, tool, tool, tool)
default:
return task
}
}
func parseAIJSONResponse(content string) interface{} {
cleaned := content
if idx := strings.Index(cleaned, "```json"); idx != -1 {
cleaned = cleaned[idx+7:]
if end := strings.Index(cleaned, "```"); end != -1 {
cleaned = cleaned[:end]
}
} else if idx := strings.Index(cleaned, "```"); idx != -1 {
cleaned = cleaned[idx+3:]
if end := strings.Index(cleaned, "```"); end != -1 {
cleaned = cleaned[:end]
}
}
cleaned = strings.TrimSpace(cleaned)
jsonStart := strings.Index(cleaned, "{")
jsonEnd := strings.LastIndex(cleaned, "}")
if jsonStart != -1 && jsonEnd > jsonStart {
cleaned = cleaned[jsonStart : jsonEnd+1]
}
var result interface{}
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
return map[string]interface{}{
"raw": content,
"error": "failed to parse AI response as JSON",
}
}
return result
}

View File

@@ -1,17 +1,133 @@
package api package api
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"io"
"log"
"net/http" "net/http"
"os"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator" "github.com/muyue/muyue/internal/orchestrator"
) )
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`) var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
var fileMentionRegex = regexp.MustCompile(`@(\S+\.[a-zA-Z0-9]+)`)
type ImageAttachment struct {
Data string `json:"data"`
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
}
func resolveFileMentions(text string) string {
return fileMentionRegex.ReplaceAllStringFunc(text, func(match string) string {
filePath := match[1:]
if strings.HasPrefix(filePath, "~/") {
if home, err := os.UserHomeDir(); err == nil {
filePath = filepath.Join(home, filePath[2:])
}
}
if !filepath.IsAbs(filePath) {
if home, err := os.UserHomeDir(); err == nil {
filePath = filepath.Join(home, filePath)
}
}
data, err := os.ReadFile(filePath)
if err != nil {
return match + fmt.Sprintf(" (erreur: fichier non trouve)")
}
content := string(data)
if len(content) > 50000 {
content = content[:50000] + "\n... (tronque a 50Ko)"
}
return fmt.Sprintf("[Fichier: %s]\n%s\n[Fin du fichier: %s]", filepath.Base(filePath), content, filepath.Base(filePath))
})
}
var vlmClient = &http.Client{Timeout: 60 * time.Second}
func (s *Server) describeImages(images []ImageAttachment) []string {
var apiKey string
for i := range s.config.AI.Providers {
if s.config.AI.Providers[i].Active {
apiKey = s.config.AI.Providers[i].APIKey
break
}
}
if apiKey == "" {
log.Printf("[vlm] no API key found for image description")
return nil
}
descriptions := make([]string, 0, len(images))
for i, img := range images {
desc, err := s.callVLM(apiKey, img)
if err != nil {
log.Printf("[vlm] image %d (%s) failed: %v", i+1, img.Filename, err)
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
} else {
descriptions = append(descriptions, desc)
}
}
return descriptions
}
func (s *Server) callVLM(apiKey string, img ImageAttachment) (string, error) {
payload := map[string]string{
"prompt": "Describe this image in detail. Include all text, UI elements, code, diagrams, or data visible. Be thorough and specific.",
"image_url": img.Data,
}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal vlm request: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.minimax.io/v1/coding_plan/vlm", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create vlm request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := vlmClient.Do(req)
if err != nil {
return "", fmt.Errorf("vlm request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read vlm response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("vlm API error (%d): %s", resp.StatusCode, string(respBody))
}
var result struct {
Content string `json:"content"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("parse vlm response: %w", err)
}
if result.Content == "" {
return "(empty description)", nil
}
return result.Content, nil
}
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
@@ -21,6 +137,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
Message string `json:"message"` Message string `json:"message"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
Images []ImageAttachment `json:"images"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
@@ -30,8 +147,44 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, "no message", http.StatusMethodNotAllowed) writeError(w, "no message", http.StatusMethodNotAllowed)
return return
} }
if len(body.Images) > 3 {
writeError(w, "max 3 images", http.StatusBadRequest)
return
}
s.convStore.Add("user", body.Message) enrichedMessage := resolveFileMentions(body.Message)
var imageIDs []string
if len(body.Images) > 0 {
descriptions := s.describeImages(body.Images)
var imgContext strings.Builder
for i, desc := range descriptions {
imgContext.WriteString(fmt.Sprintf("\n[Image %d (%s): %s]\n", i+1, body.Images[i].Filename, desc))
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
if err != nil {
log.Printf("[images] failed to save %s: %v", body.Images[i].Filename, err)
} else {
imageIDs = append(imageIDs, id)
}
}
enrichedMessage = imgContext.String() + enrichedMessage
}
displayMsg := body.Message
if len(body.Images) > 0 {
imgNames := make([]string, len(body.Images))
for i, img := range body.Images {
imgNames[i] = img.Filename
}
displayMsg += " [" + strings.Join(imgNames, ", ") + "]"
}
if len(imageIDs) > 0 {
s.convStore.AddWithImages("user", displayMsg, imageIDs)
} else {
s.convStore.Add("user", displayMsg)
}
if s.convStore.NeedsSummarization() { if s.convStore.NeedsSummarization() {
s.autoSummarize() s.autoSummarize()
@@ -42,13 +195,23 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusServiceUnavailable) writeError(w, err.Error(), http.StatusServiceUnavailable)
return 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")))
canSudo := !agent.NeedsSudoPassword()
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
if !canSudo {
studioPrompt.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
} else {
studioPrompt.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
}
orb.SetSystemPrompt(studioPrompt.String())
orb.SetTools(s.agentToolsJSON) orb.SetTools(s.agentToolsJSON)
if body.Stream { if body.Stream {
s.handleStreamChat(w, orb, body.Message) s.handleStreamChat(w, orb, enrichedMessage)
} else { } else {
s.handleNonStreamChat(w, orb, body.Message) s.handleNonStreamChat(w, orb, enrichedMessage)
} }
} }
@@ -92,6 +255,8 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
} }
s.convStore.Add("assistant", storeContent) s.convStore.Add("assistant", storeContent)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
sseWriter.Write(map[string]interface{}{"done": "true"}) sseWriter.Write(map[string]interface{}{"done": "true"})
} }
@@ -107,6 +272,9 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
} }
s.convStore.Add("assistant", finalContent) s.convStore.Add("assistant", finalContent)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
writeJSON(w, map[string]string{"content": finalContent}) writeJSON(w, map[string]string{"content": finalContent})
} }
@@ -114,22 +282,50 @@ func cleanThinkingTags(content string) string {
return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, "")) return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, ""))
} }
const contextWindowMessages = 20
func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message { func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message {
history := s.convStore.Get() history := s.convStore.Get()
start := 0
if len(history) > contextWindowMessages { sysPromptTokens := utf8.RuneCountInString(agent.StudioSystemPrompt())/charsPerToken + 50
start = len(history) - contextWindowMessages toolsTokens := utf8.RuneCountInString(string(s.agentToolsJSON)) / charsPerToken
responseMargin := 4000
userMsgTokens := utf8.RuneCountInString(userMessage) / charsPerToken
overhead := sysPromptTokens + toolsTokens + responseMargin + userMsgTokens
available := contextWindowTokens - overhead
if available < 1000 {
available = 1000
} }
messages := make([]orchestrator.Message, 0, len(history[start:])+1) included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
if tokensUsed+msgTokens > available {
break
}
tokensUsed += msgTokens
included++
}
start := len(history) - included
if start < 0 {
start = 0
}
if start > 0 {
log.Printf("[studio] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, contextWindowTokens, included, len(history), start)
}
messages := make([]orchestrator.Message, 0, included+2)
summary := s.convStore.GetSummary() summary := s.convStore.GetSummary()
if summary != "" { if summary != "" && start > 0 {
messages = append(messages, orchestrator.Message{ messages = append(messages, orchestrator.Message{
Role: "system", Role: "system",
Content: "Résumé de la conversation précédente:\n" + summary, Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
}) })
} }
@@ -154,13 +350,13 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
} }
messages = append(messages, orchestrator.Message{ messages = append(messages, orchestrator.Message{
Role: role, Role: role,
Content: content, Content: orchestrator.TextContent(content),
}) })
} }
messages = append(messages, orchestrator.Message{ messages = append(messages, orchestrator.Message{
Role: "user", Role: "user",
Content: userMessage, Content: orchestrator.TextContent(userMessage),
}) })
return messages return messages
@@ -208,8 +404,8 @@ func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"messages": messages, "messages": messages,
"tokens": s.convStore.ApproxTokenCount(), "tokens": s.convStore.ApproxTokenCount(),
"max_tokens": maxTokensApprox, "max_tokens": contextWindowTokens,
"summarize_at": summarizeThreshold, "summarize_at": int(float64(contextWindowTokens) * summarizeRatio),
"summary": s.convStore.GetSummary(), "summary": s.convStore.GetSummary(),
}) })
} }

View File

@@ -5,7 +5,21 @@ import (
"net/http" "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{}) { func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)

View File

@@ -187,6 +187,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request)
switch body.Name { switch body.Name {
case "minimax": case "minimax":
baseURL = "https://api.minimax.io/v1" baseURL = "https://api.minimax.io/v1"
case "mimo":
baseURL = "https://token-plan-ams.xiaomimimo.com/v1"
case "openai": case "openai":
baseURL = "https://api.openai.com/v1" baseURL = "https://api.openai.com/v1"
case "anthropic": case "anthropic":

View File

@@ -12,6 +12,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
@@ -24,7 +25,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
"name": version.Name, "name": version.Name,
"version": version.Version, "version": version.Version,
"author": version.Author, "author": version.Author,
"sudo": os.Geteuid() == 0, "sudo": !agent.NeedsSudoPassword(),
}) })
} }
@@ -534,6 +535,39 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
q.Healthy = p.APIKey != "" q.Healthy = p.APIKey != ""
if p.APIKey == "" { if p.APIKey == "" {
q.Error = "no API key" 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": case "claude", "anthropic":
// Claude Code n'a pas d'API externe, vérifier l'installation // Claude Code n'a pas d'API externe, vérifier l'installation
@@ -551,6 +585,15 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"providers": results}) 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) { func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
type cmdEntry struct { type cmdEntry struct {

View File

@@ -1,13 +1,17 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
"strings" "strings"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator" "github.com/muyue/muyue/internal/orchestrator"
@@ -51,81 +55,88 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
return return
} }
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req)) orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
orb.SetTools(s.shellAgentToolsJSON)
if req.Stream { if req.Stream {
s.handleShellChatStreamV2(w, orb) s.handleShellChatStream(w, orb)
} else { } 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 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. sb.WriteString(shellSystemPromptBase)
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
`)
analysis := LoadSystemAnalysis() analysis := LoadSystemAnalysis()
if analysis != "" { if analysis != "" {
sb.WriteString("=== ANALYSE SYSTÈME ACTUELLE ===\n") sb.WriteString("<system_context>\n")
sb.WriteString(analysis) 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)) sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
if hostname, err := os.Hostname(); err == nil { if hostname, err := os.Hostname(); err == nil {
sb.WriteString("Hostname: " + hostname + "\n") sb.WriteString("Hostname: " + hostname + "\n")
} }
if user := os.Getenv("USER"); user != "" {
sb.WriteString("User: " + user + "\n")
}
canSudo := !agent.NeedsSudoPassword()
sb.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
if canSudo {
sb.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
} else {
sb.WriteString("⚠️ Session sans sudo sans mot de passe — 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() 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) SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher) flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w) sseWriter := NewSSEWriter(w)
// Rebuild history into orchestrator ctx := context.Background()
history := s.shellConvStore.Get() messages := s.buildShellContextMessages()
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})
}
lastUserMsg := history[len(history)-1].Content engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.OnChunk(func(data map[string]interface{}) {
var finalContent string if data == nil {
result, err := orb.SendStream(lastUserMsg, func(chunk string) { return
finalContent = chunk }
sseWriter.Write(map[string]interface{}{"content": chunk}) sseWriter.Write(data)
if canFlush { if canFlush {
flusher.Flush() flusher.Flush()
} }
}) })
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
if err != nil { if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()}) sseWriter.Write(map[string]interface{}{"error": err.Error()})
return return
} }
content := result storeContent := finalContent
if content == "" { if len(allToolCalls) > 0 {
content = finalContent 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.Add("assistant", cleanThinkingTags(content)) s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
sseWriter.Write(map[string]interface{}{ sseWriter.Write(map[string]interface{}{
"done": "true", "done": "true",
@@ -133,30 +144,97 @@ func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrato
}) })
} }
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) { func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
history := s.shellConvStore.Get() ctx := context.Background()
for _, m := range history[:len(history)-1] { messages := s.buildShellContextMessages()
if m.Role == "system" {
continue
}
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
lastUserMsg := history[len(history)-1].Content engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
finalContent, err := engine.RunNonStream(ctx, messages)
result, err := orb.Send(lastUserMsg)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
s.shellConvStore.Add("assistant", cleanThinkingTags(result)) s.shellConvStore.Add("assistant", finalContent)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
writeJSON(w, map[string]interface{}{ writeJSON(w, map[string]interface{}{
"content": result, "content": finalContent,
"tokens": s.shellConvStore.ApproxTokens(), "tokens": s.shellConvStore.ApproxTokens(),
}) })
} }
func (s *Server) buildShellContextMessages() []orchestrator.Message {
history := s.shellConvStore.Get()
sysTokens := utf8.RuneCountInString(shellSystemPromptBase) / charsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
sysTokens += utf8.RuneCountInString(analysis) / charsPerToken
}
sysTokens += 100
toolsTokens := utf8.RuneCountInString(string(s.shellAgentToolsJSON)) / charsPerToken
responseMargin := 4000
overhead := sysTokens + toolsTokens + responseMargin
available := shellMaxTokens - overhead
if available < 1000 {
available = 1000
}
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
if tokensUsed+msgTokens > available {
break
}
tokensUsed += msgTokens
included++
}
start := len(history) - included
if start < 0 {
start = 0
}
if start > 0 {
log.Printf("[shell] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, shellMaxTokens, included, len(history), start)
}
messages := make([]orchestrator.Message, 0, included)
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: orchestrator.TextContent(content),
})
}
return messages
}
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) { func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" { if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed) writeError(w, "GET only", http.StatusMethodNotAllowed)
@@ -252,15 +330,33 @@ func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
} }
orb.SetSystemPrompt(agent.StudioSystemPrompt()) orb.SetSystemPrompt(agent.StudioSystemPrompt())
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes sur le système de l'utilisateur. analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes et génère un rapport structuré en markdown.
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
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() ` + sysInfo.String()

104
internal/api/image_cache.go Normal file
View File

@@ -0,0 +1,104 @@
package api
import (
"encoding/base64"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/muyue/muyue/internal/config"
)
var imageDir string
func init() {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
imageDir = filepath.Join(dir, "images")
os.MkdirAll(imageDir, 0755)
}
var imageCounter uint64
func saveImage(dataURI, filename, mimeType string) (string, error) {
parts := strings.SplitN(dataURI, ",", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid data URI")
}
encoded := parts[1]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("base64 decode: %w", err)
}
id := fmt.Sprintf("%d-%d", time.Now().UnixMilli(), atomic.AddUint64(&imageCounter, 1))
ext := ".png"
switch mimeType {
case "image/jpeg":
ext = ".jpg"
case "image/webp":
ext = ".webp"
}
filePath := filepath.Join(imageDir, id+ext)
if err := os.WriteFile(filePath, decoded, 0600); err != nil {
return "", fmt.Errorf("write image: %w", err)
}
return id + ext, nil
}
func imagePath(id string) string {
return filepath.Join(imageDir, filepath.Base(id))
}
func cleanupImages(ids []string) {
for _, id := range ids {
p := imagePath(id)
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
log.Printf("[images] failed to delete %s: %v", id, err)
}
}
}
func (s *Server) handleServeImage(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/images/")
if id == "" {
writeError(w, "image id required", http.StatusBadRequest)
return
}
filePath := imagePath(id)
if _, err := os.Stat(filePath); err != nil {
writeError(w, "image not found", http.StatusNotFound)
return
}
ext := strings.ToLower(filepath.Ext(id))
switch ext {
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".webp":
w.Header().Set("Content-Type", "image/webp")
default:
w.Header().Set("Content-Type", "application/octet-stream")
}
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, filePath)
}

View File

@@ -18,8 +18,11 @@ type Server struct {
mux *http.ServeMux mux *http.ServeMux
convStore *ConversationStore convStore *ConversationStore
shellConvStore *ShellConvStore shellConvStore *ShellConvStore
consumption *consumptionStore
agentRegistry *agent.Registry agentRegistry *agent.Registry
agentToolsJSON json.RawMessage agentToolsJSON json.RawMessage
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine workflowEngine *workflow.Engine
} }
@@ -48,10 +51,19 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.scanResult = scanner.ScanSystem() s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore() s.convStore = NewConversationStore()
s.shellConvStore = NewShellConvStore() s.shellConvStore = NewShellConvStore()
s.consumption = newConsumptionStore()
s.agentRegistry = agent.DefaultRegistry() s.agentRegistry = agent.DefaultRegistry()
tools := s.agentRegistry.OpenAITools() tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools) toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON) 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.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
s.routes() s.routes()
return s return s
@@ -84,6 +96,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme) s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider) s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate) s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/images/", s.handleServeImage)
s.mux.HandleFunc("/api/chat", s.handleChat) s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory) s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear) s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
@@ -120,14 +133,16 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport) s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport) s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus) s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
s.mux.HandleFunc("/api/ai/task", s.handleAITask)
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota) 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/recent-commands", s.handleRecentCommands)
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses) s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics) s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
} }
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/ws/") { if strings.HasPrefix(r.URL.Path, "/api/ws/") || strings.HasPrefix(r.URL.Path, "/api/images/") {
s.mux.ServeHTTP(w, r) s.mux.ServeHTTP(w, r)
return return
} }

View File

@@ -14,6 +14,63 @@ import (
const shellMaxTokens = 100000 const shellMaxTokens = 100000
const shellCharsPerToken = 4 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 { type ShellMessage struct {
ID string `json:"id"` ID string `json:"id"`
Role string `json:"role"` Role string `json:"role"`
@@ -92,6 +149,10 @@ func (s *ShellConvStore) ApproxTokens() int {
for _, m := range s.msgs { for _, m := range s.msgs {
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
} }
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
total += utf8.RuneCountInString(analysis) / shellCharsPerToken
}
return total return total
} }

View File

@@ -76,6 +76,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
Port int `json:"port"` Port int `json:"port"`
User string `json:"user"` User string `json:"user"`
KeyPath string `json:"key_path"` KeyPath string `json:"key_path"`
Password string `json:"password"`
} }
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil { if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"}) conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
@@ -98,7 +99,16 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
} }
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host)) sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
if sshConf.Password != "" {
sshpassPath, err := exec.LookPath("sshpass")
if err == nil {
cmd = exec.Command(sshpassPath, append([]string{"-p", sshConf.Password}, append([]string{"-e"}, sshArgs...)...)...)
} else {
cmd = exec.Command("ssh", sshArgs...) cmd = exec.Command("ssh", sshArgs...)
}
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else { } else {
shell := strings.TrimSpace(initMsg.Data) shell := strings.TrimSpace(initMsg.Data)
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell) log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
@@ -227,6 +237,7 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
Port int `json:"port"` Port int `json:"port"`
User string `json:"user"` User string `json:"user"`
KeyPath string `json:"key_path"` KeyPath string `json:"key_path"`
Password string `json:"password"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
@@ -240,12 +251,32 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
body.Port = 22 body.Port = 22
} }
for i, c := range s.config.Terminal.SSH {
if c.Name == body.Name {
s.config.Terminal.SSH[i] = config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Password: body.Password,
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
return
}
}
conn := config.SSHConnection{ conn := config.SSHConnection{
Name: body.Name, Name: body.Name,
Host: body.Host, Host: body.Host,
Port: body.Port, Port: body.Port,
User: body.User, User: body.User,
KeyPath: body.KeyPath, KeyPath: body.KeyPath,
Password: body.Password,
} }
if s.config.Terminal.SSH == nil { if s.config.Terminal.SSH == nil {
s.config.Terminal.SSH = []config.SSHConnection{} s.config.Terminal.SSH = []config.SSHConnection{}

View File

@@ -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 { func GetTerminalTheme(name string) TerminalTheme {
if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok { if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok {
return theme return theme
@@ -206,6 +222,8 @@ func Load() (*MuyueConfig, error) {
} }
} }
migrateProviders(&cfg)
return &cfg, nil return &cfg, nil
} }
@@ -271,7 +289,7 @@ func Default() *MuyueConfig {
}, },
{ {
Name: "mimo", Name: "mimo",
Model: "MiMo-V2.5-Pro", Model: "mimo-v2.5-pro",
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1", BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
Active: false, Active: false,
}, },
@@ -303,6 +321,7 @@ func Default() *MuyueConfig {
cfg.Terminal.CustomPrompt = true cfg.Terminal.CustomPrompt = true
cfg.Terminal.PromptTheme = "zerotwo" cfg.Terminal.PromptTheme = "zerotwo"
cfg.Terminal.FontSize = 14
return cfg return cfg
} }

View File

@@ -20,14 +20,42 @@ var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
const maxHistorySize = 100 const maxHistorySize = 100
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
}
type Message struct { type Message struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content,omitempty"` Content json.RawMessage `json:"content,omitempty"`
ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"` ToolCalls []ToolCallMsg `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
} }
func TextContent(s string) json.RawMessage {
b, _ := json.Marshal(s)
return b
}
func PartsContent(parts []ContentPart) json.RawMessage {
b, _ := json.Marshal(parts)
return b
}
func (m Message) ContentString() string {
var s string
if json.Unmarshal(m.Content, &s) == nil {
return s
}
return string(m.Content)
}
type ToolCallMsg struct { type ToolCallMsg struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
@@ -143,7 +171,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
o.histMu.Lock() o.histMu.Lock()
o.history = append(o.history, Message{ o.history = append(o.history, Message{
Role: "user", Role: "user",
Content: userMessage, Content: TextContent(userMessage),
}) })
if len(o.history) > maxHistorySize { if len(o.history) > maxHistorySize {
@@ -152,7 +180,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
messages := make([]Message, 0, len(o.history)+1) messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" { if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: o.systemPrompt}) messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
} }
messages = append(messages, o.history...) messages = append(messages, o.history...)
@@ -173,7 +201,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
o.histMu.Lock() o.histMu.Lock()
o.history = append(o.history, Message{ o.history = append(o.history, Message{
Role: "assistant", Role: "assistant",
Content: content, Content: TextContent(content),
}) })
_ = providerName _ = providerName
o.histMu.Unlock() o.histMu.Unlock()
@@ -185,7 +213,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
o.histMu.Lock() o.histMu.Lock()
o.history = append(o.history, Message{ o.history = append(o.history, Message{
Role: "user", Role: "user",
Content: userMessage, Content: TextContent(userMessage),
}) })
if len(o.history) > maxHistorySize { if len(o.history) > maxHistorySize {
@@ -194,7 +222,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
messages := make([]Message, 0, len(o.history)+1) messages := make([]Message, 0, len(o.history)+1)
if o.systemPrompt != "" { if o.systemPrompt != "" {
messages = append(messages, Message{Role: "system", Content: o.systemPrompt}) messages = append(messages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
} }
messages = append(messages, o.history...) messages = append(messages, o.history...)
@@ -273,7 +301,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
o.histMu.Lock() o.histMu.Lock()
o.history = append(o.history, Message{ o.history = append(o.history, Message{
Role: "assistant", Role: "assistant",
Content: content, Content: TextContent(content),
}) })
o.histMu.Unlock() o.histMu.Unlock()
@@ -283,7 +311,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) { func (o *Orchestrator) SendWithTools(messages []Message) (*ChatResponse, error) {
fullMessages := make([]Message, 0, len(messages)+1) fullMessages := make([]Message, 0, len(messages)+1)
if o.systemPrompt != "" { if o.systemPrompt != "" {
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt}) fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
} }
fullMessages = append(fullMessages, messages...) fullMessages = append(fullMessages, messages...)
@@ -314,7 +342,7 @@ type ChunkCallback func(content string, toolCalls []ToolCallMsg)
func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) { func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCallback) (*ChatResponse, error) {
fullMessages := make([]Message, 0, len(messages)+1) fullMessages := make([]Message, 0, len(messages)+1)
if o.systemPrompt != "" { if o.systemPrompt != "" {
fullMessages = append(fullMessages, Message{Role: "system", Content: o.systemPrompt}) fullMessages = append(fullMessages, Message{Role: "system", Content: TextContent(o.systemPrompt)})
} }
fullMessages = append(fullMessages, messages...) fullMessages = append(fullMessages, messages...)

View File

@@ -7,7 +7,7 @@ import (
const ( const (
Name = "muyue" Name = "muyue"
Version = "0.3.5" Version = "0.4.1"
Author = "La Légion de Muyue" Author = "La Légion de Muyue"
) )

View File

@@ -27,7 +27,7 @@ func (p *Planner) GeneratePlan(ctx context.Context, goal string) ([]Step, error)
prompt := buildPlanPrompt(goal) prompt := buildPlanPrompt(goal)
messages := []orchestrator.Message{ messages := []orchestrator.Message{
{Role: "user", Content: prompt}, {Role: "user", Content: orchestrator.TextContent(prompt)},
} }
resp, err := p.orchestrator.SendWithTools(messages) resp, err := p.orchestrator.SendWithTools(messages)
@@ -159,14 +159,18 @@ func parsePlanResponse(content string) ([]Step, error) {
return steps, nil 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: RÈGLES :
1. Comprends l'objectif de l'utilisateur 1. Analyse l'objectif → identifie les outils → décompose en étapes
2. Identifie les outils nécessaires 2. Chaque étape : {"name": string, "tool": string, "args": object}
3. Décompose en étapes logiques 3. Max 10 étapes par plan
4. Spécifie les paramètres de chaque outil 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
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

1301
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,14 @@
}, },
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@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/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", "lucide-react": "^1.8.0",
"mermaid": "^11.14.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
}, },

View File

@@ -38,6 +38,7 @@ const api = {
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }), importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
getDashboardStatus: () => request('/dashboard/status'), getDashboardStatus: () => request('/dashboard/status'),
getProvidersQuota: () => request('/providers/quota'), getProvidersQuota: () => request('/providers/quota'),
getProvidersConsumption: () => request('/providers/consumption'),
getRecentCommands: () => request('/recent-commands'), getRecentCommands: () => request('/recent-commands'),
getRunningProcesses: () => request('/running-processes'), getRunningProcesses: () => request('/running-processes'),
getSystemMetrics: () => request('/system/metrics'), getSystemMetrics: () => request('/system/metrics'),
@@ -48,6 +49,7 @@ const api = {
applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }), applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }),
validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }), validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }),
runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }), runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }),
aiTask: (task, tool) => request('/ai/task', { method: 'POST', body: JSON.stringify({ task, tool: tool || '' }) }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }), runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
getTerminalSessions: () => request('/terminal/sessions'), getTerminalSessions: () => request('/terminal/sessions'),
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }), addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
@@ -61,15 +63,15 @@ const api = {
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }), clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }), analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
getShellAnalysis: () => request('/shell/analysis'), getShellAnalysis: () => request('/shell/analysis'),
sendChat: (message, stream = true, onChunk, signal) => { sendChat: (message, stream = true, onChunk, signal, images = []) => {
if (!stream) { if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false, images }) })
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fetch(`${API_BASE}/chat`, { fetch(`${API_BASE}/chat`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stream: true }), body: JSON.stringify({ message, stream: true, images }),
signal, signal,
}).then(async (res) => { }).then(async (res) => {
if (!res.ok) { if (!res.ok) {
@@ -141,7 +143,11 @@ const api = {
if (data.error) { reject(new Error(data.error)); return } if (data.error) { reject(new Error(data.error)); return }
if (data.done) { resolve({ content: full, tokens: data.tokens }); return } if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
if (data.content) { 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) if (onChunk) onChunk(full, data)
} }
} catch {} } catch {}

View File

@@ -94,6 +94,8 @@ export default function App() {
shell: [ shell: [
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') }, { 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}+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.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') }, { keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
], ],
@@ -144,13 +146,13 @@ export default function App() {
<main className="content"> <main className="content">
<div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div> <div className={activeTab === 'dash' ? '' : 'tab-hidden'}><Dashboard api={api} refreshRef={dashRefreshRef} /></div>
<div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div> <div className={activeTab === 'studio' ? '' : 'tab-hidden'}><Studio api={api} /></div>
<div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} /></div> <div className={activeTab === 'shell' ? '' : 'tab-hidden'}><Shell api={api} isSudo={isSudo} /></div>
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div> <div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
</main> </main>
<footer className="statusbar"> <footer className="statusbar">
<div className="statusbar-left"> <div className="statusbar-left">
{isSudo && <span className="statusbar-sudo"> ROOT</span>} {isSudo && <span className="statusbar-sudo"> SUDO</span>}
{activeTab === 'dash' && ( {activeTab === 'dash' && (
<span className="statusbar-shortcut"> <span className="statusbar-shortcut">
<kbd>{layout.keys.ctrl}+R</kbd> refresh <kbd>{layout.keys.ctrl}+R</kbd> refresh

View File

@@ -49,27 +49,79 @@ export default function Config({ api }) {
const handleCheckUpdates = async () => { const handleCheckUpdates = async () => {
setChecking(true) setChecking(true)
try { try {
await api.runScan() const d = await api.aiTask('check_tools')
const d = await api.getUpdates() const result = d.result
setUpdates(d.updates || []) if (result && result.tools) {
const td = await api.getTools() const aiTools = result.tools
setTools(td.tools || []) const newUpdates = aiTools.filter(t => t.installed).map(t => ({
tool: t.name,
current: t.version || '',
latest: t.latest || '',
needsUpdate: t.needs_update || false,
error: t.error || '',
}))
const newTools = aiTools.map(t => ({
name: t.name,
installed: t.installed,
version: t.version || '',
category: t.category || '',
}))
setUpdates(newUpdates)
setTools(newTools)
showToast(t('config.upToDate')) showToast(t('config.upToDate'))
} else {
showToast(t('config.error'))
}
} catch (err) { } catch (err) {
showToast(`${t('config.error')}: ${err.message}`) showToast(`${t('config.error')}: ${err.message}`)
} }
setChecking(false) setChecking(false)
} }
const handleUpdateTool = (tool) => { const handleUpdateTool = async (tool) => {
window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) setUpdating(tool)
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } })) try {
const d = await api.aiTask('update_tool', tool)
if (d.result && d.result.updated) {
showToast(`${tool} ${t('config.updated') || 'mis à jour'}`)
} else {
showToast(d.result?.error || d.result?.message || t('config.error'))
}
handleCheckUpdates()
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
} }
const handleUpdateAll = () => { const handleInstallTool = async (tool) => {
const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool) setUpdating(`install-${tool}`)
window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) try {
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 d = await api.aiTask('install_tool', tool)
if (d.result && d.result.installed) {
showToast(`${tool} ${t('config.installed') || 'installé'}`)
} else {
showToast(d.result?.error || d.result?.message || t('config.error'))
}
handleCheckUpdates()
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setUpdating(null)
}
const handleUpdateAll = async () => {
const toUpdate = updates.filter(u => u.needsUpdate)
setUpdating('__all__')
for (const u of toUpdate) {
try {
await api.aiTask('update_tool', u.tool)
} catch (err) {
console.error(`Failed to update ${u.tool}:`, err)
}
}
setUpdating(null)
handleCheckUpdates()
} }
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
@@ -101,9 +153,9 @@ export default function Config({ api }) {
...prev, ...prev,
[p.name]: { [p.name]: {
name: p.name, name: p.name,
api_key: p.apiKey || '', api_key: p.api_key || '',
model: p.model || '', model: p.model || '',
base_url: p.baseURL || '', base_url: p.base_url || '',
}, },
})) }))
setEditProvider(p.name) setEditProvider(p.name)
@@ -160,6 +212,7 @@ export default function Config({ api }) {
installedCount={installedCount} missingCount={missingCount} installedCount={installedCount} missingCount={missingCount}
handleCheckUpdates={handleCheckUpdates} handleCheckUpdates={handleCheckUpdates}
handleUpdateTool={handleUpdateTool} handleUpdateTool={handleUpdateTool}
handleInstallTool={handleInstallTool}
handleUpdateAll={handleUpdateAll} handleUpdateAll={handleUpdateAll}
t={t} t={t}
/> />
@@ -314,7 +367,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
const validateKey = async (p) => { const validateKey = async (p) => {
setValidating(p.name) setValidating(p.name)
try { try {
await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' }) 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 } })) setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } }))
} catch (err) { } catch (err) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } })) setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } }))
@@ -324,9 +377,9 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
useEffect(() => { useEffect(() => {
providers.forEach(p => { providers.forEach(p => {
if (p.apiKey && !keyStatus[p.name]) { if (p.api_key && !keyStatus[p.name]) {
validateKey(p) validateKey(p)
} else if (!p.apiKey) { } else if (!p.api_key) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } })) setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
} }
}) })
@@ -370,7 +423,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<input <input
className="config-form-input" className="config-form-input"
type="password" type="password"
placeholder={p.apiKey ? '••••••••' : t('config.tokenPlaceholder')} placeholder={p.api_key ? '••••••••' : t('config.tokenPlaceholder')}
value={isEditing ? (providerForm[p.name]?.api_key || '') : ''} value={isEditing ? (providerForm[p.name]?.api_key || '') : ''}
onChange={e => { onChange={e => {
if (!isEditing) openProviderEdit(p) if (!isEditing) openProviderEdit(p)
@@ -406,11 +459,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
) )
} }
function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) { function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleInstallTool, 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) const missingTools = tools.filter(tool => !tool.installed)

View File

@@ -6,6 +6,12 @@ const MAX_POINTS = 30
const POLL_INTERVAL = 5000 const POLL_INTERVAL = 5000
const MAX_IDLE_POLLS = 3 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 }) { function MiniGraph({ data, max, color, label, unit }) {
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div> if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
const m = max || Math.max(...data, 1) const m = max || Math.max(...data, 1)
@@ -37,9 +43,28 @@ 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 }) { export default function Dashboard({ api, refreshRef }) {
const { t } = useI18n() const { t } = useI18n()
const [quota, setQuota] = useState(null) const [quota, setQuota] = useState(null)
const [consumption, setConsumption] = useState(null)
const [recentCmds, setRecentCmds] = useState([]) const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([]) const [processes, setProcesses] = useState([])
const [metrics, setMetrics] = useState(null) const [metrics, setMetrics] = useState(null)
@@ -51,13 +76,15 @@ export default function Dashboard({ api, refreshRef }) {
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
const [quotaData, cmdData, procData, metricsData] = await Promise.all([ const [quotaData, consumData, cmdData, procData, metricsData] = await Promise.all([
api.getProvidersQuota().catch(() => null), api.getProvidersQuota().catch(() => null),
api.getProvidersConsumption().catch(() => null),
api.getRecentCommands().catch(() => ({ commands: [] })), api.getRecentCommands().catch(() => ({ commands: [] })),
api.getRunningProcesses().catch(() => ({ processes: [] })), api.getRunningProcesses().catch(() => ({ processes: [] })),
api.getSystemMetrics().catch(() => null), api.getSystemMetrics().catch(() => null),
]) ])
setQuota(quotaData?.providers || []) setQuota(quotaData?.providers || [])
setConsumption(consumData?.providers || {})
setRecentCmds(cmdData.commands || []) setRecentCmds(cmdData.commands || [])
setProcesses(procData.processes || []) setProcesses(procData.processes || [])
if (metricsData) { if (metricsData) {
@@ -91,7 +118,6 @@ export default function Dashboard({ api, refreshRef }) {
}, [loadData, refreshRef]) }, [loadData, refreshRef])
const minimax = (quota || []).find(p => p.name === 'minimax') const minimax = (quota || []).find(p => p.name === 'minimax')
const mimo = (quota || []).find(p => p.name === 'mimo')
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 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']
@@ -135,6 +161,12 @@ export default function Dashboard({ api, refreshRef }) {
}) })
})() })()
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 ( return (
<div className="dash-grid"> <div className="dash-grid">
{/* CPU */} {/* CPU */}
@@ -165,43 +197,36 @@ export default function Dashboard({ api, refreshRef }) {
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" /> <MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
</div> </div>
{/* API Quota */} {/* Consommation */}
<div className="dash-card"> <div className="dash-card">
<div className="dash-card-head"> <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>
<div className="dash-quota-list"> <div className="dash-consumption-list">
{minimax && minimax.data?.models?.map((m, i) => ( {providerEntries.length === 0 && (
<div key={i} className="dash-quota-row"> <span className="dash-empty">Aucune donnée</span>
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span> )}
<div className="dash-bar"> {providerEntries.map(([name, p], pi) => (
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} /> <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> </div>
<span className="dash-quota-val">{m.used}/{m.total}</span>
</div> </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>
)}
{mimo && mimo.data?.models?.map((m, i) => (
<div key={i} className="dash-quota-row">
<span className="dash-quota-name">{String(m.model).replace('MiMo-', '')}</span>
<div className="dash-bar">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
</div>
<span className="dash-quota-val">{m.used}/{m.total}</span>
</div>
))}
{mimo && !mimo.data?.models?.length && (
<div className="dash-quota-row">
<span className="dash-quota-name">MiMo</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{mimo.error || (mimo.healthy ? '✓ configured' : 'no key')}</span>
</div>
)}
{!minimax && !mimo && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,8 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
import mermaid from 'mermaid'
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
const RANKS = { const RANKS = {
commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' }, commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' },
@@ -65,20 +68,30 @@ function formatText(text) {
let html = text let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>') .replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>') .replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>') .replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>') .replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>') .replace(/^---+$/gm, '<hr>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">\u2022 $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>') .replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
.replace(/\n/g, '<br/>') .replace(/\n/g, '<br/>')
html = html html = html
.replace(/<br\/>\s*<br\/>/g, '<br/>') .replace(/<br\/>\s*<br\/>/g, '<br/>')
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1') .replace(/<br\/>\s*(<h[234]|<div class="msg-|<table|<hr)/g, '$1')
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1') .replace(/(<\/h[234]|<\/div>|<\/table>|<hr>)\s*<br\/>/g, '$1')
.replace(/\s+on\w+=["'][^"']*["']/gi, '') .replace(/\s+on\w+=["'][^"']*["']/gi, '')
.replace(/javascript:/gi, '') .replace(/javascript:/gi, '')
.replace(/data:/gi, '') .replace(/data:/gi, '')
@@ -168,10 +181,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 }) { function FeedItem({ msg }) {
const isUser = msg.role === 'user' const isUser = msg.role === 'user'
const isSystem = msg.role === 'system' const isSystem = msg.role === 'system'
const rank = getRank(msg.role) 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' }) : '' const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
@@ -213,6 +285,13 @@ function FeedItem({ msg }) {
{timeStr && <span className="feed-time">{timeStr}</span>} {timeStr && <span className="feed-time">{timeStr}</span>}
</div> </div>
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />} {msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
{msg.images && msg.images.length > 0 && (
<div className="feed-images">
{msg.images.map((imgId, i) => (
<img key={i} className="feed-image" src={`/api/images/${imgId}`} alt={`Image ${i + 1}`} />
))}
</div>
)}
{parsedToolCalls && parsedToolCalls.map((tc, i) => { {parsedToolCalls && parsedToolCalls.map((tc, i) => {
const resultData = parsedToolResults const resultData = parsedToolResults
? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id) ? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id)
@@ -226,10 +305,7 @@ function FeedItem({ msg }) {
<div className="feed-content"> <div className="feed-content">
{renderContent(cleanContent).map((part, i) => {renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? ( part.type === 'code' ? (
<div key={i} className="studio-code-block"> <CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : ( ) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} /> <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
) )
@@ -245,6 +321,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
const rank = RANKS.general const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '') const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
const hasToolCalls = toolCalls && toolCalls.length > 0 const hasToolCalls = toolCalls && toolCalls.length > 0
const [copiedIdx, setCopiedIdx] = useState(null)
const renderedContent = useMemo(() => { const renderedContent = useMemo(() => {
if (!cleanContent) return [] if (!cleanContent) return []
@@ -281,10 +358,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
<div className="feed-content"> <div className="feed-content">
{renderedContent.map((part, i) => {renderedContent.map((part, i) =>
part.type === 'code' ? ( part.type === 'code' ? (
<div key={i} className="studio-code-block"> <CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : ( ) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} /> <span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
) )
@@ -306,13 +380,16 @@ export default function Studio({ api }) {
const [streamThinking, setStreamThinking] = useState('') const [streamThinking, setStreamThinking] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([]) const [streamToolCalls, setStreamToolCalls] = useState([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 100000, summarizeAt: 80000 }) const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 })
const [contextCollapsed, setContextCollapsed] = useState(false) const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false) const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const [sudoModal, setSudoModal] = useState(null)
const [attachedImages, setAttachedImages] = useState([])
const messagesEnd = useRef(null) const messagesEnd = useRef(null)
const feedRef = useRef(null) const feedRef = useRef(null)
const textareaRef = useRef(null) const textareaRef = useRef(null)
const abortRef = useRef(null) const abortRef = useRef(null)
const fileInputRef = useRef(null)
useEffect(() => { useEffect(() => {
api.getChatHistory().then(data => { api.getChatHistory().then(data => {
@@ -325,8 +402,8 @@ export default function Studio({ api }) {
} }
setTokenInfo({ setTokenInfo({
used: data.tokens || 0, used: data.tokens || 0,
max: data.max_tokens || 100000, max: data.max_tokens || 150000,
summarizeAt: data.summarize_at || 80000, summarizeAt: data.summarize_at || 120000,
}) })
setLoaded(true) setLoaded(true)
}).catch(() => { }).catch(() => {
@@ -367,8 +444,8 @@ export default function Studio({ api }) {
const data = await api.getChatHistory() const data = await api.getChatHistory()
setTokenInfo({ setTokenInfo({
used: data.tokens || 0, used: data.tokens || 0,
max: data.max_tokens || 100000, max: data.max_tokens || 150000,
summarizeAt: data.summarize_at || 80000, summarizeAt: data.summarize_at || 120000,
}) })
} catch {} } catch {}
}, [api]) }, [api])
@@ -399,12 +476,38 @@ export default function Studio({ api }) {
} catch {} } catch {}
}, [api, t]) }, [api, t])
const handleImageSelect = useCallback((e) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
const remaining = 3 - attachedImages.length
const toProcess = files.slice(0, remaining)
toProcess.forEach(file => {
if (!file.type.match(/^image\/(jpeg|jpg|png|webp)$/)) return
if (file.size > 50 * 1024 * 1024) return
const reader = new FileReader()
reader.onload = (ev) => {
setAttachedImages(prev => {
if (prev.length >= 3) return prev
return [...prev, { data: ev.target.result, filename: file.name, mime_type: file.type }]
})
}
reader.readAsDataURL(file)
})
e.target.value = ''
}, [attachedImages.length])
const removeImage = useCallback((index) => {
setAttachedImages(prev => prev.filter((_, i) => i !== index))
}, [])
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
if (!input.trim() || loading) return if (!input.trim() || loading) return
const text = input.trim() const text = input.trim()
const images = [...attachedImages]
setInput('') setInput('')
setAttachedImages([])
const isSlashCommand = (t) => /^\/(clear|help|summarize|export|model(?:\s+\S+)?|plan\s+.+)$/.test(t) const isSlashCommand = (t) => /^\/(clear|help|summarize|model(?:\s+\S+)?)$/.test(t)
if (text.startsWith('/') && !isSlashCommand(text)) { 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: 'user', content: text, time: new Date().toISOString() }])
@@ -424,19 +527,8 @@ export default function Studio({ api }) {
'- `/clear` - Effacer la conversation', '- `/clear` - Effacer la conversation',
'- `/summarize` - Résumer la conversation précédente', '- `/summarize` - Résumer la conversation précédente',
'- `/help` - Afficher cette aide', '- `/help` - Afficher cette aide',
'- `/plan <objectif>` - Demander un plan structuré',
'- `/export` - Exporter la conversation en Markdown',
'- `/model` - Afficher le provider et modèle actifs', '- `/model` - Afficher le provider et modèle actifs',
'- `/model change` - Basculer entre MiniMax et ZAI', '- `/model change` - Basculer entre MiniMax et MiMo',
'',
'## 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',
].join('\n') ].join('\n')
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }]) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: helpMsg, time: new Date().toISOString() }])
return return
@@ -481,31 +573,6 @@ export default function Studio({ api }) {
return 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
}
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() } const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
setMessages(prev => [...prev, userMsg]) setMessages(prev => [...prev, userMsg])
setLoading(true) setLoading(true)
@@ -537,6 +604,9 @@ export default function Studio({ api }) {
return return
} }
if (event && event.tool_result) { 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) const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id)
if (idx >= 0) { if (idx >= 0) {
toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result } toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result }
@@ -546,7 +616,7 @@ export default function Studio({ api }) {
} }
accumulated = partial accumulated = partial
setStreaming(partial) setStreaming(partial)
}, controller.signal) }, controller.signal, images)
const finalContent = accumulated || t('studio.noResponse') const finalContent = accumulated || t('studio.noResponse')
const aiMsg = { const aiMsg = {
@@ -594,7 +664,7 @@ export default function Studio({ api }) {
abortRef.current = null abortRef.current = null
refreshTokens() refreshTokens()
} }
}, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize]) }, [input, loading, api, t, handleClear, streaming, refreshTokens, handleSummarize, attachedImages])
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
if (abortRef.current) { if (abortRef.current) {
@@ -602,7 +672,7 @@ export default function Studio({ api }) {
} }
}, []) }, [])
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model', '/model change'] const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change']
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@@ -698,6 +768,16 @@ export default function Studio({ api }) {
</div> </div>
<div className="studio-input-area"> <div className="studio-input-area">
{attachedImages.length > 0 && (
<div className="studio-image-previews">
{attachedImages.map((img, i) => (
<div key={i} className="studio-image-preview">
<img src={img.data} alt={img.filename} />
<button className="studio-image-remove" onClick={() => removeImage(i)}>×</button>
</div>
))}
</div>
)}
<div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}> <div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
<div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}> <div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
<div <div
@@ -717,6 +797,24 @@ export default function Studio({ api }) {
)} )}
</div> </div>
<div className="studio-input-row"> <div className="studio-input-row">
<input
type="file"
ref={fileInputRef}
accept="image/jpeg,image/png,image/webp"
multiple
style={{ display: 'none' }}
onChange={handleImageSelect}
/>
<button
className="studio-attach-btn"
onClick={() => fileInputRef.current?.click()}
disabled={loading || attachedImages.length >= 3}
title="Joindre des images (max 3)"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</svg>
</button>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={input} value={input}
@@ -744,9 +842,25 @@ export default function Studio({ api }) {
)} )}
</div> </div>
<div className="studio-input-hint"> <div className="studio-input-hint">
{t('studio.inputHint')} · /clear /summarize /help /plan /export /model /model change {t('studio.inputHint')} · /clear /summarize /help /model · @fichier.ext pour joindre un fichier{attachedImages.length > 0 && ` · ${attachedImages.length} image${attachedImages.length > 1 ? 's' : ''} attachée${attachedImages.length > 1 ? 's' : ''}`}
</div> </div>
</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> </div>
) )
} }

View File

@@ -18,6 +18,10 @@ const en = {
newLine: 'New line', newLine: 'New line',
copy: 'Copy', copy: 'Copy',
paste: 'Paste', paste: 'Paste',
search: 'Search',
zoom: 'Zoom +/',
switchTab: 'Switch tab',
nextTab: 'Next tab',
runCommand: 'Run command', runCommand: 'Run command',
commandHistory: 'Command history', commandHistory: 'Command history',
}, },
@@ -116,6 +120,8 @@ const en = {
port: 'Port', port: 'Port',
user: 'User', user: 'User',
keyPath: 'SSH key path', keyPath: 'SSH key path',
password: 'Password',
passwordHint: 'requires sshpass installed',
connect: 'Connect', connect: 'Connect',
save: 'Save', save: 'Save',
cancel: 'Cancel', cancel: 'Cancel',

View File

@@ -18,6 +18,10 @@ const fr = {
newLine: 'Nouvelle ligne', newLine: 'Nouvelle ligne',
copy: 'Copier', copy: 'Copier',
paste: 'Coller', paste: 'Coller',
search: 'Rechercher',
zoom: 'Zoom +/\u2212',
switchTab: 'Changer d\u2019onglet',
nextTab: 'Onglet suivant',
runCommand: 'Ex\u00e9cuter', runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique', commandHistory: 'Historique',
}, },
@@ -116,6 +120,8 @@ const fr = {
port: 'Port', port: 'Port',
user: 'Utilisateur', user: 'Utilisateur',
keyPath: 'Chemin cl\u00e9 SSH', keyPath: 'Chemin cl\u00e9 SSH',
password: 'Mot de passe',
passwordHint: 'n\u00e9cessite sshpass install\u00e9',
connect: 'Se connecter', connect: 'Se connecter',
save: 'Enregistrer', save: 'Enregistrer',
cancel: 'Annuler', cancel: 'Annuler',

View File

@@ -329,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .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-wrapper { position: relative; }
.shell-new-tab-btn { .shell-new-tab-btn {
display: flex; align-items: center; gap: 2px; display: flex; align-items: center; gap: 2px;
@@ -383,6 +391,36 @@ input::placeholder { color: var(--text-disabled); }
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; } .shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; } .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-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 { .shell-xterm-instance {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -404,6 +442,9 @@ input::placeholder { color: var(--text-disabled); }
.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; } .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; } .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; }
.sudo-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.sudo-indicator.sudo-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); }
.sudo-indicator.sudo-blocked { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); }
.shell-analyze-btn { .shell-analyze-btn {
display: flex; align-items: center; gap: 4px; display: flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: var(--radius); padding: 4px 10px; border-radius: var(--radius);
@@ -420,6 +461,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; } .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-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 { 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(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); }
.ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); } .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.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); } .ai-message.assistant { background: var(--bg-card); border-left: 3px solid var(--accent); }
@@ -429,6 +472,13 @@ input::placeholder { color: var(--text-disabled); }
.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; } .ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); } .ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); }
.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; } .ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; }
.ai-panel-input .ai-clear-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 10px 16px; border-radius: var(--radius); border: 1px solid var(--accent);
background: var(--accent-bg); color: var(--accent); font-size: 13px; font-weight: 700;
cursor: pointer; transition: all 0.15s; font-family: var(--font-sans);
}
.ai-panel-input .ai-clear-btn:hover { background: var(--accent); color: #fff; }
.shell-code-block { .shell-code-block {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
@@ -454,6 +504,25 @@ input::placeholder { color: var(--text-disabled); }
} }
.shell-code-actions button:last-child { border-right: none; } .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: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; display: block; overflow-x: auto; }
.ai-message thead, .ai-message tbody { display: table-row-group; }
.ai-message th { background: var(--bg-surface); padding: 4px 8px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); white-space: nowrap; }
.ai-message td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; }
.ai-message tr { display: table-row; }
.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 { .shell-analysis-modal {
background: var(--bg-elevated); border: 1px solid var(--border); background: var(--bg-elevated); border: 1px solid var(--border);
@@ -469,6 +538,18 @@ input::placeholder { color: var(--text-disabled); }
flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5; flex: 1; overflow-y: auto; padding: 20px; font-size: 14px; line-height: 1.5;
color: var(--text-primary); word-break: break-word; color: var(--text-primary); word-break: break-word;
} }
.shell-analysis-modal-body table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
.shell-analysis-modal-body th { background: var(--bg-surface); padding: 4px 10px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); }
.shell-analysis-modal-body td { padding: 3px 10px; border: 1px solid var(--border); color: var(--text-primary); }
.shell-analysis-modal-body tr:nth-child(even) td { background: var(--bg-surface); }
.shell-analysis-modal-body .msg-h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin: 16px 0 6px; display: block; }
.shell-analysis-modal-body .msg-h4 { font-size: 15px; font-weight: 700; color: var(--text-secondary); margin: 12px 0 4px; display: block; }
.shell-analysis-modal-body .msg-h2 { font-size: 20px; font-weight: 700; color: var(--accent); margin: 20px 0 8px; display: block; }
.shell-analysis-modal-body .msg-bullet { display: block; padding-left: 4px; margin: 2px 0; color: var(--text-primary); }
.shell-analysis-modal-body .msg-step { display: flex; gap: 8px; align-items: baseline; margin: 2px 0; }
.shell-analysis-modal-body .msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); flex-shrink: 0; }
.shell-analysis-modal-body strong { color: var(--accent-light); }
.shell-analysis-modal-body .inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); }
.shell-modal-overlay { .shell-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); position: fixed; inset: 0; background: rgba(0,0,0,0.6);
@@ -682,6 +763,25 @@ input::placeholder { color: var(--text-disabled); }
white-space: nowrap; 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 */ /* Processes */
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; } .dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
.dash-proc-row { .dash-proc-row {
@@ -895,16 +995,38 @@ input::placeholder { color: var(--text-disabled); }
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
overflow: hidden; margin: 8px 0; overflow: hidden; margin: 8px 0;
} }
.studio-code-header {
display: flex; align-items: center; justify-content: flex-end;
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-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 { .studio-code-lang {
padding: 4px 12px; font-size: 11px; font-weight: 600; color: var(--text-tertiary); 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); }
.feed-content hr, .ai-message hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
.inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); } .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: 10px 0 4px; display: block; } .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-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 { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); }
.msg-bullet::before { content: '\2022'; position: absolute; left: 4px; color: var(--accent); }
.msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 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; } .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; } .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; }
@@ -953,6 +1075,47 @@ input::placeholder { color: var(--text-disabled); }
cursor: pointer; transition: all 0.15s; flex-shrink: 0; cursor: pointer; transition: all 0.15s; flex-shrink: 0;
} }
.studio-stop-btn:hover { opacity: 0.8; } .studio-stop-btn:hover { opacity: 0.8; }
/* ── Image Attachments ── */
.studio-attach-btn {
width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center;
border-radius: var(--radius); background: var(--bg-card); color: var(--text-tertiary);
border: 1px solid var(--border); cursor: pointer; transition: all 0.15s; flex-shrink: 0;
}
.studio-attach-btn:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
.studio-attach-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.studio-image-previews {
display: flex; gap: 10px; padding: 10px 8px; flex-wrap: wrap; justify-content: center;
}
.studio-image-preview {
position: relative; width: 110px; height: 110px; border-radius: var(--radius-lg);
overflow: hidden; border: 2px solid var(--border); background: var(--bg-surface);
transition: border-color 0.2s;
}
.studio-image-preview:hover { border-color: var(--accent-dim); }
.studio-image-preview img {
width: 100%; height: 100%; object-fit: cover;
}
.studio-image-remove {
position: absolute; top: 4px; right: 4px; width: 24px; height: 24px;
border-radius: 50%; background: rgba(0,0,0,0.75); color: #fff; border: none;
font-size: 14px; font-weight: 600; cursor: pointer; display: flex; align-items: center;
justify-content: center; line-height: 1; transition: background 0.15s;
backdrop-filter: blur(4px);
}
.studio-image-remove:hover { background: var(--error); }
/* ── Feed Images (in chat messages) ── */
.feed-images {
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 6px;
}
.feed-image {
max-width: 240px; max-height: 180px; border-radius: var(--radius);
border: 1px solid var(--border); object-fit: cover; cursor: pointer;
transition: transform 0.15s, border-color 0.15s;
}
.feed-image:hover { transform: scale(1.03); border-color: var(--accent-dim); }
.studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; } .studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
/* ── Collapsed Messages ── */ /* ── Collapsed Messages ── */
@@ -1058,3 +1221,76 @@ input::placeholder { color: var(--text-disabled); }
word-break: break-word; word-break: break-word;
background: var(--bg); 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;
}