Files
MuyueWorkspace/internal/api/handlers_config.go
Muyue 6a7b4d8001
All checks were successful
PR Check / check (pull_request) Successful in 57s
release: v0.6.0 — security audit fixes + 7 new features
Audit corrections (security, concurrency, stability):
- chat_engine: bound resp.Choices[0] access, release tool slot per-iteration
- conversation_multi: synchronous save under existing lock (was racy fire-and-forget)
- workflow/engine: short-circuit on failed deps (no more infinite busy-wait); track failed/skipped status
- handlers_workflow: rune-aware truncate for plan goal (UTF-8 safe)
- server: CORS limited to localhost origins (was wildcard)
- handlers_info / terminal: mask API keys and SSH passwords as "***" in GET responses; preserve stored secret if "***" sent on update
- terminal: sshpass uses -e + SSHPASS env var (was both -p and -e)
- handlers_chat: MaxBytesReader 50 MB on /api/chat
- image_cache: 10 MB cap per image
- handlers_config: font size <= 72; profile-save unmarshal errors propagated
- handlers_info: /lsp/auto-install ProjectDir restricted to user home
- Shell.jsx: parenthesized resize-condition (operator precedence)
- orchestrator_test: CleanAIResponse capitalization (fixes failing vet)

New features:
- platform: detect OS name (Debian, Ubuntu, Windows 11, macOS X.Y) and inject in Studio system prompt next to the date
- agents: default timeout 30 min for crush_run/claude_run (cap also 30 min)
- agents: new cwd, wsl_distro, wsl_user params; on Windows hosts launch via "wsl -d <distro> -u <user> --cd <cwd> --"
- agents: new claude_run tool (mirror of crush_run for Claude Code CLI)
- terminal: list installed WSL distros individually in new-tab menu (Windows only)
- studio: system prompt rewritten around BMAD-METHOD personas + mandatory delegation template
- studio: "Réflexion avancée" toggle — inactive provider produces a preliminary report injected as [RAPPORT PRÉALABLE] context for the active provider
- studio: "Historique compressé" toggle — collapses past tool calls to last action only, with "Tout afficher" expansion
2026-04-27 10:12:11 +02:00

512 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{}
if err := json.Unmarshal(currentJSON, &currentMap); 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"
`
}
}