package api import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "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 } currentJSON, err := json.Marshal(s.config.Profile) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } var currentMap map[string]interface{} if err := json.Unmarshal(currentJSON, ¤tMap); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } var updates map[string]interface{} body, err := io.ReadAll(r.Body) if err != nil { writeError(w, err.Error(), http.StatusBadRequest) return } if err := json.Unmarshal(body, &updates); err != nil { writeError(w, err.Error(), http.StatusBadRequest) return } deepMerge(currentMap, updates) mergedJSON, err := json.Marshal(currentMap) if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } if err := json.Unmarshal(mergedJSON, &s.config.Profile); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } if err := config.Save(s.config); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]string{"status": "ok"}) } func deepMerge(dst, src map[string]interface{}) { for k, sv := range src { if dv, ok := dst[k]; ok { dstMap, dOk := dv.(map[string]interface{}) srcMap, sOk := sv.(map[string]interface{}) if dOk && sOk { deepMerge(dstMap, srcMap) continue } } dst[k] = sv } } 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 != "" && 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 } if body.APIKey == "***" { for _, p := range s.config.AI.Providers { if p.Name == body.Name { body.APIKey = p.APIKey break } } } 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 "mimo": baseURL = "https://token-plan-ams.xiaomimimo.com/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 && body.FontSize <= 72 { 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}) } func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { writeError(w, "POST only", http.StatusMethodNotAllowed) return } dir, err := config.ConfigDir() if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return } path := filepath.Join(dir, "config.yaml") if err := os.Remove(path); err != nil && !os.IsNotExist(err) { writeError(w, err.Error(), http.StatusInternalServerError) return } s.config = config.Default() 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) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { writeError(w, "POST only", http.StatusMethodNotAllowed) return } var body struct { Theme string `json:"theme"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, err.Error(), http.StatusBadRequest) return } if body.Theme == "" { body.Theme = s.config.Terminal.PromptTheme } themeFile := ApplyStarshipTheme(body.Theme) s.config.Terminal.PromptTheme = body.Theme config.Save(s.config) writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile}) } func ApplyStarshipTheme(theme string) string { cfgDir, _ := config.ConfigDir() starshipDir := filepath.Join(cfgDir, "starship") os.MkdirAll(starshipDir, 0755) themeFile := filepath.Join(starshipDir, "starship.toml") themeContent := getStarshipThemeConfig(theme) os.WriteFile(themeFile, []byte(themeContent), 0644) home, _ := os.UserHomeDir() for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} { if _, err := os.Stat(rc); err != nil { continue } content, _ := os.ReadFile(rc) if strings.Contains(string(content), "STARSHIP_CONFIG") { continue } exportLine := fmt.Sprintf("\n# Muyue Starship config\nexport STARSHIP_CONFIG=%s\n", themeFile) f, err := os.OpenFile(rc, os.O_APPEND|os.O_WRONLY, 0644) if err != nil { continue } f.WriteString(exportLine) f.Close() } return themeFile } func getStarshipThemeConfig(theme string) string { switch theme { case "charm": return `[format] before_format = "$" format = """ $username$directory$git_branch$git_status$cmd_duration$line_break$character""" [character] success_symbol = "[➜](bold #00E676)" error_symbol = "[✗](bold #FF0033)" [directory] truncation_length = 3 truncation_symbol = "…/" style = "bold #00BCD4" [username] show_on_left = false style_user = "bold #FF0033" style_root = "bold #FF0033" [git_branch] symbol = " " format = "on [$symbol$branch]($style)" style = "bold #FFD740" [git_status] format = "[$all_status$ahead_behind]($style) " style = "bold #FF1A5E" conflicted = "!" untracked = "?" modified = "~" staged = "[+]" renamed = "»" deleted = "-" [cmd_duration] min_time = 500 format = "took [$duration]($style)" style = "bold #75715E" ` case "zerotwo": return `[format] before_format = "$" format = """ $username$directory$git_branch$git_status$cmd_duration$line_break$character""" [character] success_symbol = "[❯](bold #3B82F6)" error_symbol = "[❯](bold #EF4444)" [directory] truncation_length = 3 truncation_symbol = "…/" style = "bold #8B5CF6" [username] show_on_left = false style_user = "bold #EC4899" style_root = "bold #EF4444" [git_branch] symbol = " " format = "on [$symbol$branch]($style)" style = "bold #F472B6" [git_status] format = "[$all_status$ahead_behind]($style) " style = "bold #EF4444" conflicted = "!" untracked = "?" modified = "~" staged = "[+]" renamed = "»" deleted = "-" [cmd_duration] min_time = 500 format = "took [$duration]($style)" style = "bold #6B7280" ` default: return `[format] before_format = "$" format = """ $username$directory$git_branch$git_status$line_break$character""" [character] success_symbol = "[❯](bold green)" error_symbol = "[❯](bold red)" [directory] truncation_length = 3 truncation_symbol = "…/" style = "bold cyan" [username] show_on_left = false style_user = "bold red" style_root = "bold red" [git_branch] symbol = " " format = "on [$symbol$branch]($style)" style = "bold yellow" [cmd_duration] min_time = 500 format = "took [$duration]($style)" style = "bold bright-black" ` } }