/* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, useState, useRef, useMemo, useCallback } from 'react' import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover' import { useModelProvider } from '@/hooks/useModelProvider' import { cn, getProviderTitle, getModelDisplayName } from '@/lib/utils' import { highlightFzfMatch } from '@/utils/highlight' import Capabilities from './Capabilities' import { IconSettings, IconX } from '@tabler/icons-react' import { useNavigate } from '@tanstack/react-router' import { route } from '@/constants/routes' import { useThreads } from '@/hooks/useThreads' import { ModelSetting } from '@/containers/ModelSetting' import ProvidersAvatar from '@/containers/ProvidersAvatar' import { ModelSupportStatus } from '@/containers/ModelSupportStatus' import { Fzf } from 'fzf' import { localStorageKey } from '@/constants/localStorage' import { useTranslation } from '@/i18n/react-i18next-compat' import { useFavoriteModel } from '@/hooks/useFavoriteModel' import { predefinedProviders } from '@/consts/providers' import { useServiceHub } from '@/hooks/useServiceHub' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' type DropdownModelProviderProps = { model?: ThreadModel useLastUsedModel?: boolean } interface SearchableModel { provider: ModelProvider model: Model searchStr: string value: string highlightedId?: string } // Helper functions for localStorage const getLastUsedModel = (): { provider: string; model: string } | null => { try { const stored = localStorage.getItem(localStorageKey.lastUsedModel) return stored ? JSON.parse(stored) : null } catch (error) { console.debug('Failed to get last used model from localStorage:', error) return null } } const setLastUsedModel = (provider: string, model: string) => { try { localStorage.setItem( localStorageKey.lastUsedModel, JSON.stringify({ provider, model }) ) } catch (error) { console.debug('Failed to set last used model in localStorage:', error) } } const DropdownModelProvider = ({ model, useLastUsedModel = false, }: DropdownModelProviderProps) => { const { providers, getProviderByName, selectModelProvider, getModelBy, selectedProvider, selectedModel, updateProvider, } = useModelProvider() const [displayModel, setDisplayModel] = useState('') const { updateCurrentThreadModel } = useThreads() const navigate = useNavigate() const { t } = useTranslation() const { favoriteModels } = useFavoriteModel() const serviceHub = useServiceHub() // Search state const [open, setOpen] = useState(false) const [searchValue, setSearchValue] = useState('') const searchInputRef = useRef(null) // Helper function to check if a model exists in providers const checkModelExists = useCallback( (providerName: string, modelId: string) => { const provider = providers.find( (p) => p.provider === providerName && p.active ) return provider?.models.find((m) => m.id === modelId) }, [providers] ) // Helper function to get context size from model settings const getContextSize = useCallback((): number => { if (!selectedModel?.settings?.ctx_len?.controller_props?.value) { return 8192 // Default context size } return selectedModel.settings.ctx_len.controller_props.value as number }, [selectedModel?.settings?.ctx_len?.controller_props?.value]) // Function to check if a llamacpp model has vision capabilities and update model capabilities const checkAndUpdateModelVisionCapability = useCallback( async (modelId: string) => { try { const hasVision = await serviceHub.models().checkMmprojExists(modelId) if (hasVision) { // Update the model capabilities to include 'vision' const provider = getProviderByName('llamacpp') if (provider) { const modelIndex = provider.models.findIndex( (m) => m.id === modelId ) if (modelIndex !== -1) { const model = provider.models[modelIndex] const capabilities = model.capabilities || [] // Add 'vision' capability if not already present AND if user hasn't manually configured capabilities // Check if model has a custom capabilities config flag const hasUserConfiguredCapabilities = (model as any)._userConfiguredCapabilities === true if ( !capabilities.includes('vision') && !hasUserConfiguredCapabilities ) { const updatedModels = [...provider.models] updatedModels[modelIndex] = { ...model, capabilities: [...capabilities, 'vision'], // Mark this as auto-detected, not user-configured _autoDetectedVision: true, } as any updateProvider('llamacpp', { models: updatedModels }) } } } } } catch (error) { console.debug('Error checking mmproj for model:', modelId, error) } }, [getProviderByName, updateProvider, serviceHub] ) // Initialize model provider - avoid race conditions with manual selections useEffect(() => { const initializeModel = async () => { // Auto select model when existing thread is passed if (model) { selectModelProvider(model?.provider as string, model?.id as string) if (!checkModelExists(model.provider, model.id)) { selectModelProvider('', '') } // Check mmproj existence for llamacpp models if (model?.provider === 'llamacpp') { await serviceHub .models() .checkMmprojExistsAndUpdateOffloadMMprojSetting( model.id as string, updateProvider, getProviderByName ) // Also check vision capability await checkAndUpdateModelVisionCapability(model.id as string) } } else if (useLastUsedModel) { // Try to use last used model only when explicitly requested (for new chat) const lastUsed = getLastUsedModel() if (lastUsed && checkModelExists(lastUsed.provider, lastUsed.model)) { selectModelProvider(lastUsed.provider, lastUsed.model) if (lastUsed.provider === 'llamacpp') { await serviceHub .models() .checkMmprojExistsAndUpdateOffloadMMprojSetting( lastUsed.model, updateProvider, getProviderByName ) // Also check vision capability await checkAndUpdateModelVisionCapability(lastUsed.model) } } else { // For web-only builds, auto-select the first model from jan provider if (PlatformFeatures[PlatformFeature.WEB_AUTO_MODEL_SELECTION]) { const janProvider = providers.find( (p) => p.provider === 'jan' && p.active && p.models.length > 0 ) if (janProvider && janProvider.models.length > 0) { const firstModel = janProvider.models[0] selectModelProvider(janProvider.provider, firstModel.id) return } } selectModelProvider('', '') } } else { // Get current state for web auto-selection check const currentState = { selectedModel, selectedProvider } if ( PlatformFeatures[PlatformFeature.WEB_AUTO_MODEL_SELECTION] && !currentState.selectedModel && !currentState.selectedProvider ) { // For web-only builds, auto-select the first model from jan provider only if nothing is selected const janProvider = providers.find( (p) => p.provider === 'jan' && p.active && p.models.length > 0 ) if (janProvider && janProvider.models.length > 0) { const firstModel = janProvider.models[0] selectModelProvider(janProvider.provider, firstModel.id) } } } } initializeModel() // eslint-disable-next-line react-hooks/exhaustive-deps }, [ model, selectModelProvider, updateCurrentThreadModel, providers, useLastUsedModel, checkModelExists, updateProvider, getProviderByName, checkAndUpdateModelVisionCapability, serviceHub, // selectedModel and selectedProvider intentionally excluded to prevent race conditions ]) // Update display model when selection changes useEffect(() => { if (selectedProvider && selectedModel) { setDisplayModel(getModelDisplayName(selectedModel)) } else { setDisplayModel(t('common:selectAModel')) } }, [selectedProvider, selectedModel, t]) // Check vision capabilities for all llamacpp models useEffect(() => { const checkAllLlamacppModelsForVision = async () => { const llamacppProvider = providers.find( (p) => p.provider === 'llamacpp' && p.active ) if (llamacppProvider) { const checkPromises = llamacppProvider.models.map((model) => checkAndUpdateModelVisionCapability(model.id) ) await Promise.allSettled(checkPromises) } } if (open) { checkAllLlamacppModelsForVision() } }, [open, providers, checkAndUpdateModelVisionCapability]) // Reset search value when dropdown closes const onOpenChange = useCallback((open: boolean) => { setOpen(open) if (!open) { requestAnimationFrame(() => setSearchValue('')) } else { // Focus search input when opening setTimeout(() => { searchInputRef.current?.focus() }, 100) } }, []) // Clear search and focus input const onClearSearch = useCallback(() => { setSearchValue('') searchInputRef.current?.focus() }, []) // Create searchable items from all models const searchableItems = useMemo(() => { const items: SearchableModel[] = [] providers.forEach((provider) => { if (!provider.active) return provider.models.forEach((modelItem) => { // Skip models that require API key but don't have one (except llamacpp) if ( provider && predefinedProviders.some((e) => e.provider.includes(provider.provider) ) && provider.provider !== 'llamacpp' && !provider.api_key?.length ) return const capabilities = modelItem.capabilities || [] const capabilitiesString = capabilities.join(' ') const providerTitle = getProviderTitle(provider.provider) // Create search string with model id, provider, and capabilities const searchStr = `${modelItem.id} ${providerTitle} ${provider.provider} ${capabilitiesString}`.toLowerCase() items.push({ provider, model: modelItem, searchStr, value: `${provider.provider}:${modelItem.id}`, }) }) }) return items }, [providers]) // Create Fzf instance for fuzzy search const fzfInstance = useMemo(() => { return new Fzf(searchableItems, { selector: (item) => `${getModelDisplayName(item.model)} ${item.model.id}`.toLowerCase(), }) }, [searchableItems]) // Get favorite models that are currently available const favoriteItems = useMemo(() => { return searchableItems.filter((item) => favoriteModels.some((fav) => fav.id === item.model.id) ) }, [searchableItems, favoriteModels]) // Filter models based on search value const filteredItems = useMemo(() => { if (!searchValue) return searchableItems return fzfInstance.find(searchValue.toLowerCase()).map((result) => { const item = result.item const positions = Array.from(result.positions) || [] const highlightedId = highlightFzfMatch( item.model.id, positions, 'text-accent' ) return { ...item, highlightedId, } }) }, [searchableItems, searchValue, fzfInstance]) // Group filtered items by provider, excluding favorites when not searching const groupedItems = useMemo(() => { const groups: Record = {} if (!searchValue) { // When not searching, show all active providers (even without models) providers.forEach((provider) => { if (provider.active) { groups[provider.provider] = [] } }) } // Add the filtered items to their respective groups filteredItems.forEach((item) => { const providerKey = item.provider.provider if (!groups[providerKey]) { groups[providerKey] = [] } // When not searching, exclude favorite models from regular provider sections const isFavorite = favoriteModels.some((fav) => fav.id === item.model.id) if (!searchValue && isFavorite) return // Skip adding this item to regular provider section groups[providerKey].push(item) }) return groups }, [filteredItems, providers, searchValue, favoriteModels]) const handleSelect = useCallback( async (searchableModel: SearchableModel) => { // Immediately update display to prevent double-click issues setDisplayModel(getModelDisplayName(searchableModel.model)) setSearchValue('') setOpen(false) selectModelProvider( searchableModel.provider.provider, searchableModel.model.id ) updateCurrentThreadModel({ id: searchableModel.model.id, provider: searchableModel.provider.provider, }) // Store the selected model as last used if (useLastUsedModel) { setLastUsedModel( searchableModel.provider.provider, searchableModel.model.id ) } // Check mmproj existence for llamacpp models (async, don't block UI) if (searchableModel.provider.provider === 'llamacpp') { serviceHub .models() .checkMmprojExistsAndUpdateOffloadMMprojSetting( searchableModel.model.id, updateProvider, getProviderByName ) .catch((error) => { console.debug( 'Error checking mmproj for model:', searchableModel.model.id, error ) }) // Also check vision capability (async, don't block UI) checkAndUpdateModelVisionCapability(searchableModel.model.id).catch( (error) => { console.debug( 'Error checking vision capability for model:', searchableModel.model.id, error ) } ) } }, [ selectModelProvider, updateCurrentThreadModel, useLastUsedModel, updateProvider, getProviderByName, checkAndUpdateModelVisionCapability, serviceHub, ] ) const currentModel = selectedModel?.id ? getModelBy(selectedModel?.id) : undefined if (!providers.length) return null const provider = getProviderByName(selectedProvider) return (
{currentModel?.settings && provider && provider.provider === 'llamacpp' && ( )}
{/* Search input */}
setSearchValue(e.target.value)} placeholder={t('common:searchModels')} className="text-sm font-normal outline-0" /> {searchValue.length > 0 && (
)}
{/* Model list */}
{Object.keys(groupedItems).length === 0 && searchValue ? (
{t('common:noModelsFoundFor', { searchValue })}
) : (
{/* Favorites section - only show when not searching */} {!searchValue && favoriteItems.length > 0 && (
{/* Favorites header */}
{t('common:favorites')}
{/* Favorite models */} {favoriteItems.map((searchableModel) => { const isSelected = selectedModel?.id === searchableModel.model.id && selectedProvider === searchableModel.provider.provider const capabilities = searchableModel.model.capabilities || [] return (
handleSelect(searchableModel)} className={cn( 'mx-1 mb-1 px-2 py-1.5 rounded-sm cursor-pointer flex items-center gap-2 transition-all duration-200', 'hover:bg-main-view-fg/4', isSelected && 'bg-main-view-fg/8 hover:bg-main-view-fg/8' )} >
{getModelDisplayName(searchableModel.model)}
{capabilities.length > 0 && (
)}
) })}
)} {/* Divider between favorites and regular providers */} {favoriteItems.length > 0 && (
)} {/* Regular provider sections */} {Object.entries(groupedItems).map(([providerKey, models]) => { const providerInfo = providers.find( (p) => p.provider === providerKey ) if (!providerInfo) return null return (
{/* Provider header */}
{getProviderTitle(providerInfo.provider)}
{PlatformFeatures[ PlatformFeature.MODEL_PROVIDER_SETTINGS ] && (
{ e.stopPropagation() navigate({ to: route.settings.providers, params: { providerName: providerInfo.provider }, }) setOpen(false) }} >
)}
{/* Models for this provider */} {models.length === 0 ? ( // Show message when provider has no available models <> ) : ( models.map((searchableModel) => { const isSelected = selectedModel?.id === searchableModel.model.id && selectedProvider === searchableModel.provider.provider const capabilities = searchableModel.model.capabilities || [] return (
handleSelect(searchableModel)} className={cn( 'mx-1 mb-1 px-2 py-1.5 rounded-sm cursor-pointer flex items-center gap-2 transition-all duration-200', 'hover:bg-main-view-fg/4', isSelected && 'bg-main-view-fg/8 hover:bg-main-view-fg/8' )} >
{getModelDisplayName(searchableModel.model)}
{capabilities.length > 0 && (
)}
) }) )}
) })}
)}
) } export default DropdownModelProvider