diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 768e91e..dfdc4db 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -1,9 +1,13 @@ package api import ( + "bytes" "encoding/json" + "io" "net/http" "os/exec" + "strings" + "time" "github.com/muyue/muyue/internal/config" "github.com/muyue/muyue/internal/lsp" @@ -529,3 +533,95 @@ func (s *Server) handleRunUpdate(w http.ResponseWriter, r *http.Request) { "updated": len(needsUpdate), }) } + +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-Text-01" + } + + 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"}) +} diff --git a/internal/api/server.go b/internal/api/server.go index affd3fd..5f7ffa3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -46,6 +46,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure) s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile) s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider) + s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider) s.mux.HandleFunc("/api/update/run", s.handleRunUpdate) s.mux.HandleFunc("/api/chat", s.handleChat) s.mux.HandleFunc("/api/chat/history", s.handleChatHistory) diff --git a/web/src/api/client.js b/web/src/api/client.js index 55a02d0..03eb34d 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -28,6 +28,7 @@ const api = { savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }), saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }), saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }), + validateProvider: (provider) => request('/providers/validate', { method: 'POST', body: JSON.stringify(provider) }), runUpdate: (tool) => request('/update/run', { method: 'POST', body: JSON.stringify({ tool: tool || '' }) }), runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }), getTerminalSessions: () => request('/terminal/sessions'), diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index d3c1157..1c10848 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -252,48 +252,79 @@ function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEdi } function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) { + const [validating, setValidating] = useState(null) + const [validationStatus, setValidationStatus] = useState(null) + + const handleValidate = async (name, apiKey, model, baseUrl) => { + setValidating(name) + setValidationStatus(null) + try { + await api.validateProvider({ name, api_key: apiKey, model, base_url: baseUrl }) + setValidationStatus({ provider: name, valid: true }) + } catch (err) { + const msg = err.message || '' + if (msg.includes('invalid_api_key')) { + setValidationStatus({ provider: name, valid: false, error: t('config.keyInvalid') }) + } else { + setValidationStatus({ provider: name, valid: false, error: `${t('config.connectionFailed')}: ${msg}` }) + } + } + setValidating(null) + } + return (
- {providers.map((p, i) => ( -
-
-
- {p.name} - {p.active && {t('config.active')}} -
-
- {editProvider !== p.name && ( - - )} - {!p.active && editProvider !== p.name && ( - - )} -
-
- - {editProvider !== p.name ? ( -
- {p.model || '—'} - - {p.apiKey ? t('config.keyConfigured') : t('config.noKey')} - -
- ) : ( -
- setProviderForm(f => ({ ...f, api_key: v }))} type="password" /> - setProviderForm(f => ({ ...f, model: v }))} /> - setProviderForm(f => ({ ...f, base_url: v }))} /> -
- - +
{t('config.setupDescription')}
+ {providers.map((p, i) => { + const isEditing = editProvider === p.name + const isValidationTarget = validationStatus?.provider === p.name + return ( +
+
+
+ {p.name} + {p.apiKey && {t('config.keyConfigured')}} + {!p.apiKey && {t('config.noKey')}} + {isValidationTarget && validationStatus.valid && {t('config.keyValid')}} + {isValidationTarget && !validationStatus.valid && {validationStatus.error}}
- )} -
- ))} + +
+
+
+ + { + if (!isEditing) openProviderEdit(p) + setProviderForm(f => ({ ...f, api_key: e.target.value })) + }} + /> +
+
+ + {isValidationTarget && validationStatus.valid && ( + + )} +
+
+
+ {p.model || '—'} +
+
+
+ ) + })}
) } diff --git a/web/src/i18n/en.js b/web/src/i18n/en.js index 561f68b..c88b1ab 100644 --- a/web/src/i18n/en.js +++ b/web/src/i18n/en.js @@ -166,6 +166,14 @@ const en = { editProfile: 'Edit', cancel: 'Cancel', editProvider: 'Configure', + validateKey: 'Validate', + validating: 'Validating...', + keyValid: 'Valid key', + keyInvalid: 'Invalid key', + connectionFailed: 'Connection failed', + enterToken: 'Enter your API token for {provider}', + tokenPlaceholder: 'sk-...', + setupDescription: 'Configure your AI provider token to use the assistant.', }, } diff --git a/web/src/i18n/fr.js b/web/src/i18n/fr.js index d158726..dfb7052 100644 --- a/web/src/i18n/fr.js +++ b/web/src/i18n/fr.js @@ -165,6 +165,14 @@ const fr = { missing: 'Manquant', editProfile: 'Modifier', editProvider: 'Configurer', + validateKey: 'Valider', + validating: 'Vérification...', + keyValid: 'Clé valide', + keyInvalid: 'Clé invalide', + connectionFailed: 'Connexion échouée', + enterToken: 'Entrez votre token API pour {provider}', + tokenPlaceholder: 'sk-...', + setupDescription: 'Configurez le token de votre fournisseur IA pour utiliser l\'assistant.', cancel: 'Annuler', }, } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 2df45a5..742d2b3 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -475,6 +475,15 @@ input::placeholder { color: var(--text-disabled); } .provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; } .provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } +.provider-setup-hint { + font-size: 13px; color: var(--text-tertiary); margin-bottom: 16px; + padding: 10px 14px; border-radius: var(--radius); background: var(--bg-surface); + border-left: 3px solid var(--accent-dim); +} +.provider-setup-token-row { display: flex; gap: 12px; align-items: flex-end; } +.provider-setup-token-input { flex: 1; } +.provider-setup-token-actions { display: flex; gap: 8px; flex-shrink: 0; padding-bottom: 1px; } + .config-update-controls { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }