- Add PanelSystem with reset config and apply starship theme (charm/zerotwo/default) - Add OnboardingWizard that activates when profile is empty on first run - Fix <thing> tag parsing in Shell AI messages (wait for </thing> before rendering) - Add /api/config/reset and /api/starship/apply-theme endpoints - Wire wizard trigger in App.jsx based on profile completeness 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
487 lines
12 KiB
Go
487 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
|
||
}
|
||
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})
|
||
}
|
||
|
||
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"
|
||
`
|
||
}
|
||
}
|