refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection
All checks were successful
Beta Release / beta (push) Successful in 2m23s

- Add ChatEngine for deduplicated chat logic (handlers_chat/shell_chat)
- Add SendWithToolsStream for real-time streaming responses
- Add /help, /plan, /export, /model commands in Studio
- Fix XSS: sanitize HTML after markdown rendering
- Add ConversationStoreMulti for multi-conversation support
- Add Anthropic headers (x-api-key, anthropic-version)
- Add fallback logging when provider switch occurs
- Add API handler tests (handlers_test.go)
- Polish Studio: max-height 200px, word-break on tool args
- Update CLI version to show full info (version, go, platform)

🤖 Generated with Crush

Assisted-by: MiniMax-M2.5 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-22 22:58:05 +02:00
parent 65804aae4e
commit 3948a4c656
12 changed files with 1024 additions and 312 deletions

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
@@ -35,6 +34,22 @@ type ToolCallInfo struct {
Error string `json:"error,omitempty"`
}
func toString(v interface{}) string {
if v == nil {
return ""
}
s, _ := v.(string)
return s
}
func toBool(v interface{}) bool {
if v == nil {
return false
}
b, _ := v.(bool)
return b
}
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
@@ -102,109 +117,59 @@ Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc.
}
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher)
writeSSE := func(data map[string]interface{}) {
b, _ := json.Marshal(data)
w.Write([]byte("data: " + string(b) + "\n\n"))
if canFlush {
flusher.Flush()
}
}
sseWriter := NewSSEWriter(w)
ctx := context.Background()
messages := []orchestrator.Message{
{Role: "user", Content: req.Message},
}
var finalContent string
var toolCalls []ToolCallInfo
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
for i := 0; i < maxShellToolIterations; i++ {
resp, err := orb.SendWithTools(messages)
if err != nil {
writeSSE(map[string]interface{}{"error": err.Error()})
var toolCalls []ToolCallInfo
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
if content != "" {
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
sseWriter.Write(data)
if canFlush {
flusher.Flush()
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: content,
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
toolCallData := map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
}
writeSSE(map[string]interface{}{"tool_call": toolCallData})
if tc, ok := data["tool_call"].(map[string]interface{}); ok {
argsMap := make(map[string]interface{})
json.Unmarshal([]byte(tc.Function.Arguments), &argsMap)
tcInfo := ToolCallInfo{
ID: tc.ID,
Name: tc.Function.Name,
if args, ok := tc["args"].(string); ok {
json.Unmarshal([]byte(args), &argsMap)
}
toolCalls = append(toolCalls, ToolCallInfo{
ID: toString(tc["tool_call_id"]),
Name: toString(tc["name"]),
Args: argsMap,
}
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
result, execErr := s.agentRegistry.Execute(ctx, call)
if execErr != nil {
tcInfo.Error = execErr.Error()
writeSSE(map[string]interface{}{"tool_result": tcInfo})
} else {
tcInfo.Result = &toolResponseData{
Content: result.Content,
IsError: result.IsError,
Meta: result.Meta,
}
writeSSE(map[string]interface{}{"tool_result": tcInfo})
}
toolCalls = append(toolCalls, tcInfo)
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: result.Content,
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
if tr, ok := data["tool_result"].(map[string]interface{}); ok {
tcID := toString(tr["tool_call_id"])
for i := range toolCalls {
if toolCalls[i].ID == tcID {
if err, ok := tr["is_error"].(bool); ok && err {
toolCalls[i].Error = toString(tr["content"])
} else {
toolCalls[i].Result = &toolResponseData{
Content: toString(tr["content"]),
IsError: toBool(tr["is_error"]),
}
}
break
}
}
}
})
finalContent = ""
finalContent, _, _, err := engine.RunWithTools(ctx, messages)
if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()})
return
}
if finalContent == "" && len(toolCalls) > 0 {
@@ -215,7 +180,7 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
Content: finalContent,
ToolCalls: toolCalls,
})
writeSSE(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
sseWriter.Write(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
}
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
@@ -224,80 +189,20 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
{Role: "user", Content: req.Message},
}
var finalContent string
var toolCalls []ToolCallInfo
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
for i := 0; i < maxShellToolIterations; i++ {
resp, err := orb.SendWithTools(messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
if content != "" {
finalContent = content
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: content,
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
argsMap := make(map[string]interface{})
json.Unmarshal([]byte(tc.Function.Arguments), &argsMap)
tcInfo := ToolCallInfo{
ID: tc.ID,
Name: tc.Function.Name,
Args: argsMap,
}
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
result, execErr := s.agentRegistry.Execute(ctx, call)
if execErr != nil {
tcInfo.Error = execErr.Error()
} else {
tcInfo.Result = &toolResponseData{
Content: result.Content,
IsError: result.IsError,
Meta: result.Meta,
}
}
toolCalls = append(toolCalls, tcInfo)
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: result.Content,
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if finalContent == "" && len(toolCalls) > 0 {
if finalContent == "" {
finalContent = "(tool calls completed, no text response)"
}
writeJSON(w, ShellChatResponse{
Content: finalContent,
ToolCalls: toolCalls,
ToolCalls: nil,
})
}