SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
18.7 KB · 405 lines tsx
Raw Blame History
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">&gt;</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