/** * Providers Settings Component * Manage AI provider configurations and API keys */ import { useState, useEffect } from 'react'; import { Plus, Trash2, Edit, Eye, EyeOff, Check, X, Loader2, Star, Key, } 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, 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; try { await addProvider( { id, type, name, baseUrl: options?.baseUrl, model: options?.model, enabled: true, }, apiKey.trim() || undefined ); // Auto-set as default if this is the first provider if (providers.length === 0) { 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; } } 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" />
)} )}
setNewKey(e.target.value)} className="pr-10 h-9 text-sm" />
) : (
{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); const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === selectedType); // 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; 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?.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?.name}

setName(e.target.value)} />
{ 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); }} />
)}
)}
); }