import { Check, ChevronLeft, ChevronRight, Loader2, Pin, Search, Star } from 'lucide-react'; import React, { useEffect, useMemo, useState } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { filterComposerModels, resolveComposerEnterModelId, resolvePinnedAndRecentModels, type ComposerModelPrefEntry, type ComposerModelPrefs, type ComposerPickerModel, } from '../../infrastructure/ai/composerPicker'; import type { AgentModelPreset, ProviderConfig } from '../../infrastructure/ai/types'; import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge'; import { useProviderModelCatalog } from './useProviderModelCatalog'; export const COMPOSER_PROVIDER_PICKER_WIDTH = 260; export const COMPOSER_MODEL_PICKER_WIDTH = 260; export interface ComposerModelPickerProps { providers?: ProviderConfig[]; selectedProviderId?: string; selectedModelId?: string; modelPresets?: AgentModelPreset[]; prefs: ComposerModelPrefs; onSelectProviderModel?: (providerId: string, modelId: string, contextWindow?: number) => void; onSelectModel?: (modelId: string) => void; onTogglePinned: (entry: ComposerModelPrefEntry) => void; } const rowClassName = 'flex h-8 w-full items-center gap-2 px-2.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer'; const SectionLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
{children}
); const ModelRow: React.FC<{ model: ComposerPickerModel; selected: boolean; pinned: boolean; onSelect: () => void; onTogglePinned: () => void; pinLabel: string; unpinLabel: string; }> = ({ model, selected, pinned, onSelect, onTogglePinned, pinLabel, unpinLabel }) => (
); export const ComposerModelPicker: React.FC = ({ providers = [], selectedProviderId, selectedModelId, modelPresets = [], prefs, onSelectProviderModel, onSelectModel, onTogglePinned, }) => { const { t } = useI18n(); const hasProviders = providers.length > 0; const [previewProviderId, setPreviewProviderId] = useState( selectedProviderId || providers[0]?.id || '', ); const [query, setQuery] = useState(''); const [view, setView] = useState<'models' | 'providers'>('models'); useEffect(() => { if (selectedProviderId) setPreviewProviderId(selectedProviderId); }, [selectedProviderId]); const previewProvider = hasProviders ? providers.find((provider) => provider.id === previewProviderId) ?? providers[0] : undefined; const catalog = useProviderModelCatalog(previewProvider, hasProviders); const models = useMemo(() => { if (hasProviders) return catalog.models; return modelPresets.map((preset) => ({ id: preset.id, name: preset.name, description: preset.description, })); }, [catalog.models, hasProviders, modelPresets]); const filtered = useMemo(() => filterComposerModels(models, query), [models, query]); const grouped = useMemo( () => resolvePinnedAndRecentModels({ models: filtered, prefs, providerId: previewProvider?.id, allowMissing: Boolean(previewProvider?.id) && !query.trim(), }), [filtered, prefs, previewProvider?.id, query], ); const pinnedKeys = useMemo( () => new Set( prefs.pinned .filter((entry) => !previewProvider || !entry.providerId || entry.providerId === previewProvider.id) .map((entry) => entry.modelId), ), [prefs.pinned, previewProvider], ); const trimmedQuery = query.trim(); const showCustom = Boolean( hasProviders && trimmedQuery && !models.some((model) => model.id.toLowerCase() === trimmedQuery.toLowerCase()), ); const selectModel = (modelId: string) => { const contextWindow = models.find((model) => model.id === modelId)?.contextWindow; if (hasProviders && previewProvider) { onSelectProviderModel?.(previewProvider.id, modelId, contextWindow); return; } onSelectModel?.(modelId); }; const prefEntryFor = (modelId: string): ComposerModelPrefEntry => ( previewProvider ? { providerId: previewProvider.id, modelId } : { modelId } ); if (hasProviders && view === 'providers') { return (
{providers.map((provider) => { const isBound = provider.id === selectedProviderId; return ( ); })}
); } return (
{hasProviders && previewProvider && ( <> )}
setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && trimmedQuery) { event.preventDefault(); const nextId = resolveComposerEnterModelId({ query: trimmedQuery, models, grouped, filtered, showCustom, }); if (nextId) selectModel(nextId); } }} placeholder={t('ai.chat.searchModels')} className="h-full w-full bg-transparent text-[12px] text-foreground/88 outline-none placeholder:text-muted-foreground/40" />
{catalog.loading && (
{t('ai.chat.loadingModels')}
)} {showCustom && ( )} {grouped.pinned.length > 0 && ( <> {t('ai.chat.pinned')} {grouped.pinned.map((model) => ( selectModel(model.id)} onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))} pinLabel={t('ai.chat.pinModel')} unpinLabel={t('ai.chat.unpinModel')} /> ))} )} {grouped.recent.length > 0 && ( <> {t('ai.chat.recent')} {grouped.recent.map((model) => ( selectModel(model.id)} onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))} pinLabel={t('ai.chat.pinModel')} unpinLabel={t('ai.chat.unpinModel')} /> ))} )} {(grouped.pinned.length > 0 || grouped.recent.length > 0) && grouped.rest.length > 0 && ( {t('ai.chat.models')} )} {grouped.rest.map((model) => ( selectModel(model.id)} onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))} pinLabel={t('ai.chat.pinModel')} unpinLabel={t('ai.chat.unpinModel')} /> ))} {!catalog.loading && filtered.length === 0 && !showCustom && (
{catalog.error || t('ai.chat.noMatchingModels')}
)}
); }; export default React.memo(ComposerModelPicker);