/** * Model Picker Modal * * Allows users to browse and switch between LLM models across providers. * * TODO: Implement "Run via" toggle for featured models * - Show a single model card with toggle buttons: "Dexto / Direct / OpenRouter" * - Toggle changes both provider AND model ID (e.g., dexto uses OpenRouter IDs, * direct uses native IDs like claude-sonnet-4-5 vs anthropic/claude-sonnet-4.5) * - Disable toggles when credentials are missing (e.g., no ANTHROPIC_API_KEY) * - Requires a curated mapping table for featured models (provider/model pairs per backend) * - See feature-plans/holistic-dexto-auth-analysis/13-model-id-namespaces-and-mapping.md * - See feature-plans/holistic-dexto-auth-analysis/14-webui-effective-credentials-and-routing-awareness.md */ import { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { useLLMCatalog, useSwitchLLM, useCustomModels, useCreateCustomModel, useDeleteCustomModel, useProviderApiKey, useSaveApiKey, type SwitchLLMPayload, type CustomModel, } from '../hooks/useLLM'; import { useLocalModels, useDeleteInstalledModel, type LocalModel } from '../hooks/useModels'; import { useDextoAuth } from '../hooks/useDextoAuth'; import { CustomModelForm, type CustomModelFormData, type CustomModelProvider, } from './CustomModelForms'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { Button } from '../ui/button'; import { Alert, AlertDescription } from '../ui/alert'; import { ApiKeyModal } from '../ApiKeyModal'; import { useSessionStore } from '@/lib/stores/sessionStore'; import { useCurrentLLM } from '../hooks/useCurrentLLM'; import { Bot, ChevronDown, ChevronLeft, ChevronUp, Loader2, Star, Plus, Filter, } from 'lucide-react'; import { SearchBar } from './SearchBar'; import { ModelCard } from './ModelCard'; import { FAVORITES_STORAGE_KEY, CUSTOM_MODELS_STORAGE_KEY, DEFAULT_FAVORITES, ProviderCatalog, ModelInfo, favKey, validateBaseURL, } from './types'; import { cn } from '../../lib/utils'; import type { LLMProvider } from '@dexto/core'; import { LLM_PROVIDERS } from '@dexto/core'; import { PROVIDER_LOGOS, needsDarkModeInversion, hasLogo } from './constants'; import { useAnalytics } from '@/lib/analytics/index.js'; export default function ModelPickerModal() { const [open, setOpen] = useState(false); const [providers, setProviders] = useState>>({}); const [search, setSearch] = useState(''); const [baseURL, setBaseURL] = useState(''); const [error, setError] = useState(null); // Provider filter - empty array means 'all', can include 'custom' or any LLMProvider const [providerFilter, setProviderFilter] = useState>([]); const [activeView, setActiveView] = useState<'favorites' | 'all'>('all'); const [showCustomForm, setShowCustomForm] = useState(false); // Custom models form state (data comes from API via useCustomModels) const [customModelForm, setCustomModelForm] = useState({ provider: 'openai-compatible', name: '', baseURL: '', displayName: '', maxInputTokens: '', maxOutputTokens: '', apiKey: '', filePath: '', }); // Track original name when editing (to handle renames) const [editingModelName, setEditingModelName] = useState(null); // API key modal const [keyModalOpen, setKeyModalOpen] = useState(false); const [pendingKeyProvider, setPendingKeyProvider] = useState(null); const [pendingSelection, setPendingSelection] = useState<{ provider: LLMProvider; model: ModelInfo; } | null>(null); const currentSessionId = useSessionStore((s) => s.currentSessionId); const { data: currentLLM, refetch: refreshCurrentLLM } = useCurrentLLM(currentSessionId); // Analytics tracking const analytics = useAnalytics(); const analyticsRef = useRef(analytics); useEffect(() => { analyticsRef.current = analytics; }, [analytics]); // Load catalog when opening const { data: catalogData, isLoading: loading, error: catalogError, } = useLLMCatalog({ enabled: open }); // Load dexto auth status (for checking if user can use dexto provider) const { data: dextoAuthStatus } = useDextoAuth(open); // Load custom models from API (always enabled so trigger shows correct icon) const { data: customModels = [] } = useCustomModels(); // Load installed local GGUF models from state.json (downloaded via CLI/Interactive CLI) const { data: localModelsData } = useLocalModels({ enabled: open }); const installedLocalModels = useMemo( () => localModelsData?.models ?? [], [localModelsData?.models] ); const { mutateAsync: createCustomModelAsync } = useCreateCustomModel(); const { mutate: deleteCustomModelMutation } = useDeleteCustomModel(); const { mutate: deleteInstalledModelMutation } = useDeleteInstalledModel(); const { mutateAsync: saveApiKey } = useSaveApiKey(); // Fetch provider API key status for the current form provider (for smart storage logic) const { data: providerKeyData } = useProviderApiKey(customModelForm.provider as LLMProvider, { enabled: open && showCustomForm, }); useEffect(() => { if (catalogData && 'providers' in catalogData) { setProviders(catalogData.providers); } }, [catalogData]); // When opening, initialize from current session LLM useEffect(() => { if (!open) return; if (currentLLM) { setBaseURL(currentLLM.baseURL || ''); } }, [open, currentLLM]); const [favorites, setFavorites] = useState([]); // Load favorites from localStorage (custom models come from API) useEffect(() => { if (open) { try { const favRaw = localStorage.getItem(FAVORITES_STORAGE_KEY); // Use default favorites for new users (when localStorage key doesn't exist) const loadedFavorites = favRaw !== null ? (JSON.parse(favRaw) as string[]) : DEFAULT_FAVORITES; setFavorites(loadedFavorites); } catch (err) { console.warn('Failed to load favorites from localStorage:', err); setFavorites([]); } } }, [open]); // Migrate localStorage custom models to API (one-time migration) const [migrationDone, setMigrationDone] = useState(false); useEffect(() => { if (!open || migrationDone) return; const migrateModels = async () => { try { const localStorageRaw = localStorage.getItem(CUSTOM_MODELS_STORAGE_KEY); if (!localStorageRaw) { setMigrationDone(true); return; } const localModels = JSON.parse(localStorageRaw) as Array<{ name: string; baseURL: string; maxInputTokens?: number; maxOutputTokens?: number; }>; if (localModels.length === 0) { localStorage.removeItem(CUSTOM_MODELS_STORAGE_KEY); setMigrationDone(true); return; } // Check which models don't exist in API yet const existingNames = new Set(customModels.map((m) => m.name)); const toMigrate = localModels.filter((m) => !existingNames.has(m.name)); if (toMigrate.length === 0) { // All models already migrated, clean up localStorage localStorage.removeItem(CUSTOM_MODELS_STORAGE_KEY); setMigrationDone(true); return; } // Migrate each model - await all to complete before clearing localStorage const migrationPromises = toMigrate.map((model) => createCustomModelAsync({ name: model.name, baseURL: model.baseURL, maxInputTokens: model.maxInputTokens, maxOutputTokens: model.maxOutputTokens, }) ); // Wait for all migrations to succeed before clearing localStorage await Promise.all(migrationPromises); // Only clear localStorage after successful migration localStorage.removeItem(CUSTOM_MODELS_STORAGE_KEY); console.info(`Migrated ${toMigrate.length} custom models from localStorage to API`); setMigrationDone(true); } catch (err) { // Don't clear localStorage on failure - keep models for retry console.warn('Failed to migrate custom models from localStorage:', err); setMigrationDone(true); } }; migrateModels(); }, [open, migrationDone, customModels, createCustomModelAsync]); const toggleFavorite = useCallback((providerId: LLMProvider, modelName: string) => { const key = favKey(providerId, modelName); setFavorites((prev) => { const newFavs = prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]; localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(newFavs)); return newFavs; }); }, []); const [isAddingModel, setIsAddingModel] = useState(false); const switchLLMMutation = useSwitchLLM(); const addCustomModel = useCallback(async () => { const { provider, name, baseURL, maxInputTokens, maxOutputTokens, displayName, apiKey } = customModelForm; if (!name.trim()) { setError('Model name is required'); return; } setIsAddingModel(true); try { // Determine API key storage strategy // TODO: Deduplicate - canonical version is determineApiKeyStorage() in @dexto/agent-management // Can't import directly as WebUI runs in browser. Move to @dexto/core if this changes often. const SHARED_API_KEY_PROVIDERS = ['glama', 'openrouter', 'litellm']; const userEnteredKey = apiKey?.trim(); const providerHasKey = providerKeyData?.hasKey ?? false; const hasSharedEnvVarKey = SHARED_API_KEY_PROVIDERS.includes(provider); let saveToProviderEnvVar = false; let saveAsPerModel = false; // Only process if user actually entered a new key if (userEnteredKey) { if (hasSharedEnvVarKey) { if (!providerHasKey) { // No existing key - save to provider env var saveToProviderEnvVar = true; } else { // Provider already has a key - save as per-model override saveAsPerModel = true; } } else { // Non-shared providers always save per-model saveAsPerModel = true; } } // If user didn't enter a key, we don't modify anything - existing key (if any) is used if (saveToProviderEnvVar && userEnteredKey) { await saveApiKey({ provider: provider as LLMProvider, apiKey: userEnteredKey }); } // If editing and name changed, delete the old model first if (editingModelName && editingModelName !== name.trim()) { try { await new Promise((resolve, reject) => { deleteCustomModelMutation(editingModelName, { onSuccess: () => resolve(), onError: (err: Error) => reject(err), }); }); } catch (err) { // Log but continue - old model might already be deleted console.warn(`Failed to delete old model "${editingModelName}":`, err); } } // Create/update the custom model await createCustomModelAsync({ provider, name: name.trim(), ...(provider === 'openai-compatible' && baseURL.trim() && { baseURL: baseURL.trim() }), ...(provider === 'litellm' && baseURL.trim() && { baseURL: baseURL.trim() }), ...(displayName?.trim() && { displayName: displayName.trim() }), ...(maxInputTokens && { maxInputTokens: parseInt(maxInputTokens, 10) }), ...(maxOutputTokens && { maxOutputTokens: parseInt(maxOutputTokens, 10) }), ...(saveAsPerModel && userEnteredKey && { apiKey: userEnteredKey }), }); // Only switch to the model for new models, not edits // (user is already using edited model or chose not to switch) if (!editingModelName) { const baseSwitchPayload: SwitchLLMPayload = { provider: provider as LLMProvider, model: name.trim(), ...(provider === 'openai-compatible' && baseURL.trim() && { baseURL: baseURL.trim() }), ...(provider === 'litellm' && baseURL.trim() && { baseURL: baseURL.trim() }), ...(saveAsPerModel && userEnteredKey && { apiKey: userEnteredKey }), }; // Always update global default first (no sessionId) await switchLLMMutation.mutateAsync(baseSwitchPayload); // Then switch current session if active if (currentSessionId) { try { await switchLLMMutation.mutateAsync({ ...baseSwitchPayload, sessionId: currentSessionId, }); } catch (sessionErr) { setError( sessionErr instanceof Error ? `Model added and set as global default, but failed to switch current session: ${sessionErr.message}` : 'Model added and set as global default, but failed to switch current session' ); await refreshCurrentLLM(); setIsAddingModel(false); return; } } await refreshCurrentLLM(); // Track the switch if (currentLLM) { analyticsRef.current.trackLLMSwitched({ fromProvider: currentLLM.provider, fromModel: currentLLM.model, toProvider: provider, toModel: name.trim(), sessionId: currentSessionId || undefined, trigger: 'user_action', }); } } // Reset form and close setCustomModelForm({ provider: 'openai-compatible', name: '', baseURL: '', displayName: '', maxInputTokens: '', maxOutputTokens: '', apiKey: '', filePath: '', }); setEditingModelName(null); setShowCustomForm(false); setError(null); setOpen(false); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to add model'); } finally { setIsAddingModel(false); } }, [ customModelForm, createCustomModelAsync, switchLLMMutation, currentSessionId, currentLLM, refreshCurrentLLM, providerKeyData, saveApiKey, editingModelName, deleteCustomModelMutation, ]); const deleteCustomModel = useCallback( (name: string) => { deleteCustomModelMutation(name, { onError: (err: Error) => { setError(err.message); }, }); }, [deleteCustomModelMutation] ); const deleteInstalledModel = useCallback( (modelId: string) => { // Delete installed model and its GGUF file from disk deleteInstalledModelMutation( { modelId, deleteFile: true }, { onError: (err: Error) => { setError(err.message); }, } ); }, [deleteInstalledModelMutation] ); const editCustomModel = useCallback((model: CustomModel) => { // Map provider to form-supported provider (vertex uses openai-compatible form) const formSupportedProviders: CustomModelProvider[] = [ 'openai-compatible', 'openrouter', 'litellm', 'glama', 'bedrock', 'ollama', 'local', ]; const provider = model.provider ?? 'openai-compatible'; const formProvider: CustomModelProvider = formSupportedProviders.includes( provider as CustomModelProvider ) ? (provider as CustomModelProvider) : 'openai-compatible'; setCustomModelForm({ provider: formProvider, name: model.name, baseURL: model.baseURL ?? '', displayName: model.displayName ?? '', maxInputTokens: model.maxInputTokens?.toString() ?? '', maxOutputTokens: model.maxOutputTokens?.toString() ?? '', apiKey: model.apiKey ?? '', filePath: model.filePath ?? '', }); setEditingModelName(model.name); setShowCustomForm(true); setError(null); }, []); const modelMatchesSearch = useCallback( (providerId: LLMProvider, model: ModelInfo): boolean => { const q = search.trim().toLowerCase(); if (!q) return true; return ( model.name.toLowerCase().includes(q) || (model.displayName?.toLowerCase().includes(q) ?? false) || providerId.toLowerCase().includes(q) || (providers[providerId]?.name.toLowerCase().includes(q) ?? false) ); }, [search, providers] ); function onPickModel( providerId: LLMProvider, model: ModelInfo, customBaseURL?: string, skipApiKeyCheck = false, customApiKey?: string ) { const provider = providers[providerId]; const effectiveBaseURL = customBaseURL || baseURL; const supportsBaseURL = provider?.supportsBaseURL ?? Boolean(effectiveBaseURL); if (supportsBaseURL && effectiveBaseURL) { const v = validateBaseURL(effectiveBaseURL); if (!v.isValid) { setError(v.error || 'Invalid base URL'); return; } } // Dexto provider requires OAuth login via CLI, not manual API key entry // Check canUse from auth status API (requires both authentication AND API key) if (!skipApiKeyCheck && providerId === 'dexto') { if (!dextoAuthStatus?.canUse) { setError('Run `dexto login` or `/login` from the CLI to authenticate with Dexto'); return; } } else if (!skipApiKeyCheck && provider && !provider.hasApiKey && !customApiKey) { // Other providers - show API key modal if no key configured setPendingSelection({ provider: providerId, model }); setPendingKeyProvider(providerId); setKeyModalOpen(true); return; } const basePayload: SwitchLLMPayload = { provider: providerId, model: model.name, ...(supportsBaseURL && effectiveBaseURL && { baseURL: effectiveBaseURL }), ...(customApiKey && { apiKey: customApiKey }), }; // Always update global default first (no sessionId), then switch current session if active switchLLMMutation.mutate(basePayload, { onSuccess: async () => { // If there's an active session, also switch it to the new model if (currentSessionId) { try { await switchLLMMutation.mutateAsync({ ...basePayload, sessionId: currentSessionId, }); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to switch model for current session' ); return; } } await refreshCurrentLLM(); if (currentLLM) { analyticsRef.current.trackLLMSwitched({ fromProvider: currentLLM.provider, fromModel: currentLLM.model, toProvider: providerId, toModel: model.name, sessionId: currentSessionId || undefined, trigger: 'user_action', }); } setOpen(false); setError(null); }, onError: (error: Error) => { setError(error.message); }, }); } function onPickCustomModel(customModel: CustomModel) { const provider = (customModel.provider ?? 'openai-compatible') as LLMProvider; const modelInfo: ModelInfo = { name: customModel.name, displayName: customModel.displayName || customModel.name, maxInputTokens: customModel.maxInputTokens || 128000, supportedFileTypes: ['pdf', 'image', 'audio'], }; // Skip API key check for custom models - user already configured them. // If they didn't add an API key, it's intentional (self-hosted, local, or env var). // Pass the custom model's apiKey for per-model override if present. onPickModel(provider, modelInfo, customModel.baseURL, true, customModel.apiKey); } function onPickInstalledModel(model: LocalModel) { // Installed local models use the model ID as the name // Context length is auto-detected by node-llama-cpp at runtime const modelInfo: ModelInfo = { name: model.id, displayName: model.displayName, maxInputTokens: model.contextLength || 8192, supportedFileTypes: [], // Local models typically don't support file attachments }; // Skip API key check - local models don't need API keys onPickModel('local', modelInfo, undefined, true); } function onApiKeySaved(meta: { provider: string; envVar: string }) { const providerKey = meta.provider as LLMProvider; setProviders((prev) => ({ ...prev, [providerKey]: prev[providerKey] ? { ...prev[providerKey]!, hasApiKey: true } : prev[providerKey], })); setKeyModalOpen(false); if (pendingSelection) { const { provider: providerId, model } = pendingSelection; // Skip API key check since we just saved it onPickModel(providerId, model, undefined, true); setPendingSelection(null); } } const triggerLabel = currentLLM?.displayName || currentLLM?.model || 'Choose Model'; const isWelcomeScreen = !currentSessionId; // Toggle a filter (add if not present, remove if present) const toggleFilter = useCallback((filter: LLMProvider | 'custom') => { setProviderFilter((prev) => prev.includes(filter) ? prev.filter((f) => f !== filter) : [...prev, filter] ); }, []); // Build favorites list (includes both catalog models and custom models) const favoriteModels = useMemo(() => { return favorites .map((key) => { const [providerIdRaw, modelName] = key.split('|'); const providerId = providerIdRaw as LLMProvider; if (!LLM_PROVIDERS.includes(providerId)) return null; // Check if it's a custom model (check by model name match and provider type) const customModel = customModels.find( (cm) => cm.name === modelName && (cm.provider ?? 'openai-compatible') === providerId ); if (customModel) { return { providerId, provider: undefined, model: { name: customModel.name, displayName: customModel.displayName || customModel.name, maxInputTokens: customModel.maxInputTokens || 128000, supportedFileTypes: ['pdf', 'image', 'audio'] as string[], }, isCustom: true, customModel, }; } const provider = providers[providerId]; const model = provider?.models.find((m) => m.name === modelName); if (!provider || !model) return null; return { providerId, provider, model, isCustom: false }; }) .filter(Boolean) as Array<{ providerId: LLMProvider; provider?: ProviderCatalog; model: ModelInfo; isCustom: boolean; customModel?: CustomModel; }>; }, [favorites, providers, customModels]); // All models flat list (filtered by search and provider) const allModels = useMemo(() => { // Get non-custom provider filters const providerFilters = providerFilter.filter((f): f is LLMProvider => f !== 'custom'); // If only 'custom' is selected, don't show catalog models if (providerFilter.length > 0 && providerFilters.length === 0) return []; const result: Array<{ providerId: LLMProvider; provider: ProviderCatalog; model: ModelInfo; }> = []; for (const providerId of LLM_PROVIDERS) { // Empty filter = show all, otherwise check if provider is in filter if (providerFilter.length > 0 && !providerFilters.includes(providerId)) continue; const provider = providers[providerId]; if (!provider) continue; for (const model of provider.models) { if (modelMatchesSearch(providerId, model)) { result.push({ providerId, provider, model }); } } } return result; }, [providers, providerFilter, modelMatchesSearch]); // Filtered custom models (shown when no filter, 'custom' filter, or provider-specific filter) const filteredCustomModels = useMemo(() => { const hasCustomFilter = providerFilter.includes('custom'); const hasOpenRouterFilter = providerFilter.includes('openrouter'); const noFilter = providerFilter.length === 0; // If filter is set but neither 'custom' nor 'openrouter', hide custom models if (!noFilter && !hasCustomFilter && !hasOpenRouterFilter) return []; let filtered = customModels; // If openrouter filter is active (without custom), only show openrouter custom models if (hasOpenRouterFilter && !hasCustomFilter && !noFilter) { filtered = customModels.filter((cm) => cm.provider === 'openrouter'); } const q = search.trim().toLowerCase(); if (!q) return filtered; return filtered.filter( (cm) => cm.name.toLowerCase().includes(q) || (cm.displayName?.toLowerCase().includes(q) ?? false) || (cm.provider?.toLowerCase().includes(q) ?? false) || (cm.baseURL?.toLowerCase().includes(q) ?? false) ); }, [providerFilter, search, customModels]); // Filtered installed local models (downloaded via CLI/Interactive CLI) // Shown when no filter or 'local' filter is active const filteredInstalledModels = useMemo(() => { const hasLocalFilter = providerFilter.includes('local'); const noFilter = providerFilter.length === 0; // If filter is set but not 'local', hide installed models if (!noFilter && !hasLocalFilter) return []; const q = search.trim().toLowerCase(); if (!q) return installedLocalModels; return installedLocalModels.filter( (model) => model.id.toLowerCase().includes(q) || model.displayName.toLowerCase().includes(q) || 'local'.includes(q) ); }, [providerFilter, search, installedLocalModels]); // Available providers for filter // OpenRouter always shown (users add their own models via custom models) // Local shown when there are installed models from CLI const availableProviders = useMemo(() => { const base = LLM_PROVIDERS.filter((p) => p === 'openrouter' || providers[p]?.models.length); // Add 'local' if there are installed local models if (installedLocalModels.length > 0 && !base.includes('local')) { return [...base, 'local' as LLMProvider]; } return base; }, [providers, installedLocalModels]); const isCurrentModel = (providerId: string, modelName: string) => currentLLM?.provider === providerId && currentLLM?.model === modelName; return ( <> {/* Full-screen Add Custom Model Form - replaces all content when active */} {showCustomForm ? ( setCustomModelForm((prev) => ({ ...prev, ...updates })) } onSubmit={addCustomModel} onCancel={() => { setShowCustomForm(false); setEditingModelName(null); setError(null); }} isSubmitting={isAddingModel} error={error} isEditing={editingModelName !== null} /> ) : ( <> {/* Header - Search + Add Custom Button + Filters */}
{(error || catalogError) && ( {error || catalogError?.message} )}
Add custom model
{/* Provider Filter Pills - only in All view */} {activeView === 'all' && availableProviders.length > 1 && (
{availableProviders.map((providerId) => ( ))}
)}
{/* Main Content */}
{loading ? (
) : activeView === 'favorites' ? ( /* Favorites List View */ favoriteModels.length === 0 ? (

No favorites yet

Click "Show all" to browse and add favorites

) : (
{favoriteModels .filter(({ providerId, model }) => { if (!search.trim()) return true; const q = search.trim().toLowerCase(); return ( model.name.toLowerCase().includes(q) || (model.displayName ?.toLowerCase() .includes(q) ?? false) || providerId.toLowerCase().includes(q) ); }) .map( ({ providerId, model, isCustom, customModel, }) => (
isCustom && customModel ? onPickCustomModel(customModel) : onPickModel(providerId, model) } onKeyDown={(e) => { if (e.target !== e.currentTarget) return; if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); isCustom && customModel ? onPickCustomModel( customModel ) : onPickModel( providerId, model ); } }} role="button" tabIndex={0} className={cn( 'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors cursor-pointer', 'hover:bg-accent/50', isCurrentModel( providerId, model.name ) ? 'bg-primary/10 border border-primary/30' : 'border border-transparent' )} >
{hasLogo(providerId) ? ( ) : ( )}
{model.displayName || model.name}
{providerId === 'openai-compatible' && (
Custom
)}
{model.supportedFileTypes?.includes( 'image' ) && ( )} {model.supportedFileTypes?.includes( 'pdf' ) && ( )}
) )}
) ) : ( /* All Models Card Grid View */
{allModels.length === 0 && filteredCustomModels.length === 0 && filteredInstalledModels.length === 0 ? (

{providerFilter.includes('openrouter') ? 'No OpenRouter models yet' : providerFilter.includes('local') ? 'No local models installed' : 'No models found'}

{providerFilter.includes('openrouter') ? 'Click the + button to add an OpenRouter model' : providerFilter.includes('local') ? 'Use the CLI to download models: dexto setup' : 'Try adjusting your search or filters'}

) : (
{allModels.map( ({ providerId, provider, model }) => ( onPickModel(providerId, model) } onToggleFavorite={() => toggleFavorite( providerId, model.name ) } size="sm" /> ) )} {/* Installed local models (downloaded via CLI) - shown before custom models */} {filteredInstalledModels.map((model) => ( onPickInstalledModel(model)} onToggleFavorite={() => toggleFavorite('local', model.id) } onDelete={() => deleteInstalledModel(model.id) } size="sm" isInstalled /> ))} {/* Custom models (user-configured) */} {filteredCustomModels.map((cm) => { const cmProvider = (cm.provider ?? 'openai-compatible') as LLMProvider; return ( onPickCustomModel(cm)} onToggleFavorite={() => toggleFavorite(cmProvider, cm.name) } onEdit={() => editCustomModel(cm)} onDelete={() => deleteCustomModel(cm.name) } size="sm" isCustom /> ); })}
)}
)}
{/* Bottom Navigation Bar */}
)}
{pendingKeyProvider && ( )} ); }