ModelPicker.tsx
Language TypeScript5.6 KB157 lines
import { useEffect, useMemo, useRef, useState } from 'react'import type { ModelInfo } from '../ipc/gen/ModelInfo'import { isVision, priceLabel, tokens } from '../lib/format'import { useCurrentModel, useStore } from '../state/store'type Filter = 'all' | 'favorites' | 'vision' | 'reasoning' | 'local'const FILTERS: [Filter, string][] = [['all', 'All'],['favorites', 'Favorites'],['vision', 'Vision'],['reasoning', 'Reasoning'],['local', 'Local'],]export function ModelPicker({ onClose }: { onClose: () => void }) {const [q, setQ] = useState('')const [filter, setFilter] = useState<Filter>('all')const [cursor, setCursor] = useState(0)const input = useRef<HTMLInputElement>(null)const models = useStore((s) => s.models)const providers = useStore((s) => s.providers)const settings = useStore((s) => s.settings)const saveSettings = useStore((s) => s.saveSettings)const chooseModel = useStore((s) => s.chooseModel)const refreshModels = useStore((s) => s.refreshModels)const current = useCurrentModel()useEffect(() => input.current?.focus(), [])const favs = settings?.favorite_models ?? []const rows = useMemo(() => {const words = q.toLowerCase().split(/\s+/).filter(Boolean)const all: ModelInfo[] = providers.flatMap((p) => models[p.id]?.models ?? [])return all.filter((m) => {const hay = `${m.id} ${m.provider_id}`.toLowerCase()if (!words.every((w) => hay.includes(w))) return falseif (filter === 'favorites') return favs.includes(m.id)if (filter === 'vision') return isVision(m)if (filter === 'reasoning') return m.tags.includes('reasoning')if (filter === 'local') return m.tags.includes('local')return true})}, [q, filter, models, providers, favs])const pick = (m: ModelInfo) => {void chooseModel(m.provider_id, m.id)onClose()}const toggleFav = (id: string) => {if (!settings) returnconst next = favs.includes(id) ? favs.filter((f) => f !== id) : [...favs, id]void saveSettings({ ...settings, favorite_models: next })}const di = models.deepinfraconst source =di?.source === 'snapshot'? `Prices as of ${di.snapshot_date ?? 'the bundled list'} (offline copy)`: di?.fetched_at? `Prices from DeepInfra, updated ${new Date(di.fetched_at).toLocaleString()}`: ''return (<divclassName="popover model-picker glass-strong"role="dialog"aria-label="Choose a model"onKeyDown={(e) => {if (e.key === 'Escape') onClose()if (e.key === 'ArrowDown') {e.preventDefault()setCursor((c) => Math.min(c + 1, rows.length - 1))}if (e.key === 'ArrowUp') {e.preventDefault()setCursor((c) => Math.max(c - 1, 0))}if (e.key === 'Enter' && rows[cursor]) pick(rows[cursor])}}><inputref={input}className="search-input"placeholder="Search models"value={q}onChange={(e) => {setQ(e.target.value)setCursor(0)}}aria-label="Search models"/><div className="filters" role="tablist">{FILTERS.map(([f, label]) => (<buttonkey={f}role="tab"aria-selected={filter === f}className={`pill ${filter === f ? 'on' : ''}`}onClick={() => {setFilter(f)setCursor(0)}}>{label}</button>))}</div><div className="model-head"><span>Model</span><span>In / out per 1M</span><span>Context</span></div><ul className="model-list" role="listbox" aria-label="Models">{rows.map((m, i) => {const selected = m.id === current.modelId && m.provider_id === current.providerIdreturn (<likey={`${m.provider_id}/${m.id}`}role="option"aria-selected={selected}className={`model-row ${i === cursor ? 'cursor' : ''} ${selected ? 'selected' : ''}`}onMouseEnter={() => setCursor(i)}onClick={() => pick(m)}><span className="model-name"><buttonclassName={`star ${favs.includes(m.id) ? 'on' : ''}`}aria-label={favs.includes(m.id) ? 'Remove from favorites' : 'Add to favorites'}onClick={(e) => {e.stopPropagation()toggleFav(m.id)}}>★</button><span className="model-id">{m.id}</span>{m.tags.includes('vision') && <span className="tag">vision</span>}{m.tags.includes('reasoning') && <span className="tag">reasoning</span>}{m.provider_id !== 'deepinfra' && (<span className="tag local">{providers.find((p) => p.id === m.provider_id)?.name ?? 'local'}</span>)}</span><span className="model-price">{priceLabel(m.prices)}</span><span className="model-ctx">{m.context_length ? tokens(m.context_length) : ''}</span></li>)})}{rows.length === 0 && <li className="empty-row">No models match.</li>}</ul><div className="picker-foot"><span className="muted small">{source}</span><button className="ghost small" onClick={() => providers.forEach((p) => void refreshModels(p.id, true))}>Refresh</button></div></div>)}