Files
MuyueWorkspace/internal/api/handlers_config.go
Augustin 8005e978f0
All checks were successful
Beta Release / beta (push) Successful in 44s
feat(config): dynamic profile panel, generic save, tabs margin fix
- Config tabs now have bottom padding for visual spacing
- Profile panel dynamically renders all config fields (strings, bools,
  arrays, nested objects) — new struct fields appear automatically
- handleSaveProfile uses generic JSON merge via deepMerge, so any
  new Profile field works without handler changes
- RenderFields recursively renders config sections with edit/view modes

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:14:47 +02:00

496 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{}
json.Unmarshal(currentJSON, &currentMap)
var updates map[string]interface{}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &updates); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
deepMerge(currentMap, updates)
mergedJSON, _ := json.Marshal(currentMap)
json.Unmarshal(mergedJSON, &s.config.Profile)
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 != "" {
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})
}
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
}
cfgDir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
starshipDir := filepath.Join(cfgDir, "starship")
if err := os.MkdirAll(starshipDir, 0755); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
themeFile := filepath.Join(starshipDir, "starship.toml")
themeContent := getStarshipThemeConfig(body.Theme)
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
home, _ := os.UserHomeDir()
shellRCs := []string{
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".zshrc"),
}
for _, rc := range shellRCs {
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()
}
s.config.Terminal.PromptTheme = body.Theme
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": 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"
`
}
}