Model Selector
Selecteur deroulant pour choisir des modeles IA. Installez Model Selector depuis le registre VLLNT UI avec la CLI shadcn.
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.jsonSource
"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.