import { useEffect, useMemo, useRef, useState } from "react"; import { Bot, Brain, Check, ChevronDown, CircleAlert, Cloud, Cpu, Database, Gem, Grid3X3, Hexagon, Layers, Loader2, Moon, Orbit, Pencil, Search, Sparkles, Triangle, Waves, Zap, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { ComboboxOption, useComboboxNavigation } from "@/components/ui/combobox"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useLogoFallback } from "@/hooks/useLogoFallback"; import { fetchProviderModels } from "@/lib/api"; import { providerBrand } from "@/lib/provider-brand"; import type { ProviderModelsPayload, SettingsPayload } from "@/lib/types"; import { cn } from "@/lib/utils"; const DEFERRED_MODEL_LIST_PROVIDERS = new Set([ "aihubmix", "atomic_chat", "byteplus", "byteplus_coding_plan", "huggingface", "lm_studio", "modelscope", "novita", "ollama", "openrouter", "orcarouter", "ovms", "siliconflow", "vllm", "volcengine", "volcengine_coding_plan", ]); const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2; export function normalizeContextWindowTokens(value: number | null | undefined): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000; } function settingsProviderRow( payload: SettingsPayload, provider: string | null | undefined, ): SettingsPayload["providers"][number] | null { if (!provider) return null; return payload.providers.find((row) => row.name === provider) ?? null; } export function settingsProviderConfigured( payload: SettingsPayload, provider: string | null | undefined, resolvedProvider?: string | null, ): boolean { const row = settingsProviderRow(payload, provider); if (row) return row.configured; if (provider === "auto") { const resolvedRow = settingsProviderRow( payload, resolvedProvider ?? payload.agent.resolved_provider ?? payload.agent.provider, ); if (resolvedRow) return resolvedRow.configured; } return payload.agent.has_api_key; } export function ProviderPicker({ providers, value, emptyLabel, showProviderLogos = false, onChange, }: { providers: Array<{ name: string; label: string }>; value: string; emptyLabel: string; showProviderLogos?: boolean; onChange: (provider: string) => void; }) { const selectedProvider = providers.find((provider) => provider.name === value) ?? null; const disabled = providers.length === 0; return ( {providers.map((provider) => { const selected = provider.name === value; return ( onChange(provider.name)} className={cn( "flex cursor-default items-center justify-between gap-2 text-[13px]", selected && "bg-muted/80 text-foreground focus:bg-muted", )} > {showProviderLogos ? ( ) : null} {provider.label} {selected ? : null} ); })} ); } export function ModelIdPicker({ token, settings, provider, models, value, showProviderLogos, emptyLabel, searchPlaceholder, emptyMessage, onChange, }: { token: string; settings: SettingsPayload; provider: string; models?: string[]; value: string; showProviderLogos: boolean; emptyLabel?: string; searchPlaceholder?: string; emptyMessage?: string; onChange: (model: string) => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tokenRef = useRef(token); tokenRef.current = token; const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [payload, setPayload] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const effectiveProvider = provider === "auto" ? settings.agent.resolved_provider ?? provider : provider; const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto"); const hasStaticModels = models !== undefined; const providerRow = settingsProviderRow(settings, effectiveProvider); const providerConfigured = settingsProviderConfigured(settings, effectiveProvider); const providerRequiresConfiguration = !hasStaticModels && hasConcreteProvider && !providerConfigured; const providerHasBuiltinModels = providerRow?.model_catalog === "builtin"; const providerUsesManualModelIds = !hasStaticModels && hasConcreteProvider && providerConfigured && providerRow?.auth_type === "oauth" && !providerHasBuiltinModels; const canFetchModels = !hasStaticModels && hasConcreteProvider && providerConfigured && !providerUsesManualModelIds; const normalizedQuery = query.trim().toLowerCase(); const providerModels: ProviderModelsPayload["models"] = useMemo( () => hasStaticModels ? (models?.map((id) => ({ id })) ?? []) : (payload?.models ?? []), [hasStaticModels, models, payload?.models], ); const visibleModels = useMemo( () => providerModels .filter((model) => { if (!normalizedQuery) return true; return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""] .some((field) => field.toLowerCase().includes(normalizedQuery)); }) .slice(0, 80), [normalizedQuery, providerModels], ); const isCatalog = payload?.catalog_kind === "catalog"; const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider); const hasDeferredSearchQuery = normalizedQuery.length >= DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH; const shouldFetchModels = canFetchModels && (!defersModelList || hasDeferredSearchQuery); const waitingForModelSearch = open && canFetchModels && defersModelList && !hasDeferredSearchQuery; const hasModelList = hasStaticModels || payload?.status === "available"; const showModels = Boolean( hasModelList && (hasStaticModels || (payload && (!isCatalog || normalizedQuery))), ); const customCandidate = query.trim(); const allowCustomModel = !providerRequiresConfiguration; const exactQueryMatch = providerModels.some((model) => model.id === customCandidate); const showCustomModel = Boolean( allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value, ); const providerModelCount = payload?.model_count ?? providerModels.length; const modelUnconfigured = !value.trim() || !providerConfigured; useEffect(() => { if (!open) return; setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : ""); }, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]); useEffect(() => { if (!open || !shouldFetchModels) { setPayload(null); setError(null); setLoading(false); return; } let cancelled = false; setPayload(null); setError(null); setLoading(true); fetchProviderModels(tokenRef.current, effectiveProvider) .then((nextPayload) => { if (!cancelled) setPayload(nextPayload); }) .catch((err) => { if (!cancelled) setError((err as Error).message); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [effectiveProvider, open, shouldFetchModels]); const selectModel = (model: string) => { onChange(model); setOpen(false); }; const navigationValues = useMemo( () => [ ...(showModels ? visibleModels.map((model) => model.id) : []), ...(showCustomModel ? [customCandidate] : []), ], [customCandidate, showCustomModel, showModels, visibleModels], ); const navigation = useComboboxNavigation({ open, values: navigationValues, selectedValue: value, onSelect: selectModel, onClose: () => setOpen(false), }); const renderModelRow = ( model: ProviderModelsPayload["models"][number], options: { selected?: boolean } = {}, ) => ( {model.label ?? model.id} {model.description || (model.label && model.label !== model.id) ? ( {[model.label && model.label !== model.id ? model.id : null, model.description] .filter(Boolean) .join(" · ")} ) : null} {model.context_window ? {formatContextWindow(model.context_window)} : null} {options.selected ? : null} ); return (
setQuery(event.target.value)} {...navigation.inputProps} placeholder={ searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID") } aria-label={ searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID") } className="h-8 rounded-full pl-8 pr-3 text-[12px]" />
{providerRequiresConfiguration ? (
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
) : hasStaticModels && !providerModels.length ? (
{emptyMessage || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
) : providerUsesManualModelIds ? (
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
) : !canFetchModels ? (
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
) : waitingForModelSearch ? (
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")}
) : loading ? (
{tx("settings.models.loadingModels", "Loading models...")}
) : error || payload?.status === "error" ? (
{payload?.message || error || tx("settings.models.loadFailed", "Model list unavailable.")}
) : payload?.status === "not_configured" ? (
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
) : payload?.status === "unsupported" || payload?.status === "missing_api_base" ? (
{payload.message || tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
) : isCatalog && !normalizedQuery ? (
{tx("settings.models.searchCatalog", "Search provider catalog to choose a model.")} {providerModelCount ? ` ${providerModelCount} ${tx("settings.models.modelsAvailable", "available")}.` : ""}
) : null} {navigationValues.length ? (
{showModels ? visibleModels.map((model) => renderModelRow(model, { selected: model.id === value }), ) : null} {showCustomModel ? ( <> {showModels && visibleModels.length ? (
) : null} {tx("settings.models.useCustomModel", "Use")}{" "} “{customCandidate}” ) : null}
) : showModels ? (
{tx("settings.models.noModelResults", "No matching models.")}
) : null} ); } export function formatContextWindow(tokens: number): string { if (tokens >= 1_000_000) { const value = tokens / 1_000_000; return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}M`; } if (tokens >= 1_000) { const value = tokens / 1_000; return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}K`; } return String(tokens); } export function formatModelContextWindow(tokens: number): string { if (tokens === 65_536) return "64K"; if (tokens === 262_144) return "256K"; if (tokens === 1_048_576) return "1M"; return formatContextWindow(tokens); } export function ProviderPickerIcon({ provider, showBrandLogos, unconfigured = false, }: { provider: string; showBrandLogos: boolean; unconfigured?: boolean; }) { const brand = providerBrand(provider); const Icon = PROVIDER_ICONS[provider] ?? Hexagon; const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); if (unconfigured) { return ( ); } if (showBrandLogos && logoUrl) { return ( ); } if (showBrandLogos && brand) { return ( {brand.initials} ); } return ( ); } export function optionRowsWithCurrent( options: Array<{ name: string; label: string }>, value: string, ): Array<{ name: string; label: string }> { if (!value || options.some((option) => option.name === value)) return options; return [{ name: value, label: value }, ...options]; } export const PROVIDER_ICONS: Record = { custom: Hexagon, openrouter: Sparkles, orcarouter: Sparkles, skywork: Sparkles, aihubmix: Triangle, anthropic: Brain, openai: Bot, deepseek: Waves, zhipu: Grid3X3, dashscope: Cloud, modelscope: Layers, moonshot: Moon, minimax: Zap, minimax_anthropic: Brain, groq: Cpu, huggingface: Layers, gemini: Gem, mistral: Orbit, siliconflow: Layers, volcengine: Cloud, volcengine_coding_plan: Cloud, byteplus: Cloud, byteplus_coding_plan: Cloud, qianfan: Database, ant_ling: Sparkles, azure_openai: Cloud, bedrock: Database, bocha: Search, brave: Search, duckduckgo: Search, exa: Search, jina: Search, kagi: Search, olostep: Search, searxng: Search, tavily: Search, vllm: Cpu, ollama: Cpu, lm_studio: Cpu, atomic_chat: Cpu, ovms: Cpu, nvidia: Zap, };