fix(studio): improve chat context, thinking tags, streaming, and tool results

- Fix cleanThinkingTags to use proper regex instead of naive ReplaceAll
- Send conversation history (last 20 messages + summary) to AI instead of single message
- Store tool results alongside tool calls so history shows complete execution info
- Stream words instead of characters for smoother SSE rendering
- Add stop button to cancel in-progress AI requests (AbortController)
- Fix markdown rendering: add h2 support, use div for bullets
- Add i18n keys for cancel/stop (EN + FR)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-22 22:37:45 +02:00
parent 485e085bb0
commit 0d8e1b1e1a
7 changed files with 149 additions and 26 deletions

View File

@@ -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[^>]*>.*?</[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, "<think", "")
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
}
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() {

View File

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

View File

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

View File

@@ -59,8 +59,9 @@ function formatText(text) {
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
}
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 && <span className="feed-time">{timeStr}</span>}
</div>
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
{parsedToolCalls && parsedToolCalls.map((tc, i) => (
<ToolCallBlock key={tc.tool_call_id || i} call={tc} result={null} />
))}
{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 <ToolCallBlock key={tc.tool_call_id || i} call={tc} result={result} />
})}
{cleanContent && (
<div className="feed-content">
{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) {
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 }) {
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
{loading && (
<button className="studio-stop-btn" onClick={handleStop} title={t('studio.stop')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
)}
</div>
<div className="studio-input-hint">
{t('studio.inputHint')} &middot; /clear

View File

@@ -90,6 +90,8 @@ const en = {
you: 'You',
mentioned: 'mentioned',
cleared: 'Conversation cleared.',
cancelled: 'Request cancelled.',
stop: 'Stop',
},
shell: {

View File

@@ -90,6 +90,8 @@ const fr = {
you: 'Vous',
mentioned: 'mentionn\u00e9',
cleared: 'Conversation effac\u00e9e.',
cancelled: 'Requ\u00eate annul\u00e9e.',
stop: 'Stop',
},
shell: {

View File

@@ -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 ── */