Compare commits

...

12 Commits

Author SHA1 Message Date
Augustin
9987a586e2 fix(config): replace hardcoded model list with free text input
All checks were successful
Beta Release / beta (push) Successful in 41s
Removed PROVIDER_MODELS hardcoded map. Model is now a simple text
input pre-filled with the current model value.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:08:41 +02:00
Augustin
2827acfe96 feat(config): providers panel shows only MINIMAX/ZAI with model selector
All checks were successful
Beta Release / beta (push) Successful in 42s
- Only MINIMAX and ZAI displayed (names in uppercase)
- Each provider shows selectable model chips (MiniMax-M2.7, glm-4, etc.)
- Save button always visible when editing, not just after validation
- Removed setup hint text

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:06:21 +02:00
Augustin
afb6e77c7f feat(dashboard): show top 5 most used commands as clickable chips
All checks were successful
Beta Release / beta (push) Successful in 43s
Top commands (excluding ls/cd/pwd/clear/exit/history) displayed as
large chips with usage count. Click to copy. Full history below.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:04:37 +02:00
Augustin
84be22661b fix: tab containers height, dashboard 2-row grid, studio scroll buttons
All checks were successful
Beta Release / beta (push) Successful in 41s
- .content > div now inherits full height so all tabs fill the viewport
- Dashboard grid uses grid-template-rows: repeat(2, 1fr) for 6 equal tiles
- Studio gets floating scroll-to-top / scroll-to-bottom buttons
- Wrapped studio-feed in scroll-wrap for proper overflow

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:57:00 +02:00
Augustin
f9c4cf11ff feat(shell): dedicated System Analyst AI, no code execution, analyze system
All checks were successful
Beta Release / beta (push) Successful in 45s
- New ShellConvStore with persistent history (shell_conversation.json)
- 100k token limit — input grays out, must /clear to continue
- Commands limited to /clear and /help only
- Shell AI has NO tools — read-only analysis, never executes code
- "Analyste Système" panel with system analysis button
- System analysis uses Studio AI to write system_analysis.md,
  prepended as context on every conversation start
- Code blocks show "Copier" and "Terminal" buttons to copy or
  send code directly to the active terminal via WebSocket
- Token bar shows usage with warning at 80%

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:50:06 +02:00
Augustin
eda7293286 fix: keep all tabs mounted, switch via CSS display instead of unmount
All checks were successful
Beta Release / beta (push) Successful in 42s
All 4 tabs (Dashboard, Studio, Shell, Config) are now always mounted
and toggled via .tab-hidden (display:none). This preserves:
- Dashboard graph history across tab switches
- Terminal session state and progress
- Studio chat context
- Config form state

Dashboard polling pauses after 3 ticks when hidden to save resources
and auto-resumes when the tab becomes visible again.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:34:59 +02:00
Augustin
b55feaed09 refactor(config): locale panel with edit/save flow like profile
All checks were successful
Beta Release / beta (push) Successful in 40s
Show current language and keyboard as read-only values, then
"Modifier" button opens chip selection, "Sauvegarder" persists
via API. Centered layout matching profile panel style.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:30:02 +02:00
Augustin
54621bd960 feat(config): split profile into Personal Info + Preferences sections, centered
All checks were successful
Beta Release / beta (push) Successful in 40s
- Profile panel now shows two distinct cards with section titles
- Personal Info (name, pseudo, email, languages) and Preferences
  (editor, shell, theme, etc.) are visually separated
- Content centered with max-width 540px
- Added i18n keys for section titles

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:29:07 +02:00
Augustin
6bad2948c5 feat(studio): improve context compression UI and provider display
All checks were successful
Beta Release / beta (push) Successful in 45s
- Add visual indicator when messages are collapsed (folder icon)
- Add animation to token bar during compression (pulse effect)
- Token bar becomes more compact after compression with "· compressé" label
- Button "voir plus" to expand collapsed messages
- Add 24px spacing at end of feed to avoid last message clipping
- Simplify provider display: show name only, badge "active" instead of key status
- Dashboard: show provider name only without model suffix
- Studio /model: show just provider name, not model
- Z.AI (GLM): mark as crush-only, no external quota check
- Claude: check /usr/bin/claude installation instead of API

💘 Generated with Crush
2026-04-23 21:21:59 +02:00
Augustin
92eb783df0 fix(config): locale panel show active language/keyboard, add save button
All checks were successful
Beta Release / beta (push) Successful in 41s
- Fix language prop passing keyboard value instead of language
- Add save button that persists language + keyboard_layout via API
- Add local toast for save confirmation

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:20:30 +02:00
Augustin
8005e978f0 feat(config): dynamic profile panel, generic save, tabs margin fix
All checks were successful
Beta Release / beta (push) Successful in 44s
- Config tabs now have bottom padding for visual spacing
- Profile panel dynamically renders all config fields (strings, bools,
  arrays, nested objects) — new struct fields appear automatically
- handleSaveProfile uses generic JSON merge via deepMerge, so any
  new Profile field works without handler changes
- RenderFields recursively renders config sections with edit/view modes

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:14:47 +02:00
Augustin
6e76e7dca6 fix(dashboard): remove bg graphs, add scrollable lists, show used/total quota
All checks were successful
Beta Release / beta (push) Successful in 40s
Remove BgGraph background SVGs that were misaligned with foreground graphs.
Add max-height: 270px with overflow-y scroll to quota/processes/commands lists.
Change API quota display from remaining/total to used/total.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:02:53 +02:00
14 changed files with 1154 additions and 498 deletions

View File

@@ -53,32 +53,27 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, "no config", http.StatusNotFound) writeError(w, "no config", http.StatusNotFound)
return return
} }
var body struct {
Name string `json:"name"` currentJSON, err := json.Marshal(s.config.Profile)
Pseudo string `json:"pseudo"` if err != nil {
Email string `json:"email"` writeError(w, err.Error(), http.StatusInternalServerError)
Editor string `json:"editor"` return
Shell string `json:"shell"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { var currentMap map[string]interface{}
json.Unmarshal(currentJSON, &currentMap)
var updates map[string]interface{}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &updates); err != nil {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
return return
} }
if body.Name != "" {
s.config.Profile.Name = body.Name deepMerge(currentMap, updates)
}
if body.Pseudo != "" { mergedJSON, _ := json.Marshal(currentMap)
s.config.Profile.Pseudo = body.Pseudo json.Unmarshal(mergedJSON, &s.config.Profile)
}
if body.Email != "" {
s.config.Profile.Email = body.Email
}
if body.Editor != "" {
s.config.Profile.Preferences.Editor = body.Editor
}
if body.Shell != "" {
s.config.Profile.Preferences.Shell = body.Shell
}
if err := config.Save(s.config); err != nil { if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
@@ -86,6 +81,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
func deepMerge(dst, src map[string]interface{}) {
for k, sv := range src {
if dv, ok := dst[k]; ok {
dstMap, dOk := dv.(map[string]interface{})
srcMap, sOk := sv.(map[string]interface{})
if dOk && sOk {
deepMerge(dstMap, srcMap)
continue
}
}
dst[k] = sv
}
}
func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" { if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed) writeError(w, "PUT only", http.StatusMethodNotAllowed)

View File

@@ -477,25 +477,16 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
} }
} }
case "zai": case "zai":
if p.APIKey == "" { // Z.AI (GLM) est utilisé uniquement via Crush, pas de quota check externe
q.Error = "no API key" q.Healthy = true
results = append(results, q) q.Data = map[string]interface{}{"note": "crush-only"}
continue case "claude", "anthropic":
} // Claude Code n'a pas d'API externe, vérifier l'installation
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil) claudePath := "/usr/bin/claude"
req.Header.Set("Authorization", "Bearer "+p.APIKey) if _, err := os.Stat(claudePath); err == nil {
req.Header.Set("Accept", "application/json") q.Healthy = true
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else { } else {
body, _ := io.ReadAll(resp.Body) q.Error = "claude code not installed"
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
q.Data = data
q.Healthy = true
}
} }
default: default:
q.Error = "quota not supported" q.Error = "quota not supported"

View File

@@ -1,53 +1,24 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os"
"os/exec"
"runtime"
"strings" "strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator" "github.com/muyue/muyue/internal/orchestrator"
) )
const maxShellToolIterations = 10
type ShellChatRequest struct { type ShellChatRequest struct {
Message string `json:"message"` Message string `json:"message"`
Context string `json:"context,omitempty"` Context string `json:"context,omitempty"`
History []string `json:"history,omitempty"` Cwd string `json:"cwd,omitempty"`
Cwd string `json:"cwd,omitempty"` Platform string `json:"platform,omitempty"`
Platform string `json:"platform,omitempty"` Stream bool `json:"stream"`
Stream bool `json:"stream"`
}
type ShellChatResponse struct {
Content string `json:"content,omitempty"`
ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"`
Error string `json:"error,omitempty"`
}
type ToolCallInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
Result *toolResponseData `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
func toString(v interface{}) string {
if v == nil {
return ""
}
s, _ := v.(string)
return s
}
func toBool(v interface{}) bool {
if v == nil {
return false
}
b, _ := v.(bool)
return b
} }
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) { func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
@@ -56,6 +27,11 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
return return
} }
if s.shellConvStore.AtLimit() {
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
return
}
var req ShellChatRequest var req ShellChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)
@@ -67,142 +43,237 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
return return
} }
s.shellConvStore.Add("user", req.Message)
orb, err := orchestrator.New(s.config) orb, err := orchestrator.New(s.config)
if err != nil { if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable) writeError(w, err.Error(), http.StatusServiceUnavailable)
return return
} }
orb.SetSystemPrompt(s.buildShellSystemPrompt(req)) orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
orb.SetTools(s.agentToolsJSON)
if req.Stream { if req.Stream {
s.handleShellChatStream(w, orb, req) s.handleShellChatStreamV2(w, orb)
} else { } else {
s.handleShellChatNonStream(w, orb, req) s.handleShellChatNonStreamV2(w, orb)
} }
} }
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string { func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(`Tu es l'assistant Shell de Muyue. Tu as accès à un terminal et peux aider l'utilisateur avec: sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
- Exécuter des commandes shell Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
- Expliquer des erreurs de commandes
- Suggérer des commandes appropriées pour la tâche demandée
- Lire et explorer des fichiers
- Configurer l'environnement de développement
Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc. Sois précis et concis dans tes réponses. 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
`) `)
if req.Cwd != "" { analysis := LoadSystemAnalysis()
sb.WriteString("Répertoire courant: " + req.Cwd + "\n") if analysis != "" {
sb.WriteString("=== ANALYSE SYSTÈME ACTUELLE ===\n")
sb.WriteString(analysis)
sb.WriteString("\n=== FIN DE L'ANALYSE ===\n\n")
} }
if req.Platform != "" {
sb.WriteString("Plateforme: " + req.Platform + "\n") sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
} if hostname, err := os.Hostname(); err == nil {
if req.Context != "" { sb.WriteString("Hostname: " + hostname + "\n")
sb.WriteString("\nContexte du terminal:\n" + req.Context + "\n")
}
if len(req.History) > 0 {
sb.WriteString("\nDernières commandes exécutées:\n")
for _, h := range req.History {
sb.WriteString(" " + h + "\n")
}
} }
return sb.String() return sb.String()
} }
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) { func (s *Server) handleShellChatStreamV2(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)
ctx := context.Background() // Rebuild history into orchestrator
messages := []orchestrator.Message{ history := s.shellConvStore.Get()
{Role: "user", Content: req.Message}, 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})
} }
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) lastUserMsg := history[len(history)-1].Content
var toolCalls []ToolCallInfo var finalContent string
engine.OnChunk(func(data map[string]interface{}) { result, err := orb.SendStream(lastUserMsg, func(chunk string) {
if data == nil { finalContent = chunk
return sseWriter.Write(map[string]interface{}{"content": chunk})
}
sseWriter.Write(data)
if canFlush { if canFlush {
flusher.Flush() flusher.Flush()
} }
if tc, ok := data["tool_call"].(map[string]interface{}); ok {
argsMap := make(map[string]interface{})
if args, ok := tc["args"].(string); ok {
json.Unmarshal([]byte(args), &argsMap)
}
toolCalls = append(toolCalls, ToolCallInfo{
ID: toString(tc["tool_call_id"]),
Name: toString(tc["name"]),
Args: argsMap,
})
}
if tr, ok := data["tool_result"].(map[string]interface{}); ok {
tcID := toString(tr["tool_call_id"])
for i := range toolCalls {
if toolCalls[i].ID == tcID {
if err, ok := tr["is_error"].(bool); ok && err {
toolCalls[i].Error = toString(tr["content"])
} else {
toolCalls[i].Result = &toolResponseData{
Content: toString(tr["content"]),
IsError: toBool(tr["is_error"]),
}
}
break
}
}
}
}) })
finalContent, _, _, 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
} }
if finalContent == "" && len(toolCalls) > 0 { content := result
finalContent = "(opérations terminées)" if content == "" {
content = finalContent
} }
writeJSONResp, _ := json.Marshal(ShellChatResponse{ s.shellConvStore.Add("assistant", cleanThinkingTags(content))
Content: finalContent,
ToolCalls: toolCalls, sseWriter.Write(map[string]interface{}{
"done": "true",
"tokens": s.shellConvStore.ApproxTokens(),
}) })
sseWriter.Write(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
} }
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) { func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
ctx := context.Background() history := s.shellConvStore.Get()
messages := []orchestrator.Message{ for _, m := range history[:len(history)-1] {
{Role: "user", Content: req.Message}, if m.Role == "system" {
continue
}
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
} }
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) lastUserMsg := history[len(history)-1].Content
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
} }
if finalContent == "" { s.shellConvStore.Add("assistant", cleanThinkingTags(result))
finalContent = "(tool calls completed, no text response)" writeJSON(w, map[string]interface{}{
} "content": result,
"tokens": s.shellConvStore.ApproxTokens(),
writeJSON(w, ShellChatResponse{ })
Content: finalContent, }
ToolCalls: nil,
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.shellConvStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.shellConvStore.ApproxTokens(),
"max_tokens": shellMaxTokens,
"at_limit": s.shellConvStore.AtLimit(),
})
}
func (s *Server) handleShellChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.shellConvStore.Clear()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": 0,
})
}
func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var sysInfo strings.Builder
sysInfo.WriteString("=== INFORMATIONS SYSTÈME ===\n")
sysInfo.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
if hostname, err := os.Hostname(); err == nil {
sysInfo.WriteString("Hostname: " + hostname + "\n")
}
if user := os.Getenv("USER"); user != "" {
sysInfo.WriteString("User: " + user + "\n")
}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "model name") {
sysInfo.WriteString("CPU: " + strings.SplitN(line, ":", 2)[1] + "\n")
break
}
}
}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "MemTotal:") || strings.HasPrefix(line, "MemAvailable:") {
sysInfo.WriteString(strings.TrimSpace(line) + "\n")
}
}
}
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
lines := strings.Split(string(out), "\n")
if len(lines) >= 2 {
sysInfo.WriteString("Disk: " + strings.TrimSpace(lines[1]) + "\n")
}
}
if out, err := exec.Command("ps", "aux", "--sort=-pcpu").Output(); err == nil {
lines := strings.Split(string(out), "\n")
sysInfo.WriteString(fmt.Sprintf("\nProcessus actifs (%d total):\n", len(lines)-1))
for i := 1; i < len(lines) && i <= 10; i++ {
fields := strings.Fields(lines[i])
if len(fields) >= 11 {
sysInfo.WriteString(fmt.Sprintf(" %-20s CPU:%-6s MEM:%-6s %s\n", fields[10], fields[2]+"%", fields[3]+"%", fields[0]))
}
}
}
if s.scanResult != nil {
sysInfo.WriteString("\nOutils installés:\n")
for _, t := range s.scanResult.Tools {
status := "✗"
if t.Installed {
status = "✓"
}
sysInfo.WriteString(fmt.Sprintf(" %s %s %s\n", status, t.Name, t.Version))
}
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(agent.StudioSystemPrompt())
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes sur le système de l'utilisateur.
Génère un rapport d'analyse concis et structuré en markdown qui inclut:
1. Un résumé de l'état du système
2. Les points d'attention (performance, sécurité, configuration)
3. Des recommandations spécifiques d'optimisation
4. Les outils manquants qui pourraient être utiles
5. L'état du réseau et des connexions
Sois concret et technique. Le rapport sera utilisé comme contexte pour un assistant terminal.
` + sysInfo.String()
result, err := orb.Send(analysisPrompt)
if err != nil {
writeError(w, "analysis failed: "+err.Error(), http.StatusInternalServerError)
return
}
SaveSystemAnalysis(result)
writeJSON(w, map[string]interface{}{
"status": "ok",
"analysis": result,
}) })
} }

View File

@@ -17,6 +17,7 @@ type Server struct {
scanResult *scanner.ScanResult scanResult *scanner.ScanResult
mux *http.ServeMux mux *http.ServeMux
convStore *ConversationStore convStore *ConversationStore
shellConvStore *ShellConvStore
agentRegistry *agent.Registry agentRegistry *agent.Registry
agentToolsJSON json.RawMessage agentToolsJSON json.RawMessage
workflowEngine *workflow.Engine workflowEngine *workflow.Engine
@@ -46,6 +47,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.config = cfg s.config = cfg
s.scanResult = scanner.ScanSystem() s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore() s.convStore = NewConversationStore()
s.shellConvStore = NewShellConvStore()
s.agentRegistry = agent.DefaultRegistry() s.agentRegistry = agent.DefaultRegistry()
tools := s.agentRegistry.OpenAITools() tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools) toolsJSON, _ := json.Marshal(tools)
@@ -89,6 +91,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/tool/call", s.handleToolCall) s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
s.mux.HandleFunc("/api/tools/list", s.handleToolList) s.mux.HandleFunc("/api/tools/list", s.handleToolList)
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat) s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate) s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList) s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet) s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)

View File

@@ -0,0 +1,121 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const shellMaxTokens = 100000
const shellCharsPerToken = 4
type ShellMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type ShellConvStore struct {
mu sync.RWMutex
path string
msgs []ShellMessage
}
func NewShellConvStore() *ShellConvStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "shell_conversation.json")
s := &ShellConvStore{path: path}
s.load()
return s
}
func (s *ShellConvStore) load() {
data, err := os.ReadFile(s.path)
if err != nil {
s.msgs = []ShellMessage{}
return
}
json.Unmarshal(data, &s.msgs)
if s.msgs == nil {
s.msgs = []ShellMessage{}
}
}
func (s *ShellConvStore) save() {
data, _ := json.MarshalIndent(s.msgs, "", " ")
os.MkdirAll(filepath.Dir(s.path), 0755)
os.WriteFile(s.path, data, 0600)
}
func (s *ShellConvStore) Get() []ShellMessage {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]ShellMessage, len(s.msgs))
copy(out, s.msgs)
return out
}
func (s *ShellConvStore) Add(role, content string) ShellMessage {
s.mu.Lock()
defer s.mu.Unlock()
msg := ShellMessage{
ID: time.Now().Format("20060102150405.000"),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
s.msgs = append(s.msgs, msg)
s.save()
return msg
}
func (s *ShellConvStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.msgs = []ShellMessage{}
s.save()
}
func (s *ShellConvStore) ApproxTokens() int {
s.mu.RLock()
defer s.mu.RUnlock()
total := 0
for _, m := range s.msgs {
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
}
return total
}
func (s *ShellConvStore) AtLimit() bool {
return s.ApproxTokens() >= shellMaxTokens
}
func LoadSystemAnalysis() string {
dir, err := config.ConfigDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(dir, "system_analysis.md"))
if err != nil {
return ""
}
return string(data)
}
func SaveSystemAnalysis(content string) error {
dir, err := config.ConfigDir()
if err != nil {
return err
}
os.MkdirAll(dir, 0755)
return os.WriteFile(filepath.Join(dir, "system_analysis.md"), []byte(content), 0644)
}

View File

@@ -57,6 +57,9 @@ const api = {
getChatHistory: () => request('/chat/history'), getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }), clearChat: () => request('/chat/clear', { method: 'POST' }),
summarizeChat: () => request('/chat/summarize', { method: 'POST' }), summarizeChat: () => request('/chat/summarize', { method: 'POST' }),
getShellChatHistory: () => request('/shell/chat/history'),
clearShellChat: () => request('/shell/chat/clear', { method: 'POST' }),
analyzeSystem: () => request('/shell/analyze', { method: 'POST' }),
sendChat: (message, stream = true, onChunk, signal) => { sendChat: (message, stream = true, onChunk, signal) => {
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 }) })
@@ -104,8 +107,6 @@ const api = {
sendShellChat: (message, context = {}, stream = true, onChunk) => { sendShellChat: (message, context = {}, stream = true, onChunk) => {
const payload = { const payload = {
message, message,
context: context.context || '',
history: context.history || [],
cwd: context.cwd || '', cwd: context.cwd || '',
platform: context.platform || '', platform: context.platform || '',
stream, stream,
@@ -127,7 +128,6 @@ const api = {
const reader = res.body.getReader() const reader = res.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let full = '' let full = ''
let toolCalls = []
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
@@ -137,27 +137,15 @@ const api = {
try { try {
const data = JSON.parse(line.slice(6)) const data = JSON.parse(line.slice(6))
if (data.error) { reject(new Error(data.error)); return } if (data.error) { reject(new Error(data.error)); return }
if (data.done) { if (data.done) { resolve({ content: full, tokens: data.tokens }); return }
resolve({ content: full, tool_calls: toolCalls })
return
}
if (data.content) { if (data.content) {
full += data.content full = data.content
if (onChunk) onChunk(full, data) if (onChunk) onChunk(full, data)
} else if (data.tool_call) {
toolCalls.push(data.tool_call)
if (onChunk) onChunk(full, data, toolCalls)
} else if (data.tool_result) {
const idx = toolCalls.findIndex(tc => tc.tool_call_id === data.tool_result.id)
if (idx >= 0) {
toolCalls[idx].result = data.tool_result
}
if (onChunk) onChunk(full, data, toolCalls)
} }
} catch {} } catch {}
} }
} }
resolve({ content: full, tool_calls: toolCalls }) resolve({ content: full })
}).catch(reject) }).catch(reject)
}) })
}, },

View File

@@ -92,16 +92,6 @@ export default function App() {
config: [], config: [],
}), [layout, t]) }), [layout, t])
const renderContent = () => {
switch (activeTab) {
case 'dash': return <Dashboard api={api} refreshRef={dashRefreshRef} />
case 'studio': return <Studio api={api} />
case 'shell': return <Shell api={api} />
case 'config': return <Config api={api} />
default: return null
}
}
return ( return (
<div className="app-layout"> <div className="app-layout">
<header className="header"> <header className="header">
@@ -143,8 +133,11 @@ export default function App() {
</span> </span>
</header> </header>
<main className="content fade-in" key={`${activeTab}-${TABS.length}`}> <main className="content">
{renderContent()} <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 === 'shell' ? '' : 'tab-hidden'}><Shell api={api} /></div>
<div className={activeTab === 'config' ? '' : 'tab-hidden'}><Config api={api} /></div>
</main> </main>
<footer className="statusbar"> <footer className="statusbar">

View File

@@ -34,14 +34,7 @@ export default function Config({ api }) {
const loadData = useCallback(() => { const loadData = useCallback(() => {
api.getConfig().then(d => { api.getConfig().then(d => {
setConfig(d) setConfig(d)
setProfileForm({ setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {})
name: d.profile?.name || '',
pseudo: d.profile?.pseudo || '',
email: d.profile?.email || '',
editor: d.profile?.preferences?.editor || '',
shell: d.profile?.preferences?.shell || '',
})
}).catch(() => {}) }).catch(() => {})
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {}) api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {}) api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
@@ -190,8 +183,8 @@ export default function Config({ api }) {
)} )}
{activePanel === 'locale' && ( {activePanel === 'locale' && (
<PanelLocale <PanelLocale
language={keyboard} layouts={layouts} language={language} keyboard={keyboard} layouts={layouts}
setLanguage={setLanguage} setKeyboard={setKeyboard} api={api}
t={t} t={t}
/> />
)} )}
@@ -209,57 +202,135 @@ export default function Config({ api }) {
} }
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) { function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
const updateField = (path, value) => {
setProfileForm(prev => {
const next = JSON.parse(JSON.stringify(prev))
const keys = path.split('.')
let target = next
for (let i = 0; i < keys.length - 1; i++) {
if (target[keys[i]] == null) target[keys[i]] = {}
target = target[keys[i]]
}
target[keys[keys.length - 1]] = value
return next
})
}
const profile = editProfile ? profileForm : config?.profile
if (!profile) {
return (
<div className="config-profile-center">
<div className="config-card">
<div className="empty-state">{t('config.loadingProfile')}</div>
</div>
</div>
)
}
const personalKeys = Object.entries(profile).filter(([k, v]) => k !== 'preferences' && typeof v !== 'object')
const personalObj = Object.fromEntries(personalKeys)
const preferences = profile.preferences || null
return ( return (
<div className="config-card"> <div className="config-profile-center">
{config?.profile && !editProfile ? ( <div className="config-card">
<> <div className="section-title">{t('config.profileInfo') || 'Informations personnelles'}</div>
<div className="config-card-row"> <RenderFields obj={personalObj} path="" editing={editProfile} onChange={updateField} t={t} />
<span className="config-card-label">{t('config.name')}</span> </div>
<span className="config-card-value">{config.profile.name || '—'}</span> <div className="config-card">
</div> <div className="section-title">{t('config.profilePrefs') || 'Préférences'}</div>
<div className="config-card-row"> {preferences ? (
<span className="config-card-label">{t('config.pseudo')}</span> <RenderFields obj={preferences} path="preferences" editing={editProfile} onChange={updateField} t={t} />
<span className="config-card-value">{config.profile.pseudo || '—'}</span> ) : (
</div> <div className="config-card-row"><span className="config-card-value" style={{ color: 'var(--text-disabled)' }}></span></div>
<div className="config-card-row"> )}
<span className="config-card-label">{t('config.email')}</span> </div>
<span className="config-card-value">{config.profile.email || '—'}</span> <div className="config-card">
</div> <div className="config-card-actions" style={{ justifyContent: 'center' }}>
<div className="config-card-row"> {editProfile ? (
<span className="config-card-label">{t('config.editor')}</span> <>
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span> <button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
</div> <button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
<div className="config-card-row"> </>
<span className="config-card-label">{t('config.shell')}</span> ) : (
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span> <button className="primary sm" onClick={() => {
</div> setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
<div className="config-card-row"> setEditProfile(true)
<span className="config-card-label">{t('config.languages')}</span> }}>{t('config.editProfile')}</button>
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span> )}
</div> </div>
<div className="config-card-actions"> </div>
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
</div>
</>
) : editProfile ? (
<>
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
<div className="config-card-actions">
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</div>
</>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</div> </div>
) )
} }
function RenderFields({ obj, path, editing, onChange, t }) {
if (!obj || typeof obj !== 'object') return null
return Object.entries(obj).filter(([, v]) => v === null || typeof v !== 'object').map(([key, value]) => {
const fieldPath = path ? `${path}.${key}` : key
const label = getFieldLabel(key, t)
if (editing) {
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={value} onChange={e => onChange(fieldPath, e.target.checked)} />
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{value ? 'On' : 'Off'}</span>
</label>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" value={value.join(', ')} onChange={e => onChange(fieldPath, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} />
</div>
)
}
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" type={typeof value === 'number' ? 'number' : 'text'} value={value ?? ''} onChange={e => onChange(fieldPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} />
</div>
)
}
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value ? 'On' : 'Off'}</span>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value.length > 0 ? value.join(', ') : '—'}</span>
</div>
)
}
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value != null && value !== '' ? String(value) : '—'}</span>
</div>
)
})
}
function getFieldLabel(key, t) {
const translated = t(`config.${key}`)
if (translated !== `config.${key}`) return translated
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) { function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null) const [validating, setValidating] = useState(null)
const [validationStatus, setValidationStatus] = useState(null) const [validationStatus, setValidationStatus] = useState(null)
@@ -281,19 +352,21 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
setValidating(null) setValidating(null)
} }
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
return ( return (
<div className="config-providers-list"> <div className="config-providers-list">
<div className="provider-setup-hint">{t('config.setupDescription')}</div> {displayed.map((p, i) => {
{providers.map((p, i) => {
const isEditing = editProvider === p.name const isEditing = editProvider === p.name
const isValidationTarget = validationStatus?.provider === p.name const isValidationTarget = validationStatus?.provider === p.name
const currentModel = providerForm[p.name]?.model || p.model
return ( return (
<div key={i} className="config-card provider-card-v2"> <div key={i} className="config-card provider-card-v2">
<div className="provider-card-top"> <div className="provider-card-top">
<div className="provider-card-identity"> <div className="provider-card-identity">
<span className="provider-card-name">{p.name}</span> <span className="provider-card-name">{p.name.toUpperCase()}</span>
{p.apiKey && <span className="badge ok">{t('config.keyConfigured')}</span>} {p.active && <span className="badge ok" style={{ marginLeft: 6 }}>active</span>}
{!p.apiKey && <span className="badge error">{t('config.noKey')}</span>}
{isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>} {isValidationTarget && validationStatus?.valid && <span className="badge ok">{t('config.keyValid')}</span>}
{isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>} {isValidationTarget && !validationStatus?.valid && <span className="badge error">{validationStatus?.error}</span>}
</div> </div>
@@ -306,7 +379,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<input <input
className="config-form-input" className="config-form-input"
type="password" type="password"
placeholder={t('config.tokenPlaceholder')} placeholder={p.apiKey ? '••••••••' : 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)
@@ -321,17 +394,29 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<button <button
className="sm primary" className="sm primary"
disabled={validating === p.name || !providerForm[p.name]?.api_key} disabled={validating === p.name || !providerForm[p.name]?.api_key}
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, providerForm[p.name]?.model, providerForm[p.name]?.base_url)} onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
> >
{validating === p.name ? t('config.validating') : t('config.validateKey')} {validating === p.name ? t('config.validating') : t('config.validateKey')}
</button> </button>
{isValidationTarget && validationStatus?.valid && ( {isEditing && (
<button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button> <button className="sm" onClick={() => handleSaveProvider(p.name)}>{t('config.save')}</button>
)} )}
</div> </div>
</div> </div>
<div className="provider-card-meta" style={{ marginTop: 8 }}> <div className="provider-card-meta" style={{ marginTop: 8 }}>
<span className="mono">{p.model || '—'}</span> <span className="config-form-label">{t('config.model')}</span>
<input
className="config-form-input"
value={currentModel || ''}
onChange={e => {
setProviderForm(prev => ({
...prev,
[p.name]: { ...(prev[p.name] || {}), model: e.target.value },
}))
setEditProvider(p.name)
}}
placeholder="model-name"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -399,35 +484,93 @@ function PanelUpdates({ updates, checking, updating, needsUpdateCount, installed
) )
} }
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) { function PanelLocale({ language, keyboard, layouts, api, t }) {
const { setLanguage, setKeyboard } = useI18n()
const [editLocale, setEditLocale] = useState(false)
const [draftLang, setDraftLang] = useState(language)
const [draftKbd, setDraftKbd] = useState(keyboard)
const [saving, setSaving] = useState(false)
const [toast, setToast] = useState(null)
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 2500)
}
const handleSave = async () => {
setSaving(true)
try {
await api.savePreferences({ language: draftLang, keyboard_layout: draftKbd })
setLanguage(draftLang)
setKeyboard(draftKbd)
setEditLocale(false)
showToast(t('config.saved'))
} catch (err) {
showToast(`${t('config.error')}: ${err.message}`)
}
setSaving(false)
}
const currentLang = LANGUAGES.find(l => l.id === language)
const currentKbd = layouts.find(l => l.id === keyboard)
return ( return (
<div className="config-card"> <div className="config-profile-center">
<div className="config-card-group"> {toast && <div className="config-toast">{toast}</div>}
<span className="config-card-group-label">{t('config.language')}</span> <div className="config-card">
<div className="chip-row"> <div className="config-card-row">
{LANGUAGES.map(lang => ( <span className="config-card-label">{t('config.language')}</span>
<div <span className="config-card-value">{currentLang?.name || language}</span>
key={lang.id} </div>
className={`chip ${language === lang.id ? 'active' : ''}`} <div className="config-card-row">
onClick={() => setLanguage(lang.id)} <span className="config-card-label">{t('config.keyboardLayout')}</span>
> <span className="config-card-value">{currentKbd?.name || keyboard}</span>
{lang.name}
</div>
))}
</div> </div>
</div> </div>
<div className="config-card-group"> {editLocale && (
<span className="config-card-group-label">{t('config.keyboardLayout')}</span> <div className="config-card">
<div className="chip-row"> <div className="config-card-group">
{layouts.map(l => ( <span className="config-card-group-label">{t('config.language')}</span>
<div <div className="chip-row">
key={l.id} {LANGUAGES.map(lang => (
className={`chip ${keyboard === l.id ? 'active' : ''}`} <div
onClick={() => setKeyboard(l.id)} key={lang.id}
> className={`chip ${draftLang === lang.id ? 'active' : ''}`}
{l.name} onClick={() => setDraftLang(lang.id)}
>
{lang.name}
</div>
))}
</div> </div>
))} </div>
<div className="config-card-group">
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
<div className="chip-row">
{layouts.map(l => (
<div
key={l.id}
className={`chip ${draftKbd === l.id ? 'active' : ''}`}
onClick={() => setDraftKbd(l.id)}
>
{l.name}
</div>
))}
</div>
</div>
</div>
)}
<div className="config-card">
<div className="config-card-actions" style={{ justifyContent: 'center' }}>
{editLocale ? (
<>
<button className="primary sm" onClick={handleSave} disabled={saving}>
{saving ? t('config.saving') : t('config.save')}
</button>
<button className="ghost sm" onClick={() => setEditLocale(false)}>{t('config.cancel')}</button>
</>
) : (
<button className="primary sm" onClick={() => { setDraftLang(language); setDraftKbd(keyboard); setEditLocale(true) }}>{t('config.editProfile')}</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -435,30 +578,82 @@ function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t
} }
function PanelSkills({ skillList, t }) { function PanelSkills({ skillList, t }) {
const [selected, setSelected] = useState(null)
if (skillList.length === 0) {
return <div className="empty-state" style={{ color: 'var(--text-disabled)', padding: 40 }}>{t('config.noSkills')}</div>
}
return ( return (
<div className="config-card"> <>
{skillList.length === 0 ? ( <div className="skill-tiles">
<div className="empty-state"> {skillList.map((s, i) => (
{t('config.noSkills')} <div key={i} className="skill-tile" onClick={() => setSelected(s)}>
<span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span> <div className="skill-tile-name">{s.name}</div>
</div> <div className="skill-tile-desc">{s.description}</div>
) : ( <div className="skill-tile-tags">
skillList.map((s, i) => ( {s.target && <span className="badge neutral">{s.target}</span>}
<div key={i} className="config-skill-row"> {s.version && <span className="badge">{s.version}</span>}
<span className="config-skill-name">{s.name}</span> {s.category && <span className="badge" style={{ opacity: 0.7 }}>{s.category}</span>}
<span className="badge neutral">{s.target || 'both'}</span> </div>
{s.version && <span className="badge" style={{ fontSize: 10 }}>{s.version}</span>}
{s.category && <span className="badge" style={{ fontSize: 10, opacity: 0.7 }}>{s.category}</span>}
<span className="config-skill-desc">{s.description}</span>
{s.dependencies && s.dependencies.length > 0 && (
<div style={{ marginTop: 4, fontSize: 10, color: 'var(--muted)' }}>
deps: {s.dependencies.map(d => d.name).join(', ')}
</div>
)}
</div> </div>
)) ))}
</div>
{selected && (
<div className="skill-detail-overlay" onClick={() => setSelected(null)}>
<div className="skill-detail-panel" onClick={e => e.stopPropagation()}>
<div className="skill-detail-header">
<span className="skill-detail-name">{selected.name}</span>
<button className="ghost sm" onClick={() => setSelected(null)}></button>
</div>
<div className="skill-detail-body">
<div className="skill-detail-section">
<div className="skill-detail-label">Description</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{selected.description}</div>
</div>
<div className="skill-detail-section">
<div className="skill-detail-label">Métadonnées</div>
<div className="skill-detail-meta">
{selected.target && <span className="badge neutral">{selected.target}</span>}
{selected.version && <span className="badge">{selected.version}</span>}
{selected.category && <span className="badge">{selected.category}</span>}
{selected.author && <span className="badge ghost">{selected.author}</span>}
{selected.languages && selected.languages.map(l => <span key={l} className="badge ghost">{l}</span>)}
</div>
</div>
{selected.tags && selected.tags.length > 0 && (
<div className="skill-detail-section">
<div className="skill-detail-label">Tags</div>
<div className="chip-row">
{selected.tags.map(tag => <span key={tag} className="badge">{tag}</span>)}
</div>
</div>
)}
{selected.content && (
<div className="skill-detail-section">
<div className="skill-detail-label">Contenu</div>
<div className="skill-detail-content">{selected.content}</div>
</div>
)}
{selected.dependencies && selected.dependencies.length > 0 && (
<div className="skill-detail-section">
<div className="skill-detail-label">Dépendances</div>
<div className="skill-detail-deps">
{selected.dependencies.map((d, i) => (
<div key={i} className="skill-detail-dep">
<span className="badge">{d.type}</span>
<span>{d.name}</span>
{d.required === false && <span style={{ fontSize: 11, color: 'var(--text-disabled)' }}>optionnel</span>}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)} )}
</div> </>
) )
} }

View File

@@ -3,31 +3,8 @@ import { useI18n } from '../i18n'
const MAX_POINTS = 30 const MAX_POINTS = 30
function BgGraph({ data, max, color }) { const POLL_INTERVAL = 5000
if (!data || data.length < 2) return null const MAX_IDLE_POLLS = 3
const m = max || Math.max(...data, 1)
const w = 120
const h = 60
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w
const y = h - (v / m) * h
return `${x},${y}`
})
const area = `${points.join(' ')} ${w},${h} 0,${h}`
const line = points.join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} className="dash-bg-graph" preserveAspectRatio="none">
<defs>
<linearGradient id={`g-${color.replace('#','')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.25" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<polygon fill={`url(#g-${color.replace('#','')})`} points={area} />
<polyline fill="none" stroke={color} strokeWidth="1.5" points={line} vectorEffect="non-scaling-stroke" opacity="0.6" />
</svg>
)
}
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>
@@ -70,7 +47,6 @@ export default function Dashboard({ api, refreshRef }) {
const memRef = useRef([]) const memRef = useRef([])
const netRxRef = useRef([]) const netRxRef = useRef([])
const netTxRef = useRef([]) const netTxRef = useRef([])
const procCountRef = useRef([])
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
@@ -90,7 +66,6 @@ export default function Dashboard({ api, refreshRef }) {
netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS) netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS)
netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS) netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS)
} }
procCountRef.current = [...procCountRef.current, procData.processes?.length || 0].slice(-MAX_POINTS)
} catch (err) { } catch (err) {
console.error('Dashboard load error:', err) console.error('Dashboard load error:', err)
} }
@@ -99,105 +74,114 @@ export default function Dashboard({ api, refreshRef }) {
useEffect(() => { useEffect(() => {
loadData() loadData()
if (refreshRef) refreshRef.current = loadData if (refreshRef) refreshRef.current = loadData
const iv = setInterval(loadData, 5000) let active = true
return () => clearInterval(iv) let idleTicks = 0
const iv = setInterval(() => {
const hidden = document.querySelector('.dash-grid')?.closest('.tab-hidden')
if (hidden) {
idleTicks++
if (idleTicks >= MAX_IDLE_POLLS) return
} else {
idleTicks = 0
}
if (active) loadData()
}, POLL_INTERVAL)
return () => { active = false; clearInterval(iv) }
}, [loadData, refreshRef]) }, [loadData, refreshRef])
const minimax = (quota || []).find(p => p.name === 'minimax') const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai') const zai = (quota || []).find(p => p.name === 'zai')
const totalQuotaUsed = minimax?.data?.models?.reduce((s, m) => s + (m.used || 0), 0) || 0
const totalQuotaMax = minimax?.data?.models?.reduce((s, m) => s + (m.total || 0), 0) || 1 const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history']
const topCmds = (() => {
const counts = {}
for (const c of recentCmds) {
const base = c.cmd.split(/\s+/)[0]
if (EXCLUDE_CMDS.includes(base) || !base) continue
counts[base] = (counts[base] || 0) + 1
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([cmd, count]) => ({ cmd, count }))
})()
return ( return (
<div className="dash-grid"> <div className="dash-grid">
{/* CPU */} {/* CPU */}
<div className="dash-card dash-card-graph"> <div className="dash-card">
<BgGraph data={cpuRef.current} max={100} color="#06b6d4" /> <div className="dash-card-head">
<div className="dash-card-content"> <span className="dash-label">CPU</span>
<div className="dash-card-head"> <span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
<span className="dash-label">CPU</span>
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
</div>
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
</div> </div>
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
</div> </div>
{/* RAM */} {/* RAM */}
<div className="dash-card dash-card-graph"> <div className="dash-card">
<BgGraph data={memRef.current} max={100} color="#a78bfa" /> <div className="dash-card-head">
<div className="dash-card-content"> <span className="dash-label">RAM</span>
<div className="dash-card-head"> <span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
<span className="dash-label">RAM</span>
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
</div> </div>
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
</div> </div>
{/* Network */} {/* Network */}
<div className="dash-card dash-card-graph"> <div className="dash-card">
<BgGraph data={netRxRef.current} max={null} color="#34d399" /> <div className="dash-card-head">
<div className="dash-card-content"> <span className="dash-label">Network</span>
<div className="dash-card-head"> <span className="dash-count">{metrics ? `${metrics.net_rx_kbs.toFixed(0)}${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
<span className="dash-label">Network</span>
<span className="dash-count">{metrics ? `${metrics.net_rx_kbs.toFixed(0)}${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
</div> </div>
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
</div> </div>
{/* API Quota */} {/* API Quota */}
<div className="dash-card dash-card-graph"> <div className="dash-card">
<BgGraph data={totalQuotaMax > 0 ? [totalQuotaUsed / totalQuotaMax * 100, ...(cpuRef.current.length > 0 ? [] : [0])] : []} max={100} color="#f472b6" /> <div className="dash-card-head">
<div className="dash-card-content"> <span className="dash-label">API Quota</span>
<div className="dash-card-head"> </div>
<span className="dash-label">API Quota</span> <div className="dash-quota-list">
</div> {minimax && minimax.data?.models?.map((m, i) => (
<div className="dash-quota-list"> <div key={i} className="dash-quota-row">
{minimax && minimax.data?.models?.map((m, i) => ( <span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
<div key={i} className="dash-quota-row"> <div className="dash-bar">
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span> <div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
<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.remaining}/{m.total}</span>
</div> </div>
))} <span className="dash-quota-val">{m.used}/{m.total}</span>
{minimax && minimax.data?.models?.length === 0 && ( </div>
<div className="dash-quota-row"> ))}
<span className="dash-quota-name">MiniMax</span> {minimax && minimax.data?.models?.length === 0 && (
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span> <div className="dash-quota-row">
</div> <span className="dash-quota-name">MiniMax</span>
)} <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
{zai && ( </div>
<div className="dash-quota-row"> )}
<span className="dash-quota-name">Z.AI</span> {zai && (
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span> <div className="dash-quota-row">
</div> <span className="dash-quota-name">Z.AI</span>
)} <span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>} </div>
</div> )}
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div> </div>
</div> </div>
{/* Running Processes */} {/* Running Processes */}
<div className="dash-card dash-card-graph"> <div className="dash-card">
<BgGraph data={procCountRef.current} max={null} color="#fb923c" /> <div className="dash-card-head">
<div className="dash-card-content"> <span className="dash-label">Processes</span>
<div className="dash-card-head"> <span className="dash-count">{processes.length}</span>
<span className="dash-label">Processes</span> </div>
<span className="dash-count">{processes.length}</span> <div className="dash-proc-list">
</div> {processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
<div className="dash-proc-list"> {processes.map((p, i) => (
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>} <div key={i} className="dash-proc-row">
{processes.slice(0, 6).map((p, i) => ( <span className="dash-proc-name">{p.name}</span>
<div key={i} className="dash-proc-row"> <span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
<span className="dash-proc-name">{p.name}</span> </div>
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span> ))}
</div>
))}
</div>
</div> </div>
</div> </div>
@@ -206,9 +190,19 @@ export default function Dashboard({ api, refreshRef }) {
<div className="dash-card-head"> <div className="dash-card-head">
<span className="dash-label">Recent Commands</span> <span className="dash-label">Recent Commands</span>
</div> </div>
{topCmds.length > 0 && (
<div className="dash-cmd-top">
{topCmds.map((c, i) => (
<div key={i} className="dash-cmd-chip" onClick={() => navigator.clipboard.writeText(c.cmd)} title="Copier">
<span className="dash-cmd-chip-name">{c.cmd}</span>
<span className="dash-cmd-chip-count">{c.count}×</span>
</div>
))}
</div>
)}
<div className="dash-cmd-list"> <div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>} {recentCmds.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.slice(0, 8).map((c, i) => ( {recentCmds.map((c, i) => (
<div key={i} className="dash-cmd-row" title={c.cmd}> <div key={i} className="dash-cmd-row" title={c.cmd}>
<span className="dash-cmd-shell">{c.shell}</span> <span className="dash-cmd-shell">{c.shell}</span>
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span> <span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>

View File

@@ -2,11 +2,12 @@ import { useState, useRef, useEffect, useCallback } from 'react'
import { Terminal as XTerm } from '@xterm/xterm' import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links' import { WebLinksAddon } from '@xterm/addon-web-links'
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2 } from 'lucide-react' import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send } from 'lucide-react'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
const MAX_TABS = 7 const MAX_TABS = 7
const SHELL_MAX_TOKENS = 100000
const THEMES = { const THEMES = {
default: { default: {
@@ -163,17 +164,35 @@ export default function Shell({ api }) {
name: '', host: '', port: 22, user: '', key_path: '', name: '', host: '', port: 22, user: '', key_path: '',
}) })
const [aiMessages, setAiMessages] = useState([ const [aiMessages, setAiMessages] = useState([])
{ role: 'ai', content: t('shell.aiWelcome') }
])
const [aiInput, setAiInput] = useState('') const [aiInput, setAiInput] = useState('')
const [aiLoading, setAiLoading] = useState(false) const [aiLoading, setAiLoading] = useState(false)
const [aiTokens, setAiTokens] = useState(0)
const [aiAtLimit, setAiAtLimit] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const aiMessagesRef = useRef(null) const aiMessagesRef = useRef(null)
const aiLoadedRef = useRef(false)
useEffect(() => { useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
}, [aiMessages]) }, [aiMessages])
useEffect(() => {
if (aiLoadedRef.current) return
aiLoadedRef.current = true
api.getShellChatHistory().then(d => {
if (d.messages && d.messages.length > 0) {
setAiMessages(d.messages)
} else {
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Système Analyste prêt. Tapez /help pour les commandes.' }])
}
setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false)
}).catch(() => {
setAiMessages([{ role: 'assistant', content: 'Système Analyste prêt.' }])
})
}, [])
useEffect(() => { useEffect(() => {
api.getTerminalSessions().then(d => { api.getTerminalSessions().then(d => {
setSshConnections(d.ssh || []) setSshConnections(d.ssh || [])
@@ -372,57 +391,83 @@ export default function Shell({ api }) {
} }
} }
const handleAiSend = async () => { const sendToTerminal = useCallback((code) => {
if (!aiInput.trim() || aiLoading) return const tab = tabs.find(t => t.id === activeTab)
const text = aiInput.trim() if (!tab) return
setAiMessages(prev => [...prev, { role: 'user', content: text }]) const entry = tabsRef.current[tab.id]
setAiInput('') if (!entry?.ws || entry.ws.readyState !== WebSocket.OPEN) return
setAiLoading(true) entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
}, [tabs, activeTab])
const currentTab = tabs.find(t => t.id === activeTab) const handleAiSend = async () => {
const context = { if (!aiInput.trim() || aiLoading || aiAtLimit) return
cwd: currentTab?.cwd || '', const text = aiInput.trim()
platform: navigator.platform || '', setAiInput('')
if (text === '/clear') {
try {
await api.clearShellChat()
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
setAiTokens(0)
setAiAtLimit(false)
} catch {}
return
} }
if (text === '/help') {
setAiMessages(prev => [...prev,
{ role: 'user', content: text },
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
])
return
}
setAiMessages(prev => [...prev, { role: 'user', content: text }])
setAiLoading(true)
try { try {
let accumulated = '' let accumulated = ''
await api.sendShellChat(text, context, true, (partial, event) => { await api.sendShellChat(text, {}, true, (partial) => {
if (event && event.tool_call) {
setAiMessages(prev => [...prev, {
role: 'tool',
content: `${t('shell.toolLaunched')}: ${event.tool_call.name || 'tool'}`,
args: event.tool_call.args ? JSON.stringify(event.tool_call.args).slice(0, 100) : '',
}])
return
}
if (event && event.tool_result) {
const resultText = event.tool_result.result?.content || event.tool_result.error || 'completed'
setAiMessages(prev => [...prev, {
role: 'tool_result',
content: resultText,
isError: event.tool_result.result?.is_error,
}])
return
}
if (event && event.done) return
accumulated = partial accumulated = partial
setAiMessages(prev => { setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming) const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'ai', content: partial, _streaming: true }] return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
}) })
}) })
setAiMessages(prev => prev.filter(m => !m._streaming)) setAiMessages(prev => {
if (accumulated) { const filtered = prev.filter(m => !m._streaming)
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: accumulated }]) return [...filtered, { role: 'assistant', content: accumulated }]
} })
// Refresh token count
api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false)
}).catch(() => {})
} catch (err) { } catch (err) {
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: `${t('shell.error')}: ${err.message}` }]) if (err.message.includes('context limit')) {
setAiAtLimit(true)
}
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
} }
setAiLoading(false) setAiLoading(false)
} }
const handleAnalyze = async () => {
setAnalyzing(true)
setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }])
try {
const d = await api.analyzeSystem()
setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), {
role: 'system',
content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.'
}])
} catch (err) {
setAiMessages(prev => prev.filter(m => m.content !== 'Analyse du système en cours...'))
}
setAnalyzing(false)
}
return ( return (
<div className="shell-layout"> <div className="shell-layout">
<div className="shell-terminal-col"> <div className="shell-terminal-col">
@@ -538,13 +583,30 @@ export default function Shell({ api }) {
</div> </div>
<div className="shell-ai-col"> <div className="shell-ai-col">
<div className="ai-panel-header">{t('shell.aiAssistant')}</div> <div className="ai-panel-header">
<span>Analyste Système</span>
<button
className="shell-analyze-btn"
onClick={handleAnalyze}
disabled={analyzing}
title="Analyser le système"
>
<Search size={13} />
{analyzing ? '...' : 'Analyser'}
</button>
</div>
<div className="shell-ai-token-bar">
<div className="shell-ai-token-track">
<div
className={`shell-ai-token-fill ${aiTokens >= SHELL_MAX_TOKENS * 0.8 ? 'warn' : ''}`}
style={{ width: `${Math.min(100, (aiTokens / SHELL_MAX_TOKENS) * 100)}%` }}
/>
</div>
<span className="shell-ai-token-text">{Math.round(aiTokens / 1000)}k/{Math.round(SHELL_MAX_TOKENS / 1000)}k</span>
</div>
<div className="ai-panel-messages" ref={aiMessagesRef}> <div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => ( {aiMessages.map((msg, i) => (
<div key={i} className={`ai-message ${msg.role}`}> <ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} />
{msg.content}
{msg.args && <div className="tool-args">{msg.args}</div>}
</div>
))} ))}
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>} {aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div> </div>
@@ -553,9 +615,10 @@ export default function Shell({ api }) {
value={aiInput} value={aiInput}
onChange={e => setAiInput(e.target.value)} onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()} onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder={t('shell.askAi')} placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
disabled={aiAtLimit && aiInput !== '/clear'}
/> />
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button> <button className="sm" onClick={handleAiSend} disabled={(!aiInput.trim() && !aiAtLimit) || (aiAtLimit && aiInput !== '/clear')}>{t('shell.send')}</button>
</div> </div>
</div> </div>
@@ -611,3 +674,50 @@ export default function Shell({ api }) {
</div> </div>
) )
} }
function ShellAIMessage({ msg, sendToTerminal }) {
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
const parts = parseMarkdown(msg.content || '')
return (
<div className={`ai-message ${role}`}>
{parts.map((part, i) => {
if (part.type === 'code') {
return (
<div key={i} className="shell-code-block">
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
<pre><code>{part.code}</code></pre>
<div className="shell-code-actions">
<button onClick={() => navigator.clipboard.writeText(part.code)} title="Copier">
<Copy size={12} /> Copier
</button>
<button onClick={() => sendToTerminal(part.code)} title="Envoyer au terminal">
<Send size={12} /> Terminal
</button>
</div>
</div>
)
}
return <span key={i}>{part.text}</span>
})}
</div>
)
}
function parseMarkdown(text) {
const parts = []
const regex = /```(\w*)\n([\s\S]*?)```/g
let last = 0
let match
while ((match = regex.exec(text)) !== null) {
if (match.index > last) {
parts.push({ type: 'text', text: text.slice(last, match.index) })
}
parts.push({ type: 'code', lang: match[1] || '', code: match[2].replace(/\n$/, '') })
last = match.index + match[0].length
}
if (last < text.length) {
parts.push({ type: 'text', text: text.slice(last) })
}
return parts.length > 0 ? parts : [{ type: 'text', text }]
}

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
const RANKS = { const RANKS = {
@@ -76,7 +76,7 @@ function formatText(text) {
return html return html
} }
function ThinkingBlock({ content, done }) { function ThinkingBlock({ content, done, raw }) {
return ( return (
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}> <div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
<div className="feed-thinking-header"> <div className="feed-thinking-header">
@@ -86,7 +86,9 @@ function ThinkingBlock({ content, done }) {
<span>Reflexion</span> <span>Reflexion</span>
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>} {!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
</div> </div>
<div className="feed-thinking-content">{content}</div> <div className="feed-thinking-content">
{raw ? <span dangerouslySetInnerHTML={{ __html: content }} /> : content}
</div>
</div> </div>
) )
} }
@@ -200,7 +202,7 @@ function FeedItem({ msg }) {
<span className="feed-role">{rank.label}</span> <span className="feed-role">{rank.label}</span>
{timeStr && <span className="feed-time">{timeStr}</span>} {timeStr && <span className="feed-time">{timeStr}</span>}
</div> </div>
{msg.thinking && <ThinkingBlock content={msg.thinking} done />} {msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
{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)
@@ -234,6 +236,16 @@ function StreamingItem({ content, thinking, toolCalls }) {
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 renderedContent = useMemo(() => {
if (!cleanContent) return []
return renderContent(cleanContent)
}, [cleanContent])
const formattedThinking = useMemo(() => {
if (!thinking) return ''
return formatText(thinking)
}, [thinking])
return ( return (
<div className="feed-item assistant"> <div className="feed-item assistant">
<div className="feed-avatar ai-rank"> <div className="feed-avatar ai-rank">
@@ -246,7 +258,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
</span> </span>
<span className="feed-role">{rank.label}</span> <span className="feed-role">{rank.label}</span>
</div> </div>
{thinking && <ThinkingBlock content={thinking} done={false} />} {thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
{hasToolCalls && toolCalls.map((tc, i) => ( {hasToolCalls && toolCalls.map((tc, i) => (
<ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} /> <ToolCallBlock key={tc.call?.tool_call_id || i} call={tc.call} result={tc.result} />
))} ))}
@@ -257,7 +269,7 @@ function StreamingItem({ content, thinking, toolCalls }) {
)} )}
{cleanContent && ( {cleanContent && (
<div className="feed-content"> <div className="feed-content">
{renderContent(cleanContent).map((part, i) => {renderedContent.map((part, i) =>
part.type === 'code' ? ( part.type === 'code' ? (
<div key={i} className="studio-code-block"> <div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>} {part.lang && <div className="studio-code-lang">{part.lang}</div>}
@@ -285,7 +297,10 @@ export default function Studio({ api }) {
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: 100000, summarizeAt: 80000 })
const [contextCollapsed, setContextCollapsed] = useState(false)
const [messagesCollapsed, setMessagesCollapsed] = useState(false)
const messagesEnd = useRef(null) const messagesEnd = useRef(null)
const feedRef = useRef(null)
const textareaRef = useRef(null) const textareaRef = useRef(null)
const abortRef = useRef(null) const abortRef = useRef(null)
@@ -336,12 +351,18 @@ export default function Studio({ api }) {
const handleSummarize = useCallback(async () => { const handleSummarize = useCallback(async () => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }]) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: 'Résumé de la conversation en cours...', time: new Date().toISOString() }])
setContextCollapsed('animating')
try { try {
const data = await api.summarizeChat() const data = await api.summarizeChat()
setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 })) setTokenInfo(prev => ({ ...prev, used: data.tokens || 0 }))
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString() }]) setTimeout(() => {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: '✓ Conversation résumée automatiquement. Le contexte a été compressé.', time: new Date().toISOString(), compressed: true }])
setContextCollapsed(true)
setMessagesCollapsed(true)
}, 600)
} catch (err) { } catch (err) {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }]) setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), role: 'system', content: `Erreur de résumé: ${err.message}`, time: new Date().toISOString() }])
setContextCollapsed(false)
} }
}, [api]) }, [api])
@@ -396,7 +417,7 @@ export default function Studio({ api }) {
if (text === '/model') { if (text === '/model') {
api.getProviders().then(data => { api.getProviders().then(data => {
const active = data.providers?.find(p => p.active) const active = data.providers?.find(p => p.active)
const modelMsg = active ? `Provider: ${active.name}\nModèle: ${active.model}` : 'Aucun provider actif configuré' const modelMsg = active ? active.name : 'Aucun provider actif configuré'
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }]) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: modelMsg, time: new Date().toISOString() }])
}).catch(() => { }).catch(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }]) setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'Erreur: impossible de récupérer les providers', time: new Date().toISOString() }])
@@ -525,6 +546,34 @@ export default function Studio({ api }) {
} }
} }
const handleToggleCollapsed = useCallback(() => {
setMessagesCollapsed(prev => !prev)
}, [])
const renderMessages = () => {
if (messagesCollapsed && messages.length > 4) {
const visibleCount = 4
const hiddenCount = messages.length - visibleCount
return (
<>
{messages.slice(0, visibleCount).map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
<div className="feed-collapsed-messages" onClick={handleToggleCollapsed}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span className="feed-collapsed-text">{hiddenCount} messages antérieurs compressés</span>
<span className="feed-collapsed-count">clic pour développer</span>
</div>
</>
)
}
return messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))
}
if (!loaded) { if (!loaded) {
return ( return (
<div className="studio-feed-layout"> <div className="studio-feed-layout">
@@ -539,28 +588,42 @@ export default function Studio({ api }) {
return ( return (
<div className="studio-feed-layout"> <div className="studio-feed-layout">
<div className="studio-feed"> <div className="studio-feed-scroll-wrap">
{messages.map(msg => ( <div className="studio-feed" ref={feedRef}>
<FeedItem key={msg.id} msg={msg} /> {renderMessages()}
))} {(streaming || streamThinking || loading || streamToolCalls.length > 0) && (
{(streaming || streamThinking || loading || streamToolCalls.length > 0) && ( <StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} />
<StreamingItem content={streaming} thinking={streamThinking} toolCalls={streamToolCalls} /> )}
)} <div ref={messagesEnd} style={{ height: '24px' }} />
<div ref={messagesEnd} /> </div>
<div className="studio-scroll-btns">
<button className="studio-scroll-btn" onClick={() => feedRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} title="Remonter">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6"/></svg>
</button>
<button className="studio-scroll-btn" onClick={() => messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })} title="Descendre">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
</div>
</div> </div>
<div className="studio-input-area"> <div className="studio-input-area">
<div className="studio-token-bar"> <div className={`studio-token-bar ${contextCollapsed === true ? 'compressed' : ''}`}>
<div className="studio-token-track"> <div className={`studio-token-track ${contextCollapsed === true ? 'compressed' : ''}`}>
<div <div
className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''}`} className={`studio-token-fill ${tokenInfo.used >= tokenInfo.summarizeAt ? 'warn' : ''} ${contextCollapsed === true ? 'compressed' : ''} ${contextCollapsed === 'animating' ? 'animating' : ''}`}
style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }} style={{ width: `${Math.min(100, (tokenInfo.used / tokenInfo.max) * 100)}%` }}
/> />
</div> </div>
<span className="studio-token-text"> <span className={`studio-token-text ${contextCollapsed === true ? 'compressed' : ''}`}>
{(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens {(tokenInfo.used / 1000).toFixed(1)}k / {(tokenInfo.max / 1000).toFixed(0)}k tokens
{tokenInfo.used >= tokenInfo.summarizeAt && ' · résumé automatique déclenché'} {contextCollapsed === true && ' · compressé'}
{tokenInfo.used >= tokenInfo.summarizeAt && contextCollapsed !== true && ' · résumé auto.'}
</span> </span>
{contextCollapsed === true && (
<button className="ghost sm" onClick={handleToggleCollapsed} style={{ marginLeft: '8px', fontSize: '10px' }}>
voir plus
</button>
)}
</div> </div>
<div className="studio-input-row"> <div className="studio-input-row">
<textarea <textarea

View File

@@ -182,6 +182,8 @@ const en = {
installed: 'Installed', installed: 'Installed',
missing: 'Missing', missing: 'Missing',
editProfile: 'Edit', editProfile: 'Edit',
profileInfo: 'Personal Info',
profilePrefs: 'Preferences',
cancel: 'Cancel', cancel: 'Cancel',
editProvider: 'Configure', editProvider: 'Configure',
validateKey: 'Validate', validateKey: 'Validate',

View File

@@ -136,7 +136,7 @@ const fr = {
terminal: 'Terminal', terminal: 'Terminal',
updates: 'Mises \u00e0 jour', updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier', locale: 'Langue & Clavier',
skills: 'Comp\u00e9ENCES', skills: 'Compétences',
system: 'Syst\u00e8me', system: 'Syst\u00e8me',
}, },
profile: 'Profil', profile: 'Profil',
@@ -160,7 +160,7 @@ const fr = {
save: 'Enregistrer', save: 'Enregistrer',
saved: 'Enregistr\u00e9 !', saved: 'Enregistr\u00e9 !',
error: 'Erreur', error: 'Erreur',
skills: 'Comp\u00e9ENCES', skills: 'Compétences',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.', noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init', runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue', language: 'Langue',
@@ -182,6 +182,8 @@ const fr = {
installed: 'Install\u00e9', installed: 'Install\u00e9',
missing: 'Manquant', missing: 'Manquant',
editProfile: 'Modifier', editProfile: 'Modifier',
profileInfo: 'Informations personnelles',
profilePrefs: 'Préférences',
editProvider: 'Configurer', editProvider: 'Configurer',
validateKey: 'Valider', validateKey: 'Valider',
validating: 'V\u00e9rification...', validating: 'V\u00e9rification...',

View File

@@ -154,7 +154,9 @@ input::placeholder { color: var(--text-disabled); }
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; } .header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
.content { flex: 1; overflow: hidden; } .content { flex: 1; overflow: hidden; position: relative; }
.content > div { height: 100%; }
.tab-hidden { display: none; }
.statusbar { .statusbar {
height: 28px; height: 28px;
@@ -392,11 +394,26 @@ input::placeholder { color: var(--text-disabled); }
.connection-dot.off { background: var(--error); } .connection-dot.off { background: var(--error); }
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; } .shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); } .ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
.shell-analyze-btn {
display: flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: var(--radius);
background: transparent; border: 1px solid var(--accent-dim);
color: var(--accent); font-size: 11px; font-weight: 600;
cursor: pointer; transition: all 0.15s;
}
.shell-analyze-btn:hover:not(:disabled) { background: var(--accent-bg); }
.shell-analyze-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.shell-ai-token-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--border); }
.shell-ai-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
.shell-ai-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
.shell-ai-token-fill.warn { background: var(--warning); }
.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.ai { 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.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); } .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.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); } .ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); }
.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); } .ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); }
.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); } .ai-message.tool .tool-name { font-weight: 700; color: var(--warning); }
@@ -404,6 +421,31 @@ input::placeholder { color: var(--text-disabled); }
.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; }
.shell-code-block {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
margin: 8px 0 4px; overflow: hidden;
}
.shell-code-block pre {
padding: 10px 12px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5;
overflow-x: auto; color: var(--text-primary); margin: 0;
}
.shell-code-lang {
padding: 3px 10px; font-size: 10px; font-weight: 600; color: var(--text-tertiary);
background: var(--bg-surface); border-bottom: 1px solid var(--border);
text-transform: uppercase; letter-spacing: 0.5px;
}
.shell-code-actions {
display: flex; border-top: 1px solid var(--border); background: var(--bg-surface);
}
.shell-code-actions button {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 4px;
padding: 5px 0; background: transparent; border: none; border-right: 1px solid var(--border);
color: var(--text-tertiary); font-size: 11px; cursor: pointer; transition: all 0.1s;
font-family: var(--font-sans);
}
.shell-code-actions button:last-child { border-right: none; }
.shell-code-actions button:hover { background: var(--accent-bg); color: var(--accent); }
.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);
display: flex; align-items: center; justify-content: center; z-index: 1000; display: flex; align-items: center; justify-content: center; z-index: 1000;
@@ -429,12 +471,16 @@ input::placeholder { color: var(--text-disabled); }
.config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.config-tabs-bar { .config-tabs-bar {
display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface); display: flex; gap: 4px; padding: 12px 20px; background: var(--bg-surface);
border-bottom: 1px solid var(--border); flex-shrink: 0; border-bottom: 1px solid var(--border); flex-shrink: 0;
} }
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; } .config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
.config-profile-center {
max-width: 540px; margin: 0 auto; width: 100%;
display: flex; flex-direction: column; gap: 12px;
}
.config-card { .config-card {
background: var(--bg-card); border: 1px solid var(--border); background: var(--bg-card); border: 1px solid var(--border);
@@ -500,10 +546,24 @@ input::placeholder { color: var(--text-disabled); }
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; } .config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); } .config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; } .skill-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.config-skill-row:last-child { border-bottom: none; } .skill-tile { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; cursor: pointer; transition: border-color 0.15s; }
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; } .skill-tile:hover { border-color: var(--accent-dim); }
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .skill-tile-name { font-weight: 600; color: var(--text-primary); font-size: 14px; margin-bottom: 6px; }
.skill-tile-desc { font-size: 12px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.skill-tile-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.skill-detail-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 50; display: flex; align-items: center; justify-content: center; }
.skill-detail-panel { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); width: 90%; max-width: 600px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; }
.skill-detail-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.skill-detail-name { font-weight: 600; font-size: 16px; color: var(--text-primary); }
.skill-detail-body { flex: 1; overflow-y: auto; padding: 20px; }
.skill-detail-section { margin-bottom: 16px; }
.skill-detail-label { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.skill-detail-meta { display: flex; gap: 8px; flex-wrap: wrap; }
.skill-detail-content { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); white-space: pre-wrap; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; line-height: 1.6; max-height: 300px; overflow-y: auto; }
.skill-detail-deps { display: flex; flex-direction: column; gap: 6px; }
.skill-detail-dep { font-size: 12px; color: var(--text-tertiary); display: flex; align-items: center; gap: 8px; }
.skill-detail-dep .badge { font-size: 10px; }
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; } .chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.config-toast { .config-toast {
@@ -535,6 +595,7 @@ input::placeholder { color: var(--text-disabled); }
.dash-grid { .dash-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
height: 100%; height: 100%;
@@ -547,16 +608,7 @@ input::placeholder { color: var(--text-disabled); }
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; gap: 8px;
overflow: hidden; overflow: hidden;
} }
.dash-card-graph { padding: 0; }
.dash-bg-graph {
position: absolute; inset: 0; width: 100%; height: 100%;
opacity: 0.35; pointer-events: none;
}
.dash-card-content {
position: relative; z-index: 1;
padding: 14px 16px;
display: flex; flex-direction: column; gap: 8px;
}
.dash-span-2 { grid-column: span 2; } .dash-span-2 { grid-column: span 2; }
.dash-card-head { .dash-card-head {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
@@ -585,7 +637,7 @@ input::placeholder { color: var(--text-disabled); }
.dash-tool-tag.missing { color: var(--error); } .dash-tool-tag.missing { color: var(--error); }
/* Quota */ /* Quota */
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; } .dash-quota-list { display: flex; flex-direction: column; gap: 6px; max-height: 270px; overflow-y: auto; }
.dash-quota-row { display: flex; align-items: center; gap: 8px; } .dash-quota-row { display: flex; align-items: center; gap: 8px; }
.dash-quota-name { .dash-quota-name {
font-size: 11px; font-weight: 600; color: var(--text-primary); font-size: 11px; font-weight: 600; color: var(--text-primary);
@@ -604,21 +656,21 @@ input::placeholder { color: var(--text-disabled); }
} }
/* Processes */ /* Processes */
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; } .dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
.dash-proc-row { .dash-proc-row {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
padding: 4px 0; padding: 4px 0;
} }
.dash-proc-name { .dash-proc-name {
font-size: 11px; font-weight: 600; color: var(--text-primary); font-size: 11px; font-weight: 600; color: var(--text-primary);
font-family: var(--font-mono); font-family: var(--font-mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.dash-proc-res { .dash-proc-res {
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); flex-shrink: 0;
} }
/* Commands */ /* Commands */
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; } .dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
.dash-cmd-row { .dash-cmd-row {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
padding: 3px 0; overflow: hidden; padding: 3px 0; overflow: hidden;
@@ -631,8 +683,20 @@ input::placeholder { color: var(--text-disabled); }
.dash-cmd-text { .dash-cmd-text {
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary); font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
flex: 1; min-width: 0;
} }
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.dash-cmd-chip {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: var(--radius);
background: var(--bg-surface); border: 1px solid var(--border);
cursor: pointer; transition: all 0.15s;
}
.dash-cmd-chip:hover { border-color: var(--accent-dim); background: var(--accent-bg); }
.dash-cmd-chip-name { font-size: 13px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
.dash-cmd-chip-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); }
/* Services */ /* Services */
.dash-services { display: flex; flex-direction: column; gap: 6px; } .dash-services { display: flex; flex-direction: column; gap: 6px; }
.dash-svc-row { .dash-svc-row {
@@ -712,7 +776,17 @@ input::placeholder { color: var(--text-disabled); }
/* ── Studio Feed ── */ /* ── Studio Feed ── */
.studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .studio-feed-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.studio-feed { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; } .studio-feed-scroll-wrap { flex: 1; position: relative; overflow: hidden; }
.studio-feed { height: 100%; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 4px; }
.studio-scroll-btns { position: absolute; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 4px; z-index: 10; }
.studio-scroll-btn {
width: 32px; height: 32px; border-radius: 50%; padding: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg-card); border: 1px solid var(--border);
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
opacity: 0.7;
}
.studio-scroll-btn:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); opacity: 1; }
.feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; } .feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; }
.feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; } .feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; }
.feed-item:hover { background: var(--bg-card); } .feed-item:hover { background: var(--bg-card); }
@@ -737,6 +811,18 @@ input::placeholder { color: var(--text-disabled); }
.feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; } .feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
.feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; } .feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; }
.feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; } .feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; }
.feed-system-text.compressed { color: var(--accent); font-style: normal; }
.feed-compressed-indicator {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; margin: 4px 0;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); cursor: pointer;
transition: all 0.2s ease;
}
.feed-compressed-indicator:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
.feed-compressed-indicator svg { color: var(--accent); flex-shrink: 0; }
.feed-compressed-text { font-size: 12px; color: var(--text-tertiary); flex: 1; }
.feed-compressed-count { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-thinking-block { .feed-thinking-block {
background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim); background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim);
@@ -800,7 +886,18 @@ input::placeholder { color: var(--text-disabled); }
.studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; } .studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; }
.studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; } .studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; }
.studio-token-fill.warn { background: var(--warning); } .studio-token-fill.warn { background: var(--warning); }
.studio-token-fill.compressed { height: 2px; }
.studio-token-fill.animating { animation: compress-pulse 0.6s ease-in-out; }
@keyframes compress-pulse {
0% { height: 3px; opacity: 1; }
50% { height: 5px; opacity: 0.8; background: var(--accent-light); }
100% { height: 2px; opacity: 1; }
}
.studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; } .studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; }
.studio-token-text.compressed { font-size: 9px; }
.studio-token-track.compressed { height: 2px; }
.studio-token-bar.compressed { margin-bottom: 4px; }
.studio-input-row { display: flex; gap: 8px; align-items: flex-end; } .studio-input-row { display: flex; gap: 8px; align-items: flex-end; }
.studio-input-row textarea { .studio-input-row textarea {
flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px; flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px;
@@ -825,6 +922,21 @@ input::placeholder { color: var(--text-disabled); }
.studio-stop-btn:hover { opacity: 0.8; } .studio-stop-btn:hover { opacity: 0.8; }
.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 ── */
.feed-collapsed-messages {
display: flex; align-items: center; gap: 10px;
padding: 8px 16px; margin: 4px 0;
background: linear-gradient(135deg, var(--bg-surface), var(--bg-elevated));
border: 1px dashed var(--border-accent);
border-radius: var(--radius); cursor: pointer;
transition: all 0.2s ease;
}
.feed-collapsed-messages:hover { background: var(--bg-hover); border-color: var(--accent); }
.feed-collapsed-messages svg { color: var(--accent); flex-shrink: 0; }
.feed-collapsed-text { font-size: 11px; color: var(--text-tertiary); flex: 1; }
.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); }
.feed-expanded-messages { animation: fadeIn 0.2s ease-out; }
/* ── Studio Tool Blocks ── */ /* ── Studio Tool Blocks ── */
.studio-tool-block { .studio-tool-block {
background: var(--bg-surface); background: var(--bg-surface);