All checks were successful
Beta Release / beta (push) Successful in 1m3s
- 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>
255 lines
6.4 KiB
Go
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(),
|
|
})
|
|
}
|