Files
MuyueWorkspace/internal/api/handlers_chat.go
Augustin cb3d35756a
All checks were successful
Beta Release / beta (push) Successful in 1m3s
feat: terminal sudo blocking, token tracking, mermaid & consumption UI
- Block sudo/doas commands when not running as root
- Add real token counting from API responses
- Track and display consumption by provider/day
- Add Mermaid diagram rendering in Shell and Studio
- Add copy-to-clipboard buttons for code blocks
- Support tables in AI message rendering
- Update system prompt with context (date, time, root status)

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-26 12:43:15 +02:00

255 lines
6.4 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
var thinkingTagRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Message string `json:"message"`
Stream bool `json:"stream"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Message == "" {
writeError(w, "no message", http.StatusMethodNotAllowed)
return
}
s.convStore.Add("user", body.Message)
if s.convStore.NeedsSummarization() {
s.autoSummarize()
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
var studioPrompt strings.Builder
studioPrompt.WriteString(agent.StudioSystemPrompt())
studioPrompt.WriteString(fmt.Sprintf("\nDate: %s\nHeure: %s\n", time.Now().Format("02/01/2006"), time.Now().Format("15:04:05")))
studioPrompt.WriteString(fmt.Sprintf("Root: %t\n", os.Geteuid() == 0))
if os.Geteuid() != 0 {
studioPrompt.WriteString("⚠️ Session utilisateur standard — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
}
orb.SetSystemPrompt(studioPrompt.String())
orb.SetTools(s.agentToolsJSON)
if body.Stream {
s.handleStreamChat(w, orb, body.Message)
} else {
s.handleNonStreamChat(w, orb, body.Message)
}
}
func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w)
ctx := context.Background()
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
}
sseWriter.Write(data)
if canFlush {
flusher.Flush()
}
})
finalContent, allToolCalls, allToolResults, err := engine.RunWithTools(ctx, messages)
if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()})
return
}
storeContent := finalContent
if len(allToolCalls) > 0 {
storeObj := map[string]interface{}{
"content": storeContent,
"tool_calls": allToolCalls,
"tool_results": allToolResults,
}
storeJSON, _ := json.Marshal(storeObj)
storeContent = string(storeJSON)
}
s.convStore.Add("assistant", storeContent)
s.convStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
sseWriter.Write(map[string]interface{}{"done": "true"})
}
func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Orchestrator, userMessage string) {
ctx := context.Background()
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.convStore.Add("assistant", finalContent)
s.convStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
writeJSON(w, map[string]string{"content": finalContent})
}
func cleanThinkingTags(content string) string {
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() {
messages := s.convStore.Get()
if len(messages) < 10 {
return
}
half := len(messages) / 2
var oldText string
for _, m := range messages[:half] {
oldText += m.Role + ": " + m.Content + "\n\n"
}
summary := s.convStore.GetSummary()
if summary != "" {
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
}
orb, err := orchestrator.New(s.config)
if err != nil {
return
}
orb.SetSystemPrompt(summarizePrompt)
result, err := orb.Send(oldText)
if err != nil {
return
}
s.convStore.SetSummary(result)
s.convStore.TrimOld(len(messages) - half)
s.convStore.Add("system", "[Conversation résumée automatiquement]")
}
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
"max_tokens": maxTokensApprox,
"summarize_at": summarizeThreshold,
"summary": s.convStore.GetSummary(),
})
}
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleChatSummarize(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.autoSummarize()
writeJSON(w, map[string]interface{}{
"status": "ok",
"tokens": s.convStore.ApproxTokenCount(),
"summary": s.convStore.GetSummary(),
})
}