refactor(chat): deduplicate streaming code, add multi-conv, and XSS protection
All checks were successful
Beta Release / beta (push) Successful in 2m23s
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user