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:
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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) {
|
||||
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 }) {
|
||||
<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')} · /clear
|
||||
|
||||
@@ -90,6 +90,8 @@ const en = {
|
||||
you: 'You',
|
||||
mentioned: 'mentioned',
|
||||
cleared: 'Conversation cleared.',
|
||||
cancelled: 'Request cancelled.',
|
||||
stop: 'Stop',
|
||||
},
|
||||
|
||||
shell: {
|
||||
|
||||
@@ -90,6 +90,8 @@ const fr = {
|
||||
you: 'Vous',
|
||||
mentioned: 'mentionn\u00e9',
|
||||
cleared: 'Conversation effac\u00e9e.',
|
||||
cancelled: 'Requ\u00eate annul\u00e9e.',
|
||||
stop: 'Stop',
|
||||
},
|
||||
|
||||
shell: {
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
Reference in New Issue
Block a user