/* * ModelMenu.tsx * Zyquo Cloud Web * * Author: Simon-Pierre Boucher * Mail: contact@spboucher.ai * * The Model Menu — command-palette-style picker (also opened with ⌘K via the * command palette's model section). Instant fuzzy search, capability filter * chips, Favorites + Recents groups, provider sections (keyed providers * first), capability/context/price badges, per-row star, "not configured" * affordance, keyboard navigation. */ import { useEffect, useMemo, useRef, useState } from 'react' import { CATALOG, priceBadge } from '../features/catalogHelpers' import { PROVIDER_META } from '../providers/registry' import { useStore } from '../state/store' import { contextBadge, type AIModel, type Provider } from '../types' import { IconBrain, IconEye, IconSearch, IconStar, IconWrench, ProviderGlyph, } from './icons' const FILTERS = [ { key: 'vision', label: 'Vision' }, { key: 'tools', label: 'Tools' }, { key: 'reasoning', label: 'Reasoning' }, { key: 'jsonMode', label: 'JSON' }, { key: 'long', label: 'Long context' }, { key: 'cheap', label: 'Cheapest' }, ] as const type FilterKey = (typeof FILTERS)[number]['key'] /** Simple subsequence fuzzy match; returns a score (lower = better) or null. */ function fuzzyScore(query: string, target: string): number | null { const q = query.toLowerCase() const t = target.toLowerCase() if (q === '') return 0 const direct = t.indexOf(q) if (direct !== -1) return direct let qi = 0 let gaps = 0 let last = -1 for (let ti = 0; ti < t.length && qi < q.length; ti++) { if (t[ti] === q[qi]) { if (last !== -1) gaps += ti - last - 1 last = ti qi++ } } return qi === q.length ? 100 + gaps : null } interface Row { model: AIModel section: string } export default function ModelMenu({ onPick, onClose, currentID, title = 'Switch model', }: { onPick: (model: AIModel, scope: 'conversation' | 'default' | 'message') => void onClose: () => void currentID?: string title?: string }) { const settings = useStore((s) => s.settings) const updateSettings = useStore((s) => s.updateSettings) const keyedProviders = useStore((s) => s.keyedProviders) const openSettings = useStore((s) => s.openSettings) const [query, setQuery] = useState('') const [filters, setFilters] = useState>(new Set()) const [highlight, setHighlight] = useState(0) const inputRef = useRef(null) const listRef = useRef(null) useEffect(() => inputRef.current?.focus(), []) const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs]) const rows: Row[] = useMemo(() => { let models = [...CATALOG] // Capability filter chips if (filters.has('vision')) models = models.filter((m) => m.capabilities.vision) if (filters.has('tools')) models = models.filter((m) => m.capabilities.tools) if (filters.has('reasoning')) models = models.filter((m) => m.capabilities.reasoning) if (filters.has('jsonMode')) models = models.filter((m) => m.capabilities.jsonMode) if (filters.has('long')) models = models.filter((m) => m.contextWindow >= 400_000) if (filters.has('cheap')) { models = models .filter((m) => m.pricing) .sort((a, b) => (a.pricing?.outputPerMTok ?? 0) - (b.pricing?.outputPerMTok ?? 0)) .slice(0, 20) } // Fuzzy search over name + id + provider if (query !== '') { const scored = models .map((m) => { const scores = [ fuzzyScore(query, m.displayName), fuzzyScore(query, m.id), fuzzyScore(query, PROVIDER_META[m.provider].displayName), ].filter((s): s is number => s !== null) return scores.length > 0 ? { m, score: Math.min(...scores) } : null }) .filter((x): x is { m: AIModel; score: number } => x !== null) .sort((a, b) => a.score - b.score) return scored.map(({ m }) => ({ model: m, section: 'Results' })) } // Grouped: aliases → favorites → recents → providers (keyed first) const out: Row[] = [] for (const [alias, ref] of Object.entries(settings.aliases)) { const model = models.find((m) => m.provider === ref.provider && m.id === ref.modelID) if (model) out.push({ model, section: `Alias: ${alias}` }) } const favorites = models.filter((m) => favoriteSet.has(m.id)) for (const model of favorites) out.push({ model, section: 'Favorites' }) const recents = settings.recentModelIDs .map((id) => models.find((m) => m.id === id)) .filter((m): m is AIModel => m !== undefined && !favoriteSet.has(m.id)) .slice(0, 5) for (const model of recents) out.push({ model, section: 'Recents' }) const providerOrder: Provider[] = [...new Set(models.map((m) => m.provider))].sort((a, b) => { const ak = keyedProviders.includes(a) ? 0 : 1 const bk = keyedProviders.includes(b) ? 0 : 1 if (ak !== bk) return ak - bk return PROVIDER_META[a].displayName.localeCompare(PROVIDER_META[b].displayName) }) for (const provider of providerOrder) { const list = models .filter((m) => m.provider === provider) .sort((a, b) => rankOf(a) - rankOf(b)) for (const model of list) { out.push({ model, section: PROVIDER_META[provider].displayName }) } } return out function rankOf(m: AIModel): number { if (favoriteSet.has(m.id)) return 0 if (m.isRecommended) return 1 if (m.isLegacy) return 3 return 2 } }, [query, filters, settings.aliases, settings.recentModelIDs, favoriteSet, keyedProviders]) useEffect(() => setHighlight(0), [query, filters]) const pick = (model: AIModel, scope: 'conversation' | 'default' | 'message' = 'conversation') => { updateSettings({ recentModelIDs: [model.id, ...settings.recentModelIDs.filter((id) => id !== model.id)].slice( 0, 8 ), }) onPick(model, scope) onClose() } const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault() setHighlight((h) => Math.min(h + 1, rows.length - 1)) scrollToHighlight(1) } else if (e.key === 'ArrowUp') { e.preventDefault() setHighlight((h) => Math.max(h - 1, 0)) scrollToHighlight(-1) } else if (e.key === 'Enter') { e.preventDefault() const row = rows[highlight] if (row) pick(row.model, e.metaKey || e.ctrlKey ? 'default' : 'conversation') } else if (e.key === 'Escape') { onClose() } } const scrollToHighlight = (dir: number) => { requestAnimationFrame(() => { const el = listRef.current?.querySelector('.model-row.highlighted') el?.scrollIntoView({ block: dir > 0 ? 'nearest' : 'nearest' }) }) } const toggleFavorite = (e: React.MouseEvent, model: AIModel) => { e.stopPropagation() const next = favoriteSet.has(model.id) ? settings.favoriteModelIDs.filter((id) => id !== model.id) : [...settings.favoriteModelIDs, model.id] updateSettings({ favoriteModelIDs: next }) } let lastSection = '' let rowIndex = -1 return (
e.target === e.currentTarget && onClose()}>
setQuery(e.target.value)} />
{FILTERS.map((f) => ( ))}
{rows.length === 0 &&
No models match.
} {rows.map((row) => { rowIndex++ const index = rowIndex const header = row.section !== lastSection ? (
{row.section}
) : null lastSection = row.section const model = row.model const keyed = keyedProviders.includes(model.provider) return (
{header} )} {model.capabilities.vision && ( )} {model.capabilities.reasoning && ( )} {model.capabilities.tools && ( )} {contextBadge(model)} {priceBadge(model)}
) })}
↑↓ navigate use for this chat ⌘↵ set as default esc close
) }