fix(studio): improve chat context, thinking tags, streaming, and tool results
All checks were successful
Beta Release / beta (push) Successful in 39s
All checks were successful
Beta Release / beta (push) Successful in 39s
- 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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/muyue/muyue/internal/agent"
|
"github.com/muyue/muyue/internal/agent"
|
||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||||
|
|
||||||
const maxToolIterations = 15
|
const maxToolIterations = 15
|
||||||
|
|
||||||
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
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()
|
ctx := context.Background()
|
||||||
messages := []orchestrator.Message{
|
messages := s.buildContextMessages(userMessage)
|
||||||
{Role: "user", Content: userMessage},
|
|
||||||
}
|
|
||||||
|
|
||||||
var finalContent string
|
var finalContent string
|
||||||
var allToolCalls []map[string]interface{}
|
var allToolCalls []map[string]interface{}
|
||||||
|
var allToolResults []map[string]interface{}
|
||||||
|
|
||||||
for i := 0; i < maxToolIterations; i++ {
|
for i := 0; i < maxToolIterations; i++ {
|
||||||
resp, err := orb.SendWithTools(messages)
|
resp, err := orb.SendWithTools(messages)
|
||||||
@@ -86,8 +88,13 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
|||||||
content := cleanThinkingTags(choice.Message.Content)
|
content := cleanThinkingTags(choice.Message.Content)
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
for _, ch := range strings.Split(content, "") {
|
words := strings.Fields(content)
|
||||||
writeSSE(map[string]interface{}{"content": ch})
|
for i, w := range words {
|
||||||
|
chunk := w
|
||||||
|
if i < len(words)-1 {
|
||||||
|
chunk += " "
|
||||||
|
}
|
||||||
|
writeSSE(map[string]interface{}{"content": chunk})
|
||||||
}
|
}
|
||||||
finalContent = content
|
finalContent = content
|
||||||
}
|
}
|
||||||
@@ -133,6 +140,14 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
|||||||
}
|
}
|
||||||
writeSSE(map[string]interface{}{"tool_result": resultData})
|
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{
|
messages = append(messages, orchestrator.Message{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
Content: result.Content,
|
Content: result.Content,
|
||||||
@@ -146,7 +161,11 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
|||||||
|
|
||||||
storeContent := finalContent
|
storeContent := finalContent
|
||||||
if len(allToolCalls) > 0 {
|
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)
|
storeJSON, _ := json.Marshal(storeObj)
|
||||||
storeContent = string(storeJSON)
|
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) {
|
func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
messages := []orchestrator.Message{
|
messages := s.buildContextMessages(userMessage)
|
||||||
{Role: "user", Content: userMessage},
|
|
||||||
}
|
|
||||||
|
|
||||||
var finalContent string
|
var finalContent string
|
||||||
|
|
||||||
@@ -223,7 +240,59 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cleanThinkingTags(content string) string {
|
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() {
|
func (s *Server) autoSummarize() {
|
||||||
|
|||||||
@@ -136,8 +136,13 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
|
|||||||
content := cleanThinkingTags(choice.Message.Content)
|
content := cleanThinkingTags(choice.Message.Content)
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
for _, ch := range strings.Split(content, "") {
|
words := strings.Fields(content)
|
||||||
writeSSE(map[string]interface{}{"content": ch})
|
for i, w := range words {
|
||||||
|
chunk := w
|
||||||
|
if i < len(words)-1 {
|
||||||
|
chunk += " "
|
||||||
|
}
|
||||||
|
writeSSE(map[string]interface{}{"content": chunk})
|
||||||
}
|
}
|
||||||
finalContent = content
|
finalContent = content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const api = {
|
|||||||
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }),
|
||||||
getChatHistory: () => request('/chat/history'),
|
getChatHistory: () => request('/chat/history'),
|
||||||
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
clearChat: () => request('/chat/clear', { method: 'POST' }),
|
||||||
sendChat: (message, stream = true, onChunk) => {
|
sendChat: (message, stream = true, onChunk, signal) => {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })
|
||||||
}
|
}
|
||||||
@@ -61,6 +61,7 @@ const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message, stream: true }),
|
body: JSON.stringify({ message, stream: true }),
|
||||||
|
signal,
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
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(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||||
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
|
||||||
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
|
||||||
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
|
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
|
||||||
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
|
.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 }) {
|
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' }) : ''
|
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||||
|
|
||||||
let parsedToolCalls = null
|
let parsedToolCalls = null
|
||||||
|
let parsedToolResults = null
|
||||||
let displayContent = msg.content
|
let displayContent = msg.content
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(msg.content)
|
const parsed = JSON.parse(msg.content)
|
||||||
if (parsed && Array.isArray(parsed.tool_calls)) {
|
if (parsed && Array.isArray(parsed.tool_calls)) {
|
||||||
parsedToolCalls = parsed.tool_calls
|
parsedToolCalls = parsed.tool_calls
|
||||||
|
parsedToolResults = parsed.tool_results || null
|
||||||
displayContent = parsed.content || ''
|
displayContent = parsed.content || ''
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -186,9 +189,15 @@ function FeedItem({ msg }) {
|
|||||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||||
</div>
|
</div>
|
||||||
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
{msg.thinking && <ThinkingBlock content={msg.thinking} done />}
|
||||||
{parsedToolCalls && parsedToolCalls.map((tc, i) => (
|
{parsedToolCalls && parsedToolCalls.map((tc, i) => {
|
||||||
<ToolCallBlock key={tc.tool_call_id || i} call={tc} result={null} />
|
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 && (
|
{cleanContent && (
|
||||||
<div className="feed-content">
|
<div className="feed-content">
|
||||||
{renderContent(cleanContent).map((part, i) =>
|
{renderContent(cleanContent).map((part, i) =>
|
||||||
@@ -265,6 +274,7 @@ export default function Studio({ api }) {
|
|||||||
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)
|
||||||
|
const abortRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getChatHistory().then(data => {
|
api.getChatHistory().then(data => {
|
||||||
@@ -321,6 +331,9 @@ export default function Studio({ api }) {
|
|||||||
setStreamThinking('')
|
setStreamThinking('')
|
||||||
setStreamToolCalls([])
|
setStreamToolCalls([])
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let accumulated = ''
|
let accumulated = ''
|
||||||
let thinking = ''
|
let thinking = ''
|
||||||
@@ -349,7 +362,7 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
accumulated = partial
|
accumulated = partial
|
||||||
setStreaming(partial)
|
setStreaming(partial)
|
||||||
})
|
}, controller.signal)
|
||||||
|
|
||||||
const finalContent = accumulated || t('studio.noResponse')
|
const finalContent = accumulated || t('studio.noResponse')
|
||||||
const aiMsg = {
|
const aiMsg = {
|
||||||
@@ -367,19 +380,37 @@ export default function Studio({ api }) {
|
|||||||
}
|
}
|
||||||
setMessages(prev => [...prev, aiMsg])
|
setMessages(prev => [...prev, aiMsg])
|
||||||
} catch (err) {
|
} 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, {
|
setMessages(prev => [...prev, {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `${t('studio.error')}: ${err.message}`,
|
content: `${t('studio.error')}: ${err.message}`,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
}])
|
}])
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setStreaming('')
|
setStreaming('')
|
||||||
setStreamThinking('')
|
setStreamThinking('')
|
||||||
setStreamToolCalls([])
|
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) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
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"/>
|
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
||||||
<div className="studio-input-hint">
|
<div className="studio-input-hint">
|
||||||
{t('studio.inputHint')} · /clear
|
{t('studio.inputHint')} · /clear
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ const en = {
|
|||||||
you: 'You',
|
you: 'You',
|
||||||
mentioned: 'mentioned',
|
mentioned: 'mentioned',
|
||||||
cleared: 'Conversation cleared.',
|
cleared: 'Conversation cleared.',
|
||||||
|
cancelled: 'Request cancelled.',
|
||||||
|
stop: 'Stop',
|
||||||
},
|
},
|
||||||
|
|
||||||
shell: {
|
shell: {
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ const fr = {
|
|||||||
you: 'Vous',
|
you: 'Vous',
|
||||||
mentioned: 'mentionn\u00e9',
|
mentioned: 'mentionn\u00e9',
|
||||||
cleared: 'Conversation effac\u00e9e.',
|
cleared: 'Conversation effac\u00e9e.',
|
||||||
|
cancelled: 'Requ\u00eate annul\u00e9e.',
|
||||||
|
stop: 'Stop',
|
||||||
},
|
},
|
||||||
|
|
||||||
shell: {
|
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:hover:not(:disabled) { background: var(--accent-bright); border-color: var(--accent-bright); }
|
||||||
.studio-send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
.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-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; }
|
||||||
|
|
||||||
/* ── Studio Tool Blocks ── */
|
/* ── Studio Tool Blocks ── */
|
||||||
|
|||||||
Reference in New Issue
Block a user