spb/zyquo-cloud-web Public MIT
Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.
TypeScript 81.9%
CSS 8.9%
JavaScript 7.5%
Shell 1.1%
HTML 0.6%
1/*2 * ModelMenu.tsx3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * The Model Menu — command-palette-style picker (also opened with ⌘K via the9 * command palette's model section). Instant fuzzy search, capability filter10 * chips, Favorites + Recents groups, provider sections (keyed providers11 * first), capability/context/price badges, per-row star, "not configured"12 * affordance, keyboard navigation.13 */1415import { useEffect, useMemo, useRef, useState } from 'react'16import { CATALOG, priceBadge } from '../features/catalogHelpers'17import { PROVIDER_META } from '../providers/registry'18import { useStore } from '../state/store'19import { contextBadge, type AIModel, type Provider } from '../types'20import {21 IconBrain,22 IconEye,23 IconSearch,24 IconStar,25 IconWrench,26 ProviderGlyph,27} from './icons'2829const FILTERS = [30 { key: 'vision', label: 'Vision' },31 { key: 'tools', label: 'Tools' },32 { key: 'reasoning', label: 'Reasoning' },33 { key: 'jsonMode', label: 'JSON' },34 { key: 'long', label: 'Long context' },35 { key: 'cheap', label: 'Cheapest' },36] as const3738type FilterKey = (typeof FILTERS)[number]['key']3940/** Simple subsequence fuzzy match; returns a score (lower = better) or null. */41function fuzzyScore(query: string, target: string): number | null {42 const q = query.toLowerCase()43 const t = target.toLowerCase()44 if (q === '') return 045 const direct = t.indexOf(q)46 if (direct !== -1) return direct47 let qi = 048 let gaps = 049 let last = -150 for (let ti = 0; ti < t.length && qi < q.length; ti++) {51 if (t[ti] === q[qi]) {52 if (last !== -1) gaps += ti - last - 153 last = ti54 qi++55 }56 }57 return qi === q.length ? 100 + gaps : null58}5960interface Row {61 model: AIModel62 section: string63}6465export default function ModelMenu({66 onPick,67 onClose,68 currentID,69 title = 'Switch model',70}: {71 onPick: (model: AIModel, scope: 'conversation' | 'default' | 'message') => void72 onClose: () => void73 currentID?: string74 title?: string75}) {76 const settings = useStore((s) => s.settings)77 const updateSettings = useStore((s) => s.updateSettings)78 const keyedProviders = useStore((s) => s.keyedProviders)79 const openSettings = useStore((s) => s.openSettings)80 const [query, setQuery] = useState('')81 const [filters, setFilters] = useState<Set<FilterKey>>(new Set())82 const [highlight, setHighlight] = useState(0)83 const inputRef = useRef<HTMLInputElement>(null)84 const listRef = useRef<HTMLDivElement>(null)8586 useEffect(() => inputRef.current?.focus(), [])8788 const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs])8990 const rows: Row[] = useMemo(() => {91 let models = [...CATALOG]92 // Capability filter chips93 if (filters.has('vision')) models = models.filter((m) => m.capabilities.vision)94 if (filters.has('tools')) models = models.filter((m) => m.capabilities.tools)95 if (filters.has('reasoning')) models = models.filter((m) => m.capabilities.reasoning)96 if (filters.has('jsonMode')) models = models.filter((m) => m.capabilities.jsonMode)97 if (filters.has('long')) models = models.filter((m) => m.contextWindow >= 400_000)98 if (filters.has('cheap')) {99 models = models100 .filter((m) => m.pricing)101 .sort((a, b) => (a.pricing?.outputPerMTok ?? 0) - (b.pricing?.outputPerMTok ?? 0))102 .slice(0, 20)103 }104 // Fuzzy search over name + id + provider105 if (query !== '') {106 const scored = models107 .map((m) => {108 const scores = [109 fuzzyScore(query, m.displayName),110 fuzzyScore(query, m.id),111 fuzzyScore(query, PROVIDER_META[m.provider].displayName),112 ].filter((s): s is number => s !== null)113 return scores.length > 0 ? { m, score: Math.min(...scores) } : null114 })115 .filter((x): x is { m: AIModel; score: number } => x !== null)116 .sort((a, b) => a.score - b.score)117 return scored.map(({ m }) => ({ model: m, section: 'Results' }))118 }119 // Grouped: aliases → favorites → recents → providers (keyed first)120 const out: Row[] = []121 for (const [alias, ref] of Object.entries(settings.aliases)) {122 const model = models.find((m) => m.provider === ref.provider && m.id === ref.modelID)123 if (model) out.push({ model, section: `Alias: ${alias}` })124 }125 const favorites = models.filter((m) => favoriteSet.has(m.id))126 for (const model of favorites) out.push({ model, section: 'Favorites' })127 const recents = settings.recentModelIDs128 .map((id) => models.find((m) => m.id === id))129 .filter((m): m is AIModel => m !== undefined && !favoriteSet.has(m.id))130 .slice(0, 5)131 for (const model of recents) out.push({ model, section: 'Recents' })132 const providerOrder: Provider[] = [...new Set(models.map((m) => m.provider))].sort((a, b) => {133 const ak = keyedProviders.includes(a) ? 0 : 1134 const bk = keyedProviders.includes(b) ? 0 : 1135 if (ak !== bk) return ak - bk136 return PROVIDER_META[a].displayName.localeCompare(PROVIDER_META[b].displayName)137 })138 for (const provider of providerOrder) {139 const list = models140 .filter((m) => m.provider === provider)141 .sort((a, b) => rankOf(a) - rankOf(b))142 for (const model of list) {143 out.push({ model, section: PROVIDER_META[provider].displayName })144 }145 }146 return out147148 function rankOf(m: AIModel): number {149 if (favoriteSet.has(m.id)) return 0150 if (m.isRecommended) return 1151 if (m.isLegacy) return 3152 return 2153 }154 }, [query, filters, settings.aliases, settings.recentModelIDs, favoriteSet, keyedProviders])155156 useEffect(() => setHighlight(0), [query, filters])157158 const pick = (model: AIModel, scope: 'conversation' | 'default' | 'message' = 'conversation') => {159 updateSettings({160 recentModelIDs: [model.id, ...settings.recentModelIDs.filter((id) => id !== model.id)].slice(161 0,162 8163 ),164 })165 onPick(model, scope)166 onClose()167 }168169 const onKeyDown = (e: React.KeyboardEvent) => {170 if (e.key === 'ArrowDown') {171 e.preventDefault()172 setHighlight((h) => Math.min(h + 1, rows.length - 1))173 scrollToHighlight(1)174 } else if (e.key === 'ArrowUp') {175 e.preventDefault()176 setHighlight((h) => Math.max(h - 1, 0))177 scrollToHighlight(-1)178 } else if (e.key === 'Enter') {179 e.preventDefault()180 const row = rows[highlight]181 if (row) pick(row.model, e.metaKey || e.ctrlKey ? 'default' : 'conversation')182 } else if (e.key === 'Escape') {183 onClose()184 }185 }186187 const scrollToHighlight = (dir: number) => {188 requestAnimationFrame(() => {189 const el = listRef.current?.querySelector('.model-row.highlighted')190 el?.scrollIntoView({ block: dir > 0 ? 'nearest' : 'nearest' })191 })192 }193194 const toggleFavorite = (e: React.MouseEvent, model: AIModel) => {195 e.stopPropagation()196 const next = favoriteSet.has(model.id)197 ? settings.favoriteModelIDs.filter((id) => id !== model.id)198 : [...settings.favoriteModelIDs, model.id]199 updateSettings({ favoriteModelIDs: next })200 }201202 let lastSection = ''203 let rowIndex = -1204205 return (206 <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>207 <div className="panel" role="dialog" aria-label={title} onKeyDown={onKeyDown}>208 <div className="panel-search">209 <IconSearch size={14} />210 <input211 ref={inputRef}212 placeholder="Search models, providers, capabilities…"213 value={query}214 onChange={(e) => setQuery(e.target.value)}215 />216 </div>217 <div className="panel-filters">218 {FILTERS.map((f) => (219 <button220 key={f.key}221 className={`filter-chip${filters.has(f.key) ? ' active' : ''}`}222 onClick={() =>223 setFilters((prev) => {224 const next = new Set(prev)225 if (next.has(f.key)) next.delete(f.key)226 else next.add(f.key)227 return next228 })229 }230 >231 {f.label}232 </button>233 ))}234 </div>235 <div className="panel-list" ref={listRef}>236 {rows.length === 0 && <div className="panel-empty">No models match.</div>}237 {rows.map((row) => {238 rowIndex++239 const index = rowIndex240 const header =241 row.section !== lastSection ? (242 <div className="panel-section-header" key={`h-${row.section}`}>243 {row.section}244 </div>245 ) : null246 lastSection = row.section247 const model = row.model248 const keyed = keyedProviders.includes(model.provider)249 return (250 <div key={`${row.section}-${model.provider}-${model.id}`}>251 {header}252 <button253 className={`model-row${index === highlight ? ' highlighted' : ''}${254 model.id === currentID ? ' current' : ''255 }`}256 onClick={(e) =>257 pick(model, e.metaKey || e.ctrlKey ? 'default' : 'conversation')258 }259 onMouseMove={() => setHighlight(index)}260 >261 <span className="glyph">262 <ProviderGlyph provider={model.provider} />263 </span>264 <span className="model-row-name">{model.displayName}</span>265 <span className="model-row-provider">266 {PROVIDER_META[model.provider].displayName}267 </span>268 {model.isRecommended && <span className="badge accent">Featured</span>}269 {model.isLegacy && <span className="badge">Legacy</span>}270 {!keyed && (271 <button272 className="badge warn"273 title="No API key configured — add one"274 onClick={(e) => {275 e.stopPropagation()276 onClose()277 openSettings('providers')278 }}279 >280 Add key281 </button>282 )}283 <span className="model-row-badges">284 {model.capabilities.vision && (285 <span className="capability-badge" title="Vision">286 <IconEye />287 </span>288 )}289 {model.capabilities.reasoning && (290 <span className="capability-badge" title="Reasoning">291 <IconBrain />292 </span>293 )}294 {model.capabilities.tools && (295 <span className="capability-badge" title="Tools">296 <IconWrench />297 </span>298 )}299 <span>{contextBadge(model)}</span>300 <span title="USD per 1M tokens in / out">{priceBadge(model)}</span>301 <button302 className={`star${favoriteSet.has(model.id) ? ' active' : ''}`}303 title="Favorite"304 onClick={(e) => toggleFavorite(e, model)}305 >306 <IconStar filled={favoriteSet.has(model.id)} />307 </button>308 </span>309 </button>310 </div>311 )312 })}313 </div>314 <div className="panel-footer">315 <span>316 <kbd>↑↓</kbd> navigate317 </span>318 <span>319 <kbd>↵</kbd> use for this chat320 </span>321 <span>322 <kbd>⌘↵</kbd> set as default323 </span>324 <span>325 <kbd>esc</kbd> close326 </span>327 </div>328 </div>329 </div>330 )331}332