diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index 4fcba7d..cf69338 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -2,11 +2,17 @@ package api import ( "encoding/json" + "fmt" "net/http" + "os/exec" + "regexp" + "strings" "github.com/muyue/muyue/internal/orchestrator" ) +var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`) + func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { writeError(w, "POST only", http.StatusMethodNotAllowed) @@ -36,13 +42,27 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeError(w, err.Error(), http.StatusServiceUnavailable) return } - orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tâches de développement logiciel. Tu peux : -|- Créer et gérer des plans de développement étape par étape -|- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exécuter des tâches spécifiques -|- Suivre la progression de tâches multi-étapes -|- Suggérer des modifications de fichiers, des revues de code, et des décisions d'architecture + orb.SetSystemPrompt(`Tu es l'assistant IA de Muyue Studio. Tu as accès à un outil "crush" pour exécuter des tâches complexes sur l'ordinateur de l'utilisateur. -Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des étapes numérotées claires. Quand tu références des fichiers, utilise des chemins relatifs. Tu es intégré dans l'application desktop Muyue.`) +RÈGLES ABSOLUES: +1. Tu as DEUX possibilités ONLY: + - Répondre directement à l'utilisateur avec tes connaissances + - Demander l'exécution d'une tâche via crush en utilisant ce format EXACT: + [TOOL_CALL:{"tool":"crush","task":"description de la tâche"}] + +2. Quand tu utilises [TOOL_CALL:...], le système exécutera la tâche et te donnera le résultat. + Tu peux ensuite répondre à l'utilisateur avec ce résultat. + +3. SOIS CONCIS - pas de blabla, vais droit au but. + +4. L'utilisateur ne voit PAS tes pensées entre tags. + +5. EXEMPLES d'utilisation de tool: + - "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}] + - "aide-moi à déboguer cette erreur" → tu peux répondre directement si tu as assez d'info, sinon utiliser tool + - "quelle est la météo?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la météo actuelle"}] + +6. Ne fais PAS de multi-step tool calls dans une seule réponse. Attends le résultat avant de continuer.`) if body.Stream { w.Header().Set("Content-Type", "text/event-stream") @@ -53,6 +73,10 @@ Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des flusher, canFlush := w.(http.Flusher) result, err := orb.SendStream(body.Message, func(chunk string) { + // Skip thinking tags - user doesn't see them + if strings.HasPrefix(chunk, " %s\n\n", call.Task)) + output := executeCrush(call.Task) + result.WriteString(output) + result.WriteString("\n\n---\n\n") + } + + clean = strings.Replace(clean, match, "", 1) + } + + clean = cleanThinkingTags(clean) + + if result.Len() > 0 { + clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String()) + } + + return clean +} + +func cleanThinkingTags(content string) string { + re := regexp.MustCompile(`(?s)]*>.*?`) + return re.ReplaceAllString(content, "") +} + +func executeCrush(task string) string { + cmd := exec.Command("crush", "run", task) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Sprintf("Erreur: %v\n%s", err, string(output)) + } + return string(output) } func (s *Server) autoSummarize() { @@ -139,4 +221,4 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) { } s.convStore.Clear() writeJSON(w, map[string]string{"status": "ok"}) -} +} \ No newline at end of file diff --git a/internal/api/handlers_tools_exec.go b/internal/api/handlers_tools_exec.go new file mode 100644 index 0000000..8f65181 --- /dev/null +++ b/internal/api/handlers_tools_exec.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "net/http" + "os/exec" + "strings" +) + +type toolCallRequest struct { + Tool string `json:"tool"` + Task string `json:"task"` +} + +type toolResult struct { + Success bool `json:"success"` + Output string `json:"output"` + Error string `json:"error,omitempty"` +} + +func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var req toolCallRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Tool != "crush" { + writeError(w, "unsupported tool: "+req.Tool, http.StatusBadRequest) + return + } + + if req.Task == "" { + writeError(w, "task is required", http.StatusBadRequest) + return + } + + result := executeTool(req.Tool, req.Task) + writeJSON(w, result) +} + +func executeTool(tool, task string) toolResult { + var cmd *exec.Cmd + + switch tool { + case "crush": + cmd = exec.Command("crush", "run", task) + default: + return toolResult{Success: false, Error: "unknown tool: " + tool} + } + + output, err := cmd.CombinedOutput() + if err != nil { + return toolResult{ + Success: false, + Output: string(output), + Error: err.Error(), + } + } + + return toolResult{ + Success: true, + Output: string(output), + } +} + +func buildToolMessage(tool, task string, history []string) string { + var b strings.Builder + b.WriteString("TASK: " + task + "\n\n") + b.WriteString("CONVERSATION HISTORY:\n") + for _, msg := range history { + b.WriteString(strings.Repeat(" ", 4) + strings.Join(strings.Split(msg, "\n"), "\n"+strings.Repeat(" ", 4)) + "\n") + } + return b.String() +} \ No newline at end of file diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 9350666..35bd81f 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -1,6 +1,35 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { useI18n } from '../i18n' +const RANKS = { + commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' }, + general: { label: 'General', short: 'GEN', color: '#FF9100' }, + colonel: { label: 'Colonel', short: 'COL', color: '#FF6D00' }, + lieutenant: { label: 'Lieutenant', short: 'LT', color: '#448AFF' }, + soldat: { label: 'Soldat', short: 'SDT', color: '#00E676' }, +} + +function getRank(role) { + if (role === 'user') return RANKS.commandant + if (role === 'system') return null + return RANKS.general +} + +function RankIcon({ rank }) { + if (rank === RANKS.commandant) { + return ( + + + + ) + } + return ( + + + + ) +} + function renderContent(text) { const parts = [] const codeBlockRegex = /(```[\s\S]*?```)/g @@ -34,17 +63,25 @@ function formatText(text) { .replace(/^\s*(\d+)[.)] (.+)$/gm, '$1$2') } +function ThinkingBlock({ content, done }) { + return ( +
+
+ + + + Reflexion + {!done && } +
+
{content}
+
+ ) +} + function FeedItem({ msg }) { const isUser = msg.role === 'user' const isSystem = msg.role === 'system' - - const roleLabel = isUser ? null : isSystem ? null : ( -
- - - -
- ) + const rank = getRank(msg.role) const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' @@ -58,16 +95,24 @@ function FeedItem({ msg }) { ) } + const cleanContent = msg.content.replace(/]*>[\s\S]*?<\/think>/gi, '') + return (
- {roleLabel} +
+ +
- {isUser ? 'Vous' : 'IA'} + + {rank.short} + + {rank.label} {timeStr && {timeStr}}
+ {msg.thinking && }
- {renderContent(msg.content).map((part, i) => + {renderContent(cleanContent).map((part, i) => part.type === 'code' ? (
{part.lang &&
{part.lang}
} @@ -83,31 +128,43 @@ function FeedItem({ msg }) { ) } -function StreamingItem({ content }) { +function StreamingItem({ content, thinking }) { + const rank = RANKS.general + const cleanContent = content.replace(/]*>[\s\S]*?<\/think>/gi, '') + return (
-
- - - +
+
- IA -
-
- {renderContent(content).map((part, i) => - part.type === 'code' ? ( -
- {part.lang &&
{part.lang}
} -
{part.content}
-
- ) : ( - - ) - )} - + + {rank.short} + + {rank.label}
+ {thinking && } + {!thinking && !cleanContent && ( +
+
+
+ )} + {cleanContent && ( +
+ {renderContent(cleanContent).map((part, i) => + part.type === 'code' ? ( +
+ {part.lang &&
{part.lang}
} +
{part.content}
+
+ ) : ( + + ) + )} + +
+ )}
) @@ -119,6 +176,7 @@ export default function Studio({ api }) { const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [streaming, setStreaming] = useState('') + const [streamThinking, setStreamThinking] = useState('') const [loaded, setLoaded] = useState(false) const messagesEnd = useRef(null) const textareaRef = useRef(null) @@ -143,7 +201,7 @@ export default function Studio({ api }) { useEffect(() => { messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, streaming]) + }, [messages, streaming, streamThinking]) useEffect(() => { if (textareaRef.current) { @@ -175,21 +233,33 @@ export default function Studio({ api }) { setMessages(prev => [...prev, userMsg]) setLoading(true) setStreaming('') + setStreamThinking('') try { let accumulated = '' - await api.sendChat(text, true, (partial) => { + let thinking = '' + + await api.sendChat(text, true, (partial, event) => { + if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) { + if (event.thinking !== undefined) { + thinking += event.thinking + setStreamThinking(thinking) + } + return + } accumulated = partial setStreaming(partial) }) const finalContent = accumulated || t('studio.noResponse') - setMessages(prev => [...prev, { + const aiMsg = { id: (Date.now() + 1).toString(), role: 'assistant', content: finalContent, time: new Date().toISOString(), - }]) + } + if (thinking) aiMsg.thinking = thinking + setMessages(prev => [...prev, aiMsg]) } catch (err) { setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), @@ -200,6 +270,7 @@ export default function Studio({ api }) { } finally { setLoading(false) setStreaming('') + setStreamThinking('') } }, [input, loading, api, t, handleClear]) @@ -228,20 +299,8 @@ export default function Studio({ api }) { {messages.map(msg => ( ))} - {streaming && } - {loading && !streaming && ( -
-
- - - -
-
-
-
-
-
-
+ {(streaming || streamThinking || loading) && ( + )}