feat(studio): add tool execution and hide AI thinking tags

Changes:
- Hide <think> tags from user in Studio chat
- Add tool call detection [TOOL_CALL:{...}] in AI responses
- Execute crush tool when requested by AI
- Show loading animation while AI is thinking

The AI can now:
1. Respond directly to user
2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}]

The system automatically executes the tool and includes results.

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-22 19:04:59 +02:00
parent cd5ebe083c
commit 1eb5a6d00f
3 changed files with 279 additions and 58 deletions

View File

@@ -2,11 +2,17 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os/exec"
"regexp"
"strings"
"github.com/muyue/muyue/internal/orchestrator" "github.com/muyue/muyue/internal/orchestrator"
) )
var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`)
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed) 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) writeError(w, err.Error(), http.StatusServiceUnavailable)
return 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 : 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.
|- 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
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 <think> 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 { if body.Stream {
w.Header().Set("Content-Type", "text/event-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) flusher, canFlush := w.(http.Flusher)
result, err := orb.SendStream(body.Message, func(chunk string) { result, err := orb.SendStream(body.Message, func(chunk string) {
// Skip thinking tags - user doesn't see them
if strings.HasPrefix(chunk, "<think") {
return
}
data, _ := json.Marshal(map[string]string{"content": chunk}) data, _ := json.Marshal(map[string]string{"content": chunk})
w.Write([]byte("data: " + string(data) + "\n\n")) w.Write([]byte("data: " + string(data) + "\n\n"))
if canFlush { if canFlush {
@@ -68,7 +92,9 @@ Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des
return return
} }
s.convStore.Add("assistant", result) // Process tool calls if any
cleanResult := processToolCalls(result)
s.convStore.Add("assistant", cleanResult)
data, _ := json.Marshal(map[string]string{"done": "true"}) data, _ := json.Marshal(map[string]string{"done": "true"})
w.Write([]byte("data: " + string(data) + "\n\n")) w.Write([]byte("data: " + string(data) + "\n\n"))
@@ -83,8 +109,64 @@ Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des
writeError(w, err.Error(), http.StatusInternalServerError) writeError(w, err.Error(), http.StatusInternalServerError)
return return
} }
s.convStore.Add("assistant", result) cleanResult := processToolCalls(result)
writeJSON(w, map[string]string{"content": result}) s.convStore.Add("assistant", cleanResult)
writeJSON(w, map[string]string{"content": cleanResult})
}
func processToolCalls(content string) string {
matches := toolCallRegex.FindAllString(content, -1)
if len(matches) == 0 {
return cleanThinkingTags(content)
}
var result strings.Builder
clean := content
for _, match := range matches {
// Extract tool and task from [TOOL_CALL:{...}]
inner := strings.TrimPrefix(match, "[TOOL_CALL:")
inner = strings.TrimSuffix(inner, "]}") + "}"
var call struct {
Tool string `json:"tool"`
Task string `json:"task"`
}
if err := json.Unmarshal([]byte(inner), &call); err != nil {
continue
}
if call.Tool == "crush" && call.Task != "" {
result.WriteString(fmt.Sprintf("> %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)<think[^>]*>.*?</think>`)
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() { func (s *Server) autoSummarize() {
@@ -139,4 +221,4 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
} }
s.convStore.Clear() s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }

View File

@@ -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()
}

View File

@@ -1,6 +1,35 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { useI18n } from '../i18n' 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 (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>
</svg>
)
}
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={rank.color} strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
)
}
function renderContent(text) { function renderContent(text) {
const parts = [] const parts = []
const codeBlockRegex = /(```[\s\S]*?```)/g const codeBlockRegex = /(```[\s\S]*?```)/g
@@ -34,17 +63,25 @@ function formatText(text) {
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>') .replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
} }
function ThinkingBlock({ content, done }) {
return (
<div className={`feed-thinking-block ${done ? 'done' : 'active'}`}>
<div className="feed-thinking-header">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
</svg>
<span>Reflexion</span>
{!done && <span className="feed-thinking-dots"><span/><span/><span/></span>}
</div>
<div className="feed-thinking-content">{content}</div>
</div>
)
}
function FeedItem({ msg }) { function FeedItem({ msg }) {
const isUser = msg.role === 'user' const isUser = msg.role === 'user'
const isSystem = msg.role === 'system' const isSystem = msg.role === 'system'
const rank = getRank(msg.role)
const roleLabel = isUser ? null : isSystem ? null : (
<div className="feed-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
</div>
)
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' 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(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
return ( return (
<div className={`feed-item ${msg.role}`}> <div className={`feed-item ${msg.role}`}>
{roleLabel} <div className={`feed-avatar ${isUser ? 'user-rank' : 'ai-rank'}`}>
<RankIcon rank={rank} />
</div>
<div className="feed-body"> <div className="feed-body">
<div className="feed-header"> <div className="feed-header">
<span className="feed-role">{isUser ? 'Vous' : 'IA'}</span> <span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
{rank.short}
</span>
<span className="feed-role">{rank.label}</span>
{timeStr && <span className="feed-time">{timeStr}</span>} {timeStr && <span className="feed-time">{timeStr}</span>}
</div> </div>
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
<div className="feed-content"> <div className="feed-content">
{renderContent(msg.content).map((part, i) => {renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? ( part.type === 'code' ? (
<div key={i} className="studio-code-block"> <div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>} {part.lang && <div className="studio-code-lang">{part.lang}</div>}
@@ -83,31 +128,43 @@ function FeedItem({ msg }) {
) )
} }
function StreamingItem({ content }) { function StreamingItem({ content, thinking }) {
const rank = RANKS.general
const cleanContent = content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
return ( return (
<div className="feed-item assistant"> <div className="feed-item assistant">
<div className="feed-avatar"> <div className="feed-avatar ai-rank">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <RankIcon rank={rank} />
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
</div> </div>
<div className="feed-body"> <div className="feed-body">
<div className="feed-header"> <div className="feed-header">
<span className="feed-role">IA</span> <span className="feed-rank-badge" style={{ color: rank.color, borderColor: rank.color }}>
</div> {rank.short}
<div className="feed-content"> </span>
{renderContent(content).map((part, i) => <span className="feed-role">{rank.label}</span>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<span className="studio-cursor" />
</div> </div>
{thinking && <ThinkingBlock content={thinking} done={false} />}
{!thinking && !cleanContent && (
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
)}
{cleanContent && (
<div className="feed-content">
{renderContent(cleanContent).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
{part.lang && <div className="studio-code-lang">{part.lang}</div>}
<pre><code>{part.content}</code></pre>
</div>
) : (
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
<span className="studio-cursor" />
</div>
)}
</div> </div>
</div> </div>
) )
@@ -119,6 +176,7 @@ export default function Studio({ api }) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('') const [streaming, setStreaming] = useState('')
const [streamThinking, setStreamThinking] = useState('')
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const messagesEnd = useRef(null) const messagesEnd = useRef(null)
const textareaRef = useRef(null) const textareaRef = useRef(null)
@@ -143,7 +201,7 @@ export default function Studio({ api }) {
useEffect(() => { useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }) messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming]) }, [messages, streaming, streamThinking])
useEffect(() => { useEffect(() => {
if (textareaRef.current) { if (textareaRef.current) {
@@ -175,21 +233,33 @@ export default function Studio({ api }) {
setMessages(prev => [...prev, userMsg]) setMessages(prev => [...prev, userMsg])
setLoading(true) setLoading(true)
setStreaming('') setStreaming('')
setStreamThinking('')
try { try {
let accumulated = '' 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 accumulated = partial
setStreaming(partial) setStreaming(partial)
}) })
const finalContent = accumulated || t('studio.noResponse') const finalContent = accumulated || t('studio.noResponse')
setMessages(prev => [...prev, { const aiMsg = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
role: 'assistant', role: 'assistant',
content: finalContent, content: finalContent,
time: new Date().toISOString(), time: new Date().toISOString(),
}]) }
if (thinking) aiMsg.thinking = thinking
setMessages(prev => [...prev, aiMsg])
} catch (err) { } catch (err) {
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
@@ -200,6 +270,7 @@ export default function Studio({ api }) {
} finally { } finally {
setLoading(false) setLoading(false)
setStreaming('') setStreaming('')
setStreamThinking('')
} }
}, [input, loading, api, t, handleClear]) }, [input, loading, api, t, handleClear])
@@ -228,20 +299,8 @@ export default function Studio({ api }) {
{messages.map(msg => ( {messages.map(msg => (
<FeedItem key={msg.id} msg={msg} /> <FeedItem key={msg.id} msg={msg} />
))} ))}
{streaming && <StreamingItem content={streaming} />} {(streaming || streamThinking || loading) && (
{loading && !streaming && ( <StreamingItem content={streaming} thinking={streamThinking} />
<div className="feed-item assistant">
<div className="feed-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
</div>
<div className="feed-body">
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
</div>
</div>
)} )}
<div ref={messagesEnd} /> <div ref={messagesEnd} />
</div> </div>