import React from 'react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiPencilAiLine, RiSearchLine, RiStarFill, RiStarLine, RiTimeLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useModelLists } from '@/hooks/useModelLists'; import type { ModelMetadata } from '@/types'; type ProviderModel = Record & { id?: string; name?: string }; interface ModelSelectorProps { providerId: string; modelId: string; onChange: (providerId: string, modelId: string) => void; className?: string; allowedProviderIds?: string[]; placeholder?: string; } const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short', maximumFractionDigits: 1, minimumFractionDigits: 0, }); const formatTokens = (value?: number | null) => { if (typeof value !== 'number' || Number.isNaN(value)) { return ''; } if (value === 0) { return '0'; } const formatted = COMPACT_NUMBER_FORMATTER.format(value); return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted; }; export const ModelSelector: React.FC = ({ providerId, modelId, onChange, className, allowedProviderIds, placeholder }) => { const providers = useConfigStore((state) => state.providers); const modelsMetadata = useConfigStore((state) => state.modelsMetadata); const isMobile = useUIStore(state => state.isMobile); const hiddenModels = useUIStore(state => state.hiddenModels); const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel); const isFavoriteModel = useUIStore((state) => state.isFavoriteModel); const addRecentModel = useUIStore((state) => state.addRecentModel); const { favoriteModelsList, recentModelsList } = useModelLists(); const { isMobile: deviceIsMobile } = useDeviceInfo(); const isActuallyMobile = isMobile || deviceIsMobile; const [isMobilePanelOpen, setIsMobilePanelOpen] = React.useState(false); const [expandedMobileProviders, setExpandedMobileProviders] = React.useState>(new Set()); const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); const [selectedIndex, setSelectedIndex] = React.useState(0); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const allowedProviderSet = React.useMemo(() => { if (!Array.isArray(allowedProviderIds) || allowedProviderIds.length === 0) { return null; } return new Set(allowedProviderIds); }, [allowedProviderIds]); const visibleProviders = React.useMemo(() => { const baseProviders = allowedProviderSet ? providers.filter((provider) => allowedProviderSet.has(String(provider.id))) : providers; return baseProviders .map((provider) => { const providerModels = Array.isArray(provider.models) ? provider.models : []; const filteredModels = providerModels.filter((model: ProviderModel) => { const modelId = typeof model?.id === 'string' ? model.id : ''; return !hiddenModels.some( (hidden) => hidden.providerID === String(provider.id) && hidden.modelID === modelId ); }); return { ...provider, models: filteredModels }; }) .filter((provider) => provider.models.length > 0); }, [providers, allowedProviderSet, hiddenModels]); const closeMobilePanel = () => setIsMobilePanelOpen(false); const toggleMobileProviderExpansion = (provId: string) => { setExpandedMobileProviders(prev => { const newSet = new Set(prev); if (newSet.has(provId)) { newSet.delete(provId); } else { newSet.add(provId); } return newSet; }); }; // Reset search and selection when dropdown closes React.useEffect(() => { if (!isDropdownOpen) { setSearchQuery(''); setSelectedIndex(0); } }, [isDropdownOpen]); // Reset selection when search query changes React.useEffect(() => { setSelectedIndex(0); }, [searchQuery]); const getModelDisplayName = (model: Record) => { const name = model?.name || model?.id || ''; const nameStr = String(name); if (nameStr.length > 40) { return nameStr.substring(0, 37) + '...'; } return nameStr; }; const getModelMetadata = (provId: string, modId: string): ModelMetadata | undefined => { const key = `${provId}/${modId}`; return modelsMetadata.get(key); }; const handleProviderAndModelChange = (newProviderId: string, newModelId: string) => { onChange(newProviderId, newModelId); if (newProviderId && newModelId) { addRecentModel(newProviderId, newModelId); } setIsDropdownOpen(false); }; // Filter helper const filterByQuery = (modelName: string, providerName: string) => { if (!searchQuery.trim()) return true; const lowerQuery = searchQuery.toLowerCase(); return ( modelName.toLowerCase().includes(lowerQuery) || providerName.toLowerCase().includes(lowerQuery) ); }; // Render a model row for desktop dropdown const renderModelRow = ( model: ProviderModel, provID: string, modID: string, keyPrefix: string, flatIndex: number, isHighlighted: boolean ) => { const metadata = getModelMetadata(provID, modID); const contextTokens = formatTokens(metadata?.limit?.context); const isSelected = providerId === provID && modelId === modID; const isFavorite = isFavoriteModel(provID, modID); const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent'; return (
{ itemRefs.current[flatIndex] = el; }} className={cn( "typography-meta group flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer", isHighlighted ? "bg-interactive-selection" : "hover:bg-interactive-hover/50" )} onClick={() => handleProviderAndModelChange(provID, modID)} onMouseEnter={() => setSelectedIndex(flatIndex)} >
{showProviderLogo && ( )} {getModelDisplayName(model)} {contextTokens ? ( {contextTokens} ) : null}
{isSelected && ( )}
); }; // Filter data for desktop dropdown const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => { if (allowedProviderSet && !allowedProviderSet.has(providerID)) { return false; } const provider = providers.find(p => p.id === providerID); const providerName = provider?.name || providerID; const modelName = getModelDisplayName(model); return filterByQuery(modelName, providerName); }); const filteredRecents = recentModelsList.filter(({ model, providerID }) => { if (allowedProviderSet && !allowedProviderSet.has(providerID)) { return false; } const provider = providers.find(p => p.id === providerID); const providerName = provider?.name || providerID; const modelName = getModelDisplayName(model); return filterByQuery(modelName, providerName); }); const filteredProviders = visibleProviders .map((provider) => { const providerModels = Array.isArray(provider.models) ? provider.models : []; const filteredModels = providerModels.filter((model: ProviderModel) => { const modelName = getModelDisplayName(model); return filterByQuery(modelName, provider.name || provider.id || ''); }); return { ...provider, models: filteredModels }; }) .filter((provider) => provider.models.length > 0); const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0; const renderMobileModelPanel = () => { if (!isActuallyMobile) return null; return (
{/* Favorites Section for Mobile */} {favoriteModelsList.length > 0 && (
Favorites
{favoriteModelsList.map(({ model, providerID, modelID }) => { const isSelectedModel = providerID === providerId && modelID === modelId; return (
); })}
)} {/* Recents Section for Mobile */} {recentModelsList.length > 0 && (
Recents
{recentModelsList.map(({ model, providerID, modelID }) => { const isSelectedModel = providerID === providerId && modelID === modelId; return (
); })}
)} {visibleProviders.map((provider) => { const providerModels = Array.isArray(provider.models) ? provider.models : []; if (providerModels.length === 0) return null; const isActiveProvider = provider.id === providerId; const isExpanded = expandedMobileProviders.has(provider.id); return (
{isExpanded && (
{providerModels.map((modelItem: ProviderModel) => { const isSelectedModel = provider.id === providerId && modelItem.id === modelId; return (
{isSelectedModel && (
)}
); })}
)}
); })}
); }; return ( <> {isActuallyMobile ? ( ) : (
{providerId ? ( <> ) : ( )} {providerId && modelId ? `${providerId}/${modelId}` : (placeholder || 'Not selected')}
{(() => { // Build flat list for keyboard navigation type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string }; const flatModelList: FlatModelItem[] = []; filteredFavorites.forEach(({ model, providerID, modelID }) => { flatModelList.push({ model, providerID, modelID, section: 'fav' }); }); filteredRecents.forEach(({ model, providerID, modelID }) => { flatModelList.push({ model, providerID, modelID, section: 'recent' }); }); filteredProviders.forEach((provider) => { (provider.models as ProviderModel[]).forEach((model) => { flatModelList.push({ model, providerID: provider.id as string, modelID: model.id as string, section: 'provider' }); }); }); const totalItems = flatModelList.length; // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { e.stopPropagation(); if (e.key === 'ArrowDown') { e.preventDefault(); const nextIndex = (selectedIndex + 1) % Math.max(1, totalItems); setSelectedIndex(nextIndex); setTimeout(() => { itemRefs.current[nextIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 0); } else if (e.key === 'ArrowUp') { e.preventDefault(); const prevIndex = (selectedIndex - 1 + Math.max(1, totalItems)) % Math.max(1, totalItems); setSelectedIndex(prevIndex); setTimeout(() => { itemRefs.current[prevIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 0); } else if (e.key === 'Enter') { e.preventDefault(); const selectedItem = flatModelList[selectedIndex]; if (selectedItem) { handleProviderAndModelChange(selectedItem.providerID, selectedItem.modelID); } } else if (e.key === 'Escape') { e.preventDefault(); setIsDropdownOpen(false); } }; let currentFlatIndex = 0; return ( <> {/* Search Input */}
setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} className="pl-8 h-8 typography-meta" autoFocus />
{/* Scrollable content */}
{/* Not selected option */}
handleProviderAndModelChange('', '')} > {placeholder || 'Not selected'} {!providerId && !modelId && ( )}
{!hasResults && searchQuery && (
No models found
)} {/* Favorites Section */} {filteredFavorites.length > 0 && (
Favorites {filteredFavorites.map(({ model, providerID, modelID }) => { const idx = currentFlatIndex++; return renderModelRow(model, providerID, modelID, 'fav', idx, selectedIndex === idx); })}
)} {/* Recents Section */} {filteredRecents.length > 0 && (
{filteredFavorites.length > 0 && } Recent {filteredRecents.map(({ model, providerID, modelID }) => { const idx = currentFlatIndex++; return renderModelRow(model, providerID, modelID, 'recent', idx, selectedIndex === idx); })}
)} {/* Separator before providers */} {(filteredFavorites.length > 0 || filteredRecents.length > 0) && filteredProviders.length > 0 && ( )} {/* All Providers - Flat List */} {filteredProviders.map((provider, index) => (
{index > 0 && } {provider.name} {(provider.models as ProviderModel[]).map((model: ProviderModel) => { const idx = currentFlatIndex++; return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, selectedIndex === idx); })}
))}
{/* Keyboard hints footer */}
↑↓ navigate • Enter select • Esc close
); })()}
)} {renderMobileModelPanel()} ); };