model-combobox.tsx
3,046 bytes
| 1 | 'use client'; |
|---|---|
| 2 | |
| 3 | import { Check, ChevronsUpDown, Search } from 'lucide-react'; |
| 4 | import { useMemo, useState } from 'react'; |
| 5 | import { Button } from '@/components/ui/button'; |
| 6 | import { Input } from '@/components/ui/input'; |
| 7 | import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; |
| 8 | import { useModels, pricePerMillion, type ModelInfo } from '@/hooks/use-models'; |
| 9 | import { cn } from '@/lib/utils'; |
| 10 | |
| 11 | /** Searchable single-model selector (used for chairman + convergence model). */ |
| 12 | export function ModelCombobox({ |
| 13 | value, |
| 14 | onChange, |
| 15 | placeholder = 'Select a model', |
| 16 | exclude, |
| 17 | }: { |
| 18 | value: string; |
| 19 | onChange: (id: string) => void; |
| 20 | placeholder?: string; |
| 21 | exclude?: string[]; |
| 22 | }) { |
| 23 | const { models } = useModels(); |
| 24 | const [open, setOpen] = useState(false); |
| 25 | const [query, setQuery] = useState(''); |
| 26 | |
| 27 | const selected = models.find((m) => m.id === value); |
| 28 | const filtered = useMemo(() => filterModels(models, query, exclude), [models, query, exclude]); |
| 29 | |
| 30 | return ( |
| 31 | <Popover open={open} onOpenChange={setOpen}> |
| 32 | <PopoverTrigger asChild> |
| 33 | <Button variant="outline" role="combobox" className="w-full justify-between font-normal"> |
| 34 | <span className="truncate">{selected ? selected.name : value || placeholder}</span> |
| 35 | <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" /> |
| 36 | </Button> |
| 37 | </PopoverTrigger> |
| 38 | <PopoverContent className="w-[--radix-popover-trigger-width] p-0"> |
| 39 | <div className="flex items-center gap-2 border-b px-2"> |
| 40 | <Search className="h-4 w-4 text-muted-foreground" /> |
| 41 | <Input |
| 42 | value={query} |
| 43 | onChange={(e) => setQuery(e.target.value)} |
| 44 | placeholder="Search models..." |
| 45 | className="h-9 border-0 shadow-none focus-visible:ring-0" |
| 46 | /> |
| 47 | </div> |
| 48 | <div className="scrollbar-thin max-h-64 overflow-y-auto p-1"> |
| 49 | {filtered.length === 0 && <div className="p-3 text-sm text-muted-foreground">No models found</div>} |
| 50 | {filtered.map((m) => ( |
| 51 | <button |
| 52 | key={m.id} |
| 53 | onClick={() => { |
| 54 | onChange(m.id); |
| 55 | setOpen(false); |
| 56 | setQuery(''); |
| 57 | }} |
| 58 | className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent" |
| 59 | > |
| 60 | <Check className={cn('h-4 w-4', value === m.id ? 'opacity-100' : 'opacity-0')} /> |
| 61 | <span className="flex-1 truncate">{m.name}</span> |
| 62 | <span className="text-[11px] text-muted-foreground">{pricePerMillion(m.completionPrice)}</span> |
| 63 | </button> |
| 64 | ))} |
| 65 | </div> |
| 66 | </PopoverContent> |
| 67 | </Popover> |
| 68 | ); |
| 69 | } |
| 70 | |
| 71 | export function filterModels(models: ModelInfo[], query: string, exclude?: string[]): ModelInfo[] { |
| 72 | const q = query.trim().toLowerCase(); |
| 73 | const ex = new Set(exclude ?? []); |
| 74 | return models |
| 75 | .filter((m) => !ex.has(m.id)) |
| 76 | .filter((m) => !q || m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q)) |
| 77 | .slice(0, 100); |
| 78 | } |
| 79 | |