HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2import { ArrowRight, Clock, CornerDownLeft, Search, Terminal, X } from 'lucide-react';3import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useCallback, useEffect, useMemo, useRef, useState } from 'react';6import { EntityBadge } from '@/components/ui/badges';7import { clientApi } from '@/lib/client-api';8import { cn } from '@/lib/cn';9import { DENSITY_LABELS, nextDensity, readDensity, setDensity } from '@/lib/density';10import { EXAMPLE_QUERIES, PALETTE_PREFIXES, primaryNav, routes } from '@/lib/site';11import type { Suggestion } from '@/lib/types';12import { useSearch } from './search-context';13import { useTheme } from './theme';1415/* ------------------------------------------------------------------------------------------------------------ commands */16type Command = {17 id: string;18 label: string;19 hint?: string;20 keywords?: string;21 href?: string;22 /** Switch the palette into entity mode with this type prefix (e.g. "Open benchmark …" → `b:`). */23 prefix?: keyof typeof PALETTE_PREFIXES;24 /** What to do with the picked entity in prefix mode: open its page (default) or its graph. */25 then?: 'entity' | 'graph';26 action?: 'theme' | 'density';27};2829const today = () => new Date().toISOString().slice(0, 10);30const COMMANDS: Command[] = [31 { id: 'compare', label: 'Compare models', hint: 'Side by side, 2–6 entities', keywords: 'compare matrix versus', href: routes.compare() },32 { id: 'local', label: 'Find a local model', hint: 'What fits your hardware', keywords: 'run locally hardware fit memory gpu apple', href: routes.runLocally() },33 { id: 'benchmark', label: 'Open benchmark …', hint: 'type to pick a benchmark', keywords: 'leaderboard score', prefix: 'b' },34 { id: 'today', label: "Show today's changes", hint: 'Daily digest', keywords: 'changes today digest new', href: routes.changesDay(today()) },35 { id: 'diff', label: 'Diff two dates', hint: 'What changed between A and B', keywords: 'diff compare dates history', href: routes.diff() },36 { id: 'provider', label: 'Find provider …', hint: 'type to pick a provider', keywords: 'api pricing host inference', prefix: 'p' },37 { id: 'lineage', label: 'Explore lineage of …', hint: 'type to pick a model → graph', keywords: 'lineage graph derived fine-tuned quantized family', prefix: 'm', then: 'graph' },38 { id: 'theme', label: 'Toggle theme', hint: 'system → light → dark', keywords: 'dark light mode appearance', action: 'theme' },39 { id: 'density', label: 'Toggle density', hint: 'comfortable → compact → dense', keywords: 'compact dense rows spacing', action: 'density' },40 { id: 'go-frontier', label: 'Go to Frontier', keywords: 'leaders best', href: routes.frontier() },41 { id: 'go-prices', label: 'Go to Prices', keywords: 'price index usd tokens cost', href: routes.prices() },42 { id: 'go-graph', label: 'Go to Graph', keywords: 'knowledge graph relations', href: routes.graph() },43 { id: 'go-timeline', label: 'Go to Timeline', keywords: 'history months events', href: routes.timeline() },44 { id: 'go-time-machine', label: 'Go to Time Machine', keywords: 'as of date past', href: routes.timeMachine() },45 { id: 'go-pulse', label: 'Go to Pulse', keywords: 'live activity connectors', href: routes.pulse() },46 { id: 'go-open', label: 'Go to Open models', keywords: 'open weights license', href: routes.open() },47 { id: 'go-calculator', label: 'Go to Calculator', keywords: 'cost workload tokens estimate', href: routes.calculator() },48 { id: 'go-watchlist', label: 'Go to Watchlist', keywords: 'follow star saved', href: routes.watchlist() },49 { id: 'go-find', label: 'Go to Find a model', keywords: 'finder requirements', href: routes.findAModel() },50];5152/** Fuzzy score: exact prefix > word prefix > substring > subsequence; 0 = no match. */53function fuzzy(q: string, text: string): number {54 const t = text.toLowerCase();55 const s = q.toLowerCase().trim();56 if (!s) return 1;57 if (t.startsWith(s)) return 100 - t.length * 0.01;58 if (t.split(/[\s…]+/).some((w) => w.startsWith(s))) return 80;59 const idx = t.indexOf(s);60 if (idx >= 0) return 60 - idx * 0.1;61 let i = 0;62 for (const ch of t) if (ch === s[i]) i++;63 return i === s.length ? 30 - (t.length - s.length) * 0.05 : 0;64}6566/* -------------------------------------------------------------------------------------------------------------- recent */67const RECENT_KEY = 'aia-recent';68const RECENT_MAX = 8;69type Recent = Pick<Suggestion, 'id' | 'slug' | 'name' | 'entity_type' | 'organization_name'>;70function readRecent(): Recent[] {71 try {72 const arr = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as unknown;73 return Array.isArray(arr) ? (arr.filter((x) => x && typeof (x as Recent).slug === 'string') as Recent[]).slice(0, RECENT_MAX) : [];74 } catch {75 return [];76 }77}78function pushRecent(s: Recent) {79 try {80 const next = [{ id: s.id, slug: s.slug, name: s.name, entity_type: s.entity_type, organization_name: s.organization_name ?? null }, ...readRecent().filter((r) => r.slug !== s.slug)].slice(0, RECENT_MAX);81 localStorage.setItem(RECENT_KEY, JSON.stringify(next));82 } catch {83 /* ignore */84 }85}8687/* -------------------------------------------------------------------------------------------------------------- parse */88type Parsed = { mode: 'all' | 'commands' | 'entities'; term: string; types: string[] | null; prefixLabel: string | null };89function parse(input: string): Parsed {90 const s = input.replace(/^\s+/, '');91 if (s.startsWith('>')) return { mode: 'commands', term: s.slice(1).trim(), types: null, prefixLabel: 'Commands' };92 const m = /^([a-z]):\s*(.*)$/i.exec(s);93 if (m && PALETTE_PREFIXES[(m[1] as string).toLowerCase()]) {94 const p = PALETTE_PREFIXES[(m[1] as string).toLowerCase()] as { types: string[]; label: string };95 return { mode: 'entities', term: (m[2] ?? '').trim(), types: p.types, prefixLabel: p.label };96 }97 return { mode: 'all', term: s.trim(), types: null, prefixLabel: null };98}99100type Row = { key: string; kind: 'entity' | 'command' | 'search' | 'recent'; href?: string; run?: () => void; node: React.ReactNode; section: string };101102/**103 * Command palette (⌘K / Ctrl+K / "/"): entity suggestions + commands with fuzzy match, recent entities (last 8),104 * type prefixes (`m:` models · `b:` benchmarks · `o:` orgs · `p:` providers · `h:` hardware) and `>` for commands only.105 * Keyboard: ↑↓ move · ↵ run · esc close · ⌫ on an empty prefixed input clears the prefix. Mobile: full-screen sheet.106 */107export function SearchDialog() {108 const { open, setOpen } = useSearch();109 const router = useRouter();110 const [, setTheme] = useTheme();111 const [q, setQ] = useState('');112 const [items, setItems] = useState<Suggestion[]>([]);113 const [active, setActive] = useState(0);114 const [loading, setLoading] = useState(false);115 const [failed, setFailed] = useState(false);116 const [recent, setRecent] = useState<Recent[]>([]);117 const [then, setThen] = useState<'entity' | 'graph'>('entity');118 const inputRef = useRef<HTMLInputElement>(null);119 const listRef = useRef<HTMLUListElement>(null);120 const parsed = useMemo(() => parse(q), [q]);121 // keep the active row visible while navigating with the keyboard122 useEffect(() => {123 listRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });124 }, [active]);125126 useEffect(() => {127 if (open) {128 inputRef.current?.focus();129 setTimeout(() => inputRef.current?.focus(), 20);130 document.body.style.overflow = 'hidden';131 setRecent(readRecent());132 setActive(0);133 } else {134 document.body.style.overflow = '';135 setQ('');136 setItems([]);137 setFailed(false);138 setThen('entity');139 }140 return () => {141 document.body.style.overflow = '';142 };143 }, [open]);144145 useEffect(() => {146 if (!open || parsed.mode === 'commands') {147 setItems([]);148 return;149 }150 const term = parsed.term;151 if (term.length < 1) {152 setItems([]);153 return;154 }155 const ctrl = new AbortController();156 const t = setTimeout(async () => {157 setLoading(true);158 try {159 const res = await clientApi.suggest(term, ctrl.signal);160 setItems(res.items ?? []);161 setFailed(false);162 setActive(0);163 } catch (e) {164 if ((e as Error).name !== 'AbortError') setFailed(true);165 } finally {166 setLoading(false);167 }168 }, 120);169 return () => {170 clearTimeout(t);171 ctrl.abort();172 };173 }, [parsed.term, parsed.mode, open]);174175 const close = useCallback(() => setOpen(false), [setOpen]);176 const go = useCallback(177 (href: string) => {178 router.push(href);179 close();180 },181 [router, close],182 );183 const runCommand = useCallback(184 (c: Command) => {185 if (c.action === 'theme') {186 const cur = (localStorage.getItem('aia-theme') as 'light' | 'dark' | null) ?? 'system';187 setTheme(cur === 'system' ? 'light' : cur === 'light' ? 'dark' : 'system');188 close();189 return;190 }191 if (c.action === 'density') {192 setDensity(nextDensity(readDensity()));193 close();194 return;195 }196 if (c.prefix) {197 setQ(`${c.prefix}:`);198 setThen(c.then ?? 'entity');199 setActive(0);200 inputRef.current?.focus();201 return;202 }203 if (c.href) go(c.href);204 },205 [close, go, setTheme],206 );207208 if (!open) return null;209210 /* ------------------------------------------------------------------------------------------------------ build rows */211 const term = parsed.term;212 const rows: Row[] = [];213 const entityNode = (s: Recent | Suggestion, icon?: React.ReactNode) => (214 <>215 {icon ?? <EntityBadge type={s.entity_type} small />}216 <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{s.name}</span>217 {s.organization_name && <span className="hidden truncate text-xs text-ink-3 sm:block">{s.organization_name}</span>}218 {then === 'graph' && <span className="text-[11px] text-ink-3">→ graph</span>}219 </>220 );221 const entityHref = (s: { entity_type: string; slug: string }) => (then === 'graph' ? routes.graph(s.slug) : routes.entity(s));222223 if (parsed.mode !== 'entities') {224 const scored = COMMANDS.map((c) => ({ c, score: parsed.mode === 'commands' && !term ? 1 : Math.max(fuzzy(term, c.label), fuzzy(term, c.keywords ?? '') * 0.8) }))225 .filter((x) => x.score > 0)226 .sort((a, b) => b.score - a.score)227 .slice(0, parsed.mode === 'commands' ? COMMANDS.length : term ? 4 : 6);228 for (const { c } of scored)229 rows.push({230 key: `cmd-${c.id}`,231 kind: 'command',232 section: 'Commands',233 run: () => runCommand(c),234 node: (235 <>236 <Terminal className="size-4 shrink-0 text-ink-3" aria-hidden />237 <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{c.label}</span>238 {c.hint && <span className="hidden truncate text-xs text-ink-3 sm:block">{c.hint}</span>}239 {c.action === 'density' && <span className="mono text-[10px] text-ink-3">{DENSITY_LABELS[readDensity()]}</span>}240 </>241 ),242 });243 }244 if (parsed.mode !== 'commands') {245 const filtered = parsed.types ? items.filter((s) => (parsed.types as string[]).includes(s.entity_type)) : items;246 for (const s of filtered)247 rows.push({248 key: `ent-${s.id}`,249 kind: 'entity',250 section: parsed.prefixLabel ?? 'Entities',251 href: entityHref(s),252 run: () => pushRecent(s),253 node: entityNode(s),254 });255 if (!term && recent.length)256 for (const r of recent)257 rows.push({ key: `rec-${r.slug}`, kind: 'recent', section: 'Recent', href: entityHref(r), run: () => pushRecent(r), node: entityNode(r, <Clock className="size-4 shrink-0 text-ink-3" aria-hidden />) });258 if (term)259 rows.push({260 key: '__all',261 kind: 'search',262 section: 'Search',263 href: routes.search(term, parsed.types?.[0]),264 node: (265 <>266 <Search className="size-4 text-accent" aria-hidden />267 <span className="flex-1 text-sm text-accent">Search everything for “{term}”</span>268 <CornerDownLeft className="size-3.5 text-ink-3" aria-hidden />269 </>270 ),271 });272 }273 const clampActive = Math.min(active, Math.max(0, rows.length - 1));274275 const pick = (r: Row) => {276 r.run?.();277 if (r.href) go(r.href);278 };279 const onKey = (e: React.KeyboardEvent) => {280 if (e.key === 'ArrowDown') {281 e.preventDefault();282 setActive((a) => Math.min(a + 1, rows.length - 1));283 } else if (e.key === 'ArrowUp') {284 e.preventDefault();285 setActive((a) => Math.max(a - 1, 0));286 } else if (e.key === 'Enter') {287 e.preventDefault();288 const it = rows[clampActive];289 if (it) pick(it);290 else if (term) go(routes.search(term));291 } else if (e.key === 'Escape') close();292 else if (e.key === 'Backspace' && /^[a-z]:$/i.test(q)) {293 e.preventDefault();294 setQ('');295 setThen('entity');296 }297 };298 let lastSection = '';299 return (300 <div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/50 backdrop-blur-[2px] md:pt-[10vh]" role="dialog" aria-modal="true" aria-label="Search and commands" onClick={close} data-palette>301 <div className="panel flex h-[100dvh] w-full flex-col overflow-hidden rounded-none md:h-auto md:max-h-[72vh] md:w-[720px] md:rounded-lg" onClick={(e) => e.stopPropagation()}>302 <div className="flex items-center gap-3 border-b border-rule px-4 py-2.5">303 {parsed.mode === 'commands' ? <Terminal className="size-5 shrink-0 text-accent" aria-hidden /> : <Search className="size-5 shrink-0 text-ink-3" aria-hidden />}304 {parsed.prefixLabel && parsed.mode === 'entities' && <span className="mono shrink-0 rounded-[3px] bg-accent-soft px-1.5 py-0.5 text-[11px] text-accent">{parsed.prefixLabel}</span>}305 <input306 ref={inputRef}307 value={q}308 onChange={(e) => {309 setQ(e.target.value);310 setActive(0);311 }}312 onKeyDown={onKey}313 placeholder={parsed.mode === 'commands' ? 'Type a command…' : 'Search models, orgs, papers, benchmarks… or type > for commands'}314 className="h-11 min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none"315 autoComplete="off"316 autoFocus317 spellCheck={false}318 aria-label="Search or command"319 role="combobox"320 aria-expanded={rows.length > 0}321 aria-controls="palette-list"322 aria-activedescendant={rows[clampActive] ? `pal-${rows[clampActive].key}` : undefined}323 data-palette-input324 />325 <button type="button" onClick={close} className="-mr-1 flex size-11 items-center justify-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink" aria-label="Close">326 <X className="size-5" aria-hidden />327 </button>328 </div>329 <div className="scrollbar-thin flex-1 overflow-y-auto">330 <ul id="palette-list" ref={listRef} className="py-1" role="listbox">331 {failed && parsed.mode !== 'commands' && <li className="px-4 py-2 text-xs text-warning">Suggestions unavailable — press Enter to search.</li>}332 {term && parsed.mode !== 'commands' && !loading && !failed && rows.filter((r) => r.kind === 'entity').length === 0 && <li className="px-4 py-2 text-xs text-ink-3">No direct {parsed.prefixLabel?.toLowerCase() ?? 'entity'} match — search everything below.</li>}333 {rows.map((r, i) => {334 const header = r.section !== lastSection;335 lastSection = r.section;336 const inner = (337 <>338 {r.node}339 {i === clampActive && <kbd className="mono hidden text-[10px] text-ink-3 sm:block">↵</kbd>}340 </>341 );342 const cls = cn('flex min-h-[44px] w-full items-center gap-3 px-4 py-2 text-left', i === clampActive ? 'bg-surface-3' : 'hover:bg-surface-2');343 return (344 <li key={r.key} id={`pal-${r.key}`} role="option" aria-selected={i === clampActive} data-palette-row={r.kind}>345 {header && <p className="eyebrow px-4 pb-1 pt-3">{r.section}</p>}346 {r.href ? (347 <Link href={r.href} onClick={() => { r.run?.(); close(); }} onMouseEnter={() => setActive(i)} className={cls}>348 {inner}349 </Link>350 ) : (351 <button type="button" onClick={() => pick(r)} onMouseEnter={() => setActive(i)} className={cls}>352 {inner}353 </button>354 )}355 </li>356 );357 })}358 </ul>359 {!term && parsed.mode === 'all' && (360 <div className="border-t border-rule px-4 py-4">361 <p className="eyebrow mb-2">Try asking</p>362 <ul className="flex flex-wrap gap-2">363 {EXAMPLE_QUERIES.map((ex) => (364 <li key={ex}>365 <button type="button" onClick={() => setQ(ex)} className="min-h-11 border border-rule bg-surface px-2.5 py-1.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink md:min-h-9">366 {ex}367 </button>368 </li>369 ))}370 </ul>371 <p className="eyebrow mt-5 mb-2">Browse</p>372 <ul className="grid grid-cols-2 gap-x-4 sm:grid-cols-4">373 {primaryNav.map((n) => (374 <li key={n.href}>375 <Link href={n.href} onClick={close} className="flex h-11 items-center gap-2 text-sm text-ink-2 hover:text-accent md:h-10">376 <ArrowRight className="size-3.5" aria-hidden /> {n.label}377 </Link>378 </li>379 ))}380 </ul>381 </div>382 )}383 </div>384 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-rule px-4 py-2 text-[11px] text-ink-3">385 <span>386 <kbd className="mono border border-rule px-1">↵</kbd> open387 </span>388 <span>389 <kbd className="mono border border-rule px-1">↑↓</kbd> move390 </span>391 <span>392 <kbd className="mono border border-rule px-1">></kbd> commands393 </span>394 <span className="hidden sm:inline">395 <kbd className="mono border border-rule px-1">m:</kbd> models · <kbd className="mono border border-rule px-1">b:</kbd> benchmarks · <kbd className="mono border border-rule px-1">o:</kbd> orgs · <kbd className="mono border border-rule px-1">p:</kbd> providers · <kbd className="mono border border-rule px-1">h:</kbd> hardware396 </span>397 <span className="ml-auto">398 <kbd className="mono border border-rule px-1">esc</kbd> close399 </span>400 </div>401 </div>402 </div>403 );404}405