diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index ddbba05..6378c6a 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "net/http" + "regexp" "strings" "github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/orchestrator" ) +var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?`) + const maxToolIterations = 15 func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { @@ -68,12 +71,11 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche } ctx := context.Background() - messages := []orchestrator.Message{ - {Role: "user", Content: userMessage}, - } + messages := s.buildContextMessages(userMessage) var finalContent string var allToolCalls []map[string]interface{} + var allToolResults []map[string]interface{} for i := 0; i < maxToolIterations; i++ { resp, err := orb.SendWithTools(messages) @@ -86,8 +88,13 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche content := cleanThinkingTags(choice.Message.Content) if content != "" { - for _, ch := range strings.Split(content, "") { - writeSSE(map[string]interface{}{"content": ch}) + words := strings.Fields(content) + for i, w := range words { + chunk := w + if i < len(words)-1 { + chunk += " " + } + writeSSE(map[string]interface{}{"content": chunk}) } finalContent = content } @@ -133,6 +140,14 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche } writeSSE(map[string]interface{}{"tool_result": resultData}) + allToolResults = append(allToolResults, map[string]interface{}{ + "tool_call_id": tc.ID, + "name": tc.Function.Name, + "args": tc.Function.Arguments, + "result": result.Content, + "is_error": result.IsError, + }) + messages = append(messages, orchestrator.Message{ Role: "tool", Content: result.Content, @@ -146,7 +161,11 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche storeContent := finalContent if len(allToolCalls) > 0 { - storeObj := map[string]interface{}{"content": storeContent, "tool_calls": allToolCalls} + storeObj := map[string]interface{}{ + "content": storeContent, + "tool_calls": allToolCalls, + "tool_results": allToolResults, + } storeJSON, _ := json.Marshal(storeObj) storeContent = string(storeJSON) } @@ -157,9 +176,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) { ctx := context.Background() - messages := []orchestrator.Message{ - {Role: "user", Content: userMessage}, - } + messages := s.buildContextMessages(userMessage) var finalContent string @@ -223,7 +240,59 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or } func cleanThinkingTags(content string) string { - return strings.ReplaceAll(content, " contextWindowMessages { + start = len(history) - contextWindowMessages + } + + messages := make([]orchestrator.Message, 0, len(history[start:])+1) + + summary := s.convStore.GetSummary() + if summary != "" { + messages = append(messages, orchestrator.Message{ + Role: "system", + Content: "Résumé de la conversation précédente:\n" + summary, + }) + } + + 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, + }) + } + + messages = append(messages, orchestrator.Message{ + Role: "user", + Content: userMessage, + }) + + return messages } func (s *Server) autoSummarize() { diff --git a/internal/api/handlers_shell_chat.go b/internal/api/handlers_shell_chat.go index c33d829..60bb0d3 100644 --- a/internal/api/handlers_shell_chat.go +++ b/internal/api/handlers_shell_chat.go @@ -136,8 +136,13 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator. content := cleanThinkingTags(choice.Message.Content) if content != "" { - for _, ch := range strings.Split(content, "") { - writeSSE(map[string]interface{}{"content": ch}) + words := strings.Fields(content) + for i, w := range words { + chunk := w + if i < len(words)-1 { + chunk += " " + } + writeSSE(map[string]interface{}{"content": chunk}) } finalContent = content } diff --git a/web/src/api/client.js b/web/src/api/client.js index 91a8cc4..1aa75c5 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -52,7 +52,7 @@ const api = { saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }), getChatHistory: () => request('/chat/history'), clearChat: () => request('/chat/clear', { method: 'POST' }), - sendChat: (message, stream = true, onChunk) => { + sendChat: (message, stream = true, onChunk, signal) => { if (!stream) { return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) } @@ -61,6 +61,7 @@ const api = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, stream: true }), + signal, }).then(async (res) => { if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 8a124a1..21af5e3 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -59,8 +59,9 @@ function formatText(text) { .replace(/`([^`]+)`/g, '$1') .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') - .replace(/^\s*[-*] (.+)$/gm, '$1') - .replace(/^\s*(\d+)[.)] (.+)$/gm, '$1$2') + .replace(/^# (.+)$/gm, '

$1

') + .replace(/^\s*[-*] (.+)$/gm, '
• $1
') + .replace(/^\s*(\d+)[.)] (.+)$/gm, '
$1 $2
') } function ThinkingBlock({ content, done }) { @@ -151,11 +152,13 @@ function FeedItem({ msg }) { const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' let parsedToolCalls = null + let parsedToolResults = null let displayContent = msg.content try { const parsed = JSON.parse(msg.content) if (parsed && Array.isArray(parsed.tool_calls)) { parsedToolCalls = parsed.tool_calls + parsedToolResults = parsed.tool_results || null displayContent = parsed.content || '' } } catch {} @@ -186,9 +189,15 @@ function FeedItem({ msg }) { {timeStr && {timeStr}} {msg.thinking && } - {parsedToolCalls && parsedToolCalls.map((tc, i) => ( - - ))} + {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) => @@ -265,6 +274,7 @@ export default function Studio({ api }) { const [loaded, setLoaded] = useState(false) const messagesEnd = useRef(null) const textareaRef = useRef(null) + const abortRef = useRef(null) useEffect(() => { api.getChatHistory().then(data => { @@ -321,6 +331,9 @@ export default function Studio({ api }) { setStreamThinking('') setStreamToolCalls([]) + const controller = new AbortController() + abortRef.current = controller + try { let accumulated = '' let thinking = '' @@ -349,7 +362,7 @@ export default function Studio({ api }) { } accumulated = partial setStreaming(partial) - }) + }, controller.signal) const finalContent = accumulated || t('studio.noResponse') const aiMsg = { @@ -367,19 +380,37 @@ export default function Studio({ api }) { } setMessages(prev => [...prev, aiMsg]) } catch (err) { - setMessages(prev => [...prev, { - id: (Date.now() + 1).toString(), - role: 'system', - content: `${t('studio.error')}: ${err.message}`, - time: new Date().toISOString(), - }]) + if (err.name === 'AbortError') { + if (streaming) { + setMessages(prev => [...prev, { + id: (Date.now() + 1).toString(), + role: 'system', + content: t('studio.cancelled'), + time: new Date().toISOString(), + }]) + } + } else { + setMessages(prev => [...prev, { + id: (Date.now() + 1).toString(), + role: 'system', + content: `${t('studio.error')}: ${err.message}`, + time: new Date().toISOString(), + }]) + } } finally { setLoading(false) setStreaming('') setStreamThinking('') setStreamToolCalls([]) + abortRef.current = null } - }, [input, loading, api, t, handleClear]) + }, [input, loading, api, t, handleClear, streaming]) + + const handleStop = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort() + } + }, []) const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -432,6 +463,13 @@ export default function Studio({ api }) { + {loading && ( + + )}
{t('studio.inputHint')} · /clear diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index ba56892..0b8b066 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -90,6 +90,8 @@ const en = { you: 'You', mentioned: 'mentioned', cleared: 'Conversation cleared.', + cancelled: 'Request cancelled.', + stop: 'Stop', }, shell: { diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index 408c5a5..1e8f569 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -90,6 +90,8 @@ const fr = { you: 'Vous', mentioned: 'mentionn\u00e9', cleared: 'Conversation effac\u00e9e.', + cancelled: 'Requ\u00eate annul\u00e9e.', + stop: 'Stop', }, shell: { diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 7e855ac..31dfa28 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -752,6 +752,12 @@ input::placeholder { color: var(--text-disabled); } } .studio-send-btn:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); } .studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; } +.studio-stop-btn { + width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center; + border-radius: var(--radius); background: var(--error); color: #fff; border: 1px solid var(--error); + cursor: pointer; transition: all 0.15s; flex-shrink: 0; +} +.studio-stop-btn:hover { opacity: 0.8; } .studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; } /* ── Studio Tool Blocks ── */