import { useState, useCallback, useEffect, useRef } from 'react'; import { Check, Search, Sparkles, Wrench, Zap, Box, Loader2, CheckCircle, XCircle, FileText, Send, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import type { ProviderId } from '@/lib/ai/providers'; import { MONO_LOGO_PROVIDERS } from '@/lib/ai/providers'; import type { ProvidersConfig } from '@/lib/types/settings'; import { createVerifyModelRequest, formatContextWindow } from './utils'; interface ModelSelectorProps { providerId: ProviderId; modelId: string; onModelChange: (providerId: ProviderId, modelId: string) => void; providersConfig: ProvidersConfig; } export function ModelSelector({ providerId, modelId, onModelChange, providersConfig, }: ModelSelectorProps) { const { t } = useI18n(); const [activeProvider, setActiveProvider] = useState(providerId); const [searchQuery, setSearchQuery] = useState(''); const [searchExpanded, setSearchExpanded] = useState(false); const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); const [testMessage, setTestMessage] = useState(''); const [testingModelId, setTestingModelId] = useState(null); const selectedModelRef = useRef(null); const searchInputRef = useRef(null); // Helper function to get translated provider name const getProviderDisplayName = (pid: ProviderId, name: string) => { const translationKey = `settings.providerNames.${pid}`; const translated = t(translationKey); // If translation exists (not equal to key), use it; otherwise fallback to name return translated !== translationKey ? translated : name; }; // Helper function for model count with proper plural form const getModelCountText = (count: number) => { const key = count === 1 ? 'settings.modelSingular' : 'settings.modelCount'; return `${count} ${t(key)}`; }; const getFilteredModelCountText = (filtered: number, total: number) => { const key = total === 1 ? 'settings.modelSingular' : 'settings.modelCount'; return `${filtered}/${total} ${t(key)}`; }; // Get all providers that are ready to use: // - Provider requires API key: must have client key OR server configured // - Provider doesn't require API key (e.g. Ollama): must have explicit baseUrl OR server configured // - Has at least one model // - Has a reachable baseUrl const configuredProviders = Object.entries(providersConfig) .filter( ([, config]) => (config.requiresApiKey ? config.apiKey || config.isServerConfigured : config.isServerConfigured || config.baseUrl) && config.models.length >= 1 && (config.baseUrl || config.defaultBaseUrl || config.serverBaseUrl), ) .map(([id, config]) => ({ id: id as ProviderId, name: config.name, icon: config.icon, isServerConfigured: config.isServerConfigured, })); const handleSelect = (pid: ProviderId, mid: string) => { onModelChange(pid, mid); }; // Filter models across all providers by search query and server model restrictions const getFilteredModelsForProvider = (pid: ProviderId) => { const config = providersConfig[pid]; let models = config?.models || []; // When using server config without own key, restrict to server-allowed models if (config?.isServerConfigured && !config.apiKey && config.serverModels?.length) { const allowed = new Set(config.serverModels); models = models.filter((m) => allowed.has(m.id)); } if (!searchQuery) return models; return models.filter( (model) => model.name.toLowerCase().includes(searchQuery.toLowerCase()) || model.id.toLowerCase().includes(searchQuery.toLowerCase()), ); }; // Sync activeProvider with providerId prop changes useEffect(() => { setActiveProvider(providerId); }, [providerId]); // Fallback: if activeProvider is not in configured providers, use the first configured one const effectiveProvider = configuredProviders.some((p) => p.id === activeProvider) ? activeProvider : (configuredProviders[0]?.id ?? activeProvider); const filteredModels = getFilteredModelsForProvider(effectiveProvider); // Auto scroll to selected model when opening useEffect(() => { if (selectedModelRef.current) { selectedModelRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth', }); } }, [effectiveProvider]); // Auto focus search input when expanded useEffect(() => { if (searchExpanded && searchInputRef.current) { searchInputRef.current.focus(); } }, [searchExpanded]); // Test model function const handleTestModel = useCallback( async (pid: ProviderId, mid: string) => { const providerConfig = providersConfig[pid]; if (!providerConfig) return; const apiKey = providerConfig.apiKey; // Only send user-entered baseUrl; let server resolve fallback const baseUrl = providerConfig.baseUrl; if (providerConfig.requiresApiKey && !apiKey && !providerConfig.isServerConfigured) { setTestStatus('error'); setTestMessage(t('settings.apiKeyRequired')); setTestingModelId(mid); return; } setTestStatus('testing'); setTestMessage(''); setTestingModelId(mid); try { const response = await fetch('/api/verify-model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( createVerifyModelRequest({ providerId: pid, modelId: mid, apiKey, baseUrl, providerType: providerConfig.type, requiresApiKey: providerConfig.requiresApiKey, }), ), }); const data = await response.json(); if (data.success) { setTestStatus('success'); setTestMessage(t('settings.connectionSuccess')); } else { setTestStatus('error'); setTestMessage(data.error || t('settings.connectionFailed')); } } catch { setTestStatus('error'); setTestMessage(t('settings.connectionFailed')); } }, [providersConfig, t], ); if (configuredProviders.length === 0) { return (
{t('settings.configureProvidersFirst')}
); } return (
{/* Left: Provider List */}
{configuredProviders.map((provider) => { const filteredCount = getFilteredModelsForProvider(provider.id).length; const totalCount = providersConfig[provider.id]?.models?.length || 0; const isActive = effectiveProvider === provider.id; return ( ); })}
{/* Right: Model List */}
{/* Floating Search Button - Bottom Right */}
{searchExpanded ? (
setSearchQuery(e.target.value)} onBlur={() => { if (!searchQuery) { setSearchExpanded(false); } }} className="pl-9 h-9 pr-3 shadow-lg border-primary/20 bg-card dark:bg-card" />
) : ( )}
{/* Model Items */}
{filteredModels.length === 0 ? (
{searchQuery ? t('settings.noModelsFound') : t('settings.noModelsAvailable')}
) : ( filteredModels.map((model) => { const isSelected = providerId === effectiveProvider && modelId === model.id; const isTesting = testingModelId === model.id; const showTestResult = isTesting && testMessage; return (
{showTestResult && (
{testStatus === 'success' && ( )} {testStatus === 'error' && ( )}

{testMessage}

)}
); }) )}
); }