feat: terminal sudo blocking, token tracking, mermaid & consumption UI
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>
This commit is contained in:
Augustin
2026-04-26 12:43:15 +02:00
parent 0830e64ae6
commit cb3d35756a
21 changed files with 2166 additions and 208 deletions

View File

@@ -21,6 +21,7 @@ type ChatEngine struct {
tools json.RawMessage
onChunk func(map[string]interface{})
stream bool
TotalTokens int
}
// NewChatEngine creates a new ChatEngine instance.
@@ -71,6 +72,10 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
return finalContent, allToolCalls, allToolResults, err
}
if resp.Usage.TotalTokens > 0 {
ce.TotalTokens += resp.Usage.TotalTokens
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
@@ -123,6 +128,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
"content": result.Content,
"is_error": result.IsError,
}
if result.Meta != nil {
for k, v := range result.Meta {
resultData[k] = v
}
}
allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
@@ -149,6 +159,11 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
return finalContent, allToolCalls, allToolResults, nil
}
// ProviderName returns the name of the active provider used by the engine.
func (ce *ChatEngine) ProviderName() string {
return ce.orchestrator.ProviderName()
}
// RunNonStream executes chat without streaming content to client.
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
var finalContent string
@@ -159,6 +174,10 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
return finalContent, err
}
if resp.Usage.TotalTokens > 0 {
ce.TotalTokens += resp.Usage.TotalTokens
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)

127
internal/api/consumption.go Normal file
View File

@@ -0,0 +1,127 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/muyue/muyue/internal/config"
)
type consumptionEntry struct {
Date string `json:"date"`
Tokens int `json:"tokens"`
Requests int `json:"requests"`
}
type providerConsumption struct {
Name string `json:"name"`
Daily []consumptionEntry `json:"daily"`
Total int `json:"total_tokens"`
Requests int `json:"total_requests"`
}
type consumptionStore struct {
mu sync.Mutex
providers map[string]*providerConsumption
}
func newConsumptionStore() *consumptionStore {
cs := &consumptionStore{
providers: make(map[string]*providerConsumption),
}
cs.load()
cs.prune()
return cs
}
func (cs *consumptionStore) Record(providerName string, tokens int) {
if tokens <= 0 || providerName == "" {
return
}
cs.mu.Lock()
defer cs.mu.Unlock()
today := time.Now().UTC().Format("2006-01-02")
p, ok := cs.providers[providerName]
if !ok {
p = &providerConsumption{Name: providerName}
cs.providers[providerName] = p
}
p.Total += tokens
p.Requests++
if len(p.Daily) > 0 && p.Daily[len(p.Daily)-1].Date == today {
p.Daily[len(p.Daily)-1].Tokens += tokens
p.Daily[len(p.Daily)-1].Requests++
} else {
p.Daily = append(p.Daily, consumptionEntry{
Date: today,
Tokens: tokens,
Requests: 1,
})
}
cs.save()
}
func (cs *consumptionStore) GetAll() map[string]*providerConsumption {
cs.mu.Lock()
defer cs.mu.Unlock()
result := make(map[string]*providerConsumption)
for k, v := range cs.providers {
pc := *v
daily := make([]consumptionEntry, len(v.Daily))
copy(daily, v.Daily)
pc.Daily = daily
result[k] = &pc
}
return result
}
func (cs *consumptionStore) prune() {
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
for _, p := range cs.providers {
filtered := make([]consumptionEntry, 0)
for _, d := range p.Daily {
if d.Date >= cutoff {
filtered = append(filtered, d)
}
}
p.Daily = filtered
}
}
func (cs *consumptionStore) filePath() string {
dir, err := config.ConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "consumption.json")
}
func (cs *consumptionStore) load() {
fp := cs.filePath()
if fp == "" {
return
}
data, err := os.ReadFile(fp)
if err != nil {
return
}
json.Unmarshal(data, &cs.providers)
}
func (cs *consumptionStore) save() {
fp := cs.filePath()
if fp == "" {
return
}
data, _ := json.Marshal(cs.providers)
os.WriteFile(fp, data, 0644)
}

View File

@@ -32,9 +32,10 @@ type Conversation struct {
}
type ConversationStore struct {
mu sync.RWMutex
path string
conv *Conversation
mu sync.RWMutex
path string
conv *Conversation
realTokens int
}
type TokenCount struct {
@@ -133,6 +134,7 @@ func (cs *ConversationStore) Clear() {
cs.conv.Summary = ""
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.realTokens = 0
cs.save()
}
@@ -154,9 +156,22 @@ func (cs *ConversationStore) TrimOld(keepCount int) {
}
func (cs *ConversationStore) ApproxTokenCount() int {
if cs.realTokens > 0 {
return cs.realTokens
}
return cs.ApproxTokenCountDetailed().total
}
// AddRealTokens accumulates actual token counts from the API response.
func (cs *ConversationStore) AddRealTokens(tokens int) {
if tokens <= 0 {
return
}
cs.mu.Lock()
cs.realTokens += tokens
cs.mu.Unlock()
}
func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
cs.mu.RLock()
defer cs.mu.RUnlock()

View File

@@ -3,9 +3,12 @@ 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"
@@ -42,7 +45,14 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(agent.StudioSystemPrompt())
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 {
@@ -91,6 +101,9 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
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"})
}
@@ -107,6 +120,10 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
}
s.convStore.Add("assistant", finalContent)
s.convStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
writeJSON(w, map[string]string{"content": finalContent})
}

View File

@@ -5,7 +5,21 @@ import (
"net/http"
)
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
const summarizePrompt = `Résume cette conversation de manière ultra-concise et structurée.
CONSERVE :
- Les décisions techniques prises et leur rationale
- Les configurations modifiées (noms exacts, valeurs)
- Les fichiers/chemins manipulés
- Les erreurs rencontrées et leurs résolutions
- Le contexte nécessaire pour continuer
ÉLIMINE :
- Les échanges de politesse
- Les tentatives infructueuses (sauf si la solution n'a pas été trouvée)
- Les sorties d'outils brutes (garde seulement les conclusions)
FORMAT : Markdown structuré avec sections. Max 500 mots. Pas de méta-commentaire.`
func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data)

View File

@@ -534,6 +534,39 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
q.Healthy = p.APIKey != ""
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
mimoBase := p.BaseURL
if mimoBase == "" {
mimoBase = "https://token-plan-ams.xiaomimimo.com/v1"
}
req, _ := http.NewRequest("GET", strings.TrimRight(mimoBase, "/")+"/models", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if modelList, ok := data["data"].([]interface{}); ok {
models := make([]map[string]interface{}, 0)
for _, m := range modelList {
if mm, ok := m.(map[string]interface{}); ok {
id, _ := mm["id"].(string)
if id != "" {
models = append(models, map[string]interface{}{
"model": id,
})
}
}
}
q.Data = map[string]interface{}{"models": models, "available": len(models)}
q.Healthy = true
}
}
}
case "claude", "anthropic":
// Claude Code n'a pas d'API externe, vérifier l'installation
@@ -551,6 +584,15 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"providers": results})
}
func (s *Server) handleProvidersConsumption(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
data := s.consumption.GetAll()
writeJSON(w, map[string]interface{}{"providers": data})
}
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
home, _ := os.UserHomeDir()
type cmdEntry struct {

View File

@@ -9,6 +9,7 @@ import (
"os/exec"
"runtime"
"strings"
"time"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
@@ -62,35 +63,36 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Server) buildShellSystemPrompt(_ ShellChatRequest) string {
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
var sb strings.Builder
sb.WriteString(`Tu es l'Analyste Système de Muyue. Tu es un expert en administration système et développement.
Tu aides l'utilisateur à comprendre son système, diagnostiquer des problèmes, et optimiser son environnement.
OUTILS DISPONIBLES:
- terminal: Exécute des commandes shell sur le système local et retourne le résultat
RÈGLES:
- Utilise l'outil terminal pour exécuter des commandes quand c'est nécessaire
- Analyse les résultats et explique-les clairement
- Formate tes réponses en markdown avec des blocs de code quand approprié
- Sois concis et technique
- Quand tu proposes des commandes alternatives, utilise des blocs de code markdown
`)
sb.WriteString(shellSystemPromptBase)
analysis := LoadSystemAnalysis()
if analysis != "" {
sb.WriteString("=== ANALYSE SYSTÈME ACTUELLE ===\n")
sb.WriteString("<system_context>\n")
sb.WriteString(analysis)
sb.WriteString("\n=== FIN DE L'ANALYSE ===\n\n")
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")
}
isRoot := os.Geteuid() == 0
sb.WriteString(fmt.Sprintf("Root: %t\n", isRoot))
if isRoot {
sb.WriteString("⚠️ Session en root — toutes les commandes ont les privilèges administrateur.\n")
} else {
sb.WriteString("⚠️ Session utilisateur standard — 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()
}
@@ -131,6 +133,9 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
storeContent = string(storeJSON)
}
s.shellConvStore.Add("assistant", storeContent)
s.shellConvStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
sseWriter.Write(map[string]interface{}{
"done": "true",
@@ -150,6 +155,10 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
}
s.shellConvStore.Add("assistant", finalContent)
s.shellConvStore.AddRealTokens(engine.TotalTokens)
s.consumption.Record(engine.ProviderName(), engine.TotalTokens)
writeJSON(w, map[string]interface{}{
"content": finalContent,
"tokens": s.shellConvStore.ApproxTokens(),
@@ -289,15 +298,33 @@ func (s *Server) handleShellAnalyze(w http.ResponseWriter, r *http.Request) {
}
orb.SetSystemPrompt(agent.StudioSystemPrompt())
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes sur le système de l'utilisateur.
Génère un rapport d'analyse concis et structuré en markdown qui inclut:
1. Un résumé de l'état du système
2. Les points d'attention (performance, sécurité, configuration)
3. Des recommandations spécifiques d'optimisation
4. Les outils manquants qui pourraient être utiles
5. L'état du réseau et des connexions
analysisPrompt := `Tu es un expert en administration système. Analyse les informations suivantes et génère un rapport structuré en markdown.
Sois concret et technique. Le rapport sera utilisé comme contexte pour un assistant terminal.
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()

View File

@@ -18,6 +18,7 @@ type Server struct {
mux *http.ServeMux
convStore *ConversationStore
shellConvStore *ShellConvStore
consumption *consumptionStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
shellAgentRegistry *agent.Registry
@@ -50,6 +51,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.shellConvStore = NewShellConvStore()
s.consumption = newConsumptionStore()
s.agentRegistry = agent.DefaultRegistry()
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
@@ -131,6 +133,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
s.mux.HandleFunc("/api/providers/consumption", s.handleProvidersConsumption)
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)

View File

@@ -14,6 +14,63 @@ import (
const shellMaxTokens = 100000
const shellCharsPerToken = 4
const shellSystemPromptBase = `Tu es l'**Analyste Système** de Muyue. Tu es un expert en administration système, DevOps et développement.
<critical_rules>
1. **AGIS, ne décris pas** — Utilise l'outil terminal pour exécuter, ne te contente pas de proposer des commandes.
2. **SOIS AUTONOME** — Cherche les infos manquantes via des commandes avant de demander à l'utilisateur. Essaie plusieurs approches avant de bloquer.
3. **SOIS CONCIS** — Max 4 lignes par défaut. Pas de préambule. Réponse directe et technique.
4. **GÈRE LES ERREURS** — Si une commande échoue, lis l'erreur, comprends la cause, essaie une approche alternative. 2-3 tentatives avant de rapporter.
5. **SÉCURITÉ** — Ne révèle jamais de credentials. Demande confirmation avant les commandes destructrices (rm -rf, format, etc.).
6. **LANGUE** — Réponds dans la même langue que l'utilisateur.
</critical_rules>
<tool_usage>
Outil disponible : **terminal** — Exécute des commandes shell sur le système local.
Stratégies :
- **Diagnostique** — Enchaîne les commandes de diagnostic (ps, df, free, top, journalctl, dmesg, netstat, ss, etc.)
- **Parallélisme** — Combine les commandes avec && ou ; quand elles sont indépendantes
- **Filtrage** — Utilise grep, awk, sort, head pour extraire l'essentiel des sorties volumineuses
- **Non-interactif** — Préfère les commandes non-interactives (apt install -y, non pas apt install)
- **Troncature** — Si le résultat dépasse 2000 caractères, résume les points clés au lieu de tout afficher
</tool_usage>
<decision_making>
- Décide par toi-même : exécute des commandes pour comprendre l'état du système
- Ne demande confirmation que pour les actions destructrices
- Si tu ne connais pas la commande exacte, exécute la commande avec --help pour la trouver
- Si bloqué : documente ce que tu as essayé, pourquoi, et l'action minimale requise
- Ne t'arrête jamais pour une tâche complexe — découpe en étapes et exécute-les
</decision_making>
<error_recovery>
1. Lis le message d'erreur complet (stderr + stdout)
2. Identifie la cause racine (permissions, paquet manquant, config, service)
3. Essaie : vérifier le service, vérifier les logs, chercher le paquet, tester la connexion
4. Propose une solution concrète, pas générique
</error_recovery>
<response_format>
- **Commandes** : blocs markdown avec le langage (bash, sh, etc.)
- **Résultats** : résume les métriques clés, pas de dump complet
- **Erreurs** : cause + solution en 1-2 lignes
- **Succès** : confirmation en 1 ligne
- **Analyses** : markdown structuré avec sections si nécessaire
</response_format>
<mermaid>
Tu peux utiliser des diagrammes Mermaid pour visualiser :
- Architecture système (graph TD/LR)
- Flux réseau (sequenceDiagram)
- Processus (flowchart)
- Timeline (gantt)
Utilise un bloc de code avec le langage mermaid quand ça clarifie l'explication. Pas pour du texte simple.
</mermaid>
`
type ShellMessage struct {
ID string `json:"id"`
Role string `json:"role"`
@@ -22,9 +79,10 @@ type ShellMessage struct {
}
type ShellConvStore struct {
mu sync.RWMutex
path string
msgs []ShellMessage
mu sync.RWMutex
path string
msgs []ShellMessage
realTokens int
}
func NewShellConvStore() *ShellConvStore {
@@ -82,19 +140,37 @@ func (s *ShellConvStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.msgs = []ShellMessage{}
s.realTokens = 0
s.save()
}
func (s *ShellConvStore) ApproxTokens() int {
if s.realTokens > 0 {
return s.realTokens
}
s.mu.RLock()
defer s.mu.RUnlock()
total := 0
for _, m := range s.msgs {
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
}
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {
total += utf8.RuneCountInString(analysis) / shellCharsPerToken
}
return total
}
// AddRealTokens accumulates actual token counts from the API response.
func (s *ShellConvStore) AddRealTokens(tokens int) {
if tokens <= 0 {
return
}
s.mu.Lock()
s.realTokens += tokens
s.mu.Unlock()
}
func (s *ShellConvStore) AtLimit() bool {
return s.ApproxTokens() >= shellMaxTokens
}