/** * ProvidersPanel Component * * Provider management panel: list providers, test connections, manage custom providers/models */ import { useState, useEffect, useCallback } from 'react'; import { X, RefreshCw, Server, Plus, ChevronDown, ChevronRight, Trash2, Check, AlertCircle, Loader2, Cpu, Key, Globe, Zap, Settings, } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { toast } from 'sonner'; import { cn } from '../utils/cn'; import { modalOverlay, modalContent, smoothTransition } from '../utils/animations'; import { Button } from '../primitives/Button'; import { Input } from '../primitives/Input'; import { Skeleton } from './Skeleton'; import { listProviders, getProvider, testProviderConnection, registerProvider, deleteProvider, addProviderModel, deleteProviderModel, type ProviderListItem, type ProviderDetail, type ModelInfo, type CustomProviderDefinition, } from '../api/client.js'; import { ProviderEditor } from './ProviderEditor.js'; interface ProvidersPanelProps { onClose: () => void; /** Enable responsive layout */ responsive?: boolean; } export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelProps) { // Data state const [providers, setProviders] = useState([]); const [expandedProviders, setExpandedProviders] = useState>(new Set()); const [providerDetails, setProviderDetails] = useState>({}); // UI state const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [testingProvider, setTestingProvider] = useState(null); const [testResults, setTestResults] = useState>({}); // Editor state const [showAddProvider, setShowAddProvider] = useState(false); const [showAddModel, setShowAddModel] = useState(null); const [editingProviderId, setEditingProviderId] = useState(null); const [newProvider, setNewProvider] = useState>({}); const [newModel, setNewModel] = useState>({}); // Load provider list const loadProviders = useCallback(async (showToast = false) => { try { const result = await listProviders(); if (result.success) { setProviders(result.data); if (showToast) { toast.success('Providers refreshed'); } } else { toast.error(result.error || 'Failed to load providers'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to load providers'); } }, []); // Initial load useEffect(() => { setLoading(true); loadProviders().finally(() => setLoading(false)); }, [loadProviders]); // Refresh const handleRefresh = async () => { setRefreshing(true); await loadProviders(true); setRefreshing(false); }; // Load provider detail const loadProviderDetail = async (id: string) => { if (providerDetails[id]) return providerDetails[id]; try { const result = await getProvider(id); if (result.success && result.data) { setProviderDetails((prev) => ({ ...prev, [id]: result.data! })); return result.data; } } catch (err) { toast.error(`Failed to load provider details: ${err instanceof Error ? err.message : 'Unknown error'}`); } return null; }; // Toggle expanded const toggleExpanded = async (id: string) => { const newExpanded = new Set(expandedProviders); if (newExpanded.has(id)) { newExpanded.delete(id); } else { newExpanded.add(id); await loadProviderDetail(id); } setExpandedProviders(newExpanded); }; // Test connection const handleTestConnection = async (id: string) => { setTestingProvider(id); try { const result = await testProviderConnection(id); if (result.success) { setTestResults((prev) => ({ ...prev, [id]: result.data })); if (result.data.success) { toast.success(`${id} connection successful`); } else { toast.error(result.data.error || `${id} connection failed`); } } else { setTestResults((prev) => ({ ...prev, [id]: { success: false, error: result.error } })); toast.error(result.error || 'Test failed'); } } catch (err) { const error = err instanceof Error ? err.message : 'Test failed'; setTestResults((prev) => ({ ...prev, [id]: { success: false, error } })); toast.error(error); } finally { setTestingProvider(null); } }; // Add custom provider const handleAddProvider = async () => { if (!newProvider.id || !newProvider.name || !newProvider.baseUrl) { toast.error('ID, Name, and Base URL are required'); return; } try { const result = await registerProvider(newProvider as CustomProviderDefinition); if (result.success) { toast.success(`Provider ${newProvider.id} added`); setShowAddProvider(false); setNewProvider({}); loadProviders(); } else { toast.error(result.error || 'Failed to add provider'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to add provider'); } }; // Delete custom provider const handleDeleteProvider = async (id: string) => { if (!confirm(`Are you sure you want to delete provider "${id}"?`)) return; try { const result = await deleteProvider(id); if (result.success) { toast.success(`Provider ${id} deleted`); loadProviders(); setProviderDetails((prev) => { const newDetails = { ...prev }; delete newDetails[id]; return newDetails; }); } else { toast.error(result.error || 'Delete failed'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Delete failed'); } }; // Add custom model const handleAddModel = async (providerId: string) => { if (!newModel.id || !newModel.name) { toast.error('Model ID and Name are required'); return; } try { const result = await addProviderModel(providerId, newModel as ModelInfo); if (result.success) { toast.success(`Model ${newModel.id} added to ${providerId}`); setShowAddModel(null); setNewModel({}); // Reload provider detail setProviderDetails((prev) => { const newDetails = { ...prev }; delete newDetails[providerId]; return newDetails; }); await loadProviderDetail(providerId); } else { toast.error(result.error || 'Failed to add model'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to add model'); } }; // Delete custom model const handleDeleteModel = async (providerId: string, modelId: string) => { if (!confirm(`Are you sure you want to delete model "${modelId}"?`)) return; try { const result = await deleteProviderModel(providerId, modelId); if (result.success) { toast.success(`Model ${modelId} deleted`); // Reload provider detail setProviderDetails((prev) => { const newDetails = { ...prev }; delete newDetails[providerId]; return newDetails; }); await loadProviderDetail(providerId); } else { toast.error(result.error || 'Delete failed'); } } catch (err) { toast.error(err instanceof Error ? err.message : 'Delete failed'); } }; // Statistics const builtinProviders = providers.filter((p) => p.builtin); const customProviders = providers.filter((p) => !p.builtin); // Loading skeleton const LoadingSkeleton = () => (
{[1, 2, 3].map((i) => (
))}
); // Provider item component const ProviderItem = ({ provider }: { provider: ProviderListItem }) => { const isExpanded = expandedProviders.has(provider.id); const isTesting = testingProvider === provider.id; const testResult = testResults[provider.id]; const detail = providerDetails[provider.id]; return ( {/* Provider Header */}
toggleExpanded(provider.id)} > {/* Expand Icon */} {/* Icon */} {provider.builtin ? ( ) : ( )} {/* Info */}
{provider.name} {provider.builtin ? 'Built-in' : 'Custom'}
{provider.modelCount} models {provider.hasApiKey ? ( Configured ) : ( No API Key )}
{/* Status & Actions */}
e.stopPropagation()}> {/* Test Result */} {testResult && ( testResult.success ? ( ) : ( ) )} {/* Test Button */} {/* Edit Button */} {/* Delete (only for custom) */} {!provider.builtin && ( )}
{/* Expanded Content */} {isExpanded && (
{detail ? ( <> {/* Base URL */} {detail.baseUrl && (
Base URL:{' '} {detail.baseUrl}
)} {/* Models */}
Models ({detail.models.length + detail.config.customModels.length}) {detail.allowCustomModels && ( )}
{/* Model List */}
{/* Built-in models */} {detail.models.map((model) => (
{model.name} ({model.id})
{model.capabilities?.vision && ( Vision )} {model.capabilities?.functionCalling && ( Tools )}
))} {/* Custom models */} {detail.config.customModels.map((model) => (
{model.name} ({model.id}) custom
))}
) : (
)}
)}
); }; return ( e.stopPropagation()} className={cn( 'bg-surface-subtle max-h-[90vh] overflow-hidden flex flex-col', responsive ? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg' : 'rounded-lg w-full max-w-2xl mx-4' )} > {/* Header */}
{responsive && (
)}

Model Providers

{providers.length} providers ({builtinProviders.length} built-in, {customProviders.length} custom)

{/* Provider List */}
{loading ? ( ) : providers.length === 0 ? (

No providers available

) : ( {/* Built-in Providers */} {builtinProviders.length > 0 && (

Built-in Providers

{builtinProviders.map((provider) => ( ))}
)} {/* Custom Providers */}

Custom Providers

{customProviders.length > 0 ? (
{customProviders.map((provider) => ( ))}
) : (

No custom providers yet

)}
)}
{/* Footer */}
Config stored in{' '} ~/.ai-terminal-assistant/providers.json
{/* Add Provider Dialog */} {showAddProvider && ( setShowAddProvider(false)} > e.stopPropagation()} className="bg-surface-subtle rounded-lg p-6 w-full max-w-md mx-4 space-y-4" >

Add Custom Provider

setNewProvider((p) => ({ ...p, id: e.target.value }))} placeholder="provider-id" />
setNewProvider((p) => ({ ...p, name: e.target.value }))} placeholder="My Provider" />
setNewProvider((p) => ({ ...p, baseUrl: e.target.value }))} placeholder="http://localhost:11434/v1" />
)}
{/* Add Model Dialog */} {showAddModel && ( setShowAddModel(null)} > e.stopPropagation()} className="bg-surface-subtle rounded-lg p-6 w-full max-w-md mx-4 space-y-4" >

Add Custom Model

setNewModel((m) => ({ ...m, id: e.target.value }))} placeholder="llama3.2" />
setNewModel((m) => ({ ...m, name: e.target.value }))} placeholder="Llama 3.2" />
)}
{/* Provider Editor */} {editingProviderId && ( setEditingProviderId(null)} onSave={() => { setEditingProviderId(null); // Clear cached details and reload setProviderDetails((prev) => { const newDetails = { ...prev }; delete newDetails[editingProviderId]; return newDetails; }); loadProviders(); }} responsive={responsive} /> )} ); }