Model Selector

Selecteur deroulant pour choisir des modeles IA. Installez Model Selector depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Selecteur deroulant pour choisir des modeles IA. Fait partie de la famille ai dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Installation

Ajoutez Model Selector a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/model-selector.json

Source

"use client"; import { useMemo, useRef, useState } from "react"; import { ArrowUpDown, Filter } from "lucide-react"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; import { Button } from "../button/button"; import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList, } from "../command/command"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../dialog/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../dropdown-menu/dropdown-menu"; import { Input } from "../input/input"; /** Model information for the selector. */ export type ModelInfo = { description?: string; id: string; name: string; pricing?: { input?: number; // per 1M tokens output?: number; // per 1M tokens }; }; export type ModelSelectorProps = { models: ModelInfo[]; onOpenChange: (open: boolean) => void; onSelectModel: (modelId: string) => void; open: boolean; selectedModelId: string; }; type SortOption = "name" | "price-high" | "price-low" | "provider"; type SelectionGuardReference = { current: null | string }; function getProvider(modelId: string): string { const parts = modelId.split("/"); return parts[0] || "unknown"; } function getAveragePrice(model: ModelInfo): number { if (!model.pricing) return Number.POSITIVE_INFINITY; const inputPrice = model.pricing.input ?? 0; const outputPrice = model.pricing.output ?? inputPrice; return (inputPrice + outputPrice) / 2; } function formatPrice(price?: number): string { if (!price) return "N/A"; return `$${price.toFixed(2)}/1M`; } function getProviders(models: ModelInfo[]): string[] { const { providers } = models.reduce<{ providers: string[]; seen: Set<string>; }>( (accumulator, model) => { const provider = getProvider(model.id); if (!accumulator.seen.has(provider)) { accumulator.seen.add(provider); accumulator.providers.push(provider); } return accumulator; }, { providers: [], seen: new Set<string>() }, ); return providers.sort(); } function applyProviderFilter(models: ModelInfo[], providerFilter: string) { if (providerFilter === "all") return models; return models.filter((model) => getProvider(model.id) === providerFilter); } function applySearchFilter(models: ModelInfo[], modelSearchQuery: string) { if (!modelSearchQuery.trim()) return models; const query = modelSearchQuery.toLowerCase(); return models.filter( (model) => model.name.toLowerCase().includes(query) || model.id.toLowerCase().includes(query) || model.description?.toLowerCase().includes(query) || getProvider(model.id).toLowerCase().includes(query), ); } function sortModelsBy(models: ModelInfo[], sortBy: SortOption) { const sorted = [...models]; return sorted.sort((a, b) => { switch (sortBy) { case "name": return a.name.localeCompare(b.name); case "price-low": return getAveragePrice(a) - getAveragePrice(b); case "price-high": return getAveragePrice(b) - getAveragePrice(a); case "provider": { const providerA = getProvider(a.id); const providerB = getProvider(b.id); if (providerA !== providerB) return providerA.localeCompare(providerB); return a.name.localeCompare(b.name); } default: return 0; } }); } function promoteSelected(models: ModelInfo[], selectedModelId: string) { const sorted = [...models]; const selectedIndex = sorted.findIndex( (model) => model.id === selectedModelId, ); if (selectedIndex > 0) { const [selected] = sorted.splice(selectedIndex, 1); if (selected) sorted.unshift(selected); } return sorted; } function resetFilters( setModelSearchQuery: (value: string) => void, setProviderFilter: (value: string) => void, setSortBy: (value: SortOption) => void, ) { setModelSearchQuery(""); setProviderFilter("all"); setSortBy("name"); } type HandleModelSelectArguments = { handleClose: (nextOpen: boolean) => void; id: string; onSelectModel: (id: string) => void; selectionGuardReference: SelectionGuardReference; }; function handleModelSelect({ handleClose, id, onSelectModel, selectionGuardReference, }: HandleModelSelectArguments) { if (selectionGuardReference.current === id) return; selectionGuardReference.current = id; onSelectModel(id); setTimeout(() => { if (selectionGuardReference.current === id) selectionGuardReference.current = null; }, 0); handleClose(false); } type FilterAndSortArguments = { models: ModelInfo[]; modelSearchQuery: string; providerFilter: string; selectedModelId: string; sortBy: SortOption; }; function filterAndSortModels({ models, modelSearchQuery, providerFilter, selectedModelId, sortBy, }: FilterAndSortArguments): ModelInfo[] { const filtered = applySearchFilter( applyProviderFilter(models, providerFilter), modelSearchQuery, ); return promoteSelected(sortModelsBy(filtered, sortBy), selectedModelId); } function useFilteredModels(options: FilterAndSortArguments) { const { models, modelSearchQuery, providerFilter, selectedModelId, sortBy } = options; return useMemo( () => filterAndSortModels({ models, modelSearchQuery, providerFilter, selectedModelId, sortBy, }), [models, modelSearchQuery, sortBy, providerFilter, selectedModelId], ); } type ModelSelectorState = { filteredAndSortedModels: ModelInfo[]; handleClose: (nextOpen: boolean) => void; handleSelect: (id: string) => void; modelSearchQuery: string; providerFilter: string; providers: string[]; setModelSearchQuery: (value: string) => void; setProviderFilter: (value: string) => void; setSortBy: (value: SortOption) => void; sortBy: SortOption; }; function useModelSelectorState({ models, onOpenChange, onSelectModel, selectedModelId, }: ModelSelectorProps): ModelSelectorState { const [modelSearchQuery, setModelSearchQuery] = useState(""); const [sortBy, setSortBy] = useState<SortOption>("name"); const [providerFilter, setProviderFilter] = useState<string>("all"); const selectionGuardReference = useRef<null | string>(null); const providers = useMemo(() => getProviders(models), [models]); const filteredAndSortedModels = useFilteredModels({ models, modelSearchQuery, providerFilter, selectedModelId, sortBy, }); const handleClose = (nextOpen: boolean) => { onOpenChange(nextOpen); if (!nextOpen) { resetFilters(setModelSearchQuery, setProviderFilter, setSortBy); } }; const handleSelect = (id: string) => { handleModelSelect({ handleClose, id, onSelectModel, selectionGuardReference, }); }; return { filteredAndSortedModels, handleClose, handleSelect, modelSearchQuery, providerFilter, providers, setModelSearchQuery, setProviderFilter, setSortBy, sortBy, }; } type ProviderFilterMenuProps = { onChange: (value: string) => void; providerFilter: string; providers: string[]; }; function ProviderFilterMenu({ onChange, providerFilter, providers, }: ProviderFilterMenuProps) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button className="h-9 gap-2" size="sm" variant="outline"> <Filter className="size-4" /> {providerFilter === "all" ? "All Providers" : providerFilter} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <DropdownMenuLabel>Filter by Provider</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuRadioGroup onValueChange={onChange} value={providerFilter}> <DropdownMenuRadioItem value="all"> All Providers </DropdownMenuRadioItem> {providers.map((provider) => ( <DropdownMenuRadioItem key={provider} value={provider}> {provider} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ); } type SortMenuProps = { onChange: (value: SortOption) => void; sortBy: SortOption; }; function SortMenu({ onChange, sortBy }: SortMenuProps) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button className="h-9 gap-2" size="sm" variant="outline"> <ArrowUpDown className="size-4" /> Sort </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <DropdownMenuLabel>Sort by</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuRadioGroup onValueChange={(value) => { onChange(value as SortOption); }} value={sortBy} > <DropdownMenuRadioItem value="name">Name</DropdownMenuRadioItem> <DropdownMenuRadioItem value="provider"> Provider </DropdownMenuRadioItem> <DropdownMenuRadioItem value="price-low"> Price: Low to High </DropdownMenuRadioItem> <DropdownMenuRadioItem value="price-high"> Price: High to Low </DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ); } type ModelSelectorFiltersProps = { modelSearchQuery: string; onProviderChange: (value: string) => void; onSearchChange: (value: string) => void; onSortChange: (value: SortOption) => void; providerFilter: string; providers: string[]; sortBy: SortOption; }; function ModelSelectorFilters({ modelSearchQuery, onProviderChange, onSearchChange, onSortChange, providerFilter, providers, sortBy, }: ModelSelectorFiltersProps) { return ( <div className="flex items-center gap-2 px-1 pb-2 border-b"> <div className="flex-1"> <Input className="h-9" onChange={(event) => { onSearchChange(event.target.value); }} placeholder="Search models or providers..." value={modelSearchQuery} /> </div> <ProviderFilterMenu onChange={onProviderChange} providerFilter={providerFilter} providers={providers} /> <SortMenu onChange={onSortChange} sortBy={sortBy} /> </div> ); } type ModelListProps = { models: ModelInfo[]; onSelect: (id: string) => void; selectedModelId: string; }; function ModelList({ models, onSelect, selectedModelId }: ModelListProps) { return ( <Command className="flex-1" shouldFilter={false}> <CommandList className="max-h-[60vh]"> <CommandEmpty>No models found.</CommandEmpty> <CommandGroup> {models.map((model) => ( <ModelListItem key={model.id} model={model} onSelect={onSelect} selectedModelId={selectedModelId} /> ))} </CommandGroup> </CommandList> </Command> ); } type ModelPricingProps = { pricing?: ModelInfo["pricing"]; }; function ModelPricing({ pricing }: ModelPricingProps) { if (!pricing) return null; return ( <div className="flex items-center gap-3 text-xs text-muted-foreground pointer-events-none"> {pricing.input ? <span>In: {formatPrice(pricing.input)}</span> : null} {pricing.output ? <span>Out: {formatPrice(pricing.output)}</span> : null} </div> ); } type ModelListItemProps = { model: ModelInfo; onSelect: (id: string) => void; selectedModelId: string; }; function ModelListItem({ model, onSelect, selectedModelId, }: ModelListItemProps) { const isSelected = selectedModelId === model.id; const provider = getProvider(model.id); return ( <CommandItem className={cn( "flex flex-col items-start py-3", isSelected && "bg-accent", )} disabled={isSelected} onSelect={() => { onSelect(model.id); }} value={model.id} > <div className="flex items-center justify-between w-full pointer-events-none"> <div className="flex items-center gap-2"> <span className="font-medium">{model.name}</span> {isSelected ? ( <Badge className="text-xs pointer-events-none" variant="secondary"> Selected </Badge> ) : null} <Badge className="text-xs font-mono pointer-events-none" variant="outline" > {provider} </Badge> </div> <ModelPricing pricing={model.pricing} /> </div> {model.description ? ( <span className="text-xs text-muted-foreground mt-1 pointer-events-none"> {model.description} </span> ) : null} <span className="text-xs text-muted-foreground mt-1 font-mono pointer-events-none"> {model.id} </span> </CommandItem> ); } /** Model selector dialog with search, filtering, and sorting. */ export function ModelSelector(props: ModelSelectorProps) { const { filteredAndSortedModels, handleClose, handleSelect, modelSearchQuery, providerFilter, providers, setModelSearchQuery, setProviderFilter, setSortBy, sortBy, } = useModelSelectorState(props); return ( <Dialog onOpenChange={handleClose} open={props.open}> <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col"> <DialogHeader> <DialogTitle>Select Model</DialogTitle> <DialogDescription className="sr-only"> Search, filter, and select an AI model </DialogDescription> </DialogHeader> <ModelSelectorFilters modelSearchQuery={modelSearchQuery} onProviderChange={setProviderFilter} onSearchChange={setModelSearchQuery} onSortChange={setSortBy} providerFilter={providerFilter} providers={providers} sortBy={sortBy} /> <ModelList models={filteredAndSortedModels} onSelect={handleSelect} selectedModelId={props.selectedModelId} /> </DialogContent> </Dialog> ); }

Stories

Explorez chaque variante et etat dans le Storybook interactif :

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Dependances

  • @vllnt/ui@^0.3.0