import { useEffect, useState, useRef, useMemo, useCallback } from 'react' import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover' import { useModelProvider } from '@/hooks/useModelProvider' import { cn, getProviderTitle } 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 { 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' 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, } = useModelProvider() const [displayModel, setDisplayModel] = useState('') const { updateCurrentThreadModel } = useThreads() const navigate = useNavigate() const { t } = useTranslation() const { favoriteModels } = useFavoriteModel() // Search state const [open, setOpen] = useState(false) const [searchValue, setSearchValue] = useState('') const searchInputRef = useRef(null) // Initialize model provider only once useEffect(() => { // Auto select model when existing thread is passed if (model) { selectModelProvider(model?.provider as string, 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) { // Verify the last used model still exists const provider = providers.find( (p) => p.provider === lastUsed.provider && p.active ) const modelExists = provider?.models.find( (m) => m.id === lastUsed.model ) if (provider && modelExists) { selectModelProvider(lastUsed.provider, lastUsed.model) } else { // Fallback to default model if last used model no longer exists selectModelProvider('llamacpp', 'llama3.2:3b') } } else { // default model, we should add from setting selectModelProvider('llamacpp', 'llama3.2:3b') } } else { // default model for non-new-chat contexts selectModelProvider('llamacpp', 'llama3.2:3b') } }, [ model, selectModelProvider, updateCurrentThreadModel, providers, useLastUsedModel, ]) // Update display model when selection changes useEffect(() => { if (selectedProvider && selectedModel) { setDisplayModel(selectedModel.id) } else { setDisplayModel(t('common:selectAModel')) } }, [selectedProvider, selectedModel, t]) // 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) => 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( (searchableModel: SearchableModel) => { 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 ) } setSearchValue('') setOpen(false) }, [selectModelProvider, updateCurrentThreadModel, useLastUsedModel] ) const currentModel = selectedModel?.id ? getModelBy(selectedModel?.id) : undefined if (!providers.length) return null const provider = getProviderByName(selectedProvider) return (
{currentModel?.settings && provider && ( )}
{/* 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' )} >
{searchableModel.model.id}
{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)}
{ 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' )} >
{searchableModel.model.id}
{capabilities.length > 0 && (
)}
) }) )}
) })}
)}
) } export default DropdownModelProvider