HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
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