feat(shell): dedicated System Analyst AI, no code execution, analyze system
All checks were successful
Beta Release / beta (push) Successful in 45s

- New ShellConvStore with persistent history (shell_conversation.json)
- 100k token limit — input grays out, must /clear to continue
- Commands limited to /clear and /help only
- Shell AI has NO tools — read-only analysis, never executes code
- "Analyste Système" panel with system analysis button
- System analysis uses Studio AI to write system_analysis.md,
  prepended as context on every conversation start
- Code blocks show "Copier" and "Terminal" buttons to copy or
  send code directly to the active terminal via WebSocket
- Token bar shows usage with warning at 80%

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-23 21:50:06 +02:00
parent eda7293286
commit f9c4cf11ff
9 changed files with 634 additions and 221 deletions

View File

@@ -1,53 +1,24 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
const maxShellToolIterations = 10
type ShellChatRequest struct {
Message string `json:"message"`
Context string `json:"context,omitempty"`
History []string `json:"history,omitempty"`
Cwd string `json:"cwd,omitempty"`
Platform string `json:"platform,omitempty"`
Stream bool `json:"stream"`
}
type ShellChatResponse struct {
Content string `json:"content,omitempty"`
ToolCalls []ToolCallInfo `json:"tool_calls,omitempty"`
Error string `json:"error,omitempty"`
}
type ToolCallInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
Result *toolResponseData `json:"result,omitempty"`
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
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) {
@@ -56,6 +27,11 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
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)
@@ -67,142 +43,237 @@ func (s *Server) handleShellChat(w http.ResponseWriter, r *http.Request) {
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.agentToolsJSON)
orb.SetSystemPrompt(s.buildShellSystemPromptV2(req))
if req.Stream {
s.handleShellChatStream(w, orb, req)
s.handleShellChatStreamV2(w, orb)
} else {
s.handleShellChatNonStream(w, orb, req)
s.handleShellChatNonStreamV2(w, orb)
}
}
func (s *Server) buildShellSystemPrompt(req ShellChatRequest) string {
func (s *Server) buildShellSystemPromptV2(_ ShellChatRequest) string {
var sb strings.Builder
sb.WriteString(`Tu es l'assistant Shell de Muyue. Tu as accès à un terminal et peux aider l'utilisateur avec:
- Exécuter des commandes shell
- Expliquer des erreurs de commandes
- Suggérer des commandes appropriées pour la tâche demandée
- Lire et explorer des fichiers
- Configurer l'environnement de développement
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.
Tu peux appeler des outils pour exécuter des commandes, lire des fichiers, etc. Sois précis et concis dans tes réponses.
RÈGLES STRICTES:
- Tu ne peux JAMAIS exécuter de commande ou de code
- Tu ne peux que analyser, expliquer, et proposer des solutions
- Quand tu proposes du code ou des commandes, mets-les dans des blocs de code markdown avec le langage spécifié
- L'utilisateur pourra les copier ou les envoyer directement au terminal depuis les boutons
`)
if req.Cwd != "" {
sb.WriteString("Répertoire courant: " + req.Cwd + "\n")
analysis := LoadSystemAnalysis()
if analysis != "" {
sb.WriteString("=== ANALYSE SYSTÈME ACTUELLE ===\n")
sb.WriteString(analysis)
sb.WriteString("\n=== FIN DE L'ANALYSE ===\n\n")
}
if req.Platform != "" {
sb.WriteString("Plateforme: " + req.Platform + "\n")
}
if req.Context != "" {
sb.WriteString("\nContexte du terminal:\n" + req.Context + "\n")
}
if len(req.History) > 0 {
sb.WriteString("\nDernières commandes exécutées:\n")
for _, h := range req.History {
sb.WriteString(" " + h + "\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")
}
return sb.String()
}
func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
func (s *Server) handleShellChatStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
SetupSSEHeaders(w)
flusher, canFlush := w.(http.Flusher)
sseWriter := NewSSEWriter(w)
ctx := context.Background()
messages := []orchestrator.Message{
{Role: "user", Content: req.Message},
// Rebuild history into orchestrator
history := s.shellConvStore.Get()
for _, m := range history[:len(history)-1] { // all except last user msg
if m.Role == "system" {
continue
}
// Pre-load orchestrator history
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
lastUserMsg := history[len(history)-1].Content
var toolCalls []ToolCallInfo
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
}
sseWriter.Write(data)
var finalContent string
result, err := orb.SendStream(lastUserMsg, func(chunk string) {
finalContent = chunk
sseWriter.Write(map[string]interface{}{"content": chunk})
if canFlush {
flusher.Flush()
}
if tc, ok := data["tool_call"].(map[string]interface{}); ok {
argsMap := make(map[string]interface{})
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,
})
}
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, _, _, err := engine.RunWithTools(ctx, messages)
if err != nil {
sseWriter.Write(map[string]interface{}{"error": err.Error()})
return
}
if finalContent == "" && len(toolCalls) > 0 {
finalContent = "(opérations terminées)"
content := result
if content == "" {
content = finalContent
}
writeJSONResp, _ := json.Marshal(ShellChatResponse{
Content: finalContent,
ToolCalls: toolCalls,
s.shellConvStore.Add("assistant", cleanThinkingTags(content))
sseWriter.Write(map[string]interface{}{
"done": "true",
"tokens": s.shellConvStore.ApproxTokens(),
})
sseWriter.Write(map[string]interface{}{"done": true, "response": string(writeJSONResp)})
}
func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrator.Orchestrator, req ShellChatRequest) {
ctx := context.Background()
messages := []orchestrator.Message{
{Role: "user", Content: req.Message},
func (s *Server) handleShellChatNonStreamV2(w http.ResponseWriter, orb *orchestrator.Orchestrator) {
history := s.shellConvStore.Get()
for _, m := range history[:len(history)-1] {
if m.Role == "system" {
continue
}
orb.AppendHistory(orchestrator.Message{Role: m.Role, Content: m.Content})
}
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
lastUserMsg := history[len(history)-1].Content
finalContent, err := engine.RunNonStream(ctx, messages)
result, err := orb.Send(lastUserMsg)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if finalContent == "" {
finalContent = "(tool calls completed, no text response)"
s.shellConvStore.Add("assistant", cleanThinkingTags(result))
writeJSON(w, map[string]interface{}{
"content": result,
"tokens": s.shellConvStore.ApproxTokens(),
})
}
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
}
writeJSON(w, ShellChatResponse{
Content: finalContent,
ToolCalls: nil,
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 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
Sois concret et technique. Le rapport sera utilisé comme contexte 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,
})
}
}

View File

@@ -17,6 +17,7 @@ type Server struct {
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
shellConvStore *ShellConvStore
agentRegistry *agent.Registry
agentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
@@ -46,6 +47,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.config = cfg
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.shellConvStore = NewShellConvStore()
s.agentRegistry = agent.DefaultRegistry()
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
@@ -89,6 +91,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/tool/call", s.handleToolCall)
s.mux.HandleFunc("/api/tools/list", s.handleToolList)
s.mux.HandleFunc("/api/shell/chat", s.handleShellChat)
s.mux.HandleFunc("/api/shell/chat/history", s.handleShellChatHistory)
s.mux.HandleFunc("/api/shell/chat/clear", s.handleShellChatClear)
s.mux.HandleFunc("/api/shell/analyze", s.handleShellAnalyze)
s.mux.HandleFunc("/api/workflow", s.handleWorkflowCreate)
s.mux.HandleFunc("/api/workflow/list", s.handleWorkflowList)
s.mux.HandleFunc("/api/workflow/", s.handleWorkflowGet)

View File

@@ -0,0 +1,121 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const shellMaxTokens = 100000
const shellCharsPerToken = 4
type ShellMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type ShellConvStore struct {
mu sync.RWMutex
path string
msgs []ShellMessage
}
func NewShellConvStore() *ShellConvStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "shell_conversation.json")
s := &ShellConvStore{path: path}
s.load()
return s
}
func (s *ShellConvStore) load() {
data, err := os.ReadFile(s.path)
if err != nil {
s.msgs = []ShellMessage{}
return
}
json.Unmarshal(data, &s.msgs)
if s.msgs == nil {
s.msgs = []ShellMessage{}
}
}
func (s *ShellConvStore) save() {
data, _ := json.MarshalIndent(s.msgs, "", " ")
os.MkdirAll(filepath.Dir(s.path), 0755)
os.WriteFile(s.path, data, 0600)
}
func (s *ShellConvStore) Get() []ShellMessage {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]ShellMessage, len(s.msgs))
copy(out, s.msgs)
return out
}
func (s *ShellConvStore) Add(role, content string) ShellMessage {
s.mu.Lock()
defer s.mu.Unlock()
msg := ShellMessage{
ID: time.Now().Format("20060102150405.000"),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
s.msgs = append(s.msgs, msg)
s.save()
return msg
}
func (s *ShellConvStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.msgs = []ShellMessage{}
s.save()
}
func (s *ShellConvStore) ApproxTokens() int {
s.mu.RLock()
defer s.mu.RUnlock()
total := 0
for _, m := range s.msgs {
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
}
return total
}
func (s *ShellConvStore) AtLimit() bool {
return s.ApproxTokens() >= shellMaxTokens
}
func LoadSystemAnalysis() string {
dir, err := config.ConfigDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(dir, "system_analysis.md"))
if err != nil {
return ""
}
return string(data)
}
func SaveSystemAnalysis(content string) error {
dir, err := config.ConfigDir()
if err != nil {
return err
}
os.MkdirAll(dir, 0755)
return os.WriteFile(filepath.Join(dir, "system_analysis.md"), []byte(content), 0644)
}