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%; }