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%
19.5 KB · 405 lines tsx
Raw Blame History
1'use client';2import { Bookmark, Copy, Play, Trash2 } from 'lucide-react';3import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useEffect, useMemo, useState } from 'react';6import { Hint } from '@/components/ui/hint';7import { cn } from '@/lib/cn';8import { fmtInt } from '@/lib/format';9import { OPENNESS_LABELS, STATUS_LABELS, typeLabel } from '@/lib/site';1011/*12  Structured query builder (client). Type selector → type-specific form → a `/models?…` (or `/explore/<type>?…`) URL that13  updates as you type. The builder's own URL (`/explore?type=model&min_params=…`) is shareable; saved queries live in14  localStorage['aia-saved-queries'] (name + URL). Filters the API cannot express are shown disabled with the reason.15*/1617export type BuilderOptions = {18  types: { entity_type: string; count: number; label: string }[];19  organizations: { slug: string; name: string; count: number }[];20  families: { value: string; label: string; count: number }[];21  licenses: { value: string; label: string; count: number }[];22  openness: { value: string; count: number }[];23  modalities: { value: string; count: number }[];24  status: { value: string; count: number }[];25  benchmarks: { slug: string; name: string }[];26};27type Saved = { name: string; url: string; created_at: string };28const KEY = 'aia-saved-queries';29const MODEL_KEYS = ['org', 'family', 'min_params', 'max_params', 'min_context', 'license', 'openness', 'modality', 'year_from', 'year_to', 'status', 'reasoning', 'sort', 'order', 'q', 'trust', 'include'] as const;30const GENERIC_KEYS = ['q', 'org', 'sort'] as const;31const PARAM_PRESETS: { label: string; value: string }[] = [32  { label: 'any', value: '' },33  { label: '1B', value: '1000000000' },34  { label: '7B', value: '7000000000' },35  { label: '30B', value: '30000000000' },36  { label: '70B', value: '70000000000' },37  { label: '100B', value: '100000000000' },38  { label: '400B', value: '400000000000' },39  { label: '1T', value: '1000000000000' },40];41const CONTEXT_PRESETS = [42  { label: 'any', value: '' },43  { label: '8K', value: '8192' },44  { label: '32K', value: '32768' },45  { label: '128K', value: '131072' },46  { label: '200K', value: '200000' },47  { label: '1M', value: '1000000' },48];49const MODEL_SORTS = [50  { value: '', label: 'Default (recently updated)' },51  { value: 'release', label: 'Release date' },52  { value: 'params', label: 'Parameters' },53  { value: 'name', label: 'Name' },54  { value: 'quality', label: 'Data quality' },55  { value: 'cheapest', label: 'Cheapest output price' },56];5758function readSaved(): Saved[] {59  try {60    const v = JSON.parse(localStorage.getItem(KEY) ?? '[]');61    return Array.isArray(v) ? v.filter((x) => x && typeof x.url === 'string') : [];62  } catch {63    return [];64  }65}6667export function QueryBuilder({ options, initial }: { options: BuilderOptions; initial: Record<string, string> }) {68  const router = useRouter();69  const [type, setType] = useState(initial.type && options.types.some((t) => t.entity_type === initial.type) ? initial.type : 'model');70  const [f, setF] = useState<Record<string, string>>(() => {71    const out: Record<string, string> = {};72    for (const k of [...MODEL_KEYS, ...GENERIC_KEYS]) if (initial[k]) out[k] = initial[k]!;73    return out;74  });75  const [saved, setSaved] = useState<Saved[]>([]);76  const [ready, setReady] = useState(false);77  const [name, setName] = useState('');78  const [copied, setCopied] = useState(false);79  useEffect(() => {80    setSaved(readSaved());81    setReady(true);82  }, []);83  const set = (k: string, v: string) => setF((cur) => {84    const next = { ...cur };85    if (v === '') delete next[k];86    else next[k] = v;87    return next;88  });89  const isModel = type === 'model';90  const target = useMemo(() => {91    const p = new URLSearchParams();92    const keys = isModel ? MODEL_KEYS : GENERIC_KEYS;93    for (const k of keys) if (f[k]) p.set(k, f[k]!);94    const s = p.toString();95    return isModel ? `/models${s ? `?${s}` : ''}` : `/explore/${encodeURIComponent(type)}${s ? `?${s}` : ''}`;96  }, [f, isModel, type]);97  const shareUrl = useMemo(() => {98    const p = new URLSearchParams({ type });99    const keys = isModel ? MODEL_KEYS : GENERIC_KEYS;100    for (const k of keys) if (f[k]) p.set(k, f[k]!);101    return `/explore?${p.toString()}`;102  }, [f, isModel, type]);103  // mirror the builder state into the URL (shareable) without a navigation104  useEffect(() => {105    if (!ready) return;106    window.history.replaceState(null, '', shareUrl);107  }, [shareUrl, ready]);108  const persist = (list: Saved[]) => {109    setSaved(list);110    try {111      localStorage.setItem(KEY, JSON.stringify(list));112    } catch {113      /* ignore */114    }115  };116  const save = () => {117    const n = name.trim() || target.replace(/^\//, '').slice(0, 60);118    persist([{ name: n, url: target, created_at: new Date().toISOString() }, ...saved.filter((s) => s.url !== target)].slice(0, 30));119    setName('');120  };121  const copy = async () => {122    try {123      await navigator.clipboard.writeText(`${location.origin}${shareUrl}`);124      setCopied(true);125      setTimeout(() => setCopied(false), 1500);126    } catch {127      /* ignore */128    }129  };130  const active = Object.keys(f).length;131  const cls = 'h-10 w-full border border-rule bg-surface px-2 text-sm text-ink focus:border-accent focus:outline-none';132  const label = 'eyebrow block pb-1';133134  return (135    <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]" data-query-builder>136      <div className="min-w-0 space-y-6">137        <div>138          <p className="eyebrow mb-1.5">Entity type</p>139          <ul className="no-scrollbar -mx-4 flex gap-1 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0" role="radiogroup" aria-label="Entity type">140            {options.types.map((t) => (141              <li key={t.entity_type} className="shrink-0">142                <button type="button" role="radio" aria-checked={type === t.entity_type} onClick={() => setType(t.entity_type)} className={cn('inline-flex h-9 items-center gap-1.5 border px-2.5 text-sm', type === t.entity_type ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>143                  {t.label || typeLabel(t.entity_type, true)} <span className="tnum text-[11px] opacity-70">{fmtInt(t.count)}</span>144                </button>145              </li>146            ))}147          </ul>148        </div>149150        {isModel ? (151          <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">152            <label className="block">153              <span className={label}>Organization</span>154              <select value={f.org ?? ''} onChange={(e) => set('org', e.target.value)} className={cls}>155                <option value="">Any</option>156                {options.organizations.map((o) => (157                  <option key={o.slug} value={o.slug}>158                    {o.name} ({fmtInt(o.count)})159                  </option>160                ))}161              </select>162            </label>163            <label className="block">164              <span className={label}>Family</span>165              <select value={f.family ?? ''} onChange={(e) => set('family', e.target.value)} className={cls}>166                <option value="">Any</option>167                {options.families.map((o) => (168                  <option key={o.value} value={o.value}>169                    {o.label} ({fmtInt(o.count)})170                  </option>171                ))}172              </select>173            </label>174            <label className="block">175              <span className={label}>Parameters ≥</span>176              <select value={f.min_params ?? ''} onChange={(e) => set('min_params', e.target.value)} className={cls}>177                {PARAM_PRESETS.map((p) => (178                  <option key={p.value} value={p.value}>179                    {p.label}180                  </option>181                ))}182              </select>183            </label>184            <label className="block">185              <span className={label}>Parameters ≤</span>186              <select value={f.max_params ?? ''} onChange={(e) => set('max_params', e.target.value)} className={cls}>187                {PARAM_PRESETS.map((p) => (188                  <option key={p.value} value={p.value}>189                    {p.label}190                  </option>191                ))}192              </select>193            </label>194            <label className="block opacity-60" title="Not a /models filter yet">195              <span className={label}>196                Active parameters <Hint align="right" text="Mixture-of-experts active parameters are shown on model pages but /models has no min/max filter for them (API gap)." />197              </span>198              <input disabled placeholder="not filterable" className={cls} />199            </label>200            <label className="block opacity-60" title="Not a /models filter yet">201              <span className={label}>202                Architecture <Hint align="right" text="Architecture (dense, MoE, SSM…) is an attribute without a listing filter (API gap)." />203              </span>204              <input disabled placeholder="not filterable" className={cls} />205            </label>206            <label className="block">207              <span className={label}>Context ≥</span>208              <select value={f.min_context ?? ''} onChange={(e) => set('min_context', e.target.value)} className={cls}>209                {CONTEXT_PRESETS.map((p) => (210                  <option key={p.value} value={p.value}>211                    {p.label}212                  </option>213                ))}214              </select>215            </label>216            <label className="block">217              <span className={label}>License</span>218              <select value={f.license ?? ''} onChange={(e) => set('license', e.target.value)} className={cls}>219                <option value="">Any</option>220                {options.licenses.map((o) => (221                  <option key={o.value} value={o.value}>222                    {o.label} ({fmtInt(o.count)})223                  </option>224                ))}225              </select>226            </label>227            <label className="block">228              <span className={label}>Openness</span>229              <select value={f.openness ?? ''} onChange={(e) => set('openness', e.target.value)} className={cls}>230                <option value="">Any</option>231                <option value="open">Open (weights or source)</option>232                {options.openness.map((o) => (233                  <option key={o.value} value={o.value}>234                    {OPENNESS_LABELS[o.value] ?? o.value} ({fmtInt(o.count)})235                  </option>236                ))}237              </select>238            </label>239            <label className="block">240              <span className={label}>Modality</span>241              <select value={f.modality ?? ''} onChange={(e) => set('modality', e.target.value)} className={cls}>242                <option value="">Any</option>243                {options.modalities.map((o) => (244                  <option key={o.value} value={o.value}>245                    {o.value} ({fmtInt(o.count)})246                  </option>247                ))}248              </select>249              <span className="mt-1 block text-[11px] text-ink-3">Vision = image · Audio = audio</span>250            </label>251            <label className="block">252              <span className={label}>Released from</span>253              <input type="number" inputMode="numeric" min={2015} max={2100} placeholder="YYYY" value={f.year_from ?? ''} onChange={(e) => set('year_from', e.target.value)} className={cls} />254            </label>255            <label className="block">256              <span className={label}>Released to</span>257              <input type="number" inputMode="numeric" min={2015} max={2100} placeholder="YYYY" value={f.year_to ?? ''} onChange={(e) => set('year_to', e.target.value)} className={cls} />258            </label>259            <label className="block">260              <span className={label}>Status</span>261              <select value={f.status ?? ''} onChange={(e) => set('status', e.target.value)} className={cls}>262                <option value="">Any</option>263                {options.status.map((o) => (264                  <option key={o.value} value={o.value}>265                    {STATUS_LABELS[o.value] ?? o.value} ({fmtInt(o.count)})266                  </option>267                ))}268              </select>269            </label>270            <label className="block">271              <span className={label}>Reasoning</span>272              <select value={f.reasoning ?? ''} onChange={(e) => set('reasoning', e.target.value)} className={cls}>273                <option value="">Any</option>274                <option value="1">Reasoning / thinking</option>275                <option value="0">Non-reasoning</option>276              </select>277            </label>278            <label className="block opacity-60" title="Not a /models filter">279              <span className={label}>280                Price ≤ <Hint align="right" text="Prices live on deployments, not models: /models sorts by cheapest output but has no price bound. Use Prices or the Calculator (API gap for a bound)." />281              </span>282              <input disabled placeholder="see Prices" className={cls} />283            </label>284            <label className="block opacity-60" title="Not a /models filter">285              <span className={label}>286                Benchmark ≥ <Hint align="right" text="Score thresholds are not a /models parameter (API gap). Pick a benchmark to open its leaderboard instead." />287              </span>288              <select className={cls} defaultValue="" onChange={(e) => e.target.value && router.push(`/benchmarks/${encodeURIComponent(e.target.value)}`)}>289                <option value="">Open a leaderboard…</option>290                {options.benchmarks.map((b) => (291                  <option key={b.slug} value={b.slug}>292                    {b.name}293                  </option>294                ))}295              </select>296            </label>297            <label className="block">298              <span className={label}>Sort</span>299              <select value={f.sort ?? ''} onChange={(e) => set('sort', e.target.value)} className={cls}>300                {MODEL_SORTS.map((s) => (301                  <option key={s.value} value={s.value}>302                    {s.label}303                  </option>304                ))}305              </select>306            </label>307            <label className="block">308              <span className={label}>Order</span>309              <select value={f.order ?? ''} onChange={(e) => set('order', e.target.value)} className={cls}>310                <option value="">Descending</option>311                <option value="asc">Ascending</option>312              </select>313            </label>314            <label className="block col-span-2">315              <span className={label}>Name contains</span>316              <input value={f.q ?? ''} onChange={(e) => set('q', e.target.value)} placeholder="e.g. coder" className={cls} />317            </label>318            <label className="flex min-h-10 items-center gap-2 pt-5 text-sm text-ink-2">319              <input type="checkbox" checked={f.include === 'artifacts'} onChange={(e) => set('include', e.target.checked ? 'artifacts' : '')} className="size-4 accent-[var(--accent)]" /> Include artifacts320              <Hint align="right" text="By default /models lists canonical model releases only; artifacts (checkpoints, quantisations, conversions) and folded variants are excluded." />321            </label>322          </div>323        ) : (324          <div className="grid grid-cols-2 gap-3 md:grid-cols-3">325            <label className="block">326              <span className={label}>Name contains</span>327              <input value={f.q ?? ''} onChange={(e) => set('q', e.target.value)} className={cls} placeholder="Search by name" />328            </label>329            <label className="block">330              <span className={label}>Organization</span>331              <select value={f.org ?? ''} onChange={(e) => set('org', e.target.value)} className={cls}>332                <option value="">Any</option>333                {options.organizations.map((o) => (334                  <option key={o.slug} value={o.slug}>335                    {o.name}336                  </option>337                ))}338              </select>339            </label>340            <label className="block">341              <span className={label}>Sort</span>342              <select value={f.sort ?? ''} onChange={(e) => set('sort', e.target.value)} className={cls}>343                <option value="">Recently updated</option>344                <option value="name">Name</option>345                <option value="quality">Data quality</option>346                <option value="first_seen">First seen</option>347              </select>348            </label>349            <p className="col-span-full text-xs text-ink-3">350              {typeLabel(type, true)} use the generic listing filters (name, organization, sort). Dedicated listings have more: models, benchmarks, prices, hardware.351            </p>352          </div>353        )}354355        <div className="flex flex-wrap items-center gap-2 border-t border-rule pt-4">356          <Link href={target} className="inline-flex h-11 items-center gap-1.5 bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90" data-run-query>357            <Play className="size-4" aria-hidden /> Run query358          </Link>359          <code className="mono min-w-0 flex-1 truncate border border-rule bg-surface px-2 py-2 text-xs text-ink-2" title={target} data-target-url>360            {target}361          </code>362          <button type="button" onClick={copy} className="inline-flex h-11 items-center gap-1.5 border border-rule px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">363            <Copy className="size-4" aria-hidden /> {copied ? 'Copied' : 'Copy share link'}364          </button>365          <span className="tnum text-xs text-ink-3">{active} filter{active === 1 ? '' : 's'}</span>366        </div>367      </div>368369      <aside className="min-w-0 space-y-4">370        <div>371          <p className="eyebrow mb-1.5">Save this query</p>372          <div className="flex gap-2">373            <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name (optional)" className={cls} aria-label="Saved query name" />374            <button type="button" onClick={save} className="inline-flex h-10 shrink-0 items-center gap-1.5 border border-rule px-3 text-sm text-ink-2 hover:border-rule-strong hover:text-ink" data-save-query>375              <Bookmark className="size-4" aria-hidden /> Save376            </button>377          </div>378          <p className="mt-1 text-[11px] text-ink-3">Stored in this browser only (localStorage). Share the link instead to send it to someone.</p>379        </div>380        <div>381          <p className="eyebrow mb-1.5">382            Saved queries <span className="tnum normal-case tracking-normal text-ink-3">{ready ? saved.length : ''}</span>383          </p>384          {!ready ? null : saved.length === 0 ? (385            <p className="text-xs text-ink-3">Nothing saved yet.</p>386          ) : (387            <ul className="divide-y divide-rule border-y border-rule" data-saved-queries>388              {saved.map((s) => (389                <li key={s.url} className="flex items-center gap-2 py-2 text-sm">390                  <Link href={s.url} className="min-w-0 flex-1 truncate text-ink hover:text-accent" title={s.url}>391                    {s.name}392                  </Link>393                  <button type="button" onClick={() => persist(saved.filter((x) => x.url !== s.url))} className="flex size-9 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Remove ${s.name}`}>394                    <Trash2 className="size-4" aria-hidden />395                  </button>396                </li>397              ))}398            </ul>399          )}400        </div>401      </aside>402    </div>403  );404}405