From e8a289ccf3c42e913878491598ed549bb5e2ec1b Mon Sep 17 00:00:00 2001 From: Augustin Date: Sun, 26 Apr 2026 20:06:20 +0200 Subject: [PATCH] feat: AI task API, token-based context windows, SSH password auth, sudo bypass detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace message-count context windows with token-budget based ones for both studio and shell. Add /api/ai/task endpoint for background tool check/install/update. Enhance sudo blocking to catch piped/chained elevation commands. Add SSH password support via sshpass and connection editing UI. Remove realTokens persistence in favor of consumption tracking. Bump to 0.4.1. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- internal/agent/definitions.go | 25 +++- internal/api/conversation.go | 33 ++---- internal/api/handlers_ai_task.go | 172 ++++++++++++++++++++++++++++ internal/api/handlers_chat.go | 45 ++++++-- internal/api/handlers_shell_chat.go | 46 ++++++-- internal/api/server.go | 1 + internal/api/shell_conversation.go | 21 +--- internal/api/terminal.go | 61 +++++++--- internal/version/version.go | 2 +- web/src/api/client.js | 1 + web/src/components/App.jsx | 2 +- web/src/components/Config.jsx | 85 +++++++++++--- web/src/components/Shell.jsx | 50 ++++++-- web/src/i18n/en.js | 2 + web/src/i18n/fr.js | 2 + web/src/styles/global.css | 3 + 16 files changed, 446 insertions(+), 105 deletions(-) create mode 100644 internal/api/handlers_ai_task.go diff --git a/internal/agent/definitions.go b/internal/agent/definitions.go index affc551..d830440 100644 --- a/internal/agent/definitions.go +++ b/internal/agent/definitions.go @@ -56,9 +56,30 @@ func NewTerminalTool() (*ToolDefinition, error) { if NeedsSudoPassword() { trimmed := strings.TrimSpace(p.Command) lower := strings.ToLower(trimmed) - if strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ") { + prefixBlocked := strings.HasPrefix(lower, "sudo ") || strings.HasPrefix(lower, "doas ") || strings.HasPrefix(lower, "run0 ") || strings.HasPrefix(lower, "pkexec ") + anywhereBlocked := false + blockedCmd := "" + if !prefixBlocked { + for _, kw := range []string{"sudo", "doas", "run0", "pkexec"} { + for _, pattern := range []string{" " + kw + " ", "|" + kw + " ", ";" + kw + " ", "&&" + kw + " ", "||" + kw + " ", "`" + kw + " ", "$(" + kw + " "} { + if strings.Contains(lower, pattern) { + anywhereBlocked = true + blockedCmd = kw + break + } + } + if anywhereBlocked { + break + } + } + } + if prefixBlocked || anywhereBlocked { + elevCmd := blockedCmd + if prefixBlocked { + elevCmd = strings.Fields(trimmed)[0] + } return ToolResponse{ - Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). The current user is not root. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, strings.Fields(trimmed)[0]), + Content: fmt.Sprintf("BLOCKED: Command '%s' requires elevated privileges (%s). Passwordless sudo is not available. Do NOT retry with sudo. Explain to the user that this command needs admin privileges and suggest an alternative, or tell them to run it manually in their terminal.", trimmed, elevCmd), IsError: true, Meta: map[string]string{"sudo_blocked": "true", "command": trimmed}, }, nil diff --git a/internal/api/conversation.go b/internal/api/conversation.go index 9382870..b658b66 100644 --- a/internal/api/conversation.go +++ b/internal/api/conversation.go @@ -26,18 +26,16 @@ type FeedMessage struct { } type Conversation struct { - Messages []FeedMessage `json:"messages"` - Summary string `json:"summary,omitempty"` - RealTokens int `json:"real_tokens,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + Messages []FeedMessage `json:"messages"` + Summary string `json:"summary,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type ConversationStore struct { - mu sync.RWMutex - path string - conv *Conversation - realTokens int + mu sync.RWMutex + path string + conv *Conversation } type TokenCount struct { @@ -87,7 +85,6 @@ func (cs *ConversationStore) load() { conv.Messages = []FeedMessage{} } cs.conv = &conv - cs.realTokens = conv.RealTokens } func (cs *ConversationStore) save() error { @@ -157,10 +154,8 @@ func (cs *ConversationStore) Clear() { cs.conv.Messages = []FeedMessage{} cs.conv.Summary = "" - cs.conv.RealTokens = 0 cs.conv.CreatedAt = time.Now().Format(time.RFC3339) cs.conv.UpdatedAt = time.Now().Format(time.RFC3339) - cs.realTokens = 0 cs.save() go cleanupImages(imageIDs) @@ -184,23 +179,9 @@ func (cs *ConversationStore) TrimOld(keepCount int) { } func (cs *ConversationStore) ApproxTokenCount() int { - if cs.realTokens > 0 { - return cs.realTokens - } return cs.ApproxTokenCountDetailed().total } -// AddRealTokens accumulates actual token counts from the API response. -func (cs *ConversationStore) AddRealTokens(tokens int) { - if tokens <= 0 { - return - } - cs.mu.Lock() - cs.realTokens += tokens - cs.conv.RealTokens = cs.realTokens - cs.mu.Unlock() -} - func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount { cs.mu.RLock() defer cs.mu.RUnlock() diff --git a/internal/api/handlers_ai_task.go b/internal/api/handlers_ai_task.go new file mode 100644 index 0000000..fe7b765 --- /dev/null +++ b/internal/api/handlers_ai_task.go @@ -0,0 +1,172 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "runtime" + "strings" + "time" + + "github.com/muyue/muyue/internal/orchestrator" +) + +func (s *Server) handleAITask(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + Task string `json:"task"` + Tool string `json:"tool,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Task == "" { + writeError(w, "task is required", http.StatusBadRequest) + return + } + + orb, err := orchestrator.New(s.config) + if err != nil { + writeError(w, "AI not available: "+err.Error(), http.StatusServiceUnavailable) + return + } + + orb.SetSystemPrompt(buildAITaskSystemPrompt()) + orb.SetTools(s.shellAgentToolsJSON) + + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + + messages := []orchestrator.Message{ + {Role: "user", Content: orchestrator.TextContent(buildAITaskPrompt(body.Task, body.Tool))}, + } + + engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON) + finalContent, err := engine.RunNonStream(ctx, messages) + if err != nil { + writeError(w, "AI task failed: "+err.Error(), http.StatusInternalServerError) + return + } + + s.consumption.Record(engine.ProviderName(), engine.TotalTokens) + + parsed := parseAIJSONResponse(finalContent) + + writeJSON(w, map[string]interface{}{ + "status": "ok", + "raw": finalContent, + "result": parsed, + "tokens": engine.TotalTokens, + }) +} + +func buildAITaskSystemPrompt() string { + return fmt.Sprintf(`You are a system administration assistant. You have access to a terminal tool to run commands on the host system. + +IMPORTANT RULES: +- You MUST respond ONLY with valid JSON. No markdown, no code fences, no extra text. +- Always run the actual commands needed to complete the task. +- Be thorough: check versions, verify installations, compare with latest releases. + +OS: %s/%s +Date: %s +`, runtime.GOOS, runtime.GOARCH, time.Now().Format("2006-01-02")) +} + +func buildAITaskPrompt(task, tool string) string { + switch task { + case "check_tools": + return `Check the following tools on this system. For each tool, determine: +1. Is it installed? Run "which " or " --version" +2. If installed, what is the current version? +3. What is the latest available version? Check GitHub releases API or official sources. + +Tools to check: crush, claude, git, node, npm, pnpm, python3, pip3, uv, go, docker, gh, starship, npx + +Run the commands needed, then respond with ONLY this JSON structure (no markdown fences): +{ + "tools": [ + {"name": "tool_name", "installed": true/false, "version": "x.y.z", "latest": "a.b.c", "needs_update": true/false, "category": "ai|runtime|vcs|devops|prompt"} + ] +}` + + case "install_tool": + return fmt.Sprintf(`Install the tool "%s" on this system. + +Steps: +1. Check if it's already installed: run "which %s" and "%s --version" +2. If not installed, determine the best installation method for this OS +3. Run the installation command +4. Verify the installation succeeded + +Respond with ONLY this JSON (no markdown fences): +{ + "tool": "%s", + "installed": true/false, + "version": "installed version or empty", + "message": "what was done", + "error": "error message or empty" +}`, tool, tool, tool, tool) + + case "update_tool": + return fmt.Sprintf(`Update the tool "%s" to its latest version on this system. + +Steps: +1. Check current version: run "%s --version" +2. Find the latest version available +3. Run the update/upgrade command +4. Verify the new version + +Respond with ONLY this JSON (no markdown fences): +{ + "tool": "%s", + "previous_version": "old version", + "version": "new version", + "updated": true/false, + "message": "what was done", + "error": "error message or empty" +}`, tool, tool, tool) + + default: + return task + } +} + +func parseAIJSONResponse(content string) interface{} { + cleaned := content + + if idx := strings.Index(cleaned, "```json"); idx != -1 { + cleaned = cleaned[idx+7:] + if end := strings.Index(cleaned, "```"); end != -1 { + cleaned = cleaned[:end] + } + } else if idx := strings.Index(cleaned, "```"); idx != -1 { + cleaned = cleaned[idx+3:] + if end := strings.Index(cleaned, "```"); end != -1 { + cleaned = cleaned[:end] + } + } + + cleaned = strings.TrimSpace(cleaned) + + jsonStart := strings.Index(cleaned, "{") + jsonEnd := strings.LastIndex(cleaned, "}") + if jsonStart != -1 && jsonEnd > jsonStart { + cleaned = cleaned[jsonStart : jsonEnd+1] + } + + var result interface{} + if err := json.Unmarshal([]byte(cleaned), &result); err != nil { + return map[string]interface{}{ + "raw": content, + "error": "failed to parse AI response as JSON", + } + } + return result +} diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index b3b7f9f..c632c2c 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -13,6 +13,7 @@ import ( "regexp" "strings" "time" + "unicode/utf8" "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/orchestrator" @@ -253,7 +254,6 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche storeContent = string(storeJSON) } s.convStore.Add("assistant", storeContent) - s.convStore.AddRealTokens(engine.TotalTokens) s.consumption.Record(engine.ProviderName(), engine.TotalTokens) @@ -272,7 +272,6 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or } s.convStore.Add("assistant", finalContent) - s.convStore.AddRealTokens(engine.TotalTokens) s.consumption.Record(engine.ProviderName(), engine.TotalTokens) @@ -283,19 +282,47 @@ func cleanThinkingTags(content string) string { return strings.TrimSpace(thinkingTagRegex.ReplaceAllString(content, "")) } -const contextWindowMessages = 20 - func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message { history := s.convStore.Get() - start := 0 - if len(history) > contextWindowMessages { - start = len(history) - contextWindowMessages + + sysPromptTokens := utf8.RuneCountInString(agent.StudioSystemPrompt())/charsPerToken + 50 + toolsTokens := utf8.RuneCountInString(string(s.agentToolsJSON)) / charsPerToken + responseMargin := 4000 + userMsgTokens := utf8.RuneCountInString(userMessage) / charsPerToken + + overhead := sysPromptTokens + toolsTokens + responseMargin + userMsgTokens + available := contextWindowTokens - overhead + if available < 1000 { + available = 1000 } - messages := make([]orchestrator.Message, 0, len(history[start:])+1) + included := 0 + tokensUsed := 0 + for i := len(history) - 1; i >= 0; i-- { + msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken + if msgTokens == 0 { + msgTokens = 1 + } + if tokensUsed+msgTokens > available { + break + } + tokensUsed += msgTokens + included++ + } + + start := len(history) - included + if start < 0 { + start = 0 + } + + if start > 0 { + log.Printf("[studio] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, contextWindowTokens, included, len(history), start) + } + + messages := make([]orchestrator.Message, 0, included+2) summary := s.convStore.GetSummary() - if summary != "" { + if summary != "" && start > 0 { messages = append(messages, orchestrator.Message{ Role: "system", Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary), diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go index bb736a0..c4961ad 100644 --- a/internal/api/handlers_shell_chat.go +++ b/internal/api/handlers_shell_chat.go @@ -4,12 +4,14 @@ import ( "context" "encoding/json" "fmt" + "log" "net/http" "os" "os/exec" "runtime" "strings" "time" + "unicode/utf8" "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/orchestrator" @@ -133,7 +135,6 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator. storeContent = string(storeJSON) } s.shellConvStore.Add("assistant", storeContent) - s.shellConvStore.AddRealTokens(engine.TotalTokens) s.consumption.Record(engine.ProviderName(), engine.TotalTokens) @@ -155,7 +156,6 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat } s.shellConvStore.Add("assistant", finalContent) - s.shellConvStore.AddRealTokens(engine.TotalTokens) s.consumption.Record(engine.ProviderName(), engine.TotalTokens) @@ -167,13 +167,45 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat func (s *Server) buildShellContextMessages() []orchestrator.Message { history := s.shellConvStore.Get() - start := 0 - const shellContextWindow = 20 - if len(history) > shellContextWindow { - start = len(history) - shellContextWindow + + sysTokens := utf8.RuneCountInString(shellSystemPromptBase) / charsPerToken + if analysis := LoadSystemAnalysis(); analysis != "" { + sysTokens += utf8.RuneCountInString(analysis) / charsPerToken + } + sysTokens += 100 + toolsTokens := utf8.RuneCountInString(string(s.shellAgentToolsJSON)) / charsPerToken + responseMargin := 4000 + + overhead := sysTokens + toolsTokens + responseMargin + available := shellMaxTokens - overhead + if available < 1000 { + available = 1000 } - messages := make([]orchestrator.Message, 0, len(history[start:])) + included := 0 + tokensUsed := 0 + for i := len(history) - 1; i >= 0; i-- { + msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken + if msgTokens == 0 { + msgTokens = 1 + } + if tokensUsed+msgTokens > available { + break + } + tokensUsed += msgTokens + included++ + } + + start := len(history) - included + if start < 0 { + start = 0 + } + + if start > 0 { + log.Printf("[shell] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, shellMaxTokens, included, len(history), start) + } + + messages := make([]orchestrator.Message, 0, included) for _, m := range history[start:] { content := m.Content diff --git a/internal/api/server.go b/internal/api/server.go index 8ead886..4ef7088 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -133,6 +133,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/skills/export", s.handleSkillExport) s.mux.HandleFunc("/api/skills/import", s.handleSkillImport) s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus) + s.mux.HandleFunc("/api/ai/task", s.handleAITask) s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota) s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption) s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands) diff --git a/internal/api/shell_conversation.go b/internal/api/shell_conversation.go index 141ca0a..4840ea6 100644 --- a/internal/api/shell_conversation.go +++ b/internal/api/shell_conversation.go @@ -79,10 +79,9 @@ type ShellMessage struct { } type ShellConvStore struct { - mu sync.RWMutex - path string - msgs []ShellMessage - realTokens int + mu sync.RWMutex + path string + msgs []ShellMessage } func NewShellConvStore() *ShellConvStore { @@ -140,14 +139,10 @@ func (s *ShellConvStore) Clear() { s.mu.Lock() defer s.mu.Unlock() s.msgs = []ShellMessage{} - s.realTokens = 0 s.save() } func (s *ShellConvStore) ApproxTokens() int { - if s.realTokens > 0 { - return s.realTokens - } s.mu.RLock() defer s.mu.RUnlock() total := 0 @@ -161,16 +156,6 @@ func (s *ShellConvStore) ApproxTokens() int { return total } -// AddRealTokens accumulates actual token counts from the API response. -func (s *ShellConvStore) AddRealTokens(tokens int) { - if tokens <= 0 { - return - } - s.mu.Lock() - s.realTokens += tokens - s.mu.Unlock() -} - func (s *ShellConvStore) AtLimit() bool { return s.ApproxTokens() >= shellMaxTokens } diff --git a/internal/api/terminal.go b/internal/api/terminal.go index 3b3018a..67a4dc5 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -72,10 +72,11 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { if initMsg.Type == "ssh" && initMsg.Data != "" { var sshConf struct { - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user"` - KeyPath string `json:"key_path"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + KeyPath string `json:"key_path"` + Password string `json:"password"` } if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil { conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"}) @@ -98,7 +99,16 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { } sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host)) - cmd = exec.Command("ssh", sshArgs...) + if sshConf.Password != "" { + sshpassPath, err := exec.LookPath("sshpass") + if err == nil { + cmd = exec.Command(sshpassPath, append([]string{"-p", sshConf.Password}, append([]string{"-e"}, sshArgs...)...)...) + } else { + cmd = exec.Command("ssh", sshArgs...) + } + } else { + cmd = exec.Command("ssh", sshArgs...) + } } else { shell := strings.TrimSpace(initMsg.Data) log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell) @@ -222,11 +232,12 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) return } var body struct { - Name string `json:"name"` - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user"` - KeyPath string `json:"key_path"` + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + KeyPath string `json:"key_path"` + Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, err.Error(), http.StatusBadRequest) @@ -240,12 +251,32 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) body.Port = 22 } + for i, c := range s.config.Terminal.SSH { + if c.Name == body.Name { + s.config.Terminal.SSH[i] = config.SSHConnection{ + Name: body.Name, + Host: body.Host, + Port: body.Port, + User: body.User, + KeyPath: body.KeyPath, + Password: body.Password, + } + if err := config.Save(s.config); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "ok"}) + return + } + } + conn := config.SSHConnection{ - Name: body.Name, - Host: body.Host, - Port: body.Port, - User: body.User, - KeyPath: body.KeyPath, + Name: body.Name, + Host: body.Host, + Port: body.Port, + User: body.User, + KeyPath: body.KeyPath, + Password: body.Password, } if s.config.Terminal.SSH == nil { s.config.Terminal.SSH = []config.SSHConnection{} diff --git a/internal/version/version.go b/internal/version/version.go index 17df0a3..544039e 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.4.0" + Version = "0.4.1" Author = "La Légion de Muyue" ) diff --git a/web/src/api/client.js b/web/src/api/client.js index f67b839..ec513ca 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -49,6 +49,7 @@ const api = { applyStarshipTheme: (theme) => request('/starship/apply-theme', { method: 'POST', body: JSON.stringify({ theme }) }), validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }), runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }), + aiTask: (task, tool) => request('/ai/task', { method: 'POST', body: JSON.stringify({ task, tool: tool || '' }) }), runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }), getTerminalSessions: () => request('/terminal/sessions'), addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }), diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 13925e6..2ce14ba 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -146,7 +146,7 @@ export default function App() {
-
+
diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index 4c53311..9ec4208 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -49,27 +49,79 @@ export default function Config({ api }) { const handleCheckUpdates = async () => { setChecking(true) try { - await api.runScan() - const d = await api.getUpdates() - setUpdates(d.updates || []) - const td = await api.getTools() - setTools(td.tools || []) - showToast(t('config.upToDate')) + 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 = (tool) => { - window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) - window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour l'outil ${tool} sur mon système. Exécute les commandes nécessaires.` } })) + const 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 handleUpdateAll = () => { - const toUpdate = updates.filter(u => u.needsUpdate).map(u => u.tool) - window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) - window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Met à jour tous les outils suivants sur mon système : ${toUpdate.join(', ')}. Exécute les commandes nécessaires une par une.` } })) + const 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 () => { @@ -160,6 +212,7 @@ export default function Config({ api }) { installedCount={installedCount} missingCount={missingCount} handleCheckUpdates={handleCheckUpdates} handleUpdateTool={handleUpdateTool} + handleInstallTool={handleInstallTool} handleUpdateAll={handleUpdateAll} t={t} /> @@ -406,11 +459,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm ) } -function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) { - const handleInstallTool = (tool) => { - window.dispatchEvent(new CustomEvent('navigate-to-shell', {})) - window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } })) - } +function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleInstallTool, handleUpdateAll, t }) { const missingTools = tools.filter(tool => !tool.installed) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 6baff83..6d857e0 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -225,6 +225,7 @@ function createTerminal(container, settings = {}) { theme, allowTransparency: false, scrollback: 5000, + bracketedPaste: false, }) const fitAddon = new FitAddon() @@ -362,7 +363,7 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMes return ws } -export default function Shell({ api }) { +export default function Shell({ api, isSudo }) { const { t } = useI18n() const tabsRef = useRef({}) const nextIdRef = useRef(1) @@ -456,8 +457,9 @@ export default function Shell({ api }) { }, []) const [sshForm, setSshForm] = useState({ - name: '', host: '', port: 22, user: '', key_path: '', + name: '', host: '', port: 22, user: '', key_path: '', password: '', }) + const [sshEditing, setSshEditing] = useState(null) const [aiMessages, setAiMessages] = useState([]) const [aiInput, setAiInput] = useState('') @@ -552,6 +554,7 @@ export default function Shell({ api }) { port: tab.port || 22, user: tab.user || 'root', key_path: tab.key_path || '', + password: tab.password || '', }), } } else { @@ -893,6 +896,7 @@ export default function Shell({ api }) { port: conn.port || 22, user: conn.user || 'root', key_path: conn.key_path || '', + password: conn.password || '', connected: false, } setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next }) @@ -963,14 +967,26 @@ export default function Shell({ api }) { if (!sshForm.name.trim() || !sshForm.host.trim()) return try { await api.addSSHConnection(sshForm) - setSshConnections(prev => [...prev, { ...sshForm }]) - setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' }) + if (sshEditing) { + setSshConnections(prev => prev.map(c => c.name === sshEditing ? { ...sshForm } : c)) + } else { + setSshConnections(prev => [...prev, { ...sshForm }]) + } + setSshForm({ name: '', host: '', port: 22, user: '', key_path: '', password: '' }) + setSshEditing(null) setShowSshModal(false) } catch (err) { console.error(err) } } + const editSSHConnection = (conn) => { + setSshForm({ name: conn.name, host: conn.host, port: conn.port || 22, user: conn.user || '', key_path: conn.key_path || '', password: conn.password || '' }) + setSshEditing(conn.name) + setShowSshModal(true) + setShowMenu(false) + } + const deleteSSHConnection = async (name) => { try { await api.deleteSSHConnection(name) @@ -1300,6 +1316,13 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L {conn.name} {conn.user}@{conn.host}:{conn.port} + + diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index 984502d..5a4d0b3 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -120,6 +120,8 @@ const en = { port: 'Port', user: 'User', keyPath: 'SSH key path', + password: 'Password', + passwordHint: 'requires sshpass installed', connect: 'Connect', save: 'Save', cancel: 'Cancel', diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index cd17b3d..a811078 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -120,6 +120,8 @@ const fr = { port: 'Port', user: 'Utilisateur', keyPath: 'Chemin cl\u00e9 SSH', + password: 'Mot de passe', + passwordHint: 'n\u00e9cessite sshpass install\u00e9', connect: 'Se connecter', save: 'Enregistrer', cancel: 'Annuler', diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 2ea952d..a0dc8b5 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -442,6 +442,9 @@ input::placeholder { color: var(--text-disabled); } .shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; } .ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; } +.sudo-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; } +.sudo-indicator.sudo-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); } +.sudo-indicator.sudo-blocked { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); } .shell-analyze-btn { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: var(--radius);