diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go index c0c977b..b2e7896 100644 --- a/internal/api/handlers_config.go +++ b/internal/api/handlers_config.go @@ -187,6 +187,8 @@ func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request) switch body.Name { case "minimax": baseURL = "https://api.minimax.io/v1" + case "mimo": + baseURL = "https://token-plan-ams.xiaomimimo.com/v1" case "openai": baseURL = "https://api.openai.com/v1" case "anthropic": diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go index 630ca24..7eebaab 100644 --- a/internal/api/handlers_shell_chat.go +++ b/internal/api/handlers_shell_chat.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "fmt" "net/http" @@ -51,26 +52,31 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) { return } - orb.SetSystemPrompt(s.buildShellSystemPromptV2(req)) + orb.SetSystemPrompt(s.buildShellSystemPrompt(req)) + orb.SetTools(s.shellAgentToolsJSON) if req.Stream { - s.handleShellChatStreamV2(w, orb) + s.handleShellChatStream(w, orb) } else { - s.handleShellChatNonStreamV2(w, orb) + s.handleShellChatNonStream(w, orb) } } -func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string { +func (s *Server) buildShellSystemPrompt(_ ShellChatRequest) string { var sb strings.Builder sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement. Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement. -RÈGLES STRICTES: -- Tu ne peux JAMAIS exécuter de commande ou de code -- Tu ne peux que analyser, expliquer, et proposer des solutions -- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié -- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons +OUTILS DISPONIBLES: +- terminal: Exécute des commandes shell sur le système local et retourne le résultat + +RÈGLES: +- Utilise l'outil terminal pour exécuter des commandes quand c'est nécessaire +- Analyse les résultats et explique-les clairement +- Formate tes réponses en markdown avec des blocs de code quand approprié +- Sois concis et technique +- Quand tu proposes des commandes alternatives, utilise des blocs de code markdown `) @@ -89,43 +95,42 @@ RÈGLES STRICTES: return sb.String() } -func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) { +func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) { SetupSSEHeaders(w) flusher, canFlush := w.(http.Flusher) sseWriter := NewSSEWriter(w) - // Rebuild history into orchestrator - history := s.shellConvStore.Get() - for _, m := range history[:len(history)-1] { // all except last user msg - if m.Role == "system" { - continue + ctx := context.Background() + messages := s.buildShellContextMessages() + + engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON) + engine.OnChunk(func(data map[string]interface{}) { + if data == nil { + return } - // Pre-load orchestrator history - orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content}) - } - - lastUserMsg := history[len(history)-1].Content - - var finalContent string - result, err := orb.SendStream(lastUserMsg, func(chunk string) { - finalContent = chunk - sseWriter.Write(map[string]interface{}{"content": chunk}) + sseWriter.Write(data) if canFlush { flusher.Flush() } }) + finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages) if err != nil { sseWriter.Write(map[string]interface{}{"error": err.Error()}) return } - content := result - if content == "" { - content = finalContent + storeContent := finalContent + if len(allToolCalls) > 0 { + storeObj := map[string]interface{}{ + "content": storeContent, + "tool_calls": allToolCalls, + "tool_results": allToolResults, + } + storeJSON, _ := json.Marshal(storeObj) + storeContent = string(storeJSON) } - - s.shellConvStore.Add("assistant", cleanThinkingTags(content)) + s.shellConvStore.Add("assistant", storeContent) sseWriter.Write(map[string]interface{}{ "done": "true", @@ -133,30 +138,62 @@ func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrato }) } -func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) { - history := s.shellConvStore.Get() - for _, m := range history[:len(history)-1] { - if m.Role == "system" { - continue - } - orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content}) - } +func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) { + ctx := context.Background() + messages := s.buildShellContextMessages() - lastUserMsg := history[len(history)-1].Content - - result, err := orb.Send(lastUserMsg) + engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON) + finalContent, err := engine.RunNonStream(ctx, messages) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } - s.shellConvStore.Add("assistant", cleanThinkingTags(result)) + s.shellConvStore.Add("assistant", finalContent) writeJSON(w, map[string]interface{}{ - "content": result, + "content": finalContent, "tokens": s.shellConvStore.ApproxTokens(), }) } +func (s *Server) buildShellContextMessages() []orchestrator.Message { + history := s.shellConvStore.Get() + start := 0 + const shellContextWindow = 20 + if len(history) > shellContextWindow { + start = len(history) - shellContextWindow + } + + messages := make([]orchestrator.Message, 0, len(history[start:])) + + for _, m := range history[start:] { + content := m.Content + if m.Role == "assistant" { + var parsed struct { + Content string `json:"content"` + ToolCalls []struct { + ToolCallID string `json:"tool_call_id"` + Name string `json:"name"` + Args string `json:"args"` + } `json:"tool_calls"` + } + if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" { + content = parsed.Content + } + } + role := m.Role + if role == "system" { + continue + } + messages = append(messages, orchestrator.Message{ + Role: role, + Content: content, + }) + } + + return messages +} + func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { writeError(w, "GET only", http.StatusMethodNotAllowed) diff --git a/internal/api/server.go b/internal/api/server.go index 4b89dd7..e07e75c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -13,14 +13,16 @@ import ( ) type Server struct { - config *config.MuyueConfig - scanResult *scanner.ScanResult - mux *http.ServeMux - convStore *ConversationStore - shellConvStore *ShellConvStore - agentRegistry *agent.Registry - agentToolsJSON json.RawMessage - workflowEngine *workflow.Engine + config *config.MuyueConfig + scanResult *scanner.ScanResult + mux *http.ServeMux + convStore *ConversationStore + shellConvStore *ShellConvStore + agentRegistry *agent.Registry + agentToolsJSON json.RawMessage + shellAgentRegistry *agent.Registry + shellAgentToolsJSON json.RawMessage + workflowEngine *workflow.Engine } func NewServer(cfg *config.MuyueConfig) *Server { @@ -52,6 +54,14 @@ func NewServer(cfg *config.MuyueConfig) *Server { tools := s.agentRegistry.OpenAITools() toolsJSON, _ := json.Marshal(tools) s.agentToolsJSON = json.RawMessage(toolsJSON) + + s.shellAgentRegistry = agent.NewRegistry() + terminalTool, _ := agent.NewTerminalTool() + s.shellAgentRegistry.Register(terminalTool) + shellTools := s.shellAgentRegistry.OpenAITools() + shellToolsJSON, _ := json.Marshal(shellTools) + s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON) + s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry) s.routes() return s diff --git a/internal/config/config.go b/internal/config/config.go index ab693b1..d0e143e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -128,6 +128,22 @@ var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{ }, } +func migrateProviders(cfg *MuyueConfig) { + defaults := Default().AI.Providers + for _, dp := range defaults { + found := false + for _, p := range cfg.AI.Providers { + if p.Name == dp.Name { + found = true + break + } + } + if !found { + cfg.AI.Providers = append(cfg.AI.Providers, dp) + } + } +} + func GetTerminalTheme(name string) TerminalTheme { if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok { return theme @@ -206,6 +222,8 @@ func Load() (*MuyueConfig, error) { } } + migrateProviders(&cfg) + return &cfg, nil } @@ -303,6 +321,7 @@ func Default() *MuyueConfig { cfg.Terminal.CustomPrompt = true cfg.Terminal.PromptTheme = "zerotwo" + cfg.Terminal.FontSize = 14 return cfg } diff --git a/internal/version/version.go b/internal/version/version.go index a7385cd..17df0a3 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.3.5" + Version = "0.4.0" Author = "La Légion de Muyue" ) diff --git a/web/src/api/client.js b/web/src/api/client.js index 3845dea..5148a10 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -141,7 +141,11 @@ const api = { if (data.error) { reject(new Error(data.error)); return } if (data.done) { resolve({ content: full, tokens: data.tokens }); return } if (data.content) { - full = data.content + full += data.content + if (onChunk) onChunk(full, data) + } else if (data.tool_call || data.tool_result) { + if (onChunk) onChunk(full, data) + } else if (data.thinking !== undefined || data.thinking_end) { if (onChunk) onChunk(full, data) } } catch {} diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index 1534c7b..4c53311 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -101,9 +101,9 @@ export default function Config({ api }) { ...prev, [p.name]: { name: p.name, - api_key: p.apiKey || '', + api_key: p.api_key || '', model: p.model || '', - base_url: p.baseURL || '', + base_url: p.base_url || '', }, })) setEditProvider(p.name) @@ -314,7 +314,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm const validateKey = async (p) => { setValidating(p.name) try { - await api.validateProvider({ name: p.name, api_key: p.apiKey, model: p.model, base_url: p.baseURL || '' }) + await api.validateProvider({ name: p.name, api_key: p.api_key, model: p.model, base_url: p.base_url || '' }) setKeyStatus(prev => ({ ...prev, [p.name]: { valid: true, checked: true } })) } catch (err) { setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: err.message || 'Clé invalide' } })) @@ -324,9 +324,9 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm useEffect(() => { providers.forEach(p => { - if (p.apiKey && !keyStatus[p.name]) { + if (p.api_key && !keyStatus[p.name]) { validateKey(p) - } else if (!p.apiKey) { + } else if (!p.api_key) { setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } })) } }) @@ -370,7 +370,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm { if (!isEditing) openProviderEdit(p) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 887791d..2d14767 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -204,7 +204,7 @@ function createTerminal(container, settings = {}) { const term = new XTerm({ cursorBlink: true, allowProposedApi: true, - fontSize: settings.fontSize || 6, + fontSize: settings.fontSize || 14, fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme, allowTransparency: false, @@ -350,7 +350,7 @@ export default function Shell({ api }) { const { t } = useI18n() const tabsRef = useRef({}) const nextIdRef = useRef(1) - const settingsRef = useRef({ fontSize: 6, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'system' }) + const settingsRef = useRef({ fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'system' }) const pendingCommandsRef = useRef({}) const [tabs, setTabs] = useState(() => { @@ -399,7 +399,7 @@ export default function Shell({ api }) { const [editingTab, setEditingTab] = useState(null) const [editName, setEditName] = useState('') const [terminalSettings, setTerminalSettings] = useState({ - fontSize: 6, + fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme: 'system', }) @@ -414,7 +414,7 @@ export default function Shell({ api }) { useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings]) useEffect(() => { - baseFontSizeRef.current = terminalSettings.fontSize || 6 + baseFontSizeRef.current = terminalSettings.fontSize || 14 }, [terminalSettings.fontSize]) useEffect(() => { @@ -496,7 +496,7 @@ export default function Shell({ api }) { api.getConfig().then(d => { if (d.terminal) { setTerminalSettings({ - fontSize: d.terminal.font_size || 6, + fontSize: d.terminal.font_size || 14, fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme: d.terminal.theme || 'system', }) @@ -1000,7 +1000,7 @@ export default function Shell({ api }) { if (trimmed === '/help') { setAiMessages(prev => [...prev, { role: 'user', content: trimmed }, - { role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' } + { role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe peux exécuter des commandes via l\'outil terminal. Les blocs de code proposés peuvent aussi être copiés ou envoyés directement au terminal actif.' } ]) aiLoadingRef.current = false return @@ -1013,17 +1013,56 @@ export default function Shell({ api }) { try { let accumulated = '' - await api.sendShellChat(trimmed, {}, true, (partial) => { + let toolCalls = [] + const controller = new AbortController() + + await api.sendShellChat(trimmed, {}, true, (partial, event) => { + if (event && event.tool_call) { + toolCalls = [...toolCalls, { call: event.tool_call, result: null }] + setAiMessages(prev => { + const filtered = prev.filter(m => !m._streaming) + return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }] + }) + return + } + if (event && event.tool_result) { + 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 } + setAiMessages(prev => { + const filtered = prev.filter(m => !m._streaming) + return [...filtered, { role: 'assistant', content: accumulated, _streaming: true, _tabId: currentTab, _toolCalls: [...toolCalls] }] + }) + } + return + } + if (event && (event.thinking !== undefined || event.thinking_end)) { + return + } accumulated = partial setAiMessages(prev => { const filtered = prev.filter(m => !m._streaming) - return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab }] + return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab, _toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined }] }) - }) + }, controller.signal) + + const finalMsg = { role: 'assistant', content: accumulated, _tabId: currentTab } + if (toolCalls.length > 0) { + finalMsg._toolCalls = toolCalls + 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, + })), + }) + } setAiMessages(prev => { const filtered = prev.filter(m => !m._streaming) - return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }] + return [...filtered, finalMsg] }) api.getShellChatHistory().then(d => { setAiTokens(d.tokens || 0) @@ -1336,6 +1375,40 @@ export default function Shell({ api }) { ) } +function ShellToolBlock({ call, result }) { + const icon = '⌨' + const label = call.name === 'terminal' ? 'Terminal' : call.name + const isErr = result && result.is_error + + let argsPreview = '' + try { + const args = typeof call.args === 'string' ? JSON.parse(call.args) : call.args + if (args.command) argsPreview = args.command + else argsPreview = JSON.stringify(args).slice(0, 80) + } catch { + argsPreview = String(call.args).slice(0, 80) + } + + const truncatedResult = result ? (result.content || '').slice(0, 1500) : null + + return ( +
{truncatedResult}
+