All checks were successful
PR Check / check (pull_request) Successful in 57s
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
512 lines
12 KiB
Go
512 lines
12 KiB
Go
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"
|
||
`
|
||
}
|
||
}
|