Some checks failed
Stable Release / stable (push) Failing after 33s
- Agent slot limiter for concurrent tool execution - Conversation summarization with soft-delete (MarkSummarized) - ANSI stripping in terminal tool output - Configurable crush-run timeout (default 600s, max 900s) - Starship theme refactor, AI tools config grid, system update UI - Streaming segments refactor, summarized messages block in feed - CSS: headings, scrollbars, tool cards, summary block styles - i18n additions (en+fr) for tools, updates, config 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
377 lines
10 KiB
Go
377 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/muyue/muyue/internal/agent"
|
|
"github.com/muyue/muyue/internal/orchestrator"
|
|
)
|
|
|
|
type ShellChatRequest struct {
|
|
Message string `json:"message"`
|
|
Context string `json:"context,omitempty"`
|
|
Cwd string `json:"cwd,omitempty"`
|
|
Platform string `json:"platform,omitempty"`
|
|
Stream bool `json:"stream"`
|
|
}
|
|
|
|
func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if s.shellConvStore.AtLimit() {
|
|
writeError(w, "context limit reached, use /clear", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req ShellChatRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Message == "" {
|
|
writeError(w, "message is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
s.shellConvStore.Add("user", req.Message)
|
|
|
|
orb, err := orchestrator.New(s.config)
|
|
if err != nil {
|
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
orb.SetSystemPrompt(s.buildShellSystemPrompt(req))
|
|
orb.SetTools(s.shellAgentToolsJSON)
|
|
|
|
if req.Stream {
|
|
s.handleShellChatStream(w, orb)
|
|
} else {
|
|
s.handleShellChatNonStream(w, orb)
|
|
}
|
|
}
|
|
|
|
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(shellSystemPromptBase)
|
|
|
|
analysis := LoadSystemAnalysis()
|
|
if analysis != "" {
|
|
sb.WriteString("<system_context>\n")
|
|
sb.WriteString(analysis)
|
|
sb.WriteString("\n</system_context>\n\n")
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
|
if hostname, err := os.Hostname(); err == nil {
|
|
sb.WriteString("Hostname: " + hostname + "\n")
|
|
}
|
|
if user := os.Getenv("USER"); user != "" {
|
|
sb.WriteString("User: " + user + "\n")
|
|
}
|
|
|
|
canSudo := !agent.NeedsSudoPassword()
|
|
sb.WriteString(fmt.Sprintf("Root: %t\n", !canSudo))
|
|
if canSudo {
|
|
sb.WriteString("⚠️ Session avec privilèges sudo sans mot de passe — les commandes sudo s'exécuteront directement.\n")
|
|
} else {
|
|
sb.WriteString("⚠️ Session sans sudo sans mot de passe — les commandes sudo/doas nécessitent une autorisation. N'utilise PAS sudo ou doas sans demander.\n")
|
|
}
|
|
|
|
now := time.Now()
|
|
sb.WriteString(fmt.Sprintf("Date: %s\nHeure: %s\n", now.Format("02/01/2006"), now.Format("15:04:05")))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
|
SetupSSEHeaders(w)
|
|
flusher, canFlush := w.(http.Flusher)
|
|
sseWriter := NewSSEWriter(w)
|
|
|
|
ctx := context.Background()
|
|
messages := s.buildShellContextMessages()
|
|
|
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
|
engine.SetLimiter(s.AcquireAgentSlot)
|
|
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.shellConvStore.Add("assistant", storeContent)
|
|
|
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
|
|
|
sseWriter.Write(map[string]interface{}{
|
|
"done": "true",
|
|
"tokens": s.shellConvStore.ApproxTokens(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
|
|
ctx := context.Background()
|
|
messages := s.buildShellContextMessages()
|
|
|
|
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
|
engine.SetLimiter(s.AcquireAgentSlot)
|
|
finalContent, err := engine.RunNonStream(ctx, messages)
|
|
if err != nil {
|
|
writeError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.shellConvStore.Add("assistant", finalContent)
|
|
|
|
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
|
|
|
|
writeJSON(w, map[string]interface{}{
|
|
"content": finalContent,
|
|
"tokens": s.shellConvStore.ApproxTokens(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
|
history := s.shellConvStore.Get()
|
|
|
|
sysTokens := utf8.RuneCountInString(shellSystemPromptBase) / charsPerToken
|
|
if analysis := LoadSystemAnalysis(); analysis != "" {
|
|
sysTokens += utf8.RuneCountInString(analysis) / charsPerToken
|
|
}
|
|
sysTokens += 100
|
|
toolsTokens := utf8.RuneCountInString(string(s.shellAgentToolsJSON)) / charsPerToken
|
|
responseMargin := 4000
|
|
|
|
overhead := sysTokens + toolsTokens + responseMargin
|
|
available := shellMaxTokens - overhead
|
|
if available < 1000 {
|
|
available = 1000
|
|
}
|
|
|
|
included := 0
|
|
tokensUsed := 0
|
|
for i := len(history) - 1; i >= 0; i-- {
|
|
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
|
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
|
if msgTokens == 0 {
|
|
msgTokens = 1
|
|
}
|
|
if tokensUsed+msgTokens > available {
|
|
break
|
|
}
|
|
tokensUsed += msgTokens
|
|
included++
|
|
}
|
|
|
|
start := len(history) - included
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
|
|
if start > 0 {
|
|
_ = start
|
|
}
|
|
|
|
messages := make([]orchestrator.Message, 0, included)
|
|
|
|
for _, m := range history[start:] {
|
|
if m.Role == "system" {
|
|
continue
|
|
}
|
|
displayContent := extractDisplayContent(m.Role, m.Content)
|
|
messages = append(messages, orchestrator.Message{
|
|
Role: m.Role,
|
|
Content: orchestrator.TextContent(displayContent),
|
|
})
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
func (s *Server) handleShellChatHistory(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
messages := s.shellConvStore.Get()
|
|
writeJSON(w, map[string]interface{}{
|
|
"messages": messages,
|
|
"tokens": s.shellConvStore.ApproxTokens(),
|
|
"max_tokens": shellMaxTokens,
|
|
"at_limit": s.shellConvStore.AtLimit(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleShellChatClear(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
s.shellConvStore.Clear()
|
|
writeJSON(w, map[string]interface{}{
|
|
"status": "ok",
|
|
"tokens": 0,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var sysInfo strings.Builder
|
|
sysInfo.WriteString("=== INFORMATIONS SYSTÈME ===\n")
|
|
sysInfo.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
|
|
if hostname, err := os.Hostname(); err == nil {
|
|
sysInfo.WriteString("Hostname: " + hostname + "\n")
|
|
}
|
|
if user := os.Getenv("USER"); user != "" {
|
|
sysInfo.WriteString("User: " + user + "\n")
|
|
}
|
|
|
|
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if strings.HasPrefix(line, "model name") {
|
|
sysInfo.WriteString("CPU: " + strings.SplitN(line, ":", 2)[1] + "\n")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if strings.HasPrefix(line, "MemTotal:") || strings.HasPrefix(line, "MemAvailable:") {
|
|
sysInfo.WriteString(strings.TrimSpace(line) + "\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
|
|
lines := strings.Split(string(out), "\n")
|
|
if len(lines) >= 2 {
|
|
sysInfo.WriteString("Disk: " + strings.TrimSpace(lines[1]) + "\n")
|
|
}
|
|
}
|
|
|
|
if out, err := exec.Command("ps", "aux", "--sort=-pcpu").Output(); err == nil {
|
|
lines := strings.Split(string(out), "\n")
|
|
sysInfo.WriteString(fmt.Sprintf("\nProcessus actifs (%d total):\n", len(lines)-1))
|
|
for i := 1; i < len(lines) && i <= 10; i++ {
|
|
fields := strings.Fields(lines[i])
|
|
if len(fields) >= 11 {
|
|
sysInfo.WriteString(fmt.Sprintf(" %-20s CPU:%-6s MEM:%-6s %s\n", fields[10], fields[2]+"%", fields[3]+"%", fields[0]))
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.scanResult != nil {
|
|
sysInfo.WriteString("\nOutils installés:\n")
|
|
for _, t := range s.scanResult.Tools {
|
|
status := "✗"
|
|
if t.Installed {
|
|
status = "✓"
|
|
}
|
|
sysInfo.WriteString(fmt.Sprintf(" %s %s %s\n", status, t.Name, t.Version))
|
|
}
|
|
}
|
|
|
|
orb, err := orchestrator.New(s.config)
|
|
if err != nil {
|
|
writeError(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
orb.SetSystemPrompt(agent.StudioSystemPrompt())
|
|
|
|
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes et génère un rapport structuré en markdown.
|
|
|
|
STRUCTURE REQUISE :
|
|
|
|
## État du système
|
|
- Résumé en 2-3 phrases de l'état général (OK/Attention/Critique)
|
|
|
|
## Points d'attention
|
|
Liste les problèmes détectés par priorité :
|
|
- **CRITIQUE** : problèmes de sécurité, espace disque < 10%, mémoire < 10%
|
|
- **ATTENTION** : CPU élevé, services en échec, config non-optimale
|
|
- **INFO** : améliorations possibles, mises à jour disponibles
|
|
|
|
## Recommandations
|
|
Pour chaque point d'attention, donne UNE commande ou action corrective concrète.
|
|
|
|
## Outils manquants
|
|
Liste les outils utiles non installés avec la commande d'installation.
|
|
|
|
## Réseau
|
|
- Interfaces actives, ports en écoute, connectivité
|
|
|
|
RÈGLES :
|
|
- Pas de blabla générique — sois spécifique à CE système
|
|
- Inclus les valeurs numériques réelles (%, Go, MHz)
|
|
- Max 1500 mots
|
|
- Le rapport sert de contexte persistant pour un assistant terminal
|
|
|
|
` + sysInfo.String()
|
|
|
|
result, err := orb.Send(analysisPrompt)
|
|
if err != nil {
|
|
writeError(w, "analysis failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
SaveSystemAnalysis(result)
|
|
|
|
writeJSON(w, map[string]interface{}{
|
|
"status": "ok",
|
|
"analysis": result,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleShellAnalysisGet(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
analysis := LoadSystemAnalysis()
|
|
if analysis == "" {
|
|
writeJSON(w, map[string]interface{}{"analysis": nil})
|
|
return
|
|
}
|
|
writeJSON(w, map[string]interface{}{"analysis": analysis})
|
|
}
|