diff --git a/internal/agent/definitions.go b/internal/agent/definitions.go index d830440..037ae56 100644 --- a/internal/agent/definitions.go +++ b/internal/agent/definitions.go @@ -6,11 +6,18 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "sync" "time" ) +var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][AB012]|\[\]`) + +func stripANSI(s string) string { + return ansiRegex.ReplaceAllString(s, "") +} + var ( sudoCache bool sudoCacheSet bool @@ -103,6 +110,7 @@ func NewTerminalTool() (*ToolDefinition, error) { output, err := cmd.CombinedOutput() result := string(output) + result = stripANSI(result) if len(result) > 10000 { result = result[:10000] + "\n... [truncated]" } @@ -116,7 +124,8 @@ func NewTerminalTool() (*ToolDefinition, error) { } type CrushRunParams struct { - Task string `json:"task" description:"The task description for Crush to execute"` + Task string `json:"task" description:"The task description for Crush to execute"` + Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 600, max 900)"` } func NewCrushRunTool() (*ToolDefinition, error) { @@ -127,7 +136,14 @@ func NewCrushRunTool() (*ToolDefinition, error) { return TextErrorResponse("task is required"), nil } - ctx, cancel := context.WithTimeout(ctx, 300*time.Second) + timeout := time.Duration(p.Timeout) * time.Second + if timeout == 0 { + timeout = 600 * time.Second + } + if timeout > 900*time.Second { + timeout = 900 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() cmd := exec.CommandContext(ctx, "crush", "run", p.Task) @@ -139,7 +155,14 @@ func NewCrushRunTool() (*ToolDefinition, error) { } if err != nil { - return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil + errMsg := fmt.Sprintf("Crush error: %v", err) + if ctx.Err() == context.DeadlineExceeded { + errMsg = fmt.Sprintf("Crush timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds())) + } + if result != "" { + errMsg += "\n\n" + result + } + return TextErrorResponse(errMsg), nil } return TextResponse(result), nil diff --git a/internal/api/chat_engine.go b/internal/api/chat_engine.go index cc463df..e4146e9 100644 --- a/internal/api/chat_engine.go +++ b/internal/api/chat_engine.go @@ -13,6 +13,9 @@ const ( MaxToolIterations = 15 ) +// ToolLimiter checks if a tool call is allowed and returns a release function. +type ToolLimiter func(toolName string) (release func(), err error) + // ChatEngine handles chat interactions with tool execution. // This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go. type ChatEngine struct { @@ -21,6 +24,7 @@ type ChatEngine struct { tools json.RawMessage onChunk func(map[string]interface{}) stream bool + limiter ToolLimiter TotalTokens int } @@ -44,6 +48,11 @@ func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) { ce.onChunk = fn } +// SetLimiter sets the tool call limiter for agent concurrency control. +func (ce *ChatEngine) SetLimiter(l ToolLimiter) { + ce.limiter = l +} + // RunWithTools executes the chat loop with tool calls. // Returns final content, tool calls, tool results, and error. func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) { @@ -77,7 +86,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator. } choice := resp.Choices[0] - content := cleanThinkingTags(choice.Message.Content) + content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content)) if content != "" { if ce.onChunk != nil { @@ -115,6 +124,35 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator. Arguments: json.RawMessage(tc.Function.Arguments), } + if ce.limiter != nil { + release, limitErr := ce.limiter(tc.Function.Name) + if limitErr != nil { + limResultData := map[string]interface{}{ + "tool_call_id": tc.ID, + "content": limitErr.Error(), + "is_error": true, + } + allToolResults = append(allToolResults, map[string]interface{}{ + "tool_call_id": tc.ID, + "name": tc.Function.Name, + "args": tc.Function.Arguments, + "result": limitErr.Error(), + "is_error": true, + }) + if ce.onChunk != nil { + ce.onChunk(map[string]interface{}{"tool_result": limResultData}) + } + messages = append(messages, orchestrator.Message{ + Role: "tool", + Content: orchestrator.TextContent(limitErr.Error()), + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + continue + } + defer release() + } + result, execErr := ce.registry.Execute(ctx, call) if execErr != nil { result = agent.ToolResponse{ @@ -179,7 +217,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator. } choice := resp.Choices[0] - content := cleanThinkingTags(choice.Message.Content) + content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content)) if content != "" { finalContent = content @@ -203,6 +241,20 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator. Arguments: json.RawMessage(tc.Function.Arguments), } + if ce.limiter != nil { + release, limitErr := ce.limiter(tc.Function.Name) + if limitErr != nil { + messages = append(messages, orchestrator.Message{ + Role: "tool", + Content: orchestrator.TextContent(limitErr.Error()), + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + continue + } + defer release() + } + result, execErr := ce.registry.Execute(ctx, call) if execErr != nil { result = agent.ToolResponse{ diff --git a/internal/api/conversation.go b/internal/api/conversation.go index b658b66..de5454f 100644 --- a/internal/api/conversation.go +++ b/internal/api/conversation.go @@ -17,12 +17,53 @@ const contextWindowTokens = 150000 const summarizeRatio = 0.80 const charsPerToken = 4 +func extractDisplayContent(role, content string) string { + if role != "assistant" { + return content + } + var parsed struct { + Content string `json:"content"` + ToolCalls []struct { + Name string `json:"name"` + Args string `json:"args"` + } `json:"tool_calls"` + ToolResults []struct { + Name string `json:"name"` + Result string `json:"result"` + } `json:"tool_results"` + } + if err := json.Unmarshal([]byte(content), &parsed); err != nil { + return content + } + var sb strings.Builder + if parsed.Content != "" { + sb.WriteString(parsed.Content) + } + for _, tc := range parsed.ToolCalls { + sb.WriteString("\n[") + sb.WriteString(tc.Name) + sb.WriteString("] ") + sb.WriteString(tc.Args) + } + for _, tr := range parsed.ToolResults { + sb.WriteString("\n[result") + if tr.Name != "" { + sb.WriteString(":") + sb.WriteString(tr.Name) + } + sb.WriteString("] ") + sb.WriteString(tr.Result) + } + return sb.String() +} + type FeedMessage struct { - ID string `json:"id"` - Role string `json:"role"` - Content string `json:"content"` - Time string `json:"time"` - Images []string `json:"images,omitempty"` + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + Time string `json:"time"` + Images []string `json:"images,omitempty"` + Summarized bool `json:"summarized,omitempty"` } type Conversation struct { @@ -168,13 +209,15 @@ func (cs *ConversationStore) SetSummary(summary string) { cs.save() } -func (cs *ConversationStore) TrimOld(keepCount int) { +func (cs *ConversationStore) MarkSummarized(upToIndex int) { cs.mu.Lock() defer cs.mu.Unlock() - if len(cs.conv.Messages) <= keepCount { + if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) { return } - cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:] + for i := 0; i < upToIndex; i++ { + cs.conv.Messages[i].Summarized = true + } cs.save() } @@ -191,7 +234,10 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount { } for _, m := range cs.conv.Messages { - count := utf8.RuneCountInString(m.Content) / charsPerToken + if m.Role == "system" || m.Summarized { + continue + } + count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken result.byMessage += count result.byRole[m.Role] += count } diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index c632c2c..8e2d920 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" "os" "path/filepath" @@ -64,15 +63,13 @@ func (s *Server) describeImages(images []ImageAttachment) []string { } } if apiKey == "" { - log.Printf("[vlm] no API key found for image description") return nil } descriptions := make([]string, 0, len(images)) - for i, img := range images { + for _, img := range images { desc, err := s.callVLM(apiKey, img) if err != nil { - log.Printf("[vlm] image %d (%s) failed: %v", i+1, img.Filename, err) descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err)) } else { descriptions = append(descriptions, desc) @@ -163,7 +160,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType) if err != nil { - log.Printf("[images] failed to save %s: %v", body.Images[i].Filename, err) + _ = err } else { imageIDs = append(imageIDs, id) } @@ -227,6 +224,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche messages := s.buildContextMessages(userMessage) engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) + engine.SetLimiter(s.AcquireAgentSlot) engine.OnChunk(func(data map[string]interface{}) { if data == nil { return @@ -265,6 +263,7 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or messages := s.buildContextMessages(userMessage) engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON) + engine.SetLimiter(s.AcquireAgentSlot) finalContent, err := engine.RunNonStream(ctx, messages) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) @@ -299,7 +298,11 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message included := 0 tokensUsed := 0 for i := len(history) - 1; i >= 0; i-- { - msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken + if history[i].Summarized { + break + } + displayContent := extractDisplayContent(history[i].Role, history[i].Content) + msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken if msgTokens == 0 { msgTokens = 1 } @@ -315,14 +318,21 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message start = 0 } + hasSummarized := false + for i := 0; i < start; i++ { + if history[i].Summarized { + hasSummarized = true + break + } + } if start > 0 { - log.Printf("[studio] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, contextWindowTokens, included, len(history), start) + _ = start } messages := make([]orchestrator.Message, 0, included+2) summary := s.convStore.GetSummary() - if summary != "" && start > 0 { + if summary != "" && (start > 0 || hasSummarized) { messages = append(messages, orchestrator.Message{ Role: "system", Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary), @@ -330,27 +340,13 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message } for _, m := range history[start:] { - content := m.Content - if m.Role == "assistant" { - var parsed struct { - Content string `json:"content"` - ToolCalls []struct { - ToolCallID string `json:"tool_call_id"` - Name string `json:"name"` - Args string `json:"args"` - } `json:"tool_calls"` - } - if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" { - content = parsed.Content - } - } - role := m.Role - if role == "system" { + if m.Role == "system" { continue } + displayContent := extractDisplayContent(m.Role, m.Content) messages = append(messages, orchestrator.Message{ - Role: role, - Content: orchestrator.TextContent(content), + Role: m.Role, + Content: orchestrator.TextContent(displayContent), }) } @@ -391,8 +387,7 @@ func (s *Server) autoSummarize() { } s.convStore.SetSummary(result) - s.convStore.TrimOld(len(messages) - half) - s.convStore.Add("system", "[Conversation résumée automatiquement]") + s.convStore.MarkSummarized(half) } func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go index b2e7896..044037a 100644 --- a/internal/api/handlers_config.go +++ b/internal/api/handlers_config.go @@ -335,30 +335,25 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request body.Theme = s.config.Terminal.PromptTheme } - cfgDir, err := config.ConfigDir() - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } + themeFile := ApplyStarshipTheme(body.Theme) + + s.config.Terminal.PromptTheme = body.Theme + config.Save(s.config) + + writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile}) +} + +func ApplyStarshipTheme(theme string) string { + cfgDir, _ := config.ConfigDir() starshipDir := filepath.Join(cfgDir, "starship") - if err := os.MkdirAll(starshipDir, 0755); err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } + os.MkdirAll(starshipDir, 0755) themeFile := filepath.Join(starshipDir, "starship.toml") - themeContent := getStarshipThemeConfig(body.Theme) - if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } + themeContent := getStarshipThemeConfig(theme) + os.WriteFile(themeFile, []byte(themeContent), 0644) home, _ := os.UserHomeDir() - shellRCs := []string{ - filepath.Join(home, ".bashrc"), - filepath.Join(home, ".zshrc"), - } - for _, rc := range shellRCs { + for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} { if _, err := os.Stat(rc); err != nil { continue } @@ -375,10 +370,7 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request f.Close() } - s.config.Terminal.PromptTheme = body.Theme - config.Save(s.config) - - writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile}) + return themeFile } func getStarshipThemeConfig(theme string) string { diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go index cf99f6b..ae027b7 100644 --- a/internal/api/handlers_info.go +++ b/internal/api/handlers_info.go @@ -91,6 +91,9 @@ func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) { writeError(w, err.Error(), http.StatusInternalServerError) return } + for i := range list { + list[i].Deployed = skills.IsDeployed(list[i].Name) + } writeJSON(w, map[string]interface{}{ "skills": list, "count": len(list), diff --git a/internal/api/handlers_missing.go b/internal/api/handlers_missing.go index ff6007b..727c01f 100644 --- a/internal/api/handlers_missing.go +++ b/internal/api/handlers_missing.go @@ -226,6 +226,29 @@ func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "all deployed"}) } +func (s *Server) handleSkillsUndeploy(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + if err := skills.Undeploy(body.Name); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "undeployed", "skill": body.Name}) +} + func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { writeError(w, "GET only", http.StatusMethodNotAllowed) diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go index c4961ad..753c1c2 100644 --- a/internal/api/handlers_shell_chat.go +++ b/internal/api/handlers_shell_chat.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log" "net/http" "os" "os/exec" @@ -108,6 +107,7 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator. messages := s.buildShellContextMessages() engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON) + engine.SetLimiter(s.AcquireAgentSlot) engine.OnChunk(func(data map[string]interface{}) { if data == nil { return @@ -149,6 +149,7 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat messages := s.buildShellContextMessages() engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON) + engine.SetLimiter(s.AcquireAgentSlot) finalContent, err := engine.RunNonStream(ctx, messages) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) @@ -185,7 +186,8 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message { included := 0 tokensUsed := 0 for i := len(history) - 1; i >= 0; i-- { - msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken + displayContent := extractDisplayContent(history[i].Role, history[i].Content) + msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken if msgTokens == 0 { msgTokens = 1 } @@ -202,33 +204,19 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message { } if start > 0 { - log.Printf("[shell] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, shellMaxTokens, included, len(history), start) + _ = start } messages := make([]orchestrator.Message, 0, included) for _, m := range history[start:] { - content := m.Content - if m.Role == "assistant" { - var parsed struct { - Content string `json:"content"` - ToolCalls []struct { - ToolCallID string `json:"tool_call_id"` - Name string `json:"name"` - Args string `json:"args"` - } `json:"tool_calls"` - } - if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" { - content = parsed.Content - } - } - role := m.Role - if role == "system" { + if m.Role == "system" { continue } + displayContent := extractDisplayContent(m.Role, m.Content) messages = append(messages, orchestrator.Message{ - Role: role, - Content: orchestrator.TextContent(content), + Role: m.Role, + Content: orchestrator.TextContent(displayContent), }) } diff --git a/internal/api/image_cache.go b/internal/api/image_cache.go index f5dd286..bcbfe86 100644 --- a/internal/api/image_cache.go +++ b/internal/api/image_cache.go @@ -3,7 +3,6 @@ package api import ( "encoding/base64" "fmt" - "log" "net/http" "os" "path/filepath" @@ -64,7 +63,7 @@ func cleanupImages(ids []string) { for _, id := range ids { p := imagePath(id) if err := os.Remove(p); err != nil && !os.IsNotExist(err) { - log.Printf("[images] failed to delete %s: %v", id, err) + _ = err } } } diff --git a/internal/api/server.go b/internal/api/server.go index 4ef7088..229b278 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2,12 +2,15 @@ package api import ( "encoding/json" - "log" + "fmt" "net/http" + "os/exec" "strings" + "sync/atomic" "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/config" + "github.com/muyue/muyue/internal/installer" "github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/workflow" ) @@ -24,6 +27,8 @@ type Server struct { shellAgentRegistry *agent.Registry shellAgentToolsJSON json.RawMessage workflowEngine *workflow.Engine + activeCrushAgents atomic.Int32 + activeClaudeAgents atomic.Int32 } func NewServer(cfg *config.MuyueConfig) *Server { @@ -43,7 +48,7 @@ func NewServer(cfg *config.MuyueConfig) *Server { } // Save initial config to establish the file for first-time usage if err := config.Save(defaultCfg); err != nil { - log.Printf("config: initial save failed: %v", err) + _ = err } cfg = defaultCfg } @@ -65,6 +70,7 @@ func NewServer(cfg *config.MuyueConfig) *Server { s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON) s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry) + s.initStarship() s.routes() return s } @@ -120,6 +126,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation) s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall) s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy) + s.mux.HandleFunc("/api/skills/undeploy", s.handleSkillsUndeploy) s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections) s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest) @@ -156,3 +163,37 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } s.mux.ServeHTTP(w, r) } + +const maxCrushAgents = 2 +const maxClaudeAgents = 2 + +func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) { + var counter *atomic.Int32 + var max int32 + switch toolName { + case "crush_run": + counter = &s.activeCrushAgents + max = maxCrushAgents + case "claude_run": + counter = &s.activeClaudeAgents + max = maxClaudeAgents + default: + return func() {}, nil + } + current := counter.Add(1) + if current > max { + counter.Add(-1) + return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName) + } + return func() { counter.Add(-1) }, nil +} + +func (s *Server) initStarship() { + if _, err := exec.LookPath("starship"); err != nil { + inst := installer.New(s.config) + if result := inst.InstallTool("starship"); !result.Success { + return + } + } + ApplyStarshipTheme(s.config.Terminal.PromptTheme) +} diff --git a/internal/api/shell_conversation.go b/internal/api/shell_conversation.go index 4840ea6..a362a24 100644 --- a/internal/api/shell_conversation.go +++ b/internal/api/shell_conversation.go @@ -147,7 +147,10 @@ func (s *ShellConvStore) ApproxTokens() int { defer s.mu.RUnlock() total := 0 for _, m := range s.msgs { - total += utf8.RuneCountInString(m.Content) / shellCharsPerToken + if m.Role == "system" { + continue + } + total += utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / shellCharsPerToken } total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken if analysis := LoadSystemAnalysis(); analysis != "" { diff --git a/internal/api/terminal.go b/internal/api/terminal.go index 67a4dc5..f2eed64 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -3,7 +3,6 @@ package api import ( "encoding/json" "fmt" - "log" "net/http" "os" "os/exec" @@ -48,7 +47,6 @@ type wsMessage struct { func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { - log.Printf("ws upgrade: %v", err) return } defer conn.Close() @@ -56,17 +54,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { var initMsg wsMessage _, raw, err := conn.ReadMessage() if err != nil { - log.Printf("terminal: read init message failed: %v", err) conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"}) return } - log.Printf("terminal: init message received: %s", string(raw)) if err := json.Unmarshal(raw, &initMsg); err != nil { - log.Printf("terminal: unmarshal init message failed: %v", err) conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"}) return } - log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data) var cmd *exec.Cmd @@ -111,24 +105,19 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { } } else { shell := strings.TrimSpace(initMsg.Data) - log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell) if shell == "" { shell = detectShell() - log.Printf("terminal: auto-detected shell=%q", shell) } if shell == "" { - log.Printf("terminal: no shell detected, falling back to /bin/sh") shell = "/bin/sh" } if path, err := exec.LookPath(shell); err == nil { shell = path - log.Printf("terminal: resolved shell path=%q", shell) } if _, err := os.Stat(shell); err != nil { - log.Printf("terminal: shell stat failed: %v for %q", err, shell) conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)}) return } @@ -148,14 +137,11 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { cmd.Env = append(os.Environ(), "TERM=xterm-256color") - log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args) ptmx, err := pty.Start(cmd) if err != nil { - log.Printf("terminal: pty start failed: %v", err) conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()}) return } - log.Printf("terminal: pty started successfully") var once sync.Once cleanup := func() { diff --git a/internal/config/config.go b/internal/config/config.go index b2941d9..6b9c7e1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,7 +2,6 @@ package config import ( "fmt" - "log" "os" "path/filepath" @@ -162,7 +161,7 @@ func ConfigDir() (string, error) { if _, err := os.Stat(legacyDir); err == nil { if _, err := os.Stat(dir); err != nil { if err := os.Rename(legacyDir, dir); err != nil { - log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err) + _ = err } } } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 57e95ae..4725011 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" "regexp" "strings" @@ -17,6 +16,14 @@ import ( ) var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?`) +var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?`) +var providerTagRegex = regexp.MustCompile(`(?s)]*>`) +var xmlToolTagRegex = regexp.MustCompile(`(?s)]*>`) +var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`) + +var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`) +var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`) +var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`) const maxHistorySize = 100 @@ -197,7 +204,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { return "", err } - content := cleanAIResponse(chatResp.Choices[0].Message.Content) + content := CleanAIResponse(chatResp.Choices[0].Message.Content) o.histMu.Lock() o.history = append(o.history, Message{ Role: "assistant", @@ -297,7 +304,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str return fullContent.String(), fmt.Errorf("read stream: %w", err) } - content := cleanAIResponse(fullContent.String()) + content := CleanAIResponse(fullContent.String()) o.histMu.Lock() o.history = append(o.history, Message{ Role: "assistant", @@ -388,6 +395,7 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall var fullContent strings.Builder var accumulatedToolCalls []ToolCallMsg var totalTokens int + var insideToolBlock bool scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) @@ -411,7 +419,10 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall chunk := chatResp.Choices[0].Delta.Content if chunk != "" { fullContent.WriteString(chunk) - onChunk(chunk, nil) + cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock) + if cleanedChunk != "" { + onChunk(cleanedChunk, nil) + } } // Handle delta tool calls @@ -463,15 +474,19 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall }{}, } - finalContent := cleanAIResponse(fullContent.String()) + finalContent := CleanAIResponse(fullContent.String()) finalResp.Choices[0].Message.Content = finalContent finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls return finalResp, nil } -func cleanAIResponse(content string) string { +func CleanAIResponse(content string) string { content = thinkRegex.ReplaceAllString(content, "") + content = providerToolBlockRegex.ReplaceAllString(content, "") + content = providerTagRegex.ReplaceAllString(content, "") + content = xmlToolTagRegex.ReplaceAllString(content, "") + content = bracketToolCallRegex.ReplaceAllString(content, "") lines := strings.Split(content, "\n") var clean []string inBlock := false @@ -494,6 +509,35 @@ func cleanAIResponse(content string) string { return result } +// CleanStreamChunk applies lightweight cleaning to individual streaming chunks. +// It tracks state via a bool pointer to suppress content inside tool-call blocks. +func CleanStreamChunk(chunk string, insideBlock *bool) string { + if *insideBlock { + // Check for closing tag + if strings.Contains(chunk, ":tool_call>") { + *insideBlock = false + } + return "" + } + + // Check for opening tool_call block + if streamBlockStartRegex.MatchString(chunk) { + *insideBlock = true + // If closing tag also in same chunk, emit nothing + if strings.Contains(chunk, ":tool_call>") { + *insideBlock = false + } + return "" + } + + // Clean individual tags and bracket calls + cleaned := providerTagRegex.ReplaceAllString(chunk, "") + cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "") + cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "") + + return cleaned +} + func getProviderBaseURL(name string) string { switch name { case "minimax": @@ -616,6 +660,5 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str return &chatResp, prov.Name, nil } - log.Printf("[orchestrator] fallback from %v to next provider", triedProviders) return nil, "", lastErr } diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 13270f6..609f2ef 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -33,6 +33,7 @@ type Skill struct { Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"` Category string `yaml:"category,omitempty" json:"category,omitempty"` + Deployed bool `yaml:"-" json:"deployed,omitempty"` } type ValidationError struct { @@ -155,6 +156,27 @@ func Delete(name string) error { return nil } +func IsDeployed(name string) bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md") + claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md") + _, crushErr := os.Stat(crushPath) + _, claudeErr := os.Stat(claudePath) + return crushErr == nil || claudeErr == nil +} + +func Undeploy(name string) error { + skill, err := Get(name) + if err != nil { + return err + } + undeployFromTargets(skill.Name) + return nil +} + func Update(skill *Skill) error { if errs := Validate(skill); len(errs) > 0 { return fmt.Errorf("validation failed: %v", errs) diff --git a/web/src/api/client.js b/web/src/api/client.js index ec513ca..73823b4 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -36,6 +36,8 @@ const api = { testSkill: (name, sampleTask) => request('/skills/test', { method: 'POST', body: JSON.stringify({ name, sample_task: sampleTask || '' }) }), exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }), importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }), + deploySkill: (name) => request('/skills/deploy', { method: 'POST', body: JSON.stringify({ name }) }), + undeploySkill: (name) => request('/skills/undeploy', { method: 'POST', body: JSON.stringify({ name }) }), getDashboardStatus: () => request('/dashboard/status'), getProvidersQuota: () => request('/providers/quota'), getProvidersConsumption: () => request('/providers/consumption'), diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 2ce14ba..299ee65 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -16,8 +16,6 @@ export default function App() { const [isSudo, setIsSudo] = useState(false) const [dashRefreshKey, setDashRefreshKey] = useState(0) const dashRefreshRef = useRef(null) - const [updates, setUpdates] = useState([]) - const [tools, setTools] = useState([]) const [config, setConfig] = useState(null) const [showOnboarding, setShowOnboarding] = useState(false) const { t, layout } = useI18n() @@ -31,8 +29,6 @@ export default function App() { useEffect(() => { api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {}) - api.getTools().then(d => setTools(d.tools || [])).catch(() => {}) - api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {}) api.getConfig().then(d => { setConfig(d) const theme = d.profile?.preferences?.theme || 'cyberpunk-red' @@ -82,9 +78,6 @@ export default function App() { return () => window.removeEventListener('navigate-to-shell', handler) }, []) - const hasUpdates = updates.some(u => u.needsUpdate) - const installed = tools.filter(tool => tool.installed).length - const WINDOW_SHORTCUTS = useMemo(() => ({ dash: [], studio: [ @@ -127,17 +120,6 @@ export default function App() {
-
- 0 ? 'ok' : 'off'}`} - title={t('header.toolsInstalled', { count: installed })} - /> - -
- {clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })} diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index 9ec4208..3f91804 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -1,11 +1,10 @@ import { useState, useEffect, useCallback } from 'react' -import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react' +import { User, Brain, Wrench, Monitor, AlertTriangle, Bot, Sparkles, Zap, GitBranch, Container, Circle, Hexagon, Code, Rocket, Download } from 'lucide-react' import { useI18n } from '../i18n' const PANELS = [ { id: 'profile', icon: User }, { id: 'providers', icon: Brain }, - { id: 'updates', icon: RefreshCw }, { id: 'skills', icon: Wrench }, { id: 'system', icon: Monitor }, ] @@ -16,10 +15,7 @@ export default function Config({ api }) { const [config, setConfig] = useState(null) const [providers, setProviders] = useState([]) const [skillList, setSkillList] = useState([]) - const [updates, setUpdates] = useState([]) - const [tools, setTools] = useState([]) - const [checking, setChecking] = useState(false) - const [updating, setUpdating] = useState(null) + const [editProfile, setEditProfile] = useState(false) const [editProvider, setEditProvider] = useState(null) const [profileForm, setProfileForm] = useState({}) @@ -34,8 +30,6 @@ export default function Config({ api }) { }).catch(() => {}) api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {}) api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {}) - api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {}) - api.getTools().then(d => setTools(d.tools || [])).catch(() => {}) }, [api]) @@ -46,83 +40,6 @@ export default function Config({ api }) { setTimeout(() => setToast(null), 2500) } - const handleCheckUpdates = async () => { - setChecking(true) - try { - const d = await api.aiTask('check_tools') - const result = d.result - if (result && result.tools) { - const aiTools = result.tools - const newUpdates = aiTools.filter(t => t.installed).map(t => ({ - tool: t.name, - current: t.version || '', - latest: t.latest || '', - needsUpdate: t.needs_update || false, - error: t.error || '', - })) - const newTools = aiTools.map(t => ({ - name: t.name, - installed: t.installed, - version: t.version || '', - category: t.category || '', - })) - setUpdates(newUpdates) - setTools(newTools) - showToast(t('config.upToDate')) - } else { - showToast(t('config.error')) - } - } catch (err) { - showToast(`${t('config.error')}: ${err.message}`) - } - setChecking(false) - } - - const handleUpdateTool = async (tool) => { - setUpdating(tool) - try { - const d = await api.aiTask('update_tool', tool) - if (d.result && d.result.updated) { - showToast(`${tool} ${t('config.updated') || 'mis à jour'}`) - } else { - showToast(d.result?.error || d.result?.message || t('config.error')) - } - handleCheckUpdates() - } catch (err) { - showToast(`${t('config.error')}: ${err.message}`) - } - setUpdating(null) - } - - const handleInstallTool = async (tool) => { - setUpdating(`install-${tool}`) - try { - const d = await api.aiTask('install_tool', tool) - if (d.result && d.result.installed) { - showToast(`${tool} ${t('config.installed') || 'installé'}`) - } else { - showToast(d.result?.error || d.result?.message || t('config.error')) - } - handleCheckUpdates() - } catch (err) { - showToast(`${t('config.error')}: ${err.message}`) - } - setUpdating(null) - } - - const handleUpdateAll = async () => { - const toUpdate = updates.filter(u => u.needsUpdate) - setUpdating('__all__') - for (const u of toUpdate) { - try { - await api.aiTask('update_tool', u.tool) - } catch (err) { - console.error(`Failed to update ${u.tool}:`, err) - } - } - setUpdating(null) - handleCheckUpdates() - } const handleSaveProfile = async () => { try { @@ -161,9 +78,7 @@ export default function Config({ api }) { setEditProvider(p.name) } - const needsUpdateCount = updates.filter(u => u.needsUpdate).length - const installedCount = tools.filter(tool => tool.installed).length - const missingCount = tools.filter(tool => !tool.installed).length + return (
@@ -204,21 +119,8 @@ export default function Config({ api }) { t={t} /> )} - {activePanel === 'updates' && ( - - )} {activePanel === 'skills' && ( - + )} {activePanel === 'system' && ( @@ -459,176 +361,80 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm ) } -function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleInstallTool, handleUpdateAll, t }) { +function PanelSkills({ skillList, api, loadData, t }) { + const [deploying, setDeploying] = useState(null) - const missingTools = tools.filter(tool => !tool.installed) + const handleDeploy = async (name) => { + setDeploying(name + '-deploy') + try { + await api.deploySkill(name) + loadData() + } catch (err) { + console.error('deploy skill:', err) + } + setDeploying(null) + } - return ( - <> -
-
-
- {installedCount} {t('config.installed')} - {missingCount > 0 && {missingCount} {t('config.missing')}} - {needsUpdateCount > 0 && {needsUpdateCount} {t('config.needsUpdate')}} -
-
- - {needsUpdateCount > 0 && ( - - )} -
-
-
- - {missingTools.length > 0 && ( - <> -
{t('config.missing') || 'Modules manquants'}
-
- {missingTools.map((tool, i) => ( -
-
- {tool.name} - - {t('config.notInstalled') || 'Non installé'} - -
- -
- ))} -
- - )} - - {updates.length === 0 ? ( -
-
{t('config.noUpdates')}
-
- ) : ( -
- {updates.map((u, i) => ( -
-
- {u.tool} - - {u.needsUpdate ? ( - <>{u.current} → {u.latest} - ) : ( - {u.current} - )} - -
- {u.needsUpdate && ( - - )} -
- ))} -
- )} - - ) -} - - - -function PanelSkills({ skillList, t }) { - const [selected, setSelected] = useState(null) + const handleUndeploy = async (name) => { + setDeploying(name + '-undeploy') + try { + await api.undeploySkill(name) + loadData() + } catch (err) { + console.error('undeploy skill:', err) + } + setDeploying(null) + } if (skillList.length === 0) { return
{t('config.noSkills')}
} return ( - <> -
- {skillList.map((s, i) => ( -
setSelected(s)}> -
{s.name}
-
{s.description}
-
- {s.target && {s.target}} - {s.version && {s.version}} - {s.category && {s.category}} +
+ {skillList.map((s, i) => ( +
+
+
+ {s.name} + {s.deployed ? ( + {t('config.installed')} + ) : ( + {t('config.notInstalled')} + )}
+
{s.description}
- ))} -
- {selected && ( -
setSelected(null)}> -
e.stopPropagation()}> -
- {selected.name} - -
-
-
-
Description
-
{selected.description}
-
-
-
Métadonnées
-
- {selected.target && {selected.target}} - {selected.version && {selected.version}} - {selected.category && {selected.category}} - {selected.author && {selected.author}} - {selected.languages && selected.languages.map(l => {l})} -
-
- {selected.tags && selected.tags.length > 0 && ( -
-
Tags
-
- {selected.tags.map(tag => {tag})} -
-
- )} - {selected.content && ( -
-
Contenu
-
{selected.content}
-
- )} - {selected.dependencies && selected.dependencies.length > 0 && ( -
-
Dépendances
-
- {selected.dependencies.map((d, i) => ( -
- {d.type} - {d.name} - {d.required === false && optionnel} -
- ))} -
-
- )} -
+
+ +
- )} - + ))} +
) } function PanelSystem({ api, t }) { const [showResetModal, setShowResetModal] = useState(false) const [toast, setToast] = useState(null) + const [isSudo, setIsSudo] = useState(false) + + useEffect(() => { + api.getInfo().then(d => setIsSudo(!!d.sudo)).catch(() => {}) + }, [api]) const showToast = (msg) => { setToast(msg) @@ -646,26 +452,123 @@ function PanelSystem({ api, t }) { } } - const handleApplyStarship = () => { - window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) - window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Vérifie si starship est installé sur le système. S'il ne l'est pas, installe-le (avec curl ou le gestionnaire de paquets). Ensuite, applique la configuration du thème "charm" pour starship. Assure-toi que starship est bien initialisé dans le shell de l'utilisateur.` } })) + const handleSystemUpdate = () => { + window.dispatchEvent(new CustomEvent('navigate-to-shell')) + if (isSudo) { + window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Mets à jour le système et tous les outils utilisés par l'application Muyue. Exécute les commandes suivantes dans l'ordre :\n1. Met à jour les paquets système : sudo apt update && sudo apt upgrade -y\n2. Installe les dépendances utiles si manquantes : sudo apt install -y sshpass git curl wget\n3. Mets à jour les outils installés : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n4. Pour chaque outil, vérifie la version actuelle, mets à jour si possible, puis vérifie la nouvelle version\n5. Donne un récapitulatif final de tout ce qui a été mis à jour ou installé` } })) + } else { + window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Je n'ai pas les droits sudo sur ce système. Donne-moi les commandes nécessaires pour mettre à jour le système et les outils suivants. Pour chaque outil, indique la commande exacte à exécuter :\n1. Paquets système (apt update && apt upgrade)\n2. Outils à mettre à jour : crush, claude, gh, go, node/npm, python3/pip3/uv, docker, starship\n3. Dépendances utiles à installer : sshpass, git, curl, wget\n4. Présente les commandes dans un tableau markdown avec le nom de l'outil, la commande, et si sudo est requis` } })) + } } + const configureTool = (tool) => { + window.dispatchEvent(new CustomEvent('navigate-to-shell')) + window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: tool.prompt } })) + } + + const AI_TOOLS = [ + { + id: 'crush', + name: 'Crush', + icon: 'Zap', + description: t('config.toolCrushDesc'), + prompt: `Configure l'outil Crush sur ce système. Vérifie d'abord s'il est installé avec "crush --version". S'il n'est pas installé, installe-le avec la méthode appropriée (npm install -g @anthropic/crush ou via le script officiel). S'il est déjà installé, vérifie sa configuration dans ~/.config/crush/ et affiche son état. Demande-moi les informations nécessaires si besoin (clés API, préférences, etc.).`, + }, + { + id: 'claude', + name: 'Claude Code', + icon: 'Bot', + description: t('config.toolClaudeDesc'), + prompt: `Configure l'outil Claude Code (claude) sur ce système. Vérifie d'abord s'il est installé avec "claude --version". S'il n'est pas installé, installe-le avec npm install -g @anthropic-ai/claude-code. S'il est installé, vérifie sa configuration et son authentification. Demande-moi les informations nécessaires si besoin (clé API Anthropic, etc.).`, + }, + { + id: 'gh', + name: 'GitHub CLI', + icon: 'GitBranch', + description: t('config.toolGhDesc'), + prompt: `Configure l'outil GitHub CLI (gh) sur ce système. Vérifie d'abord s'il est installé avec "gh --version". S'il n'est pas installé, installe-le avec la méthode appropriée pour ce système. S'il est installé, vérifie son authentification avec "gh auth status". Si non authentifié, guide-moi pour le configurer avec "gh auth login". Demande-moi le token si nécessaire.`, + }, + { + id: 'docker', + name: 'Docker', + icon: 'Container', + description: t('config.toolDockerDesc'), + prompt: `Configure Docker sur ce système. Vérifie d'abord s'il est installé avec "docker --version". Vérifie aussi si le daemon tourne avec "docker info". S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que l'utilisateur est dans le groupe docker. Si des problèmes de permissions existent, explique comment les résoudre.`, + }, + { + id: 'go', + name: 'Go', + icon: 'Circle', + description: t('config.toolGoDesc'), + prompt: `Configure l'environnement Go sur ce système. Vérifie s'il est installé avec "go version". Vérifie le GOPATH, GOROOT et les variables d'environnement. S'il n'est pas installé, installe-le avec la méthode appropriée. Vérifie que les binaires Go sont dans le PATH.`, + }, + { + id: 'node', + name: 'Node.js', + icon: 'Hexagon', + description: t('config.toolNodeDesc'), + prompt: `Configure l'environnement Node.js sur ce système. Vérifie s'il est installé avec "node --version" et "npm --version". Vérifie aussi pnpm et npx. S'il n'est pas installé, installe-le avec la méthode recommandée (nvm, fnm ou le gestionnaire de paquets). Vérifie la version LTS vs Current.`, + }, + { + id: 'python', + name: 'Python', + icon: 'Code', + description: t('config.toolPythonDesc'), + prompt: `Configure l'environnement Python sur ce système. Vérifie python3 --version, pip3 --version, et uv --version. S'ils ne sont pas installés, installe-les avec la méthode appropriée. Vérifie les paquets essentiels (venv, pip). Configure uv si nécessaire.`, + }, + { + id: 'starship', + name: 'Starship', + icon: 'Rocket', + description: t('config.toolStarshipDesc'), + prompt: `Configure Starship (prompt shell) sur ce système. Vérifie s'il est installé avec "starship --version". S'il n'est pas installé, installe-le. Ensuite, configure le thème "charm" dans ~/.config/starship.toml. Assure-toi que starship est initialisé dans le shell de l'utilisateur (.bashrc, .zshrc ou config fish).`, + }, + ] + + const ICON_MAP = { Zap, Bot, GitBranch, Container, Circle, Hexagon, Code, Rocket } + return ( <> {toast &&
{toast}
} -
Configuration Système
-
-
- {t('config.applyStarship')} +
{t('config.systemConfig')}
+ +
+ + {t('config.aiToolsConfig')} +
+
+ {AI_TOOLS.map(tool => { + const Icon = ICON_MAP[tool.icon] || Bot + return ( +
+
+ + {tool.name} +
+
{tool.description}
+ +
+ ) + })} +
+ +
+
+
+ {t('config.systemUpdate')} +
+ {isSudo ? t('config.systemUpdateDescSudo') : t('config.systemUpdateDescNoSudo')} +
+
+
-
- Vérifie l'installation de starship et configure le thème charm via l'IA. -
-
diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 6d857e0..3c3fb79 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import { useState, useRef, useEffect, useCallback, useMemo, memo, Fragment } from 'react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' @@ -409,6 +409,7 @@ export default function Shell({ api, isSudo }) { }) const activeTabRef = useRef(activeTab) useEffect(() => { activeTabRef.current = activeTab }, [activeTab]) + const tabIdsKey = useMemo(() => tabs.map(t => t.id).join(','), [tabs]) const [sshConnections, setSshConnections] = useState([]) const [systemTerminals, setSystemTerminals] = useState([]) const [showMenu, setShowMenu] = useState(false) @@ -474,8 +475,22 @@ export default function Shell({ api, isSudo }) { const aiLoadedRef = useRef(false) const aiLoadingRef = useRef(false) const analysisSavingRef = useRef(false) + const _streamRafRef = useRef(null) + const _streamPendingRef = useRef(null) + + const _flushStreamUpdate = useCallback(() => { + _streamRafRef.current = null + const pending = _streamPendingRef.current + if (!pending) return + _streamPendingRef.current = null + setAiMessages(pending) + }, []) useEffect(() => { + if (_streamRafRef.current) { + cancelAnimationFrame(_streamRafRef.current) + _streamRafRef.current = null + } aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) }, [aiMessages]) @@ -760,7 +775,7 @@ export default function Shell({ api, isSudo }) { pending.forEach(clearTimeout) observer?.disconnect() } - }, [tabs, initTerminal, initPendingTabs, configLoaded]) + }, [tabIdsKey, initTerminal, initPendingTabs, configLoaded]) useEffect(() => { const entry = tabsRef.current[activeTab] @@ -778,12 +793,18 @@ export default function Shell({ api, isSudo }) { const wrapper = document.querySelector('.shell-layout')?.parentElement if (wrapper && wrapper.classList.contains('tab-hidden')) return const entry = tabsRef.current[activeTabRef.current] - if (entry) { - entry.fitAddon.fit() + if (entry && entry.fitAddon && entry.term) { + const container = document.getElementById(`terminal-${activeTabRef.current}`) + if (!container) return + const rect = container.getBoundingClientRect() + const dims = entry.fitAddon.proposeDimensions() + if (dims && entry.term.cols !== dims.cols || entry.term.rows !== dims.rows) { + entry.fitAddon.fit() + } } }, 2000) return () => clearInterval(iv) - }, [tabs]) + }, [tabIdsKey]) useEffect(() => { return () => { @@ -813,25 +834,26 @@ export default function Shell({ api, isSudo }) { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return + const currentTabs = tabsRef.current._tabList || [] if (e.key === 'Tab' && e.shiftKey) { const shellTab = document.querySelector('.shell-layout') if (!shellTab || shellTab.closest('.tab-hidden')) return e.preventDefault() - const idx = tabs.findIndex(t => t.id === activeTab) - const next = (idx + 1) % tabs.length - setActiveTab(tabs[next].id) + const idx = currentTabs.findIndex(t => t.id === activeTabRef.current) + const next = (idx + 1) % currentTabs.length + setActiveTab(currentTabs[next].id) return } const num = parseInt(e.key) - if (num >= 1 && num <= tabs.length) { + if (num >= 1 && num <= currentTabs.length) { e.preventDefault() - setActiveTab(tabs[num - 1].id) + setActiveTab(currentTabs[num - 1].id) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [tabs]) + }, []) useEffect(() => { if (showSearch && searchInputRef.current) { @@ -1103,16 +1125,31 @@ export default function Shell({ api, isSudo }) { setAiLoading(true) try { - let accumulated = '' - let toolCalls = [] + let segments = [] + let textStartIdx = 0 const controller = new AbortController() + const _updateLastText = (text) => { + if (!text) return + const last = segments.length > 0 ? segments[segments.length - 1] : null + if (last && last.type === 'text') { + last.content = text + } else { + segments.push({ type: 'text', content: text }) + } + } + await api.sendShellChat(trimmed, {}, true, (partial, event) => { if (event && event.tool_call) { - toolCalls = [...toolCalls, { call: event.tool_call, result: null }] + _updateLastText(partial.slice(textStartIdx)) + textStartIdx = partial.length + segments.push({ type: 'tool', call: event.tool_call, result: null }) + const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) })) + if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null } + _streamPendingRef.current = null setAiMessages(prev => { const filtered = prev.filter(m => !m._streaming) - return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }] + return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }] }) return } @@ -1120,12 +1157,15 @@ export default function Shell({ api, isSudo }) { if (event.tool_result.sudo_blocked) { setSudoModal({ command: event.tool_result.command || event.tool_result.content }) } - const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id) - if (idx >= 0) { - toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result } + const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id) + if (segIdx >= 0) { + segments[segIdx].result = event.tool_result + const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) })) + if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null } + _streamPendingRef.current = null setAiMessages(prev => { const filtered = prev.filter(m => !m._streaming) - return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }] + return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }] }) } return @@ -1133,23 +1173,37 @@ export default function Shell({ api, isSudo }) { if (event && (event.thinking !== undefined || event.thinking_end)) { return } - accumulated = partial - setAiMessages(prev => { + _updateLastText(partial.slice(textStartIdx)) + const nextMsgs = prev => { const filtered = prev.filter(m => !m._streaming) - return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab, _toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined }] - }) + const snap = segments.map(s => ({ ...s, ...(s.type === 'tool' ? { call: s.call, result: s.result } : {}) })) + return [...filtered, { role: 'assistant', _streaming: true, _tabId: currentTab, _segments: snap }] + } + _streamPendingRef.current = nextMsgs + if (!_streamRafRef.current) { + _streamRafRef.current = requestAnimationFrame(_flushStreamUpdate) + } }, controller.signal) - const finalMsg = { role: 'assistant', content: accumulated, _tabId: currentTab } - if (toolCalls.length > 0) { - finalMsg._toolCalls = toolCalls + if (_streamRafRef.current) { cancelAnimationFrame(_streamRafRef.current); _streamRafRef.current = null } + _streamPendingRef.current = null + + const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('') + const toolSegs = segments.filter(s => s.type === 'tool') + + const finalMsg = { role: 'assistant', content: allText, _tabId: currentTab } + if (toolSegs.length > 0 || segments.length > 1) { finalMsg.content = JSON.stringify({ - content: accumulated, - tool_calls: toolCalls.map(tc => tc.call), - tool_results: toolCalls.map(tc => ({ - tool_call_id: tc.call?.tool_call_id, - result: tc.result?.content || '', - is_error: tc.result?.is_error || false, + segments: segments.map(s => s.type === 'text' + ? { type: 'text', content: s.content } + : { type: 'tool', call: s.call, result: { content: s.result?.content || '', is_error: s.result?.is_error || false, tool_call_id: s.call?.tool_call_id } } + ), + content: allText, + tool_calls: toolSegs.map(s => s.call), + tool_results: toolSegs.map(s => ({ + tool_call_id: s.call?.tool_call_id, + result: s.result?.content || '', + is_error: s.result?.is_error || false, })), }) } @@ -1159,10 +1213,10 @@ export default function Shell({ api, isSudo }) { return [...filtered, finalMsg] }) - if (analysisSavingRef.current && accumulated) { + if (analysisSavingRef.current && allText) { analysisSavingRef.current = false - setAnalysisContent(accumulated) - try { localStorage.setItem('shell_analysis', accumulated) } catch {} + setAnalysisContent(allText) + try { localStorage.setItem('shell_analysis', allText) } catch {} setAnalyzing(false) } @@ -1182,7 +1236,7 @@ export default function Shell({ api, isSudo }) { } setAiLoading(false) aiLoadingRef.current = false - }, [api, t, aiAtLimit, focusAiTerminal]) + }, [api, t, aiAtLimit, focusAiTerminal, _flushStreamUpdate]) const handleAiSend = () => _sendAiMessage(aiInput, false) @@ -1190,7 +1244,7 @@ export default function Shell({ api, isSudo }) { const handler = (e) => { const msg = e.detail?.message if (!msg) return - setAiInput(msg) + setAiInput('') setTimeout(() => _sendAiMessage(msg, true), 100) } window.addEventListener('ask-ai-terminal', handler) @@ -1378,11 +1432,9 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
-
- Analyste Système + Analyste Système +
-
-
+ +
+
+ ) + } + return +}) + +const ShellAIMessage = memo(function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) { const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant' const content = msg.content || '' const [copiedIdx, setCopiedIdx] = useState(null) @@ -1640,18 +1724,51 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) { return
{content}
} + // Ordered segments (streaming or final with segments) + let segments = msg._segments || null + if (!segments) { + try { + const parsed = JSON.parse(content) + if (parsed && Array.isArray(parsed.segments)) { + segments = parsed.segments + } + } catch {} + } + + if (segments && segments.length > 0) { + const hasTools = segments.some(s => s.type === 'tool') + if (hasTools) { + return ( +
+ {segments.map((seg, i) => { + if (seg.type === 'text') { + if (!seg.content) return null + return {_renderParts(renderContent(seg.content), copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId)} + } + if (seg.type === 'tool') { + const r = seg.result + const result = r && (r.content !== undefined || r.is_error !== undefined) + ? { content: r.content, is_error: r.is_error } + : null + return + } + return null + })} +
+ ) + } + } + + // Fallback: old format (all tools then all text) let parsedToolCalls = null let parsedToolResults = null let displayContent = content - let streamingToolCalls = msg._toolCalls || null try { const parsed = JSON.parse(content) if (parsed && Array.isArray(parsed.tool_calls)) { - if (!streamingToolCalls) { - parsedToolCalls = parsed.tool_calls - parsedToolResults = parsed.tool_results || null - } + parsedToolCalls = parsed.tool_calls + parsedToolResults = parsed.tool_results || null displayContent = parsed.content || '' } } catch {} @@ -1660,9 +1777,6 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) { return (
- {streamingToolCalls && streamingToolCalls.map((tc, i) => ( - - ))} {parsedToolCalls && parsedToolCalls.map((tc, i) => { const resultData = parsedToolResults ? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id) @@ -1672,37 +1786,7 @@ function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) { : null return })} - {parts.map((part, i) => { - if (part.type === 'code' && part.lang === 'mermaid') { - return ( -
-
mermaid
- -
- ) - } - if (part.type === 'code') { - return ( -
- {part.lang &&
{part.lang}
} -
{part.content}
-
- - -
-
- ) - } - return - })} + {_renderParts(parts, copiedIdx, setCopiedIdx, sendToTerminal, terminalTabId)}
) -} +}) diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index ddb7f5a..004297d 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -142,10 +142,17 @@ const TOOL_LABELS = { web_fetch: 'Web Fetch', } -function ToolCallBlock({ call, result }) { +function ToolCallBlock({ call, result, activeAgents, onModeChange }) { const icon = TOOL_ICONS[call.name] || '🔧' const label = TOOL_LABELS[call.name] || call.name const isErr = result && result.is_error + const isCrush = call.name === 'crush_run' + const isClaude = call.name === 'claude_run' + const isAgent = isCrush || isClaude + const agentType = isCrush ? 'crush' : isClaude ? 'claude' : null + const maxAgents = isCrush ? 2 : isClaude ? 2 : 0 + const currentCount = agentType && activeAgents ? (activeAgents[agentType] || 0) : 0 + const [mode, setMode] = useState('sync') let argsPreview = '' try { @@ -163,15 +170,39 @@ function ToolCallBlock({ call, result }) { const truncatedResult = result ? (result.content || '').slice(0, 2000) : null + const handleModeChange = (newMode) => { + setMode(newMode) + if (onModeChange) onModeChange(call.tool_call_id, newMode) + } + return (
{icon} {label} + {isAgent && !result && ( + {currentCount}/{maxAgents} + )} {!result && } {result && {isErr ? '✗' : '✓'}}
{argsPreview}
+ {isAgent && !result && ( +
+ + +
+ )} {truncatedResult && (
{truncatedResult}
@@ -249,10 +280,16 @@ function FeedItem({ msg }) { let parsedToolCalls = null let parsedToolResults = null + let parsedSegments = null let displayContent = msg.content try { const parsed = JSON.parse(msg.content) - if (parsed && Array.isArray(parsed.tool_calls)) { + if (parsed && Array.isArray(parsed.segments)) { + parsedSegments = parsed.segments + parsedToolCalls = parsed.tool_calls || null + parsedToolResults = parsed.tool_results || null + displayContent = parsed.content || '' + } else if (parsed && Array.isArray(parsed.tool_calls)) { parsedToolCalls = parsed.tool_calls parsedToolResults = parsed.tool_results || null displayContent = parsed.content || '' @@ -292,32 +329,63 @@ function FeedItem({ msg }) { ))}
)} - {parsedToolCalls && parsedToolCalls.map((tc, i) => { - const resultData = parsedToolResults - ? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id) - : null - const result = resultData - ? { content: resultData.result, is_error: resultData.is_error } - : null - return - })} - {cleanContent && ( -
- {renderContent(cleanContent).map((part, i) => - part.type === 'code' ? ( - - ) : ( - + {parsedSegments && parsedSegments.some(s => s.type === 'tool') ? ( + parsedSegments.map((seg, i) => { + if (seg.type === 'text') { + if (!seg.content) return null + const c = seg.content.replace(/]*>[\s\S]*?<\/think>/gi, '') + if (!c) return null + return ( +
+ {renderContent(c).map((part, j) => + part.type === 'code' ? ( + + ) : ( + + ) + )} +
) + } + if (seg.type === 'tool') { + const r = seg.result + const result = r && (r.content !== undefined || r.is_error !== undefined) + ? { content: r.content, is_error: r.is_error } + : null + return + } + return null + }) + ) : ( + <> + {parsedToolCalls && parsedToolCalls.map((tc, i) => { + const resultData = parsedToolResults + ? parsedToolResults.find(r => r.tool_call_id === tc.tool_call_id) + : null + const result = resultData + ? { content: resultData.result, is_error: resultData.is_error } + : null + return + })} + {cleanContent && ( +
+ {renderContent(cleanContent).map((part, i) => + part.type === 'code' ? ( + + ) : ( + + ) + )} +
)} -
+ )}
) } -function StreamingItem({ content, thinking, toolCalls }) { +function StreamingItem({ content, thinking, toolCalls, segments }) { const rank = RANKS.general const cleanContent = content.replace(/]*>[\s\S]*?<\/think>/gi, '') const hasToolCalls = toolCalls && toolCalls.length > 0 @@ -333,6 +401,8 @@ function StreamingItem({ content, thinking, toolCalls }) { return formatText(thinking) }, [thinking]) + const hasOrderedSegments = segments && segments.some(s => s.type === 'tool') + return (
@@ -346,25 +416,54 @@ function StreamingItem({ content, thinking, toolCalls }) { {rank.label}
{thinking && } - {hasToolCalls && toolCalls.map((tc, i) => ( - - ))} - {!thinking && !cleanContent && !hasToolCalls && ( + {hasOrderedSegments ? ( + segments.map((seg, i) => { + if (seg.type === 'text') { + if (!seg.content) return null + const parts = renderContent(seg.content) + return ( +
+ {parts.map((part, j) => + part.type === 'code' ? ( + + ) : ( + + ) + )} +
+ ) + } + if (seg.type === 'tool') { + return + } + return null + }) + ) : ( + <> + {hasToolCalls && toolCalls.map((tc, i) => ( + + ))} + {cleanContent && ( +
+ {renderedContent.map((part, i) => + part.type === 'code' ? ( + + ) : ( + + ) + )} + +
+ )} + + )} + {!thinking && !cleanContent && !hasToolCalls && !hasOrderedSegments && (
)} - {cleanContent && ( -
- {renderedContent.map((part, i) => - part.type === 'code' ? ( - - ) : ( - - ) - )} - -
+ {!hasOrderedSegments && cleanContent && ( + )}
@@ -379,12 +478,17 @@ export default function Studio({ api }) { const [streaming, setStreaming] = useState('') const [streamThinking, setStreamThinking] = useState('') const [streamToolCalls, setStreamToolCalls] = useState([]) + const [streamSegments, setStreamSegments] = useState(null) const [loaded, setLoaded] = useState(false) const [tokenInfo, setTokenInfo] = useState({ used: 0, max: 150000, summarizeAt: 120000 }) const [contextCollapsed, setContextCollapsed] = useState(false) const [messagesCollapsed, setMessagesCollapsed] = useState(false) const [sudoModal, setSudoModal] = useState(null) const [attachedImages, setAttachedImages] = useState([]) + const [activeAgents, setActiveAgents] = useState({ crush: 0, claude: 0 }) + const [toolModes, setToolModes] = useState({}) + const MAX_CRUSH_AGENTS = 2 + const MAX_CLAUDE_AGENTS = 2 const messagesEnd = useRef(null) const feedRef = useRef(null) const textareaRef = useRef(null) @@ -584,9 +688,19 @@ export default function Studio({ api }) { abortRef.current = controller try { - let accumulated = '' + let segments = [] + let textStartIdx = 0 let thinking = '' - let toolCalls = [] + + const _updateLastText = (text) => { + if (!text) return + const last = segments.length > 0 ? segments[segments.length - 1] : null + if (last && last.type === 'text') { + last.content = text + } else { + segments.push({ type: 'text', content: text }) + } + } await api.sendChat(text, true, (partial, event) => { if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) { @@ -597,28 +711,47 @@ export default function Studio({ api }) { return } if (event && event.tool_call) { - toolCalls = [...toolCalls, { call: event.tool_call, result: null }] - setStreamToolCalls([...toolCalls]) - accumulated = '' - setStreaming('') + _updateLastText(partial.slice(textStartIdx)) + textStartIdx = partial.length + segments.push({ type: 'tool', call: event.tool_call, result: null }) + const toolName = event.tool_call.name + if (toolName === 'crush_run' || toolName === 'claude_run') { + const agentType = toolName === 'crush_run' ? 'crush' : 'claude' + setActiveAgents(prev => ({ ...prev, [agentType]: prev[agentType] + 1 })) + } + const snap = segments.map(s => ({ ...s })) + setStreamToolCalls(snap.filter(s => s.type === 'tool')) + setStreamSegments(snap) + setStreaming(partial) return } if (event && event.tool_result) { if (event.tool_result.sudo_blocked) { setSudoModal({ command: event.tool_result.command || event.tool_result.content }) } - const idx = toolCalls.findIndex(tc => tc.call && tc.call.tool_call_id === event.tool_result.tool_call_id) - if (idx >= 0) { - toolCalls[idx] = { ...toolCalls[idx], result: event.tool_result } - setStreamToolCalls([...toolCalls]) + const segIdx = segments.findIndex(s => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id) + if (segIdx >= 0) { + segments[segIdx].result = event.tool_result + const toolName = segments[segIdx].call?.name + if (toolName === 'crush_run' || toolName === 'claude_run') { + const agentType = toolName === 'crush_run' ? 'crush' : 'claude' + setActiveAgents(prev => ({ ...prev, [agentType]: Math.max(0, prev[agentType] - 1) })) + } + const snap = segments.map(s => ({ ...s })) + setStreamToolCalls(snap.filter(s => s.type === 'tool')) + setStreamSegments(snap) } return } - accumulated = partial + _updateLastText(partial.slice(textStartIdx)) setStreaming(partial) + const snap = segments.map(s => ({ ...s })) + setStreamSegments(snap) }, controller.signal, images) - const finalContent = accumulated || t('studio.noResponse') + const allText = segments.filter(s => s.type === 'text').map(s => s.content).join('') + const toolSegs = segments.filter(s => s.type === 'tool') + const finalContent = allText || t('studio.noResponse') const aiMsg = { id: (Date.now() + 1).toString(), role: 'assistant', @@ -626,14 +759,18 @@ export default function Studio({ api }) { time: new Date().toISOString(), } if (thinking) aiMsg.thinking = thinking - if (toolCalls.length > 0) { + if (toolSegs.length > 0 || segments.length > 1) { aiMsg.content = JSON.stringify({ - content: finalContent, - tool_calls: toolCalls.map(tc => tc.call), - tool_results: toolCalls.map(tc => ({ - tool_call_id: tc.call?.tool_call_id, - result: tc.result?.content || '', - is_error: tc.result?.is_error || false, + segments: segments.map(s => s.type === 'text' + ? { type: 'text', content: s.content } + : { type: 'tool', call: s.call, result: { content: s.result?.content || '', is_error: s.result?.is_error || false, tool_call_id: s.call?.tool_call_id } } + ), + content: allText, + tool_calls: toolSegs.map(s => s.call), + tool_results: toolSegs.map(s => ({ + tool_call_id: s.call?.tool_call_id, + result: s.result?.content || '', + is_error: s.result?.is_error || false, })), }) } @@ -661,6 +798,9 @@ export default function Studio({ api }) { setStreaming('') setStreamThinking('') setStreamToolCalls([]) + setStreamSegments(null) + setActiveAgents({ crush: 0, claude: 0 }) + setToolModes({}) abortRef.current = null refreshTokens() } @@ -672,6 +812,10 @@ export default function Studio({ api }) { } }, []) + const handleToolModeChange = useCallback((toolCallId, mode) => { + setToolModes(prev => ({ ...prev, [toolCallId]: mode })) + }, []) + const COMMANDS = ['/clear', '/summarize', '/help', '/model', '/model change'] const handleKeyDown = (e) => { @@ -695,29 +839,61 @@ export default function Studio({ api }) { if (afterSlash) { const partial = afterSlash[0] const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial) - if (matches.length === 1) { - const completed = matches[0] + ' ' - const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos) - setInput(newText) - requestAnimationFrame(() => { - ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length - }) + if (matches.length >= 1) { + let completed = matches[0] + for (const m of matches) { + while (!m.startsWith(completed)) completed = completed.slice(0, -1) + } + if (completed === partial && matches.length === 1) completed = matches[0] + if (completed.length > partial.length) { + const suffix = completed[completed.length - 1] === ' ' ? '' : (matches.length === 1 ? ' ' : '') + completed += suffix + const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos) + setInput(newText) + requestAnimationFrame(() => { + ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length + }) + } } } } } + const [summarizedExpanded, setSummarizedExpanded] = useState(false) + const handleToggleCollapsed = useCallback(() => { setMessagesCollapsed(prev => !prev) }, []) const renderMessages = () => { - if (messagesCollapsed && messages.length > 4) { + const summarizedMsgs = messages.filter(m => m.summarized) + const activeMsgs = messages.filter(m => !m.summarized) + + const renderSummaryBlock = () => summarizedMsgs.length > 0 && ( +
+
setSummarizedExpanded(prev => !prev)}> + + + + + + + Résumé · {summarizedMsgs.length} messages + {summarizedExpanded ? 'masquer' : 'voir'} +
+ {summarizedExpanded && summarizedMsgs.map(msg => ( + + ))} +
+ ) + + if (messagesCollapsed && activeMsgs.length > 4) { const visibleCount = 4 - const hiddenCount = messages.length - visibleCount + const hiddenCount = activeMsgs.length - visibleCount return ( <> - {messages.slice(0, visibleCount).map(msg => ( + {renderSummaryBlock()} + {activeMsgs.slice(0, visibleCount).map(msg => ( ))}
@@ -730,9 +906,15 @@ export default function Studio({ api }) { ) } - return messages.map(msg => ( - - )) + + return ( + <> + {renderSummaryBlock()} + {activeMsgs.map(msg => ( + + ))} + + ) } if (!loaded) { @@ -753,7 +935,7 @@ export default function Studio({ api }) {
{renderMessages()} {(streaming || streamThinking || loading || streamToolCalls.length > 0) && ( - + )}
diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index 5a4d0b3..0cc4767 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -211,8 +211,27 @@ const en = { resetConfirm: 'Are you sure? All preferences will be erased.', resetDone: 'Settings reset.', applyStarship: 'Apply starship', + apply: 'Apply', + remove: 'Remove', starshipApplied: 'Starship theme applied! Restart your shell to see the result.', starshipError: 'Failed to apply starship theme.', + systemConfig: 'System Configuration', + aiToolsConfig: 'Tools & Environments', + configureViaAI: 'Configure', + toolCrushDesc: 'Autonomous AI agent for code writing and refactoring.', + toolClaudeDesc: 'AI coding assistant by Anthropic.', + toolGhDesc: 'Command-line interface for GitHub.', + toolDockerDesc: 'Application containerization platform.', + toolGoDesc: 'Programming language and runtime environment.', + toolNodeDesc: 'JavaScript runtime and package manager.', + toolPythonDesc: 'Programming language, pip and uv manager.', + toolStarshipDesc: 'Modern and customizable shell prompt.', + systemUpdate: 'System Update', + systemUpdateDescSudo: 'Updates the system and all tools (sshpass, crush, claude, gh, etc.).', + systemUpdateDescNoSudo: 'Shows update commands to run manually.', + updateBtn: 'Update', + notInstalled: 'Not installed', + install: 'Install', }, } diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index a811078..b006f36 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -211,8 +211,27 @@ const fr = { resetConfirm: '\u00cates-vous s\u00fbr ? Toutes les pr\u00e9f\u00e9rences seront effac\u00e9es.', resetDone: 'Param\u00e8tres r\u00e9initialis\u00e9s.', applyStarship: 'Appliquer starship', + apply: 'Appliquer', + remove: 'Retirer', starshipApplied: 'Th\u00e8me starship appliqu\u00e9 ! Red\u00e9marrez votre shell pour voir le r\u00e9sultat.', starshipError: '\u00c9chec de l\u2019application du th\u00e8me starship.', + systemConfig: 'Configuration Syst\u00e8me', + aiToolsConfig: 'Outils & Environnements', + configureViaAI: 'Configurer', + toolCrushDesc: 'Agent IA autonome pour l\u2019\u00e9criture et le refactoring de code.', + toolClaudeDesc: 'Assistant de codage IA par Anthropic.', + toolGhDesc: 'Interface en ligne de commande pour GitHub.', + toolDockerDesc: 'Plateforme de conteneurisation d\u2019applications.', + toolGoDesc: 'Langage de programmation et environnement d\u2019ex\u00e9cution.', + toolNodeDesc: 'Environnement d\u2019ex\u00e9cution JavaScript et gestionnaire de paquets.', + toolPythonDesc: 'Langage de programmation, pip et gestionnaire uv.', + toolStarshipDesc: 'Prompt shell moderne et personnalisable.', + systemUpdate: 'Mise à jour système', + systemUpdateDescSudo: 'Met à jour le système et tous les outils (sshpass, crush, claude, gh, etc.).', + systemUpdateDescNoSudo: 'Affiche les commandes de mise à jour à exécuter manuellement.', + updateBtn: 'Mettre à jour', + notInstalled: 'Non installé', + install: 'Installer', }, } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index a0dc8b5..a09cb17 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -379,11 +379,11 @@ input::placeholder { color: var(--text-disabled); } .shell-menu-item-row { display: flex; align-items: center; } .shell-menu-item-icon { display: flex; align-items: center; justify-content: center; - width: 24px; height: 24px; border-radius: var(--radius); - background: transparent; border: none; color: var(--text-disabled); + width: 26px; height: 26px; border-radius: var(--radius); + background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; transition: all 0.1s; flex-shrink: 0; } -.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); } +.shell-menu-item-icon:hover { background: var(--accent-bg); color: var(--accent); border-color: var(--accent-dim); } .shell-menu-empty { font-size: 12px; color: var(--text-disabled); padding: 8px 10px; font-style: italic; @@ -459,7 +459,7 @@ input::placeholder { color: var(--text-disabled); } .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; scrollbar-width: thin; scrollbar-color: var(--border) transparent; } .ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; } .ai-message.user { background: var(--bg-elevated); border-left: 3px solid var(--accent-bright); color: var(--text-primary); } .ai-message.user.analysis { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated)); } @@ -511,7 +511,7 @@ input::placeholder { color: var(--text-disabled); } .shell-mermaid-loading { padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; } .shell-mermaid-error { padding: 10px 12px; color: var(--accent-bright); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; } -.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; } +.ai-message table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; display: block; overflow-x: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; } .ai-message thead, .ai-message tbody { display: table-row-group; } .ai-message th { background: var(--bg-surface); padding: 4px 8px; text-align: left; font-weight: 600; border: 1px solid var(--border); color: var(--text-secondary); white-space: nowrap; } .ai-message td { padding: 3px 8px; border: 1px solid var(--border); color: var(--text-primary); white-space: nowrap; } @@ -1024,8 +1024,10 @@ input::placeholder { color: var(--text-disabled); } .feed-content tr:nth-child(even) td { background: var(--bg-surface); } .feed-content hr, .ai-message hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; } .inline-code { background: var(--bg-input); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 13px; color: var(--accent-muted); } -.msg-h3 { font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; } -.msg-h4 { font-size: 14px; font-weight: 700; color: var(--text-secondary); margin: 8px 0 3px; display: block; } +.msg-h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; display: block; } +.msg-h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; display: block; } +.msg-h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; display: block; } +.msg-h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; display: block; } .msg-bullet { display: block; padding-left: 4px; margin: 1px 0; color: var(--text-primary); } .msg-step { display: flex; gap: 8px; align-items: baseline; margin: 1px 0; } .msg-step-num { color: var(--accent); font-weight: 700; font-family: var(--font-mono); font-size: 13px; flex-shrink: 0; min-width: 20px; } @@ -1133,6 +1135,22 @@ input::placeholder { color: var(--text-disabled); } .feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); } .feed-expanded-messages { animation: fadeIn 0.2s ease-out; } +.feed-summary-block { margin: 4px 0; } +.feed-summary-header { + display: flex; align-items: center; gap: 10px; + padding: 8px 16px; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); cursor: pointer; + transition: all 0.2s ease; +} +.feed-summary-header:hover { background: var(--bg-hover); border-color: var(--accent-dim); } +.feed-summary-header svg { color: var(--accent); flex-shrink: 0; } +.feed-summary-text { font-size: 11px; color: var(--text-tertiary); flex: 1; font-weight: 600; } +.feed-summary-toggle { font-size: 10px; color: var(--accent); font-family: var(--font-mono); } + +.skill-list-info { display: flex; flex-direction: column; flex: 1; min-width: 0; } +.skills-list { display: flex; flex-direction: column; gap: 2px; } + /* ── Studio Tool Blocks ── */ .studio-tool-block { background: var(--bg-surface); @@ -1294,3 +1312,51 @@ input::placeholder { color: var(--text-disabled); } .shell-xterm-instance .xterm-link:hover { color: var(--accent-muted) !important; } + +.config-ai-tools-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + margin-bottom: 8px; +} + +.config-ai-tool-card { + display: flex; + flex-direction: column; + padding: 14px; + border-radius: var(--radius); + background: var(--bg-card); + border: 1px solid var(--border); + transition: border-color 0.15s; + min-height: 120px; +} + +.config-ai-tool-card:hover { + border-color: var(--accent-dim); +} + +.config-ai-tool-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.config-ai-tool-icon { + font-size: 18px; + line-height: 1; +} + +.config-ai-tool-name { + font-weight: 600; + font-size: 13px; + color: var(--text-primary); +} + +.config-ai-tool-desc { + font-size: 11px; + color: var(--text-tertiary); + line-height: 1.4; + margin-bottom: 10px; + flex: 1; +}