From 04b0fff7919cb8a5f387f4395c0568890c88ab11 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 18:34:14 +0200 Subject: [PATCH 01/12] refactor(api): split monolithic handlers.go into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break down the 627-line handlers.go into specialized modules: - handlers_chat.go: chat and streaming endpoints - handlers_config.go: configuration endpoints - handlers_common.go: shared utilities - handlers_info.go: info and status endpoints - handlers_terminal.go: terminal/shell endpoints - handlers_tools.go: tool-related endpoints Also includes config improvements, orchestrator enhancements, and web component updates. 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- .gitea/workflows/ci-develop.yml | 2 +- .gitea/workflows/ci-main.yml | 2 +- .gitea/workflows/ci-pr.yml | 2 +- CHANGELOG.md | 27 + go.mod | 4 +- internal/api/conversation.go | 3 +- internal/api/handlers.go | 627 --------------------- internal/api/handlers_chat.go | 142 +++++ internal/api/handlers_common.go | 17 + internal/api/handlers_config.go | 283 ++++++++++ internal/api/handlers_info.go | 119 ++++ internal/api/handlers_terminal.go | 44 ++ internal/api/handlers_tools.go | 94 +++ internal/api/server.go | 2 + internal/api/terminal.go | 22 +- internal/config/config.go | 72 ++- internal/config/config_test.go | 6 +- internal/installer/installer.go | 15 +- internal/lsp/lsp.go | 2 - internal/mcp/mcp.go | 6 +- internal/orchestrator/orchestrator.go | 102 ++++ internal/orchestrator/orchestrator_test.go | 136 ++++- internal/platform/platform_test.go | 12 +- internal/profiler/profiler.go | 2 +- internal/scanner/scanner.go | 3 +- internal/version/version.go | 2 +- web/src/api/client.js | 11 +- web/src/components/App.jsx | 15 +- web/src/components/Config.jsx | 139 ++++- web/src/components/Dashboard.jsx | 10 +- web/src/components/Shell.jsx | 158 +++--- web/src/components/Studio.jsx | 7 +- web/src/i18n/en.js | 10 +- web/src/i18n/fr.js | 10 +- web/src/styles/global.css | 9 - 35 files changed, 1338 insertions(+), 779 deletions(-) delete mode 100644 internal/api/handlers.go create mode 100644 internal/api/handlers_chat.go create mode 100644 internal/api/handlers_common.go create mode 100644 internal/api/handlers_config.go create mode 100644 internal/api/handlers_info.go create mode 100644 internal/api/handlers_terminal.go create mode 100644 internal/api/handlers_tools.go diff --git a/.gitea/workflows/ci-develop.yml b/.gitea/workflows/ci-develop.yml index ab354f7..7f461e2 100644 --- a/.gitea/workflows/ci-develop.yml +++ b/.gitea/workflows/ci-develop.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.24.3' + go-version: '1.24' - name: Setup Node uses: actions/setup-node@v4 diff --git a/.gitea/workflows/ci-main.yml b/.gitea/workflows/ci-main.yml index df6926c..9421d47 100644 --- a/.gitea/workflows/ci-main.yml +++ b/.gitea/workflows/ci-main.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.24.3' + go-version: '1.24' - name: Setup Node uses: actions/setup-node@v4 diff --git a/.gitea/workflows/ci-pr.yml b/.gitea/workflows/ci-pr.yml index 0e1c784..e7f9bdd 100644 --- a/.gitea/workflows/ci-pr.yml +++ b/.gitea/workflows/ci-pr.yml @@ -13,7 +13,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.24.3' + go-version: '1.24' - name: Setup Node uses: actions/setup-node@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index da7a432..6b11123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Security + +- **Command injection**: Removed non-functional AI sidebar from Shell.jsx that interpolated user input directly into a shell command (`echo "AI: ${text}"`). The panel was a stub with no real AI integration. +- **WebSocket origin validation**: Terminal WebSocket handler now validates the `Origin` header matches the server's own host. +- **DELETE method guard**: Terminal sessions DELETE endpoint now rejects non-DELETE methods. + +### Fixed + +- **Message ID collisions**: `generateMsgID()` now appends nanosecond suffix to prevent collisions under rapid creation. +- **Legacy dir migration**: Config migration from `~/.muyue` to XDG path now logs errors instead of silently failing. +- **MCP JSON parsing**: `json.Unmarshal` errors in MCP config loading are now handled instead of ignored. +- **API header merging**: `client.js` `request()` now correctly merges caller headers with defaults (was overwriting `Content-Type`). +- **Variable shadowing**: `t` translation function shadowed by `.filter(t => ...)` in Config.jsx and App.jsx — renamed to `tool`. + +### Changed + +- **Real SSE streaming**: Chat endpoint now streams AI responses via SSE (`data: {"content":"..."}` chunks) instead of fake 8-rune chunking. Frontend renders responses progressively as they arrive. +- **Progressive rendering**: Studio.jsx now uses `StreamingItem` component to display partial AI output during streaming, with cursor animation. +- **Theme from config**: App.jsx loads theme from user profile preferences on startup (was hardcoded to `cyberpunk-red`). +- **Handlers split**: Monolithic `handlers.go` split into 6 focused files: `handlers_common.go`, `handlers_info.go`, `handlers_tools.go`, `handlers_config.go`, `handlers_chat.go`, `handlers_terminal.go`. +- **Dynamic version**: Config `Version` field now uses `version.Version` constant instead of hardcoded `"0.1.0"`. +- **Path construction**: `filepath.Join` used consistently in installer, MCP, scanner, and profiler for cross-platform safety. +- **CI Go version**: All 3 CI workflows updated from `go-version: '1.24.3'` to `'1.24'` to match `go.mod`. +- **Dead code removed**: Unused `addNotif` function in Dashboard.jsx, unused `layout` destructuring, dead `tools`/`updates`/`onRescan` props, dead AI sidebar in Shell.jsx, associated CSS and i18n keys. + ### Added +- **SendStream tests**: 3 new tests for the SSE streaming method (chunk parsing, history accumulation, API error handling) using `httptest` server. + - **Desktop mode**: React 19 web UI served locally, auto-opens in browser. Frontend embedded in Go binary via `go:embed`. - **API backend**: 15 REST endpoints (`/api/info`, `/api/system`, `/api/tools`, `/api/config`, `/api/providers`, `/api/skills`, `/api/lsp`, `/api/mcp`, `/api/updates`, `/api/scan`, `/api/install`, `/api/terminal`, `/api/mcp/configure`, `/api/preferences`). - **i18n**: Full FR/EN translation system with keyboard layout awareness (AZERTY, QWERTY, QWERTZ). Preferences synced to backend. diff --git a/go.mod b/go.mod index 2383d31..fdedab1 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/muyue/muyue -go 1.24.3 +go 1.24.2 + +toolchain go1.24.3 require ( github.com/charmbracelet/huh v1.0.0 diff --git a/internal/api/conversation.go b/internal/api/conversation.go index 4266fd0..3676f79 100644 --- a/internal/api/conversation.go +++ b/internal/api/conversation.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "fmt" "os" "path/filepath" "sync" @@ -153,5 +154,5 @@ func (cs *ConversationStore) NeedsSummarization() bool { } func generateMsgID() string { - return time.Now().Format("20060102150405.000") + return time.Now().Format("20060102150405.000") + "-" + fmt.Sprintf("%d", time.Now().UnixNano()) } diff --git a/internal/api/handlers.go b/internal/api/handlers.go deleted file mode 100644 index dfdc4db..0000000 --- a/internal/api/handlers.go +++ /dev/null @@ -1,627 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "os/exec" - "strings" - "time" - - "github.com/muyue/muyue/internal/config" - "github.com/muyue/muyue/internal/lsp" - "github.com/muyue/muyue/internal/mcp" - "github.com/muyue/muyue/internal/orchestrator" - "github.com/muyue/muyue/internal/scanner" - "github.com/muyue/muyue/internal/skills" - "github.com/muyue/muyue/internal/updater" - "github.com/muyue/muyue/internal/version" -) - -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.` - -func writeJSON(w http.ResponseWriter, data interface{}) { - json.NewEncoder(w).Encode(data) -} - -func writeError(w http.ResponseWriter, msg string, code int) { - w.WriteHeader(code) - json.NewEncoder(w).Encode(map[string]string{"error": msg}) -} - -func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]interface{}{ - "name": version.Name, - "version": version.Version, - "author": version.Author, - }) -} - -func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) { - if s.scanResult == nil { - s.scanResult = scanner.ScanSystem() - } - writeJSON(w, map[string]interface{}{ - "system": s.scanResult.System, - }) -} - -func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) { - if s.scanResult == nil { - s.scanResult = scanner.ScanSystem() - } - type toolInfo struct { - Name string `json:"name"` - Installed bool `json:"installed"` - Version string `json:"version"` - Path string `json:"path"` - } - tools := make([]toolInfo, len(s.scanResult.Tools)) - for i, t := range s.scanResult.Tools { - tools[i] = toolInfo{ - Name: t.Name, - Installed: t.Installed, - Version: t.Version, - Path: t.Path, - } - } - writeJSON(w, map[string]interface{}{ - "tools": tools, - "total": len(tools), - }) -} - -func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { - if s.config == nil { - writeError(w, "no config", http.StatusNotFound) - return - } - writeJSON(w, map[string]interface{}{ - "profile": s.config.Profile, - "terminal": s.config.Terminal, - "bmad": s.config.BMAD, - }) -} - -func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) { - if s.config == nil { - writeError(w, "no config", http.StatusNotFound) - return - } - writeJSON(w, map[string]interface{}{ - "providers": s.config.AI.Providers, - }) -} - -func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) { - list, err := skills.List() - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, map[string]interface{}{ - "skills": list, - "count": len(list), - }) -} - -func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) { - servers := lsp.ScanServers() - writeJSON(w, map[string]interface{}{ - "servers": servers, - }) -} - -func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { - servers := mcp.ScanServers() - writeJSON(w, map[string]interface{}{ - "servers": servers, - "configured": true, - }) -} - -func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - writeError(w, "POST only", http.StatusMethodNotAllowed) - return - } - if err := mcp.ConfigureAll(s.config); err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, map[string]string{"status": "ok"}) -} - -func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) { - result := scanner.ScanSystem() - statuses := updater.CheckUpdates(result) - type updateInfo struct { - Tool string `json:"tool"` - Current string `json:"current"` - Latest string `json:"latest"` - NeedsUpdate bool `json:"needsUpdate"` - Error string `json:"error,omitempty"` - } - updates := make([]updateInfo, len(statuses)) - for i, u := range statuses { - updates[i] = updateInfo{ - Tool: u.Tool, - Current: u.Current, - Latest: u.Latest, - NeedsUpdate: u.NeedsUpdate, - Error: u.Error, - } - } - writeJSON(w, map[string]interface{}{ - "updates": updates, - }) -} - -func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - writeError(w, "POST only", http.StatusMethodNotAllowed) - return - } - var body struct { - Tools []string `json:"tools"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - return - } - if len(body.Tools) == 0 { - writeError(w, "no tools specified", http.StatusBadRequest) - return - } - writeJSON(w, map[string]string{"status": "installing"}) -} - -func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { - s.scanResult = scanner.ScanSystem() - writeJSON(w, map[string]string{"status": "ok"}) -} - -func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) { - if r.Method != "PUT" { - writeError(w, "PUT only", http.StatusMethodNotAllowed) - return - } - if s.config == nil { - writeError(w, "no config", http.StatusNotFound) - return - } - var body struct { - Language string `json:"language"` - KeyboardLayout string `json:"keyboard_layout"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - return - } - if body.Language != "" { - s.config.Profile.Preferences.Language = body.Language - } - if body.KeyboardLayout != "" { - s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout - } - if err := config.Save(s.config); err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, map[string]string{"status": "ok"}) -} - -func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - writeError(w, "POST only", http.StatusMethodNotAllowed) - return - } - var body struct { - Command string `json:"command"` - Cwd string `json:"cwd"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - return - } - if body.Command == "" { - writeError(w, "no command", http.StatusBadRequest) - return - } - - shell := "/bin/sh" - if s, err := exec.LookPath("bash"); err == nil { - shell = s - } - - cmd := exec.Command(shell, "-c", body.Command) - if body.Cwd != "" { - cmd.Dir = body.Cwd - } - out, err := cmd.CombinedOutput() - - type termResult struct { - Output string `json:"output"` - Error string `json:"error,omitempty"` - } - result := termResult{Output: string(out)} - if err != nil { - result.Error = err.Error() - } - writeJSON(w, result) -} - -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.StatusBadRequest) - 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 - } - orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tĂąches de dĂ©veloppement logiciel. Tu peux : -- CrĂ©er et gĂ©rer des plans de dĂ©veloppement Ă©tape par Ă©tape -- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exĂ©cuter des tĂąches spĂ©cifiques -- Suivre la progression de tĂąches multi-Ă©tapes -- SuggĂ©rer des modifications de fichiers, des revues de code, et des dĂ©cisions d'architecture - -Sois concis, actionnable, et structurĂ©. Quand tu proposes un plan, utilise des Ă©tapes numĂ©rotĂ©es claires. Quand tu rĂ©fĂ©rences des fichiers, utilise des chemins relatifs. Tu es intĂ©grĂ© dans l'application desktop Muyue.`) - - if body.Stream { - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.WriteHeader(http.StatusOK) - flusher, canFlush := w.(http.Flusher) - - result, err := orb.Send(body.Message) - if err != nil { - data, _ := json.Marshal(map[string]string{"error": err.Error()}) - w.Write([]byte("data: " + string(data) + "\n\n")) - if canFlush { - flusher.Flush() - } - return - } - - s.convStore.Add("assistant", result) - - chunkSize := 8 - runes := []rune(result) - for i := 0; i < len(runes); i += chunkSize { - end := i + chunkSize - if end > len(runes) { - end = len(runes) - } - chunk := string(runes[i:end]) - data, _ := json.Marshal(map[string]string{"content": chunk}) - w.Write([]byte("data: " + string(data) + "\n\n")) - if canFlush { - flusher.Flush() - } - } - - data, _ := json.Marshal(map[string]string{"done": "true"}) - w.Write([]byte("data: " + string(data) + "\n\n")) - if canFlush { - flusher.Flush() - } - return - } - - result, err := orb.Send(body.Message) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - s.convStore.Add("assistant", result) - writeJSON(w, map[string]string{"content": result}) -} - -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(), - }) -} - -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) handleSaveProfile(w http.ResponseWriter, r *http.Request) { - if r.Method != "PUT" { - writeError(w, "PUT only", http.StatusMethodNotAllowed) - return - } - if s.config == nil { - writeError(w, "no config", http.StatusNotFound) - return - } - var body struct { - Name string `json:"name"` - Pseudo string `json:"pseudo"` - Email string `json:"email"` - Editor string `json:"editor"` - Shell string `json:"shell"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - return - } - if body.Name != "" { - s.config.Profile.Name = body.Name - } - if body.Pseudo != "" { - s.config.Profile.Pseudo = body.Pseudo - } - if body.Email != "" { - s.config.Profile.Email = body.Email - } - if body.Editor != "" { - s.config.Profile.Preferences.Editor = body.Editor - } - if body.Shell != "" { - s.config.Profile.Preferences.Shell = body.Shell - } - if err := config.Save(s.config); err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, map[string]string{"status": "ok"}) -} - -func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) { - if r.Method != "PUT" { - writeError(w, "PUT only", http.StatusMethodNotAllowed) - return - } - if s.config == nil { - writeError(w, "no config", http.StatusNotFound) - return - } - var body struct { - Name string `json:"name"` - APIKey string `json:"api_key"` - Model string `json:"model"` - BaseURL string `json:"base_url"` - Active *bool `json:"active"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - return - } - if body.Name == "" { - writeError(w, "name required", http.StatusBadRequest) - return - } - found := false - for i := range s.config.AI.Providers { - if s.config.AI.Providers[i].Name == body.Name { - if body.APIKey != "" { - s.config.AI.Providers[i].APIKey = body.APIKey - } - if body.Model != "" { - s.config.AI.Providers[i].Model = body.Model - } - if body.BaseURL != "" { - s.config.AI.Providers[i].BaseURL = body.BaseURL - } - if body.Active != nil { - if *body.Active { - for j := range s.config.AI.Providers { - s.config.AI.Providers[j].Active = false - } - } - s.config.AI.Providers[i].Active = *body.Active - } - found = true - break - } - } - if !found { - writeError(w, "provider not found", http.StatusNotFound) - return - } - if err := config.Save(s.config); err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, map[string]string{"status": "ok"}) -} - -func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - writeError(w, "POST only", http.StatusMethodNotAllowed) - return - } - var body struct { - Tool string `json:"tool"` - } - json.NewDecoder(r.Body).Decode(&body) - - result := scanner.ScanSystem() - statuses := updater.CheckUpdates(result) - - if body.Tool != "" { - for _, u := range statuses { - if u.Tool == body.Tool && u.NeedsUpdate { - updater.RunAutoUpdate([]updater.UpdateStatus{u}) - } - } - writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool}) - return - } - - needsUpdate := make([]updater.UpdateStatus, 0) - for _, u := range statuses { - if u.NeedsUpdate { - needsUpdate = append(needsUpdate, u) - } - } - if len(needsUpdate) > 0 { - updater.RunAutoUpdate(needsUpdate) - } - writeJSON(w, map[string]interface{}{ - "status": "ok", - "updated": len(needsUpdate), - }) -} - -func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - writeError(w, "POST only", http.StatusMethodNotAllowed) - return - } - var body struct { - Name string `json:"name"` - APIKey string `json:"api_key"` - Model string `json:"model"` - BaseURL string `json:"base_url"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - return - } - if body.APIKey == "" { - writeError(w, "api_key required", http.StatusBadRequest) - return - } - - baseURL := body.BaseURL - if baseURL == "" { - for _, p := range s.config.AI.Providers { - if p.Name == body.Name { - baseURL = p.BaseURL - break - } - } - } - if baseURL == "" { - switch body.Name { - case "minimax": - baseURL = "https://api.minimax.io/v1" - case "openai": - baseURL = "https://api.openai.com/v1" - case "anthropic": - baseURL = "https://api.anthropic.com/v1" - default: - baseURL = "https://api.minimax.io/v1" - } - } - - model := body.Model - if model == "" { - for _, p := range s.config.AI.Providers { - if p.Name == body.Name { - model = p.Model - break - } - } - } - if model == "" { - model = "MiniMax-Text-01" - } - - reqBody, _ := json.Marshal(map[string]interface{}{ - "model": model, - "messages": []map[string]string{{"role": "user", "content": "Hi"}}, - "max_tokens": 5, - "stream": false, - }) - - url := strings.TrimRight(baseURL, "/") + "/chat/completions" - req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody)) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+body.APIKey) - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - writeError(w, "connection failed: "+err.Error(), http.StatusBadGateway) - return - } - defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) - - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - writeError(w, "invalid_api_key", http.StatusUnauthorized) - return - } - if resp.StatusCode != http.StatusOK { - writeError(w, "api_error: "+string(respBody), http.StatusBadGateway) - return - } - - writeJSON(w, map[string]interface{}{"status": "valid"}) -} diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go new file mode 100644 index 0000000..4fcba7d --- /dev/null +++ b/internal/api/handlers_chat.go @@ -0,0 +1,142 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/muyue/muyue/internal/orchestrator" +) + +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.StatusBadRequest) + 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 + } + orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tĂąches de dĂ©veloppement logiciel. Tu peux : +|- CrĂ©er et gĂ©rer des plans de dĂ©veloppement Ă©tape par Ă©tape +|- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exĂ©cuter des tĂąches spĂ©cifiques +|- Suivre la progression de tĂąches multi-Ă©tapes +|- SuggĂ©rer des modifications de fichiers, des revues de code, et des dĂ©cisions d'architecture + +Sois concis, actionnable, et structurĂ©. Quand tu proposes un plan, utilise des Ă©tapes numĂ©rotĂ©es claires. Quand tu rĂ©fĂ©rences des fichiers, utilise des chemins relatifs. Tu es intĂ©grĂ© dans l'application desktop Muyue.`) + + if body.Stream { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusOK) + flusher, canFlush := w.(http.Flusher) + + result, err := orb.SendStream(body.Message, func(chunk string) { + data, _ := json.Marshal(map[string]string{"content": chunk}) + w.Write([]byte("data: " + string(data) + "\n\n")) + if canFlush { + flusher.Flush() + } + }) + if err != nil { + data, _ := json.Marshal(map[string]string{"error": err.Error()}) + w.Write([]byte("data: " + string(data) + "\n\n")) + if canFlush { + flusher.Flush() + } + return + } + + s.convStore.Add("assistant", result) + + data, _ := json.Marshal(map[string]string{"done": "true"}) + w.Write([]byte("data: " + string(data) + "\n\n")) + if canFlush { + flusher.Flush() + } + return + } + + result, err := orb.Send(body.Message) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + s.convStore.Add("assistant", result) + writeJSON(w, map[string]string{"content": result}) +} + +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(), + }) +} + +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"}) +} diff --git a/internal/api/handlers_common.go b/internal/api/handlers_common.go new file mode 100644 index 0000000..50ae5f8 --- /dev/null +++ b/internal/api/handlers_common.go @@ -0,0 +1,17 @@ +package api + +import ( + "encoding/json" + "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.` + +func writeJSON(w http.ResponseWriter, data interface{}) { + json.NewEncoder(w).Encode(data) +} + +func writeError(w http.ResponseWriter, msg string, code int) { + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go new file mode 100644 index 0000000..0232a18 --- /dev/null +++ b/internal/api/handlers_config.go @@ -0,0 +1,283 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/muyue/muyue/internal/config" +) + +func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + writeError(w, "PUT only", http.StatusMethodNotAllowed) + return + } + if s.config == nil { + writeError(w, "no config", http.StatusNotFound) + return + } + var body struct { + Language string `json:"language"` + KeyboardLayout string `json:"keyboard_layout"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Language != "" { + s.config.Profile.Preferences.Language = body.Language + } + if body.KeyboardLayout != "" { + s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout + } + if err := config.Save(s.config); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + writeError(w, "PUT only", http.StatusMethodNotAllowed) + return + } + if s.config == nil { + writeError(w, "no config", http.StatusNotFound) + return + } + var body struct { + Name string `json:"name"` + Pseudo string `json:"pseudo"` + Email string `json:"email"` + Editor string `json:"editor"` + Shell string `json:"shell"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Name != "" { + s.config.Profile.Name = body.Name + } + if body.Pseudo != "" { + s.config.Profile.Pseudo = body.Pseudo + } + if body.Email != "" { + s.config.Profile.Email = body.Email + } + if body.Editor != "" { + s.config.Profile.Preferences.Editor = body.Editor + } + if body.Shell != "" { + s.config.Profile.Preferences.Shell = body.Shell + } + if err := config.Save(s.config); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + writeError(w, "PUT only", http.StatusMethodNotAllowed) + return + } + if s.config == nil { + writeError(w, "no config", http.StatusNotFound) + return + } + var body struct { + Name string `json:"name"` + APIKey string `json:"api_key"` + Model string `json:"model"` + BaseURL string `json:"base_url"` + Active *bool `json:"active"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Name == "" { + writeError(w, "name required", http.StatusBadRequest) + return + } + found := false + for i := range s.config.AI.Providers { + if s.config.AI.Providers[i].Name == body.Name { + if body.APIKey != "" { + s.config.AI.Providers[i].APIKey = body.APIKey + } + if body.Model != "" { + s.config.AI.Providers[i].Model = body.Model + } + if body.BaseURL != "" { + s.config.AI.Providers[i].BaseURL = body.BaseURL + } + if body.Active != nil { + if *body.Active { + for j := range s.config.AI.Providers { + s.config.AI.Providers[j].Active = false + } + } + s.config.AI.Providers[i].Active = *body.Active + } + found = true + break + } + } + if !found { + writeError(w, "provider not found", http.StatusNotFound) + return + } + if err := config.Save(s.config); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleValidateProvider(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Name string `json:"name"` + APIKey string `json:"api_key"` + Model string `json:"model"` + BaseURL string `json:"base_url"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.APIKey == "" { + writeError(w, "api_key required", http.StatusBadRequest) + return + } + + baseURL := body.BaseURL + if baseURL == "" { + for _, p := range s.config.AI.Providers { + if p.Name == body.Name { + baseURL = p.BaseURL + break + } + } + } + if baseURL == "" { + switch body.Name { + case "minimax": + baseURL = "https://api.minimax.io/v1" + case "openai": + baseURL = "https://api.openai.com/v1" + case "anthropic": + baseURL = "https://api.anthropic.com/v1" + default: + baseURL = "https://api.minimax.io/v1" + } + } + + model := body.Model + if model == "" { + for _, p := range s.config.AI.Providers { + if p.Name == body.Name { + model = p.Model + break + } + } + } + if model == "" { + model = "MiniMax-M2.7" + } + + reqBody, _ := json.Marshal(map[string]interface{}{ + "model": model, + "messages": []map[string]string{{"role": "user", "content": "Hi"}}, + "max_tokens": 5, + "stream": false, + }) + + url := strings.TrimRight(baseURL, "/") + "/chat/completions" + req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody)) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+body.APIKey) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + writeError(w, "connection failed: "+err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + writeError(w, "invalid_api_key", http.StatusUnauthorized) + return + } + if resp.StatusCode != http.StatusOK { + writeError(w, "api_error: "+string(respBody), http.StatusBadGateway) + return + } + + writeJSON(w, map[string]interface{}{"status": "valid"}) +} + +func (s *Server) handleSaveTerminalSettings(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + writeError(w, "PUT only", http.StatusMethodNotAllowed) + return + } + if s.config == nil { + writeError(w, "no config", http.StatusNotFound) + return + } + var body struct { + FontSize int `json:"font_size"` + FontFamily string `json:"font_family"` + Theme string `json:"theme"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.FontSize > 0 { + s.config.Terminal.FontSize = body.FontSize + } + if body.FontFamily != "" { + s.config.Terminal.FontFamily = body.FontFamily + } + if body.Theme != "" { + s.config.Terminal.Theme = body.Theme + } + if err := config.Save(s.config); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{ + "status": "ok", + "theme": config.GetTerminalTheme(s.config.Terminal.Theme), + }) +} + +func (s *Server) handleGetTerminalThemes(w http.ResponseWriter, r *http.Request) { + themes := make([]map[string]string, 0, len(config.DEFAULT_TERMINAL_THEMES)) + for id, theme := range config.DEFAULT_TERMINAL_THEMES { + themes = append(themes, map[string]string{ + "id": id, + "name": theme.Name, + }) + } + writeJSON(w, map[string]interface{}{"themes": themes}) +} diff --git a/internal/api/handlers_info.go b/internal/api/handlers_info.go new file mode 100644 index 0000000..4d02f76 --- /dev/null +++ b/internal/api/handlers_info.go @@ -0,0 +1,119 @@ +package api + +import ( + "net/http" + + "github.com/muyue/muyue/internal/lsp" + "github.com/muyue/muyue/internal/mcp" + "github.com/muyue/muyue/internal/scanner" + "github.com/muyue/muyue/internal/skills" + "github.com/muyue/muyue/internal/version" +) + +func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]interface{}{ + "name": version.Name, + "version": version.Version, + "author": version.Author, + }) +} + +func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) { + if s.scanResult == nil { + s.scanResult = scanner.ScanSystem() + } + writeJSON(w, map[string]interface{}{ + "system": s.scanResult.System, + }) +} + +func (s *Server) handleTools(w http.ResponseWriter, r *http.Request) { + if s.scanResult == nil { + s.scanResult = scanner.ScanSystem() + } + type toolInfo struct { + Name string `json:"name"` + Installed bool `json:"installed"` + Version string `json:"version"` + Path string `json:"path"` + } + tools := make([]toolInfo, len(s.scanResult.Tools)) + for i, t := range s.scanResult.Tools { + tools[i] = toolInfo{ + Name: t.Name, + Installed: t.Installed, + Version: t.Version, + Path: t.Path, + } + } + writeJSON(w, map[string]interface{}{ + "tools": tools, + "total": len(tools), + }) +} + +func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { + if s.config == nil { + writeError(w, "no config", http.StatusNotFound) + return + } + writeJSON(w, map[string]interface{}{ + "profile": s.config.Profile, + "terminal": s.config.Terminal, + "bmad": s.config.BMAD, + }) +} + +func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) { + if s.config == nil { + writeError(w, "no config", http.StatusNotFound) + return + } + writeJSON(w, map[string]interface{}{ + "providers": s.config.AI.Providers, + }) +} + +func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) { + list, err := skills.List() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{ + "skills": list, + "count": len(list), + }) +} + +func (s *Server) handleLSP(w http.ResponseWriter, r *http.Request) { + servers := lsp.ScanServers() + writeJSON(w, map[string]interface{}{ + "servers": servers, + }) +} + +func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { + servers := mcp.ScanServers() + writeJSON(w, map[string]interface{}{ + "servers": servers, + "configured": true, + }) +} + +func (s *Server) handleMCPConfigure(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + if err := mcp.ConfigureAll(s.config); err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { + s.scanResult = scanner.ScanSystem() + writeJSON(w, map[string]string{"status": "ok"}) +} diff --git a/internal/api/handlers_terminal.go b/internal/api/handlers_terminal.go new file mode 100644 index 0000000..3f14880 --- /dev/null +++ b/internal/api/handlers_terminal.go @@ -0,0 +1,44 @@ +package api + +import ( + "encoding/json" + "net/http" + "os/exec" +) + +func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Command string `json:"command"` + Cwd string `json:"cwd"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Command == "" { + writeError(w, "no command", http.StatusBadRequest) + return + } + + shell := detectShell() + + cmd := exec.Command(shell, "-c", body.Command) + if body.Cwd != "" { + cmd.Dir = body.Cwd + } + out, err := cmd.CombinedOutput() + + type termResult struct { + Output string `json:"output"` + Error string `json:"error,omitempty"` + } + result := termResult{Output: string(out)} + if err != nil { + result.Error = err.Error() + } + writeJSON(w, result) +} diff --git a/internal/api/handlers_tools.go b/internal/api/handlers_tools.go new file mode 100644 index 0000000..d618322 --- /dev/null +++ b/internal/api/handlers_tools.go @@ -0,0 +1,94 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/muyue/muyue/internal/scanner" + "github.com/muyue/muyue/internal/updater" +) + +func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) { + result := scanner.ScanSystem() + statuses := updater.CheckUpdates(result) + type updateInfo struct { + Tool string `json:"tool"` + Current string `json:"current"` + Latest string `json:"latest"` + NeedsUpdate bool `json:"needsUpdate"` + Error string `json:"error,omitempty"` + } + updates := make([]updateInfo, len(statuses)) + for i, u := range statuses { + updates[i] = updateInfo{ + Tool: u.Tool, + Current: u.Current, + Latest: u.Latest, + NeedsUpdate: u.NeedsUpdate, + Error: u.Error, + } + } + writeJSON(w, map[string]interface{}{ + "updates": updates, + }) +} + +func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Tools []string `json:"tools"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + if len(body.Tools) == 0 { + writeError(w, "no tools specified", http.StatusBadRequest) + return + } + writeJSON(w, map[string]string{"status": "installing"}) +} + +func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + var body struct { + Tool string `json:"tool"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + result := scanner.ScanSystem() + statuses := updater.CheckUpdates(result) + + if body.Tool != "" { + for _, u := range statuses { + if u.Tool == body.Tool && u.NeedsUpdate { + updater.RunAutoUpdate([]updater.UpdateStatus{u}) + } + } + writeJSON(w, map[string]string{"status": "ok", "tool": body.Tool}) + return + } + + needsUpdate := make([]updater.UpdateStatus, 0) + for _, u := range statuses { + if u.NeedsUpdate { + needsUpdate = append(needsUpdate, u) + } + } + if len(needsUpdate) > 0 { + updater.RunAutoUpdate(needsUpdate) + } + writeJSON(w, map[string]interface{}{ + "status": "ok", + "updated": len(needsUpdate), + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index 5f7ffa3..0a567a1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -43,6 +43,8 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/ws/terminal", s.handleTerminalWS) s.mux.HandleFunc("/api/terminal/sessions", s.handleTerminalSessions) s.mux.HandleFunc("/api/terminal/sessions/", s.handleTerminalSessionsDelete) + s.mux.HandleFunc("/api/terminal/themes", s.handleGetTerminalThemes) + s.mux.HandleFunc("/api/terminal/settings", s.handleSaveTerminalSettings) s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure) s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile) s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider) diff --git a/internal/api/terminal.go b/internal/api/terminal.go index d991d85..fe0dc37 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -18,7 +18,23 @@ import ( ) var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + switch { + case strings.HasPrefix(origin, "http://127.0.0.1"), + strings.HasPrefix(origin, "http://localhost"), + strings.HasPrefix(origin, "http://[::1]"), + strings.HasPrefix(origin, "https://127.0.0.1"), + strings.HasPrefix(origin, "https://localhost"), + strings.HasPrefix(origin, "https://[::1]"): + return true + default: + return false + } + }, } type wsMessage struct { @@ -232,6 +248,10 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) } func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + writeError(w, "DELETE only", http.StatusMethodNotAllowed) + return + } name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/") if name == "" { writeError(w, "name required", http.StatusBadRequest) diff --git a/internal/config/config.go b/internal/config/config.go index 236b0f3..a8b5036 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,10 +2,12 @@ package config import ( "fmt" + "log" "os" "path/filepath" "github.com/muyue/muyue/internal/secret" + "github.com/muyue/muyue/internal/version" "gopkg.in/yaml.v3" ) @@ -66,9 +68,73 @@ type MuyueConfig struct { CustomPrompt bool `yaml:"custom_prompt"` PromptTheme string `yaml:"prompt_theme"` SSH []SSHConnection `yaml:"ssh"` + FontSize int `yaml:"font_size"` + FontFamily string `yaml:"font_family"` + Theme string `yaml:"theme"` } `yaml:"terminal"` } +type TerminalTheme struct { + Name string `yaml:"name"` + Background string `yaml:"background"` + Foreground string `yaml:"foreground"` + Cursor string `yaml:"cursor"` + Black string `yaml:"black"` + Red string `yaml:"red"` + Green string `yaml:"green"` + Yellow string `yaml:"yellow"` + Blue string `yaml:"blue"` + Magenta string `yaml:"magenta"` + Cyan string `yaml:"cyan"` + White string `yaml:"white"` +} + +var DEFAULT_TERMINAL_THEMES = map[string]TerminalTheme{ + "default": { + Name: "Default", Background: "#0A0A0C", Foreground: "#EAE0E2", + Cursor: "#FF0033", Black: "#0A0A0C", Red: "#FF0033", + Green: "#00E676", Yellow: "#FFD740", Blue: "#448AFF", + Magenta: "#FF1A5E", Cyan: "#00BCD4", White: "#EAE0E2", + }, + "monokai": { + Name: "Monokai", Background: "#272822", Foreground: "#F8F8F2", + Cursor: "#F8F8F0", Black: "#272822", Red: "#F92672", + Green: "#A6E22E", Yellow: "#E6DB74", Blue: "#66D9EF", + Magenta: "#AE81FF", Cyan: "#A1EFE4", White: "#F8F8F2", + }, + "gruvbox": { + Name: "Gruvbox", Background: "#282828", Foreground: "#EBDBB2", + Cursor: "#FB4934", Black: "#282828", Red: "#CC241D", + Green: "#98971A", Yellow: "#D79921", Blue: "#458588", + Magenta: "#B16286", Cyan: "#689D6A", White: "#EBDBB2", + }, + "nord": { + Name: "Nord", Background: "#2E3440", Foreground: "#D8DEE9", + Cursor: "#D8DEE9", Black: "#2E3440", Red: "#BF616A", + Green: "#A3BE8C", Yellow: "#EBCB8B", Blue: "#81A1C1", + Magenta: "#B48EAD", Cyan: "#88C0D0", White: "#D8DEE9", + }, + "solarized-dark": { + Name: "Solarized Dark", Background: "#002B36", Foreground: "#839496", + Cursor: "#D33682", Black: "#002B36", Red: "#DC322F", + Green: "#859900", Yellow: "#B58900", Blue: "#268BD2", + Magenta: "#D33682", Cyan: "#2AA198", White: "#FDF6E3", + }, + "dracula": { + Name: "Dracula", Background: "#282A36", Foreground: "#F8F8F2", + Cursor: "#F8F8F2", Black: "#282A36", Red: "#FF5555", + Green: "#50FA7B", Yellow: "#F1FA8C", Blue: "#BD93F9", + Magenta: "#FF79C6", Cyan: "#8BE9FD", White: "#F8F8F2", + }, +} + +func GetTerminalTheme(name string) TerminalTheme { + if theme, ok := DEFAULT_TERMINAL_THEMES[name]; ok { + return theme + } + return DEFAULT_TERMINAL_THEMES["default"] +} + func ConfigDir() (string, error) { configDir, err := os.UserConfigDir() if err != nil { @@ -79,7 +145,9 @@ func ConfigDir() (string, error) { legacyDir := filepath.Join(homeDir(), ".muyue") if _, err := os.Stat(legacyDir); err == nil { if _, err := os.Stat(dir); err != nil { - os.Rename(legacyDir, dir) + if err := os.Rename(legacyDir, dir); err != nil { + log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err) + } } } @@ -179,7 +247,7 @@ func Save(cfg *MuyueConfig) error { func Default() *MuyueConfig { cfg := &MuyueConfig{ - Version: "0.1.0", + Version: version.Version, Profile: Profile{ Name: "", Pseudo: "muyue", diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 82d506d..f91fa47 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,12 +4,14 @@ import ( "os" "path/filepath" "testing" + + "github.com/muyue/muyue/internal/version" ) func TestDefault(t *testing.T) { cfg := Default() - if cfg.Version != "0.1.0" { - t.Errorf("Expected version 0.1.0, got %s", cfg.Version) + if cfg.Version != version.Version { + t.Errorf("Expected version %s, got %s", version.Version, cfg.Version) } if cfg.Profile.Pseudo != "muyue" { t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo) diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 850973d..32da467 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "runtime" "strings" @@ -123,7 +124,7 @@ func (i *Installer) installBMAD() InstallResult { return InstallResult{Tool: "bmad", Success: false, Message: err.Error()} } - bmadDir := configDir + "/bmad" + bmadDir := filepath.Join(configDir, "bmad") os.MkdirAll(bmadDir, 0755) cmd := exec.Command("npx", "bmad-method@latest", "install", @@ -175,7 +176,7 @@ func (i *Installer) installGo() InstallResult { } home, _ := os.UserHomeDir() - goDir := home + "/.local/go" + goDir := filepath.Join(home, ".local", "go") cmd := exec.Command("bash", "-c", fmt.Sprintf( "curl -sL https://go.dev/dl/go1.24.3.%s-%s.tar.gz | tar -C %s/.local -xzf -", @@ -291,15 +292,15 @@ func (i *Installer) installGit() InstallResult { } -func (i *Installer) getRCFile() string { + func (i *Installer) getRCFile() string { home, _ := os.UserHomeDir() switch i.system.Shell { case "zsh": - return home + "/.zshrc" + return filepath.Join(home, ".zshrc") case "fish": - return home + "/.config/fish/config.fish" + return filepath.Join(home, ".config", "fish", "config.fish") default: - return home + "/.bashrc" + return filepath.Join(home, ".bashrc") } } @@ -340,7 +341,7 @@ func (i *Installer) installUv() InstallResult { return InstallResult{Tool: "uv", Success: false, Message: fmt.Sprintf("install failed: %s: %s", err, string(output))} } rcFile := i.getRCFile() - appendLine(rcFile, "export PATH="+home+"/.local/bin:$PATH") + appendLine(rcFile, "export PATH="+filepath.Join(home, ".local", "bin")+":$PATH") return InstallResult{Tool: "uv", Success: true, Message: "installed (added ~/.local/bin to PATH)"} case platform.Windows: cmd = exec.Command("powershell", "-Command", "irm https://astral.sh/uv/install.ps1 | iex") diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go index 6d55614..d39d611 100644 --- a/internal/lsp/lsp.go +++ b/internal/lsp/lsp.go @@ -101,5 +101,3 @@ func InstallForLanguages(languages []string) []LSPServer { return results } - - diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index f91f35a..3bcced8 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -53,7 +53,7 @@ func ScanServers() []MCPServer { func getCoreEntries(homeDir string) []mcpEntry { return []mcpEntry{ - {"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", homeDir + "/projects"}, nil}, + {"filesystem", "npx", []string{"-y", "@modelcontextprotocol/server-filesystem", filepath.Join(homeDir, "projects")}, nil}, {"fetch", "npx", []string{"-y", "@modelcontextprotocol/server-fetch"}, nil}, {"memory", "npx", []string{"-y", "@modelcontextprotocol/server-memory"}, nil}, } @@ -86,7 +86,9 @@ func writeMCPConfig(configPath string, mcpKey string, entries []mcpEntry) error existing := map[string]interface{}{} data, err := os.ReadFile(configPath) if err == nil { - json.Unmarshal(data, &existing) + if err := json.Unmarshal(data, &existing); err != nil { + return fmt.Errorf("parse existing config: %w", err) + } } mcpMap := map[string]interface{}{} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index daa2d0c..b81e404 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -1,6 +1,7 @@ package orchestrator import ( + "bufio" "bytes" "encoding/json" "fmt" @@ -34,6 +35,9 @@ type ChatResponse struct { Message struct { Content string `json:"content"` } `json:"message"` + Delta struct { + Content string `json:"content"` + } `json:"delta"` } `json:"choices"` Usage struct { TotalTokens int `json:"total_tokens"` @@ -161,6 +165,104 @@ func (o *Orchestrator) Send(userMessage string) (string, error) { return content, nil } +func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (string, error) { + o.histMu.Lock() + o.history = append(o.history, Message{ + Role: "user", + Content: userMessage, + }) + + if len(o.history) > maxHistorySize { + o.history = o.history[len(o.history)-maxHistorySize:] + } + + messages := make([]Message, 0, len(o.history)+1) + if o.systemPrompt != "" { + messages = append(messages, Message{Role: "system", Content: o.systemPrompt}) + } + messages = append(messages, o.history...) + + reqBody := ChatRequest{ + Model: o.provider.Model, + Messages: messages, + Stream: true, + } + o.histMu.Unlock() + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + baseURL := o.provider.BaseURL + if baseURL == "" { + baseURL = getProviderBaseURL(o.provider.Name) + } + + url := strings.TrimRight(baseURL, "/") + "/chat/completions" + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+o.provider.APIKey) + + resp, err := o.client.Do(req) + if err != nil { + return "", fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + } + + var fullContent strings.Builder + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chatResp ChatResponse + if err := json.Unmarshal([]byte(data), &chatResp); err != nil { + continue + } + + if len(chatResp.Choices) > 0 { + chunk := chatResp.Choices[0].Delta.Content + if chunk != "" { + fullContent.WriteString(chunk) + onChunk(chunk) + } + } + } + + if err := scanner.Err(); err != nil { + return fullContent.String(), fmt.Errorf("read stream: %w", err) + } + + content := cleanAIResponse(fullContent.String()) + o.histMu.Lock() + o.history = append(o.history, Message{ + Role: "assistant", + Content: content, + }) + o.histMu.Unlock() + + return content, nil +} + func cleanAIResponse(content string) string { content = thinkRegex.ReplaceAllString(content, "") lines := strings.Split(content, "\n") diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index ee9b372..5d59de3 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -1,6 +1,9 @@ package orchestrator import ( + "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" @@ -14,7 +17,7 @@ func TestCleanAIResponse(t *testing.T) { expected string }{ { - "removes standard think tags", + "malformed think tags pass through", "reasoningreasoning ({ error: res.statusText })) @@ -34,9 +34,11 @@ const api = { getTerminalSessions: () => request('/terminal/sessions'), addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }), deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }), + getTerminalThemes: () => request('/terminal/themes'), + saveTerminalSettings: (settings) => request('/terminal/settings', { method: 'PUT', body: JSON.stringify(settings) }), getChatHistory: () => request('/chat/history'), clearChat: () => request('/chat/clear', { method: 'POST' }), - sendChat: (message, stream = true) => { + sendChat: (message, stream = true, onChunk) => { if (!stream) { return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) }) } @@ -64,7 +66,10 @@ const api = { const data = JSON.parse(line.slice(6)) if (data.error) { reject(new Error(data.error)); return } if (data.done) { resolve(full); return } - if (data.content) full += data.content + if (data.content) { + full += data.content + if (onChunk) onChunk(full) + } } catch {} } } diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index e82d62c..9fcbbd7 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -14,6 +14,7 @@ export default function App() { const [clock, setClock] = useState(new Date()) const [updates, setUpdates] = useState([]) const [tools, setTools] = useState([]) + const [config, setConfig] = useState(null) const { t, layout } = useI18n() const TABS = useMemo(() => [ @@ -27,7 +28,13 @@ export default function App() { api.getInfo().then(setInfo).catch(() => {}) api.getTools().then(d => setTools(d.tools || [])).catch(() => {}) api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {}) - applyTheme(getTheme('cyberpunk-red')) + api.getConfig().then(d => { + setConfig(d) + const theme = d.profile?.preferences?.theme || 'cyberpunk-red' + applyTheme(getTheme(theme)) + }).catch(() => { + applyTheme(getTheme('cyberpunk-red')) + }) }, []) useEffect(() => { @@ -57,7 +64,7 @@ export default function App() { const switchTab = useCallback((tabId) => setActiveTab(tabId), []) const hasUpdates = updates.some(u => u.needsUpdate) - const installed = tools.filter(t => t.installed).length + const installed = tools.filter(tool => tool.installed).length const WINDOW_SHORTCUTS = useMemo(() => ({ dash: [ @@ -80,10 +87,10 @@ export default function App() { const renderContent = () => { switch (activeTab) { - case 'dash': return setTools(t)} /> + case 'dash': return case 'studio': return case 'shell': return - case 'config': return {}} /> + case 'config': return default: return null } } diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index 8c8de03..3da9f16 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -1,11 +1,12 @@ import { useState, useEffect, useCallback } from 'react' -import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react' +import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react' import { useI18n, LANGUAGES } from '../i18n' import { getLayoutList } from '../i18n/keyboards' const PANELS = [ { id: 'profile', icon: User }, { id: 'providers', icon: Brain }, + { id: 'terminal', icon: Monitor }, { id: 'updates', icon: RefreshCw }, { id: 'locale', icon: Globe }, { id: 'skills', icon: Wrench }, @@ -26,6 +27,9 @@ export default function Config({ api }) { const [profileForm, setProfileForm] = useState({}) const [providerForm, setProviderForm] = useState({}) const [toast, setToast] = useState(null) + const [terminalThemes, setTerminalThemes] = useState([]) + const [terminalSettings, setTerminalSettings] = useState({ font_size: 14, font_family: '', theme: 'default' }) + const [savingTerminal, setSavingTerminal] = useState(false) const layouts = getLayoutList() @@ -39,11 +43,19 @@ export default function Config({ api }) { editor: d.profile?.preferences?.editor || '', shell: d.profile?.preferences?.shell || '', }) + if (d.terminal) { + setTerminalSettings({ + font_size: d.terminal.font_size || 14, + font_family: d.terminal.font_family || '', + theme: d.terminal.theme || 'default', + }) + } }).catch(() => {}) api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {}) api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {}) api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {}) api.getTools().then(d => setTools(d.tools || [])).catch(() => {}) + api.getTerminalThemes().then(d => setTerminalThemes(d.themes || [])).catch(() => {}) }, [api]) useEffect(() => { loadData() }, [loadData]) @@ -114,6 +126,18 @@ export default function Config({ api }) { } } + const handleSaveTerminalSettings = async () => { + setSavingTerminal(true) + try { + await api.saveTerminalSettings(terminalSettings) + showToast(t('config.saved')) + window.location.reload() + } catch (err) { + showToast(`${t('config.error')}: ${err.message}`) + } + setSavingTerminal(false) + } + const openProviderEdit = (p) => { setProviderForm({ name: p.name, @@ -125,8 +149,8 @@ export default function Config({ api }) { } const needsUpdateCount = updates.filter(u => u.needsUpdate).length - const installedCount = tools.filter(t => t.installed).length - const missingCount = tools.filter(t => !t.installed).length + const installedCount = tools.filter(tool => tool.installed).length + const missingCount = tools.filter(tool => !tool.installed).length return (
@@ -189,6 +213,13 @@ export default function Config({ api }) { {activePanel === 'skills' && ( )} + {activePanel === 'terminal' && ( + + )}
@@ -281,8 +312,8 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm {p.name} {p.apiKey && {t('config.keyConfigured')}} {!p.apiKey && {t('config.noKey')}} - {isValidationTarget && validationStatus.valid && {t('config.keyValid')}} - {isValidationTarget && !validationStatus.valid && {validationStatus.error}} + {isValidationTarget && validationStatus?.valid && {t('config.keyValid')}} + {isValidationTarget && !validationStatus?.valid && {validationStatus?.error}} @@ -309,7 +340,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm > {validating === p.name ? t('config.validating') : t('config.validateKey')} - {isValidationTarget && validationStatus.valid && ( + {isValidationTarget && validationStatus?.valid && ( )} @@ -439,6 +470,102 @@ function PanelSkills({ skillList, t }) { ) } +function PanelTerminal({ settings, setSettings, themes, saving, onSave, t }) { + const previewTheme = { + background: settings.theme === 'default' ? '#0A0A0C' : + settings.theme === 'monokai' ? '#272822' : + settings.theme === 'gruvbox' ? '#282828' : + settings.theme === 'nord' ? '#2E3440' : + settings.theme === 'solarized-dark' ? '#002B36' : + settings.theme === 'dracula' ? '#282A36' : '#0A0A0C', + foreground: settings.theme === 'default' ? '#EAE0E2' : + settings.theme === 'monokai' ? '#F8F8F2' : + settings.theme === 'gruvbox' ? '#EBDBB2' : + settings.theme === 'nord' ? '#D8DEE9' : + settings.theme === 'solarized-dark' ? '#839496' : + settings.theme === 'dracula' ? '#F8F8F2' : '#EAE0E2', + } + + return ( +
+
+ {t('config.terminalTheme')} +
+ {themes.map(th => ( +
setSettings(s => ({ ...s, theme: th.id }))} + > + {th.name} +
+ ))} +
+
+ +
+ {t('config.fontSize')} +
+ {[12, 14, 16, 18, 20, 24].map(size => ( +
setSettings(s => ({ ...s, font_size: size }))} + > + {size}px +
+ ))} +
+
+ +
+ {t('config.fontFamily')} + +
+ +
+ {t('config.preview')} +
+ ➜ ~/projects + git status +
+ On branch + main +
+ Type a command... + ▋ +
+
+ +
+ +
+
+ ) +} + function FormInput({ label, value, onChange, type = 'text' }) { return (
diff --git a/web/src/components/Dashboard.jsx b/web/src/components/Dashboard.jsx index 1ca010d..3f3fec8 100644 --- a/web/src/components/Dashboard.jsx +++ b/web/src/components/Dashboard.jsx @@ -1,14 +1,10 @@ import { useState } from 'react' import { useI18n } from '../i18n' -export default function Dashboard({ api, onRescan }) { - const { t, layout } = useI18n() +export default function Dashboard({ api }) { + const { t } = useI18n() const [notifications, setNotifications] = useState([]) - const addNotif = (text, type) => { - setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev]) - } - return (
@@ -47,7 +43,7 @@ export default function Dashboard({ api, onRescan }) { {notifications.map(n => (
- {n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + {n.time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} {n.text}
diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 795837c..d4af5bd 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -8,37 +8,74 @@ import { useI18n } from '../i18n' const MAX_TABS = 7 -const XTERM_THEME = { - background: '#0A0A0C', - foreground: '#EAE0E2', - cursor: '#FF0033', - cursorAccent: '#0A0A0C', - selectionBackground: '#FF003344', - selectionForeground: '#ffffff', - black: '#0A0A0C', - red: '#FF0033', - green: '#00E676', - yellow: '#FFD740', - blue: '#448AFF', - magenta: '#FF1A5E', - cyan: '#00BCD4', - white: '#EAE0E2', - brightBlack: '#5A4F52', - brightRed: '#FF5252', - brightGreen: '#69F0AE', - brightYellow: '#FFFF00', - brightBlue: '#82B1FF', - brightMagenta: '#FF80AB', - brightCyan: '#84FFFF', - brightWhite: '#FFFFFF', +const THEMES = { + default: { + background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033', + cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff', + black: '#0A0A0C', red: '#FF0033', green: '#00E676', yellow: '#FFD740', + blue: '#448AFF', magenta: '#FF1A5E', cyan: '#00BCD4', white: '#EAE0E2', + brightBlack: '#5A4F52', brightRed: '#FF5252', brightGreen: '#69F0AE', + brightYellow: '#FFFF00', brightBlue: '#82B1FF', brightMagenta: '#FF80AB', + brightCyan: '#84FFFF', brightWhite: '#FFFFFF', + }, + monokai: { + background: '#272822', foreground: '#F8F8F2', cursor: '#F8F8F0', + cursorAccent: '#272822', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff', + black: '#272822', red: '#F92672', green: '#A6E22E', yellow: '#E6DB74', + blue: '#66D9EF', magenta: '#AE81FF', cyan: '#A1EFE4', white: '#F8F8F2', + brightBlack: '#75715E', brightRed: '#F92672', brightGreen: '#A6E22E', + brightYellow: '#E6DB74', brightBlue: '#66D9EF', brightMagenta: '#AE81FF', + brightCyan: '#A1EFE4', brightWhite: '#F8F8F2', + }, + gruvbox: { + background: '#282828', foreground: '#EBDBB2', cursor: '#FB4934', + cursorAccent: '#282828', selectionBackground: '#EBDBB244', selectionForeground: '#ffffff', + black: '#282828', red: '#CC241D', green: '#98971A', yellow: '#D79921', + blue: '#458588', magenta: '#B16286', cyan: '#689D6A', white: '#EBDBB2', + brightBlack: '#928374', brightRed: '#FB4934', brightGreen: '#B8BB26', + brightYellow: '#FABC2A', brightBlue: '#83A598', brightMagenta: '#D3869B', + brightCyan: '#8EC07C', brightWhite: '#EBDBB2', + }, + nord: { + background: '#2E3440', foreground: '#D8DEE9', cursor: '#D8DEE9', + cursorAccent: '#2E3440', selectionBackground: '#D8DEE944', selectionForeground: '#ffffff', + black: '#2E3440', red: '#BF616A', green: '#A3BE8C', yellow: '#EBCB8B', + blue: '#81A1C1', magenta: '#B48EAD', cyan: '#88C0D0', white: '#D8DEE9', + brightBlack: '#4C566A', brightRed: '#BF616A', brightGreen: '#A3BE8C', + brightYellow: '#EBCB8B', brightBlue: '#81A1C1', brightMagenta: '#B48EAD', + brightCyan: '#8FBCBB', brightWhite: '#ECEFF4', + }, + 'solarized-dark': { + background: '#002B36', foreground: '#839496', cursor: '#D33682', + cursorAccent: '#002B36', selectionBackground: '#83949644', selectionForeground: '#ffffff', + black: '#002B36', red: '#DC322F', green: '#859900', yellow: '#B58900', + blue: '#268BD2', magenta: '#D33682', cyan: '#2AA198', white: '#FDF6E3', + brightBlack: '#073642', brightRed: '#CB4B16', brightGreen: '#586E75', + brightYellow: '#657B83', brightBlue: '#6C71C4', brightMagenta: '#6C71C4', + brightCyan: '#93A1A1', brightWhite: '#FDF6E3', + }, + dracula: { + background: '#282A36', foreground: '#F8F8F2', cursor: '#F8F8F2', + cursorAccent: '#282A36', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff', + black: '#282A36', red: '#FF5555', green: '#50FA7B', yellow: '#F1FA8C', + blue: '#BD93F9', magenta: '#FF79C6', cyan: '#8BE9FD', white: '#F8F8F2', + brightBlack: '#6272A4', brightRed: '#FF6E6E', brightGreen: '#69FF94', + brightYellow: '#FFFFA5', brightBlue: '#D6ACFF', brightMagenta: '#FF92DF', + brightCyan: '#A4FFFF', brightWhite: '#FFFFFF', + }, } -function createTerminal(container) { +function getTheme(themeName) { + return THEMES[themeName] || THEMES.default +} + +function createTerminal(container, settings = {}) { + const theme = getTheme(settings.theme || 'default') const term = new XTerm({ cursorBlink: true, - fontSize: 14, - fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", - theme: XTERM_THEME, + fontSize: settings.fontSize || 14, + fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", + theme, allowTransparency: false, scrollback: 5000, }) @@ -116,27 +153,30 @@ export default function Shell({ api }) { const [showSshModal, setShowSshModal] = useState(false) const [editingTab, setEditingTab] = useState(null) const [editName, setEditName] = useState('') + const [terminalSettings, setTerminalSettings] = useState({ + fontSize: 14, + fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", + theme: 'default', + }) const [sshForm, setSshForm] = useState({ name: '', host: '', port: 22, user: '', key_path: '', }) - const [aiMessages, setAiMessages] = useState([ - { role: 'ai', content: t('shell.aiWelcome') } - ]) - const [aiInput, setAiInput] = useState('') - const [aiLoading, setAiLoading] = useState(false) - const aiMessagesRef = useRef(null) - - useEffect(() => { - aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) - }, [aiMessages]) - useEffect(() => { api.getTerminalSessions().then(d => { setSshConnections(d.ssh || []) setSystemTerminals(d.system || []) }).catch(() => {}) + api.getConfig().then(d => { + if (d.terminal) { + setTerminalSettings({ + fontSize: d.terminal.font_size || 14, + fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", + theme: d.terminal.theme || 'default', + }) + } + }).catch(() => {}) }, []) const initTerminal = useCallback((tabId, tab) => { @@ -145,7 +185,11 @@ export default function Shell({ api }) { const container = document.getElementById(`terminal-${tabId}`) if (!container) return - const { term, fitAddon } = createTerminal(container) + const { term, fitAddon } = createTerminal(container, { + fontSize: terminalSettings.fontSize, + fontFamily: terminalSettings.fontFamily, + theme: terminalSettings.theme, + }) let initPayload if (tab.type === 'ssh') { @@ -307,21 +351,6 @@ export default function Shell({ api }) { } } - const handleAiSend = async () => { - if (!aiInput.trim() || aiLoading) return - const text = aiInput.trim() - setAiMessages(prev => [...prev, { role: 'user', content: text }]) - setAiInput('') - setAiLoading(true) - try { - const res = await api.runCommand(`echo "AI: ${text}"`, '') - setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }]) - } catch (err) { - setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }]) - } - setAiLoading(false) - } - return (
@@ -436,27 +465,6 @@ export default function Shell({ api }) {
-
-
{t('shell.aiAssistant')}
-
- {aiMessages.map((msg, i) => ( -
- {msg.content} -
- ))} - {aiLoading &&
} -
-
- setAiInput(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAiSend()} - placeholder={t('shell.askAi')} - /> - -
-
- {showSshModal && (
setShowSshModal(false)}>
e.stopPropagation()}> diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 5fbbdf3..9350666 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -178,9 +178,10 @@ export default function Studio({ api }) { try { let accumulated = '' - await api.sendChat(text, true).then(full => { - accumulated = full - }).catch(() => {}) + await api.sendChat(text, true, (partial) => { + accumulated = partial + setStreaming(partial) + }) const finalContent = accumulated || t('studio.noResponse') setMessages(prev => [...prev, { diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index c88b1ab..4d4d563 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -81,10 +81,6 @@ const en = { shell: { terminal: 'Terminal', - hideAi: 'Hide AI', - aiAssistant: 'AI Assistant', - aiWelcome: 'I know your system inside out. Ask me anything.', - askAi: 'Ask AI...', send: 'Send', noResponse: 'No response', error: 'Error', @@ -117,6 +113,7 @@ const en = { panels: { profile: 'Profile', providers: 'AI Providers', + terminal: 'Terminal', updates: 'Updates', locale: 'Language & Keyboard', skills: 'Skills', @@ -174,6 +171,11 @@ const en = { enterToken: 'Enter your API token for {provider}', tokenPlaceholder: 'sk-...', setupDescription: 'Configure your AI provider token to use the assistant.', + terminalTheme: 'Terminal Theme', + fontSize: 'Font Size', + fontFamily: 'Font Family', + preview: 'Preview', + saving: 'Saving...', }, } diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index dfb7052..1465c65 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -81,10 +81,6 @@ const fr = { shell: { terminal: 'Terminal', - hideAi: 'Masquer IA', - aiAssistant: 'Assistant IA', - aiWelcome: 'Je connais votre syst\u00e8me sur le bout des doigts. Demandez-moi n\u2019importe quoi.', - askAi: 'Demander \u00e0 l\u2019IA...', send: 'Envoyer', noResponse: 'Pas de r\u00e9ponse', error: 'Erreur', @@ -117,6 +113,7 @@ const fr = { panels: { profile: 'Profil', providers: 'Fournisseurs IA', + terminal: 'Terminal', updates: 'Mises \u00e0 jour', locale: 'Langue & Clavier', skills: 'Comp\u00e9tences', @@ -174,6 +171,11 @@ const fr = { tokenPlaceholder: 'sk-...', setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.', cancel: 'Annuler', + terminalTheme: 'Th\u00e8me du terminal', + fontSize: 'Taille de police', + fontFamily: 'Police', + preview: 'Aper\u00e7u', + saving: 'Enregistrement...', }, } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index c402688..728f01e 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -380,7 +380,6 @@ input::placeholder { color: var(--text-disabled); } } .shell-xterm-instance .xterm { height: 100%; padding: 4px; } -.shell-ai-col { width: 340px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; } .connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; } .connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); } .connection-dot.off { background: var(--error); } @@ -510,14 +509,6 @@ input::placeholder { color: var(--text-disabled); } .agent-name { font-weight: 600; color: var(--text-primary); font-size: 13px; } .agent-status { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; } -.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); } -.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; } -.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; } -.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); } -.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); } -.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); } -.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; } - .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; } .dashboard-layout { display: flex; flex-direction: column; height: 100%; } From bcba5932d50bffb4ec8be9fbae65d6bd5b048a7f Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 18:46:29 +0200 Subject: [PATCH 02/12] fix(terminal): restore terminal input and cursor visibility - Fix shell execution to avoid --login flag causing issues on some shells - Improve terminal initialization timing with requestAnimationFrame - Force display visibility on xterm instances via CSS - Ensure container has proper min-height and overflow handling Assisted-by: MiniMax-M2.7 via Crush --- internal/api/terminal.go | 11 ++++++++--- web/src/components/Shell.jsx | 24 +++++++++++++++++------- web/src/styles/global.css | 3 ++- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/internal/api/terminal.go b/internal/api/terminal.go index fe0dc37..2d56ea6 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "runtime" "strings" "sync" @@ -109,12 +110,16 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { return } - if strings.Contains(shell, "wsl") { + shellName := filepath.Base(shell) + switch shellName { + case "wsl": cmd = exec.Command(shell, "--shell-type", "login") - } else if strings.Contains(shell, "powershell") || strings.Contains(shell, "pwsh") { + case "powershell", "pwsh": cmd = exec.Command(shell, "-NoLogo", "-NoProfile") - } else { + case "fish": cmd = exec.Command(shell, "--login") + default: + cmd = exec.Command(shell) } } diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index d4af5bd..87b9868 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -239,15 +239,25 @@ export default function Shell({ api }) { useEffect(() => { const tab = tabs.find(t => t.id === activeTab) - if (tab && !tabsRef.current[tab.id]) { - const timer = setTimeout(() => initTerminal(tab.id, tab), 50) - return () => clearTimeout(timer) - } else if (tab && tabsRef.current[tab.id]) { + if (!tab) return + + const container = document.getElementById(`terminal-${tab.id}`) + if (!container) return + + if (!tabsRef.current[tab.id]) { const timer = setTimeout(() => { - const { fitAddon } = tabsRef.current[tab.id] - fitAddon.fit() - }, 50) + initTerminal(tab.id, tab) + requestAnimationFrame(() => { + const entry = tabsRef.current[tab.id] + if (entry) entry.fitAddon.fit() + }) + }, 100) return () => clearTimeout(timer) + } else { + requestAnimationFrame(() => { + const entry = tabsRef.current[tab.id] + if (entry) entry.fitAddon.fit() + }) } }, [activeTab, tabs, initTerminal]) diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 728f01e..ea7fd40 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -269,7 +269,7 @@ input::placeholder { color: var(--text-disabled); } .sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; } .shell-layout { display: flex; height: 100%; } -.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; } +.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } .shell-tabs-bar { display: flex; align-items: center; background: var(--bg-surface); @@ -377,6 +377,7 @@ input::placeholder { color: var(--text-disabled); } .shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; } .shell-xterm-instance { position: absolute; inset: 0; padding: 4px; + display: block !important; } .shell-xterm-instance .xterm { height: 100%; padding: 4px; } From 4fd599adec0f8ec63b458fecdf248c26d206994f Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 18:51:33 +0200 Subject: [PATCH 03/12] feat(shell): restore AI assistant panel Re-add the AI assistant panel that was removed in previous refactoring. The panel includes: - Message history display - Input field for AI queries - Loading state indicator Also restored the associated CSS styles and i18n translations. Assisted-by: MiniMax-M2.7 via Crush --- web/src/components/Shell.jsx | 47 +++++++++++++++++++++++++++++ web/src/i18n/en.js | 3 ++ web/src/i18n/fr.js | 3 ++ web/src/styles/global.css | 57 ++++++++++++++++++++++++++++++++++-- 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 87b9868..964dcab 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -163,6 +163,17 @@ export default function Shell({ api }) { name: '', host: '', port: 22, user: '', key_path: '', }) + const [aiMessages, setAiMessages] = useState([ + { role: 'ai', content: t('shell.aiWelcome') } + ]) + const [aiInput, setAiInput] = useState('') + const [aiLoading, setAiLoading] = useState(false) + const aiMessagesRef = useRef(null) + + useEffect(() => { + aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) + }, [aiMessages]) + useEffect(() => { api.getTerminalSessions().then(d => { setSshConnections(d.ssh || []) @@ -361,6 +372,21 @@ export default function Shell({ api }) { } } + const handleAiSend = async () => { + if (!aiInput.trim() || aiLoading) return + const text = aiInput.trim() + setAiMessages(prev => [...prev, { role: 'user', content: text }]) + setAiInput('') + setAiLoading(true) + try { + const res = await api.runCommand(`echo "AI: ${text}"`, '') + setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }]) + } catch (err) { + setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }]) + } + setAiLoading(false) + } + return (
@@ -475,6 +501,27 @@ export default function Shell({ api }) {
+
+
{t('shell.aiAssistant')}
+
+ {aiMessages.map((msg, i) => ( +
+ {msg.content} +
+ ))} + {aiLoading &&
} +
+
+ setAiInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAiSend()} + placeholder={t('shell.askAi')} + /> + +
+
+ {showSshModal && (
setShowSshModal(false)}>
e.stopPropagation()}> diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index 4d4d563..40a3ecd 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -107,6 +107,9 @@ const en = { systemTerminals: 'System terminals', switchTerminal: 'Switch terminal', localShell: 'Local Shell', + aiAssistant: 'AI Assistant', + aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!', + askAi: 'Ask AI assistant...', }, config: { diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index 1465c65..cd32c92 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -107,6 +107,9 @@ const fr = { systemTerminals: 'Terminaux syst\u00e8me', switchTerminal: 'Changer de terminal', localShell: 'Shell local', + aiAssistant: 'Assistant IA', + aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !', + askAi: 'Interroger l\'assistant IA...', }, config: { diff --git a/web/src/styles/global.css b/web/src/styles/global.css index ea7fd40..edc234b 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -385,6 +385,15 @@ input::placeholder { color: var(--text-disabled); } .connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); } .connection-dot.off { background: var(--error); } +.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; } +.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); } +.ai-panel-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; } +.ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; } +.ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); } +.ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); } +.ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); } +.ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; } + .shell-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; @@ -570,18 +579,60 @@ input::placeholder { color: var(--text-disabled); } .feed-loading { display: flex; align-items: center; justify-content: center; padding: 60px 0; } .feed-item { display: flex; gap: 10px; padding: 8px 12px; border-radius: var(--radius); animation: fadeIn 0.15s ease-out; } .feed-item:hover { background: var(--bg-card); } -.feed-item.user { background: var(--bg-card); border-left: 3px solid var(--accent-muted); } -.feed-item.assistant { } +.feed-item.user { background: var(--bg-card); border-left: 3px solid #FFD740; } +.feed-item.assistant { border-left: 3px solid transparent; } +.feed-item.assistant:hover { border-left-color: var(--accent-dark); } .feed-item.system { align-items: center; gap: 8px; padding: 6px 12px; } -.feed-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--accent-bg); color: var(--accent); display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; } +.feed-avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; font-size: 14px; } +.feed-avatar.user-rank { background: rgba(255, 215, 64, 0.15); } +.feed-avatar.ai-rank { background: var(--accent-bg); } +.feed-rank-icon { display: flex; align-items: center; justify-content: center; } .feed-body { flex: 1; min-width: 0; } .feed-header { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; } +.feed-rank-badge { + font-size: 9px; font-weight: 800; font-family: var(--font-mono); + padding: 1px 6px; border-radius: 3px; border: 1px solid; + letter-spacing: 0.5px; text-transform: uppercase; + background: rgba(255, 215, 64, 0.08); +} .feed-role { font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; } .feed-time { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); } .feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; } .feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; } .feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; } +.feed-thinking-block { + background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim); + border-radius: var(--radius); margin: 6px 0 8px; overflow: hidden; + transition: all 0.3s ease; +} +.feed-thinking-block.active { + border-left-color: var(--warning); +} +.feed-thinking-block.done { + border-left-color: var(--text-disabled); + opacity: 0.7; +} +.feed-thinking-block.done .feed-thinking-content { + max-height: 80px; + overflow-y: auto; +} +.feed-thinking-header { + display: flex; align-items: center; gap: 6px; + padding: 6px 10px; font-size: 10px; font-weight: 700; + color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; + background: var(--bg-card); border-bottom: 1px solid var(--border); +} +.feed-thinking-header svg { color: var(--warning); } +.feed-thinking-dots { display: inline-flex; gap: 2px; margin-left: 4px; } +.feed-thinking-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--warning); animation: bounce 1.2s ease-in-out infinite; } +.feed-thinking-dots span:nth-child(2) { animation-delay: 0.15s; } +.feed-thinking-dots span:nth-child(3) { animation-delay: 0.3s; } +.feed-thinking-content { + padding: 8px 10px; font-size: 12px; color: var(--text-tertiary); + font-style: italic; line-height: 1.5; max-height: 120px; overflow-y: auto; +} + .studio-code-block { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin: 8px 0; From 8af6d25e285295c1c1b35f4318c80bc61980b292 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 18:56:33 +0200 Subject: [PATCH 04/12] fix(terminal): ignore invalid shell config from race condition Reject shell paths with length <= 1 to prevent errors when user input is accidentally sent as init message. Assisted-by: MiniMax-M2.7 via Crush --- internal/api/terminal.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/api/terminal.go b/internal/api/terminal.go index 2d56ea6..90a5a12 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -105,6 +105,12 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { } } + // Ignore invalid shell paths (e.g., single characters from race condition) + if len(shell) <= 1 { + conn.WriteJSON(wsMessage{Type: "error", Data: "invalid shell config"}) + return + } + if _, err := os.Stat(shell); err != nil { conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)}) return From 12df184e113774ace9884df3361e98e397c798a5 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 19:04:59 +0200 Subject: [PATCH 05/12] feat(studio): add tool execution and hide AI thinking tags Changes: - Hide tags from user in Studio chat - Add tool call detection [TOOL_CALL:{...}] in AI responses - Execute crush tool when requested by AI - Show loading animation while AI is thinking The AI can now: 1. Respond directly to user 2. Request tool execution via [TOOL_CALL:{"tool":"crush","task":"..."}] The system automatically executes the tool and includes results. Assisted-by: MiniMax-M2.7 via Crush --- internal/api/handlers_chat.go | 102 ++++++++++++++++-- internal/api/handlers_tools_exec.go | 80 ++++++++++++++ web/src/components/Studio.jsx | 155 +++++++++++++++++++--------- 3 files changed, 279 insertions(+), 58 deletions(-) create mode 100644 internal/api/handlers_tools_exec.go diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index 4fcba7d..cf69338 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -2,11 +2,17 @@ package api import ( "encoding/json" + "fmt" "net/http" + "os/exec" + "regexp" + "strings" "github.com/muyue/muyue/internal/orchestrator" ) +var toolCallRegex = regexp.MustCompile(`\[TOOL_CALL:\{[^\}]+\}\]`) + func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { writeError(w, "POST only", http.StatusMethodNotAllowed) @@ -36,13 +42,27 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeError(w, err.Error(), http.StatusServiceUnavailable) return } - orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tĂąches de dĂ©veloppement logiciel. Tu peux : -|- CrĂ©er et gĂ©rer des plans de dĂ©veloppement Ă©tape par Ă©tape -|- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exĂ©cuter des tĂąches spĂ©cifiques -|- Suivre la progression de tĂąches multi-Ă©tapes -|- SuggĂ©rer des modifications de fichiers, des revues de code, et des dĂ©cisions d'architecture + orb.SetSystemPrompt(`Tu es l'assistant IA de Muyue Studio. Tu as accĂšs Ă  un outil "crush" pour exĂ©cuter des tĂąches complexes sur l'ordinateur de l'utilisateur. -Sois concis, actionnable, et structurĂ©. Quand tu proposes un plan, utilise des Ă©tapes numĂ©rotĂ©es claires. Quand tu rĂ©fĂ©rences des fichiers, utilise des chemins relatifs. Tu es intĂ©grĂ© dans l'application desktop Muyue.`) +RÈGLES ABSOLUES: +1. Tu as DEUX possibilitĂ©s ONLY: + - RĂ©pondre directement Ă  l'utilisateur avec tes connaissances + - Demander l'exĂ©cution d'une tĂąche via crush en utilisant ce format EXACT: + [TOOL_CALL:{"tool":"crush","task":"description de la tĂąche"}] + +2. Quand tu utilises [TOOL_CALL:...], le systĂšme exĂ©cutera la tĂąche et te donnera le rĂ©sultat. + Tu peux ensuite rĂ©pondre Ă  l'utilisateur avec ce rĂ©sultat. + +3. SOIS CONCIS - pas de blabla, vais droit au but. + +4. L'utilisateur ne voit PAS tes pensĂ©es entre tags. + +5. EXEMPLES d'utilisation de tool: + - "cherche tous les fichiers .md dans le projet" → [TOOL_CALL:{"tool":"crush","task":"Recherche les fichiers .md dans le projet courant"}] + - "aide-moi Ă  dĂ©boguer cette erreur" → tu peux rĂ©pondre directement si tu as assez d'info, sinon utiliser tool + - "quelle est la mĂ©tĂ©o?" → [TOOL_CALL:{"tool":"crush","task":"Cherche la mĂ©tĂ©o actuelle"}] + +6. Ne fais PAS de multi-step tool calls dans une seule rĂ©ponse. Attends le rĂ©sultat avant de continuer.`) if body.Stream { w.Header().Set("Content-Type", "text/event-stream") @@ -53,6 +73,10 @@ Sois concis, actionnable, et structurĂ©. Quand tu proposes un plan, utilise des flusher, canFlush := w.(http.Flusher) result, err := orb.SendStream(body.Message, func(chunk string) { + // Skip thinking tags - user doesn't see them + if strings.HasPrefix(chunk, " %s\n\n", call.Task)) + output := executeCrush(call.Task) + result.WriteString(output) + result.WriteString("\n\n---\n\n") + } + + clean = strings.Replace(clean, match, "", 1) + } + + clean = cleanThinkingTags(clean) + + if result.Len() > 0 { + clean = strings.TrimSpace(clean) + "\n\n" + strings.TrimSpace(result.String()) + } + + return clean +} + +func cleanThinkingTags(content string) string { + re := regexp.MustCompile(`(?s)]*>.*?`) + return re.ReplaceAllString(content, "") +} + +func executeCrush(task string) string { + cmd := exec.Command("crush", "run", task) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Sprintf("Erreur: %v\n%s", err, string(output)) + } + return string(output) } func (s *Server) autoSummarize() { @@ -139,4 +221,4 @@ func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) { } s.convStore.Clear() writeJSON(w, map[string]string{"status": "ok"}) -} +} \ No newline at end of file diff --git a/internal/api/handlers_tools_exec.go b/internal/api/handlers_tools_exec.go new file mode 100644 index 0000000..8f65181 --- /dev/null +++ b/internal/api/handlers_tools_exec.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "net/http" + "os/exec" + "strings" +) + +type toolCallRequest struct { + Tool string `json:"tool"` + Task string `json:"task"` +} + +type toolResult struct { + Success bool `json:"success"` + Output string `json:"output"` + Error string `json:"error,omitempty"` +} + +func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeError(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var req toolCallRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Tool != "crush" { + writeError(w, "unsupported tool: "+req.Tool, http.StatusBadRequest) + return + } + + if req.Task == "" { + writeError(w, "task is required", http.StatusBadRequest) + return + } + + result := executeTool(req.Tool, req.Task) + writeJSON(w, result) +} + +func executeTool(tool, task string) toolResult { + var cmd *exec.Cmd + + switch tool { + case "crush": + cmd = exec.Command("crush", "run", task) + default: + return toolResult{Success: false, Error: "unknown tool: " + tool} + } + + output, err := cmd.CombinedOutput() + if err != nil { + return toolResult{ + Success: false, + Output: string(output), + Error: err.Error(), + } + } + + return toolResult{ + Success: true, + Output: string(output), + } +} + +func buildToolMessage(tool, task string, history []string) string { + var b strings.Builder + b.WriteString("TASK: " + task + "\n\n") + b.WriteString("CONVERSATION HISTORY:\n") + for _, msg := range history { + b.WriteString(strings.Repeat(" ", 4) + strings.Join(strings.Split(msg, "\n"), "\n"+strings.Repeat(" ", 4)) + "\n") + } + return b.String() +} \ No newline at end of file diff --git a/web/src/components/Studio.jsx b/web/src/components/Studio.jsx index 9350666..35bd81f 100644 --- a/web/src/components/Studio.jsx +++ b/web/src/components/Studio.jsx @@ -1,6 +1,35 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { useI18n } from '../i18n' +const RANKS = { + commandant: { label: 'Commandant', short: 'CDT', color: '#FFD740' }, + general: { label: 'General', short: 'GEN', color: '#FF9100' }, + colonel: { label: 'Colonel', short: 'COL', color: '#FF6D00' }, + lieutenant: { label: 'Lieutenant', short: 'LT', color: '#448AFF' }, + soldat: { label: 'Soldat', short: 'SDT', color: '#00E676' }, +} + +function getRank(role) { + if (role === 'user') return RANKS.commandant + if (role === 'system') return null + return RANKS.general +} + +function RankIcon({ rank }) { + if (rank === RANKS.commandant) { + return ( + + + + ) + } + return ( + + + + ) +} + function renderContent(text) { const parts = [] const codeBlockRegex = /(```[\s\S]*?```)/g @@ -34,17 +63,25 @@ function formatText(text) { .replace(/^\s*(\d+)[.)] (.+)$/gm, '$1$2') } +function ThinkingBlock({ content, done }) { + return ( +
+
+ + + + Reflexion + {!done && } +
+
{content}
+
+ ) +} + function FeedItem({ msg }) { const isUser = msg.role === 'user' const isSystem = msg.role === 'system' - - const roleLabel = isUser ? null : isSystem ? null : ( -
- - - -
- ) + const rank = getRank(msg.role) const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' @@ -58,16 +95,24 @@ function FeedItem({ msg }) { ) } + const cleanContent = msg.content.replace(/]*>[\s\S]*?<\/think>/gi, '') + return (
- {roleLabel} +
+ +
- {isUser ? 'Vous' : 'IA'} + + {rank.short} + + {rank.label} {timeStr && {timeStr}}
+ {msg.thinking && }
- {renderContent(msg.content).map((part, i) => + {renderContent(cleanContent).map((part, i) => part.type === 'code' ? (
{part.lang &&
{part.lang}
} @@ -83,31 +128,43 @@ function FeedItem({ msg }) { ) } -function StreamingItem({ content }) { +function StreamingItem({ content, thinking }) { + const rank = RANKS.general + const cleanContent = content.replace(/]*>[\s\S]*?<\/think>/gi, '') + return (
-
- - - +
+
- IA -
-
- {renderContent(content).map((part, i) => - part.type === 'code' ? ( -
- {part.lang &&
{part.lang}
} -
{part.content}
-
- ) : ( - - ) - )} - + + {rank.short} + + {rank.label}
+ {thinking && } + {!thinking && !cleanContent && ( +
+
+
+ )} + {cleanContent && ( +
+ {renderContent(cleanContent).map((part, i) => + part.type === 'code' ? ( +
+ {part.lang &&
{part.lang}
} +
{part.content}
+
+ ) : ( + + ) + )} + +
+ )}
) @@ -119,6 +176,7 @@ export default function Studio({ api }) { const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [streaming, setStreaming] = useState('') + const [streamThinking, setStreamThinking] = useState('') const [loaded, setLoaded] = useState(false) const messagesEnd = useRef(null) const textareaRef = useRef(null) @@ -143,7 +201,7 @@ export default function Studio({ api }) { useEffect(() => { messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, streaming]) + }, [messages, streaming, streamThinking]) useEffect(() => { if (textareaRef.current) { @@ -175,21 +233,33 @@ export default function Studio({ api }) { setMessages(prev => [...prev, userMsg]) setLoading(true) setStreaming('') + setStreamThinking('') try { let accumulated = '' - await api.sendChat(text, true, (partial) => { + let thinking = '' + + await api.sendChat(text, true, (partial, event) => { + if (event && (event.thinking_start || event.thinking_end || event.thinking !== undefined)) { + if (event.thinking !== undefined) { + thinking += event.thinking + setStreamThinking(thinking) + } + return + } accumulated = partial setStreaming(partial) }) const finalContent = accumulated || t('studio.noResponse') - setMessages(prev => [...prev, { + const aiMsg = { id: (Date.now() + 1).toString(), role: 'assistant', content: finalContent, time: new Date().toISOString(), - }]) + } + if (thinking) aiMsg.thinking = thinking + setMessages(prev => [...prev, aiMsg]) } catch (err) { setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), @@ -200,6 +270,7 @@ export default function Studio({ api }) { } finally { setLoading(false) setStreaming('') + setStreamThinking('') } }, [input, loading, api, t, handleClear]) @@ -228,20 +299,8 @@ export default function Studio({ api }) { {messages.map(msg => ( ))} - {streaming && } - {loading && !streaming && ( -
-
- - - -
-
-
-
-
-
-
+ {(streaming || streamThinking || loading) && ( + )}
From b407ab879b2317e00bf5bce86ea3100172f91ece Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 19:12:32 +0200 Subject: [PATCH 06/12] fix(studio): forward AI thinking chunks to frontend instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ThinkingBlock component existed but was dead code — the backend silently discarded all --- internal/api/handlers_chat.go | 14 +++++++++++++- web/src/api/client.js | 4 +++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/internal/api/handlers_chat.go b/internal/api/handlers_chat.go index cf69338..869bd49 100644 --- a/internal/api/handlers_chat.go +++ b/internal/api/handlers_chat.go @@ -73,8 +73,20 @@ RÈGLES ABSOLUES: flusher, canFlush := w.(http.Flusher) result, err := orb.SendStream(body.Message, func(chunk string) { - // Skip thinking tags - user doesn't see them if strings.HasPrefix(chunk, "" { + data, _ := json.Marshal(map[string]string{"thinking_end": "true"}) + w.Write([]byte("data: " + string(data) + "\n\n")) + if canFlush { + flusher.Flush() + } return } data, _ := json.Marshal(map[string]string{"content": chunk}) diff --git a/web/src/api/client.js b/web/src/api/client.js index 594f1a4..f8b43db 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -68,7 +68,9 @@ const api = { if (data.done) { resolve(full); return } if (data.content) { full += data.content - if (onChunk) onChunk(full) + if (onChunk) onChunk(full, data) + } else if (data.thinking !== undefined || data.thinking_end) { + if (onChunk) onChunk(full, data) } } catch {} } From 0496ca789bc36160b67a7f60797750dce73decfb Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 19:41:42 +0200 Subject: [PATCH 07/12] feat(studio): parse AI thinking and tool launch messages in terminal panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add message type detection: thinking (Reflexion/Thought/>), tool (TOOL_CALL), and normal AI responses - Style thinking messages with italic blue, tool messages with yellow border - Add toolLaunched i18n key for both fr and en locales 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- web/src/components/Shell.jsx | 51 +++++++++++++++++++++++++++++++++++- web/src/i18n/en.js | 1 + web/src/i18n/fr.js | 1 + web/src/styles/global.css | 4 +++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 964dcab..8f3a16b 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -380,13 +380,61 @@ export default function Shell({ api }) { setAiLoading(true) try { const res = await api.runCommand(`echo "AI: ${text}"`, '') - setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }]) + const output = res.output || t('shell.noResponse') + parseAndAddAiMessages(output) } catch (err) { setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }]) } setAiLoading(false) } + const parseAndAddAiMessages = (text) => { + const lines = text.split('\n') + let buffer = '' + let inBlock = false + + const flushBuffer = () => { + if (buffer.trim()) { + setAiMessages(prev => [...prev, { role: 'ai', content: buffer.trim() }]) + } + buffer = '' + } + + for (const line of lines) { + const toolMatch = line.match(/^\[TOOL_CALL:\{.*\}\]$/) + if (toolMatch) { + flushBuffer() + try { + const toolData = JSON.parse(toolMatch[0].slice(10, -1)) + setAiMessages(prev => [...prev, { + role: 'tool', + content: `${t('shell.toolLaunched')}: ${toolData.tool || 'tool'}`, + args: toolData.task || toolData.args || '', + }]) + } catch { + setAiMessages(prev => [...prev, { role: 'tool', content: line, args: '' }]) + } + } else if (line.match(/^(Reflexion|Thought|thinking):/i) || line.startsWith('>')) { + if (buffer.trim() && !inBlock) { + flushBuffer() + } + inBlock = true + const cleaned = line.replace(/^(Reflexion|Thought|thinking):\s*/i, '').replace(/^>\s*/, '') + if (buffer) buffer += ' ' + buffer += cleaned + } else { + if (inBlock && buffer.trim()) { + setAiMessages(prev => [...prev, { role: 'thinking', content: buffer.trim() }]) + buffer = '' + } + inBlock = false + if (buffer) buffer += '\n' + buffer += line + } + } + flushBuffer() + } + return (
@@ -507,6 +555,7 @@ export default function Shell({ api }) { {aiMessages.map((msg, i) => (
{msg.content} + {msg.args &&
{msg.args}
}
))} {aiLoading &&
} diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index 40a3ecd..1fa96fe 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -110,6 +110,7 @@ const en = { aiAssistant: 'AI Assistant', aiWelcome: 'Hello! I can help you with terminal commands. Ask me anything!', askAi: 'Ask AI assistant...', + toolLaunched: 'Tool launched', }, config: { diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index cd32c92..a2cf03b 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -110,6 +110,7 @@ const fr = { aiAssistant: 'Assistant IA', aiWelcome: 'Bonjour ! Je peux vous aider avec les commandes du terminal. Demandez-moi n\'importe quoi !', askAi: 'Interroger l\'assistant IA...', + toolLaunched: 'Outil lanc\u00e9', }, config: { diff --git a/web/src/styles/global.css b/web/src/styles/global.css index edc234b..c0aba4d 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -391,6 +391,10 @@ input::placeholder { color: var(--text-disabled); } .ai-message { padding: 8px 12px; border-radius: var(--radius); font-size: 13px; line-height: 1.5; word-break: break-word; } .ai-message.ai { background: var(--bg-card); border-left: 3px solid var(--accent); } .ai-message.user { background: var(--accent-bg); border-left: 3px solid var(--accent-muted); } +.ai-message.thinking { background: var(--bg-elevated); border-left: 3px solid var(--info); font-style: italic; color: var(--text-tertiary); } +.ai-message.tool { background: var(--bg-elevated); border-left: 3px solid var(--warning); } +.ai-message.tool .tool-name { font-weight: 700; color: var(--warning); } +.ai-message.tool .tool-args { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); margin-top: 4px; } .ai-panel-input { display: flex; gap: 6px; padding: 10px 12px; border-top: 1px solid var(--border); } .ai-panel-input input { flex: 1; font-size: 13px; padding: 6px 10px; } From e0e1e73bca09609a138a93f69925f4885c5d6294 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 20:02:55 +0200 Subject: [PATCH 08/12] fix(terminal): improve shell resolution with better error handling and ws proxy support The `len(shell) <= 1` guard was too aggressive and provided no diagnostic info. Now trims whitespace, resolves path in all cases, falls back to /bin/sh, and logs detailed context for debugging. Also enable WebSocket proxying in Vite dev. --- internal/api/terminal.go | 31 ++++++++++++++++++++----------- web/vite.config.js | 1 + 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/internal/api/terminal.go b/internal/api/terminal.go index 90a5a12..31d2e87 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -56,13 +56,17 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { var initMsg wsMessage _, raw, err := conn.ReadMessage() if err != nil { + log.Printf("terminal: read init message failed: %v", err) conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"}) return } + log.Printf("terminal: init message received: %s", string(raw)) if err := json.Unmarshal(raw, &initMsg); err != nil { + log.Printf("terminal: unmarshal init message failed: %v", err) conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"}) return } + log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data) var cmd *exec.Cmd @@ -96,23 +100,26 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { cmd = exec.Command("ssh", sshArgs...) } else { - shell := initMsg.Data + shell := strings.TrimSpace(initMsg.Data) + log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell) if shell == "" { shell = detectShell() - } else { - if path, err := exec.LookPath(shell); err == nil { - shell = path - } + log.Printf("terminal: auto-detected shell=%q", shell) } - // Ignore invalid shell paths (e.g., single characters from race condition) - if len(shell) <= 1 { - conn.WriteJSON(wsMessage{Type: "error", Data: "invalid shell config"}) - return + if shell == "" { + log.Printf("terminal: no shell detected, falling back to /bin/sh") + shell = "/bin/sh" + } + + if path, err := exec.LookPath(shell); err == nil { + shell = path + log.Printf("terminal: resolved shell path=%q", shell) } if _, err := os.Stat(shell); err != nil { - conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s", shell)}) + log.Printf("terminal: shell stat failed: %v for %q", err, shell) + conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)}) return } @@ -131,12 +138,14 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { cmd.Env = append(os.Environ(), "TERM=xterm-256color") + log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args) ptmx, err := pty.Start(cmd) if err != nil { - log.Printf("pty start: %v", err) + log.Printf("terminal: pty start failed: %v", err) conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()}) return } + log.Printf("terminal: pty started successfully") defer func() { ptmx.Close() if cmd.Process != nil { diff --git a/web/vite.config.js b/web/vite.config.js index e3523f9..e471e2b 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -13,6 +13,7 @@ export default defineConfig({ '/api': { target: 'http://127.0.0.1:8095', changeOrigin: true, + ws: true, }, }, }, From 93a22d4075eb506ca3bda08764ec10ca9e7a96a4 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 20:05:10 +0200 Subject: [PATCH 09/12] fix(terminal): init payload never sent due to ws.onopen being overwritten connectWebSocket set ws.onopen to send the shell init payload, but initTerminal immediately overwrote it with a state-only handler. Switched to addEventListener so both handlers coexist. --- web/src/components/Shell.jsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index 8f3a16b..2d0dbbb 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -94,15 +94,15 @@ function connectWebSocket(term, fitAddon, initPayload) { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`) - ws.onopen = () => { + ws.addEventListener('open', () => { ws.send(JSON.stringify(initPayload)) const dims = fitAddon.proposeDimensions() if (dims) { ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols })) } - } + }) - ws.onmessage = (event) => { + ws.addEventListener('message', (event) => { try { const msg = JSON.parse(event.data) if (msg.type === 'output') { @@ -113,15 +113,15 @@ function connectWebSocket(term, fitAddon, initPayload) { } catch { term.write(event.data) } - } + }) - ws.onclose = () => { + ws.addEventListener('close', () => { term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n') - } + }) - ws.onerror = () => { + ws.addEventListener('error', () => { term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n') - } + }) term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { From 3b6cc38ea0da36313d18cf7f5f3315a8826259d3 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 20:13:17 +0200 Subject: [PATCH 10/12] refactor(config): remove Terminal sub-tab from Configuration page --- web/src/components/Config.jsx | 132 ++-------------------------------- 1 file changed, 5 insertions(+), 127 deletions(-) diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index 3da9f16..22140c6 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { User, Brain, RefreshCw, Globe, Wrench, Monitor } from 'lucide-react' +import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react' import { useI18n, LANGUAGES } from '../i18n' import { getLayoutList } from '../i18n/keyboards' @@ -27,9 +27,7 @@ export default function Config({ api }) { const [profileForm, setProfileForm] = useState({}) const [providerForm, setProviderForm] = useState({}) const [toast, setToast] = useState(null) - const [terminalThemes, setTerminalThemes] = useState([]) - const [terminalSettings, setTerminalSettings] = useState({ font_size: 14, font_family: '', theme: 'default' }) - const [savingTerminal, setSavingTerminal] = useState(false) + const layouts = getLayoutList() @@ -43,19 +41,13 @@ export default function Config({ api }) { editor: d.profile?.preferences?.editor || '', shell: d.profile?.preferences?.shell || '', }) - if (d.terminal) { - setTerminalSettings({ - font_size: d.terminal.font_size || 14, - font_family: d.terminal.font_family || '', - theme: d.terminal.theme || 'default', - }) - } + }).catch(() => {}) api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {}) api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {}) api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {}) api.getTools().then(d => setTools(d.tools || [])).catch(() => {}) - api.getTerminalThemes().then(d => setTerminalThemes(d.themes || [])).catch(() => {}) + }, [api]) useEffect(() => { loadData() }, [loadData]) @@ -126,18 +118,6 @@ export default function Config({ api }) { } } - const handleSaveTerminalSettings = async () => { - setSavingTerminal(true) - try { - await api.saveTerminalSettings(terminalSettings) - showToast(t('config.saved')) - window.location.reload() - } catch (err) { - showToast(`${t('config.error')}: ${err.message}`) - } - setSavingTerminal(false) - } - const openProviderEdit = (p) => { setProviderForm({ name: p.name, @@ -213,13 +193,7 @@ export default function Config({ api }) { {activePanel === 'skills' && ( )} - {activePanel === 'terminal' && ( - - )} +
@@ -470,102 +444,6 @@ function PanelSkills({ skillList, t }) { ) } -function PanelTerminal({ settings, setSettings, themes, saving, onSave, t }) { - const previewTheme = { - background: settings.theme === 'default' ? '#0A0A0C' : - settings.theme === 'monokai' ? '#272822' : - settings.theme === 'gruvbox' ? '#282828' : - settings.theme === 'nord' ? '#2E3440' : - settings.theme === 'solarized-dark' ? '#002B36' : - settings.theme === 'dracula' ? '#282A36' : '#0A0A0C', - foreground: settings.theme === 'default' ? '#EAE0E2' : - settings.theme === 'monokai' ? '#F8F8F2' : - settings.theme === 'gruvbox' ? '#EBDBB2' : - settings.theme === 'nord' ? '#D8DEE9' : - settings.theme === 'solarized-dark' ? '#839496' : - settings.theme === 'dracula' ? '#F8F8F2' : '#EAE0E2', - } - - return ( -
-
- {t('config.terminalTheme')} -
- {themes.map(th => ( -
setSettings(s => ({ ...s, theme: th.id }))} - > - {th.name} -
- ))} -
-
- -
- {t('config.fontSize')} -
- {[12, 14, 16, 18, 20, 24].map(size => ( -
setSettings(s => ({ ...s, font_size: size }))} - > - {size}px -
- ))} -
-
- -
- {t('config.fontFamily')} - -
- -
- {t('config.preview')} -
- ➜ ~/projects - git status -
- On branch - main -
- Type a command... - ▋ -
-
- -
- -
-
- ) -} - function FormInput({ label, value, onChange, type = 'text' }) { return (
From 0fe82f67df463f688f0249bf80c05a89b2cc1cb0 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 20:19:47 +0200 Subject: [PATCH 11/12] chore: bump version to 3.2 --- internal/version/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/version/version.go b/internal/version/version.go index 4b12d15..36652dd 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -2,7 +2,7 @@ package version const ( Name = "muyue" - Version = "0.3.1" + Version = "3.2" Author = "La LĂ©gion de Muyue" ) From 83d7a573c7bbd6baaf2e851d2489a304c99b9911 Mon Sep 17 00:00:00 2001 From: Augustin Date: Wed, 22 Apr 2026 20:29:09 +0200 Subject: [PATCH 12/12] fix: correct version from 3.2 to 0.3.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version was incorrectly bumped to 3.2 instead of 0.3.2. This follows the existing semver pattern (v0.2.0, v0.2.1, v0.3.1). đŸ’Ÿ Generated with Crush Assisted-by: GLM-5-Turbo via Crush --- internal/version/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/version/version.go b/internal/version/version.go index 36652dd..265f33b 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -2,7 +2,7 @@ package version const ( Name = "muyue" - Version = "3.2" + Version = "0.3.2" Author = "La LĂ©gion de Muyue" )