/** * Providers Settings Component * Manage AI provider configurations and API keys */ import React, { useState, useEffect } from 'react'; import { Plus, Trash2, Edit, Eye, EyeOff, Check, X, Loader2, Star, Key, ExternalLink, Copy, XCircle, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { useProviderStore, type ProviderConfig, type ProviderWithKeyInfo } from '@/stores/providers'; import { PROVIDER_TYPE_INFO, type ProviderType, getProviderIconUrl, resolveProviderApiKeyForSave, shouldInvertInDark, } from '@/lib/providers'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; export function ProvidersSettings() { const { t } = useTranslation('settings'); const { providers, defaultProviderId, loading, fetchProviders, addProvider, deleteProvider, updateProviderWithKey, setDefaultProvider, validateApiKey, } = useProviderStore(); const [showAddDialog, setShowAddDialog] = useState(false); const [editingProvider, setEditingProvider] = useState(null); // Fetch providers on mount useEffect(() => { fetchProviders(); }, [fetchProviders]); const handleAddProvider = async ( type: ProviderType, name: string, apiKey: string, options?: { baseUrl?: string; model?: string } ) => { // Only custom supports multiple instances. // Built-in providers remain singleton by type. const id = type === 'custom' ? `custom-${crypto.randomUUID()}` : type; const effectiveApiKey = resolveProviderApiKeyForSave(type, apiKey); try { await addProvider( { id, type, name, baseUrl: options?.baseUrl, model: options?.model, enabled: true, }, effectiveApiKey ); // Auto-set as default if no default is currently configured if (!defaultProviderId) { await setDefaultProvider(id); } setShowAddDialog(false); toast.success(t('aiProviders.toast.added')); } catch (error) { toast.error(`${t('aiProviders.toast.failedAdd')}: ${error}`); } }; const handleDeleteProvider = async (providerId: string) => { try { await deleteProvider(providerId); toast.success(t('aiProviders.toast.deleted')); } catch (error) { toast.error(`${t('aiProviders.toast.failedDelete')}: ${error}`); } }; const handleSetDefault = async (providerId: string) => { try { await setDefaultProvider(providerId); toast.success(t('aiProviders.toast.defaultUpdated')); } catch (error) { toast.error(`${t('aiProviders.toast.failedDefault')}: ${error}`); } }; return (
{loading ? (
) : providers.length === 0 ? (

{t('aiProviders.empty.title')}

{t('aiProviders.empty.desc')}

) : (
{providers.map((provider) => ( setEditingProvider(provider.id)} onCancelEdit={() => setEditingProvider(null)} onDelete={() => handleDeleteProvider(provider.id)} onSetDefault={() => handleSetDefault(provider.id)} onSaveEdits={async (payload) => { await updateProviderWithKey( provider.id, payload.updates || {}, payload.newApiKey ); setEditingProvider(null); }} onValidateKey={(key, options) => validateApiKey(provider.id, key, options)} /> ))}
)} {/* Add Provider Dialog */} {showAddDialog && ( p.type))} onClose={() => setShowAddDialog(false)} onAdd={handleAddProvider} onValidateKey={(type, key, options) => validateApiKey(type, key, options)} /> )}
); } interface ProviderCardProps { provider: ProviderWithKeyInfo; isDefault: boolean; isEditing: boolean; onEdit: () => void; onCancelEdit: () => void; onDelete: () => void; onSetDefault: () => void; onSaveEdits: (payload: { newApiKey?: string; updates?: Partial }) => Promise; onValidateKey: ( key: string, options?: { baseUrl?: string } ) => Promise<{ valid: boolean; error?: string }>; } function ProviderCard({ provider, isDefault, isEditing, onEdit, onCancelEdit, onDelete, onSetDefault, onSaveEdits, onValidateKey, }: ProviderCardProps) { const { t } = useTranslation('settings'); const [newKey, setNewKey] = useState(''); const [baseUrl, setBaseUrl] = useState(provider.baseUrl || ''); const [modelId, setModelId] = useState(provider.model || ''); const [showKey, setShowKey] = useState(false); const [validating, setValidating] = useState(false); const [saving, setSaving] = useState(false); const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === provider.type); const canEditConfig = Boolean(typeInfo?.showBaseUrl || typeInfo?.showModelId); useEffect(() => { if (isEditing) { setNewKey(''); setShowKey(false); setBaseUrl(provider.baseUrl || ''); setModelId(provider.model || ''); } }, [isEditing, provider.baseUrl, provider.model]); const handleSaveEdits = async () => { setSaving(true); try { const payload: { newApiKey?: string; updates?: Partial } = {}; if (newKey.trim()) { setValidating(true); const result = await onValidateKey(newKey, { baseUrl: baseUrl.trim() || undefined, }); setValidating(false); if (!result.valid) { toast.error(result.error || t('aiProviders.toast.invalidKey')); setSaving(false); return; } payload.newApiKey = newKey.trim(); } if (canEditConfig) { if (typeInfo?.showModelId && !modelId.trim()) { toast.error(t('aiProviders.toast.modelRequired')); setSaving(false); return; } const updates: Partial = {}; if ((baseUrl.trim() || undefined) !== (provider.baseUrl || undefined)) { updates.baseUrl = baseUrl.trim() || undefined; } if ((modelId.trim() || undefined) !== (provider.model || undefined)) { updates.model = modelId.trim() || undefined; } if (Object.keys(updates).length > 0) { payload.updates = updates; } } // Keep Ollama key optional in UI, but persist a placeholder when // editing legacy configs that have no stored key. if (provider.type === 'ollama' && !provider.hasKey && !payload.newApiKey) { payload.newApiKey = resolveProviderApiKeyForSave(provider.type, '') as string; } if (!payload.newApiKey && !payload.updates) { onCancelEdit(); setSaving(false); return; } await onSaveEdits(payload); setNewKey(''); toast.success(t('aiProviders.toast.updated')); } catch (error) { toast.error(`${t('aiProviders.toast.failedUpdate')}: ${error}`); } finally { setSaving(false); setValidating(false); } }; return ( {/* Top row: icon + name */}
{getProviderIconUrl(provider.type) ? ( {typeInfo?.name ) : ( {typeInfo?.icon || '⚙️'} )}
{provider.name}
{provider.type}
{/* Key row */} {isEditing ? (
{canEditConfig && ( <> {typeInfo?.showBaseUrl && (
setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className="h-9 text-sm" />
)} {typeInfo?.showModelId && (
setModelId(e.target.value)} placeholder={typeInfo.modelIdPlaceholder || 'provider/model-id'} className="h-9 text-sm" />
)} )} {typeInfo?.apiKeyUrl && ( )}
setNewKey(e.target.value)} className="pr-10 h-9 text-sm" />
) : (
{typeInfo?.isOAuth ? ( <> {t('aiProviders.card.configured')} ) : ( <> {provider.hasKey ? (provider.keyMasked && provider.keyMasked.length > 12 ? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}` : provider.keyMasked) : t('aiProviders.card.noKey')} {provider.hasKey && ( {t('aiProviders.card.configured')} )} )}
)}
); } interface AddProviderDialogProps { existingTypes: Set; onClose: () => void; onAdd: ( type: ProviderType, name: string, apiKey: string, options?: { baseUrl?: string; model?: string } ) => Promise; onValidateKey: ( type: string, apiKey: string, options?: { baseUrl?: string } ) => Promise<{ valid: boolean; error?: string }>; } function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: AddProviderDialogProps) { const { t } = useTranslation('settings'); const [selectedType, setSelectedType] = useState(null); const [name, setName] = useState(''); const [apiKey, setApiKey] = useState(''); const [baseUrl, setBaseUrl] = useState(''); const [modelId, setModelId] = useState(''); const [showKey, setShowKey] = useState(false); const [saving, setSaving] = useState(false); const [validationError, setValidationError] = useState(null); // OAuth Flow State const [oauthFlowing, setOauthFlowing] = useState(false); const [oauthData, setOauthData] = useState<{ verificationUri: string; userCode: string; expiresIn: number; } | null>(null); const [oauthError, setOauthError] = useState(null); // For providers that support both OAuth and API key, let the user choose const [authMode, setAuthMode] = useState<'oauth' | 'apikey'>('oauth'); const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === selectedType); const isOAuth = typeInfo?.isOAuth ?? false; const supportsApiKey = typeInfo?.supportsApiKey ?? false; // Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth'); // Keep a ref to the latest values so the effect closure can access them const latestRef = React.useRef({ selectedType, typeInfo, onAdd, onClose, t }); useEffect(() => { latestRef.current = { selectedType, typeInfo, onAdd, onClose, t }; }); // Manage OAuth events useEffect(() => { const handleCode = (data: unknown) => { setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number }); setOauthError(null); }; const handleSuccess = async () => { setOauthFlowing(false); setOauthData(null); setValidationError(null); const { onClose: close, t: translate } = latestRef.current; // device-oauth.ts already saved the provider config to the backend, // including the dynamically resolved baseUrl for the region (e.g. CN vs Global). // If we call add() here with undefined baseUrl, it will overwrite and erase it! // So we just fetch the latest list from the backend to update the UI. try { const store = useProviderStore.getState(); await store.fetchProviders(); // Auto-set as default if no default is currently configured if (!store.defaultProviderId && latestRef.current.selectedType) { // Provider type is expected to match provider ID for built-in OAuth providers await store.setDefaultProvider(latestRef.current.selectedType); } } catch (err) { console.error('Failed to refresh providers after OAuth:', err); } close(); toast.success(translate('aiProviders.toast.added')); }; const handleError = (data: unknown) => { setOauthError((data as { message: string }).message); setOauthData(null); }; window.electron.ipcRenderer.on('oauth:code', handleCode); window.electron.ipcRenderer.on('oauth:success', handleSuccess); window.electron.ipcRenderer.on('oauth:error', handleError); return () => { if (typeof window.electron.ipcRenderer.off === 'function') { window.electron.ipcRenderer.off('oauth:code', handleCode); window.electron.ipcRenderer.off('oauth:success', handleSuccess); window.electron.ipcRenderer.off('oauth:error', handleError); } }; }, []); const handleStartOAuth = async () => { if (!selectedType) return; if (selectedType === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { toast.error(t('aiProviders.toast.minimaxConflict')); return; } if (selectedType === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) { toast.error(t('aiProviders.toast.minimaxConflict')); return; } setOauthFlowing(true); setOauthData(null); setOauthError(null); try { await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedType); } catch (e) { setOauthError(String(e)); setOauthFlowing(false); } }; const handleCancelOAuth = async () => { setOauthFlowing(false); setOauthData(null); setOauthError(null); await window.electron.ipcRenderer.invoke('provider:cancelOAuth'); }; // Only custom can be added multiple times. const availableTypes = PROVIDER_TYPE_INFO.filter( (t) => t.id === 'custom' || !existingTypes.has(t.id), ); const handleAdd = async () => { if (!selectedType) return; if (selectedType === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { toast.error(t('aiProviders.toast.minimaxConflict')); return; } if (selectedType === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) { toast.error(t('aiProviders.toast.minimaxConflict')); return; } setSaving(true); setValidationError(null); try { // Validate key first if the provider requires one and a key was entered const requiresKey = typeInfo?.requiresApiKey ?? false; if (requiresKey && !apiKey.trim()) { setValidationError(t('aiProviders.toast.invalidKey')); // reusing invalid key msg or should add 'required' msg? null checks setSaving(false); return; } if (requiresKey && apiKey) { const result = await onValidateKey(selectedType, apiKey, { baseUrl: baseUrl.trim() || undefined, }); if (!result.valid) { setValidationError(result.error || t('aiProviders.toast.invalidKey')); setSaving(false); return; } } const requiresModel = typeInfo?.showModelId ?? false; if (requiresModel && !modelId.trim()) { setValidationError(t('aiProviders.toast.modelRequired')); setSaving(false); return; } await onAdd( selectedType, name || (typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name) || selectedType, apiKey.trim(), { baseUrl: baseUrl.trim() || undefined, model: (typeInfo?.defaultModelId || modelId.trim()) || undefined, } ); } catch { // error already handled via toast in parent } finally { setSaving(false); } }; return (
{t('aiProviders.dialog.title')} {t('aiProviders.dialog.desc')} {!selectedType ? (
{availableTypes.map((type) => ( ))}
) : (
{getProviderIconUrl(selectedType!) ? ( {typeInfo?.name} ) : ( {typeInfo?.icon} )}

{typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name}

setName(e.target.value)} />
{/* Auth mode toggle for providers supporting both */} {isOAuth && supportsApiKey && (
)} {/* API Key input — shown for non-OAuth providers or when apikey mode is selected */} {(!isOAuth || (supportsApiKey && authMode === 'apikey')) && (
{typeInfo?.apiKeyUrl && ( {t('aiProviders.oauth.getApiKey')} )}
{ setApiKey(e.target.value); setValidationError(null); }} className="pr-10" />
{validationError && (

{validationError}

)}

{t('aiProviders.dialog.apiKeyStored')}

)} {typeInfo?.showBaseUrl && (
setBaseUrl(e.target.value)} />
)} {typeInfo?.showModelId && (
{ setModelId(e.target.value); setValidationError(null); }} />
)} {/* Device OAuth Trigger — only shown when in OAuth mode */} {useOAuthFlow && (

{t('aiProviders.oauth.loginPrompt')}

{/* OAuth Active State Modal / Inline View */} {oauthFlowing && (
{/* Background pulse effect */}
{oauthError ? (

{t('aiProviders.oauth.authFailed')}

{oauthError}

) : !oauthData ? (

{t('aiProviders.oauth.requestingCode')}

) : (

{t('aiProviders.oauth.approveLogin')}

1. {t('aiProviders.oauth.step1')}

2. {t('aiProviders.oauth.step2')}

3. {t('aiProviders.oauth.step3')}

{oauthData.userCode}
{t('aiProviders.oauth.waitingApproval')}
)}
)}
)}
)}
); }