"use client" import { Bot, Check, ChevronDown, Monitor, Plus, Server, Settings2, User, } from "lucide-react" import { type ComponentProps, type ReactNode, useEffect, useMemo, useRef, useState, } from "react" import { ProviderLogo } from "@/components/provider-logo" import { Button } from "@/components/ui/button" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@/components/ui/command" import { Dialog, DialogContent, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { useDictionary } from "@/hooks/use-dictionary" import type { FlattenedModel, ProviderName } from "@/lib/types/model-config" import { cn } from "@/lib/utils" interface ModelSelectorProps { models: FlattenedModel[] selectedModelId: string | undefined onSelect: (modelId: string | undefined) => void onConfigure?: () => void /** Opens the settings on the list of providers to add */ onAddProvider?: () => void disabled?: boolean } type ProviderGroup = { provider: ProviderName; models: FlattenedModel[] } // Group models by providerLabel (handles duplicate providers) function groupModelsByProvider( models: FlattenedModel[], ): Map { const groups = new Map() for (const model of models) { const existing = groups.get(model.providerLabel) if (existing) { existing.models.push(model) } else { groups.set(model.providerLabel, { provider: model.provider, models: [model], }) } } return groups } /** * The model id without the provider or region prefix that the logo already * shows: "nvidia/nemotron-3-ultra" and "global.anthropic.claude-opus-5-5" * become "nemotron-3-ultra" and "claude-opus-5-5". The full id is in the * tooltip and the list. */ export function shortModelName(id: string): string { const name = id.slice(id.lastIndexOf("/") + 1) const short = name.replace( /^(?:(?:global|us|eu|apac|jp|au|ca|us-gov)\.)?[a-z][a-z0-9-]*\.(?=[a-z])/i, "", ) // Ids like "deepseek.r1-v1:0" name the vendor only in the prefix: drop // just the region if (!short || /^[a-z]\d/i.test(short)) { return name.replace(/^(?:global|us|eu|apac|jp|au|ca|us-gov)\./i, "") } return short } // The command list with a shadow at the bottom while more items are below function ModelList({ className, ...props }: ComponentProps) { const listRef = useRef(null) const [showShadow, setShowShadow] = useState(false) useEffect(() => { const listElement = listRef.current if (!listElement) return const checkScroll = () => { const { scrollTop, scrollHeight, clientHeight } = listElement // A small threshold handles fractional pixel rendering setShowShadow( scrollHeight > Math.ceil(scrollTop + clientHeight) + 1, ) } checkScroll() listElement.addEventListener("scroll", checkScroll) window.addEventListener("resize", checkScroll) // Content changes, e.g. the search filtering items const observer = new MutationObserver(checkScroll) observer.observe(listElement, { childList: true, subtree: true }) return () => { listElement.removeEventListener("scroll", checkScroll) window.removeEventListener("resize", checkScroll) observer.disconnect() } }, []) return (
) } function SectionHeader({ icon, label }: { icon: ReactNode; label: string }) { return (
{label}
) } export function ModelSelector({ models, selectedModelId, onSelect, onConfigure, onAddProvider, disabled = false, }: ModelSelectorProps) { const dict = useDictionary() const [open, setOpen] = useState(false) // Separate server and user models. Every user model is listed; one not // tested yet, or that failed its test, says so. const serverModels = useMemo( () => models.filter((m) => m.source === "server"), [models], ) const userModels = useMemo( () => models.filter((m) => m.source !== "server"), [models], ) // Group each category separately const groupedServerModels = useMemo( () => groupModelsByProvider(serverModels), [serverModels], ) const groupedUserModels = useMemo( () => groupModelsByProvider(userModels), [userModels], ) // Find selected model for display const selectedModel = useMemo( () => models.find((m) => m.id === selectedModelId), [models, selectedModelId], ) // Leaving for the settings: closing, the picker must not give the focus // back to its button, behind the settings dialog const toSettingsRef = useRef(false) const goToSettings = (openSettings: () => void) => { toSettingsRef.current = true setOpen(false) openSettings() } const footerButton = "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring" const handleSelect = (value: string) => { if (value === "__server_default__") { onSelect(undefined) } else { onSelect(value) } setOpen(false) } // One group per provider; `trailing` is the small label at the end of a // row ("Default" for server models, the test state for user models) const renderGroups = ( groups: Map, keyPrefix: "server" | "user", trailing: (model: FlattenedModel) => ReactNode, ) => Array.from(groups.entries()).map( ([providerLabel, { provider, models: providerModels }]) => ( {providerModels.map((model) => ( handleSelect(model.id)} className="cursor-pointer" > {model.modelId} {trailing(model)} ))} ), ) const tooltipContent = selectedModel ? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}` : `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}` const wrapperRef = useRef(null) const [showLabel, setShowLabel] = useState(true) // Threshold (px) under which we hide the label (tweak as needed) const HIDE_THRESHOLD = 240 const SHOW_THRESHOLD = 260 useEffect(() => { const el = wrapperRef.current if (!el) return const target = el.parentElement ?? el const ro = new ResizeObserver((entries) => { for (const entry of entries) { const width = entry.contentRect.width setShowLabel((prev) => { // if currently showing and width dropped below hide threshold -> hide if (prev && width <= HIDE_THRESHOLD) return false // if currently hidden and width rose above show threshold -> show if (!prev && width >= SHOW_THRESHOLD) return true // otherwise keep previous state (hysteresis) return prev }) } }) ro.observe(target) const initialWidth = target.getBoundingClientRect().width setShowLabel(initialWidth >= SHOW_THRESHOLD) return () => ro.disconnect() }, []) return (
{ if (next) toSettingsRef.current = false setOpen(next) }} > {/* The dialog trigger must wrap the button itself: Radix Tooltip's root renders no element to pass its props to */} {tooltipContent} { if (!toSettingsRef.current) return toSettingsRef.current = false e.preventDefault() }} > {dict.modelConfig.selectModel}
{dict.modelConfig.noModelsFound} {/* Server Default Option - only show when no server models are configured */} {serverModels.length === 0 && ( { dict.modelConfig .serverDefault } )} {/* Server Models Section */} {serverModels.length > 0 && ( <> } label={ dict.modelConfig .serverModels } /> {renderGroups( groupedServerModels, "server", (model) => model.isDefault && ( { dict.modelConfig .default } ), )} )} {/* User Models Section */} {userModels.length > 0 && ( <> {serverModels.length > 0 && ( )} } label={ dict.modelConfig.userModels } /> {renderGroups( groupedUserModels, "user", (model) => model.validated !== true && ( {model.validated === false ? dict .modelConfig .modelFailed : dict .modelConfig .modelUntested} ), )} )}
{/* Pinned footer: add a provider, configure models (z-10 above list shadow). Buttons, outside the search: reachable with Tab, never filtered out */} {(onAddProvider || onConfigure) && (
e.stopPropagation()} > {onAddProvider && ( )} {onConfigure && ( )}
)}
) }