SPB Git

spb/ai-atlas

Public

Web wave 2: compare, timeline, price index, leaderboards, hardware fit, history/as-of/diff, graph explorer, admin console, typed listings, OG images

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 08b1775 Browse files

77 changed files +6,501 −253

77 changed files
Unified Split
modified apps/web/package.json +2 −0
@@ -11,6 +11,7 @@
11 }, 11 },
12 "dependencies": { 12 "dependencies": {
13 "d3-array": "^3.2.4", 13 "d3-array": "^3.2.4",
14+ "d3-force": "^3.0.0",
14 "d3-scale": "^4.0.2", 15 "d3-scale": "^4.0.2",
15 "d3-shape": "^3.2.0", 16 "d3-shape": "^3.2.0",
16 "geist": "^1.5.1", 17 "geist": "^1.5.1",
@@ -23,6 +24,7 @@
23 "devDependencies": { 24 "devDependencies": {
24 "@tailwindcss/postcss": "^4", 25 "@tailwindcss/postcss": "^4",
25 "@types/d3-array": "^3.2.1", 26 "@types/d3-array": "^3.2.1",
27+ "@types/d3-force": "^3.0.10",
26 "@types/d3-scale": "^4.0.9", 28 "@types/d3-scale": "^4.0.9",
27 "@types/d3-shape": "^3.1.7", 29 "@types/d3-shape": "^3.1.7",
28 "@types/node": "^24.0.0", 30 "@types/node": "^24.0.0",
added apps/web/qa/screens-wave2.mjs +212 −0
@@ -0,0 +1,212 @@
1+/**
2+ * Wave-2 QA sweep: every route added in wave 2 (compare, timeline, prices, benchmark leaderboards, hardware fit, history/as-of,
3+ * diff, graph, admin, typed listings, manifest/OG) at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal
4+ * overflow, screenshot — plus interaction flows: compare tray (add 2 models → matrix), hardware-fit form submit, admin login →
5+ * connectors → Run now, diff with two dates, graph renders nodes.
6+ *
7+ * Run: node qa/screens-wave2.mjs [BASE_URL] [API_URL] [ADMIN_TOKEN] (defaults http://localhost:8330, http://127.0.0.1:8331, dev-admin-token)
8+ * Screenshots → qa/screens/wave2/. Slugs are discovered live from the API — nothing is hardcoded.
9+ */
10+import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
11+import { mkdirSync } from 'node:fs';
12+
13+const BASE = process.argv[2] ?? 'http://localhost:8330';
14+const API = process.argv[3] ?? 'http://127.0.0.1:8331';
15+const TOKEN = process.argv[4] ?? process.env.AIA_ADMIN_TOKEN ?? 'dev-admin-token';
16+const OUT = new URL('./screens/wave2/', import.meta.url).pathname;
17+mkdirSync(OUT, { recursive: true });
18+
19+const j = async (path, headers = {}) => (await fetch(`${API}/api/v1${path}`, { headers })).json();
20+const models = (await j('/models?limit=3&sort=quality')).items.map((m) => m.slug);
21+const bench = (await j('/benchmarks')).items.sort((a, b) => Number(b.result_count) - Number(a.result_count))[0]?.slug;
22+const benchModel = bench ? (await j(`/benchmarks/${bench}/results?limit=1`)).items[0]?.model?.slug : null;
23+const hardware = (await j('/hardware?limit=1&sort=memory')).items[0]?.slug;
24+const company = (await j('/companies?limit=1&sort=models')).items[0]?.slug;
25+const today = new Date().toISOString().slice(0, 10);
26+const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
27+const [m1, m2, m3] = models;
28+console.log(`discovered models=${models.join(',')} bench=${bench} benchModel=${benchModel} hardware=${hardware} company=${company}`);
29+
30+const PAGES = [
31+ '/compare',
32+ `/compare?ids=${m1},${m2}`,
33+ `/compare?ids=${m1},${m2},${m3}`,
34+ '/timeline',
35+ `/timeline?year=${today.slice(0, 4)}&category=price`,
36+ `/timeline?entity=${m1}`,
37+ '/prices',
38+ '/prices?sort=output&days=90&scale=log',
39+ '/benchmarks',
40+ `/benchmarks/${bench}`,
41+ `/benchmarks/${bench}?model=${benchModel}`,
42+ '/benchmarks/does-not-exist',
43+ '/hardware',
44+ '/hardware/fit',
45+ '/hardware/fit?memory_gb=32&quant=4bit&context=8192',
46+ `/hardware/${hardware}`,
47+ `/models/${m1}`,
48+ `/models/${m1}?tab=history`,
49+ `/models/${m1}?tab=history&asof=${today}`,
50+ `/models/${m1}?tab=history&asof=2020-01-01`,
51+ `/companies/${company}?tab=history`,
52+ '/diff',
53+ `/diff?a=${weekAgo}&b=${today}&scope=models`,
54+ `/graph/${m1}`,
55+ `/graph/${m1}?depth=2`,
56+ '/graph/does-not-exist',
57+ '/papers',
58+ '/frameworks',
59+ '/datasets',
60+ '/tools',
61+ '/developers',
62+ '/admin',
63+ '/admin/connectors',
64+ '/does-not-exist-wave2',
65+];
66+const LIGHT_SUBSET = new Set(['/compare', `/compare?ids=${m1},${m2}`, '/timeline', '/prices', `/benchmarks/${bench}`, '/hardware/fit?memory_gb=32&quant=4bit&context=8192', `/models/${m1}?tab=history&asof=${today}`, `/diff?a=${weekAgo}&b=${today}&scope=models`, `/graph/${m1}`, '/papers', '/frameworks', '/admin', '/developers']);
67+const WIDTHS = [390, 1440];
68+const THEMES = ['dark', 'light'];
69+const IGNORE = /favicon|Failed to load resource: the server responded with a status of 404|Failed to load resource: the server responded with a status of 401|Failed to load resource: the server responded with a status of 403/;
70+
71+const browser = await chromium.launch();
72+let failures = 0;
73+const slug = (p) => p.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home';
74+
75+function watch(page) {
76+ const errors = [];
77+ const onErr = (e) => errors.push(String(e));
78+ const onCon = (m) => {
79+ if (m.type() === 'error') errors.push(m.text());
80+ };
81+ page.on('pageerror', onErr);
82+ page.on('console', onCon);
83+ return { errors, off: () => (page.off('pageerror', onErr), page.off('console', onCon)) };
84+}
85+const clean = (errors, expected) => errors.filter((e) => !IGNORE.test(e)).filter((e) => !(expected === 404 && /Encountered a script tag while rendering React component/.test(e)));
86+
87+for (const theme of THEMES) {
88+ for (const width of WIDTHS) {
89+ const mobile = width < 768;
90+ const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });
91+ await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme);
92+ const page = await ctx.newPage();
93+ for (const path of PAGES) {
94+ if (theme === 'light' && !LIGHT_SUBSET.has(path)) continue;
95+ const w = watch(page);
96+ const t0 = Date.now();
97+ const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));
98+ await page.waitForTimeout(700);
99+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);
100+ const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null);
101+ const expected = /does-not-exist/.test(path) ? 404 : 200;
102+ const status = res.status();
103+ const filtered = clean(w.errors, expected);
104+ const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme;
105+ if (!ok) failures++;
106+ console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} theme=${applied} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}`);
107+ await page.screenshot({ path: `${OUT}${theme}-${width}-${slug(path)}.png`, fullPage: false }).catch(() => undefined);
108+ w.off();
109+ }
110+ await ctx.close();
111+ }
112+}
113+
114+/* ------------------------------------------------------------------------------------------------------------ flows */
115+async function flow(name, width, fn) {
116+ const mobile = width < 768;
117+ const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: 'dark' });
118+ await ctx.addInitScript(() => localStorage.setItem('aia-theme', 'dark'));
119+ const page = await ctx.newPage();
120+ const w = watch(page);
121+ try {
122+ await fn(page);
123+ const filtered = clean(w.errors, 200);
124+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
125+ const ok = filtered.length === 0 && overflow <= 0;
126+ if (!ok) failures++;
127+ console.log(`${ok ? 'OK ' : 'FAIL'} flow ${name} @${width} errors=${filtered.length} overflow=${overflow}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}`);
128+ } catch (e) {
129+ failures++;
130+ console.log(`FAIL flow ${name} @${width} :: ${String(e.message ?? e).slice(0, 200)}`);
131+ }
132+ await page.screenshot({ path: `${OUT}flow-${name}-${width}.png`, fullPage: false }).catch(() => undefined);
133+ w.off();
134+ await ctx.close();
135+}
136+
137+for (const width of WIDTHS) {
138+ // Compare tray: add two models from the /models listing, then open /compare and expect the matrix.
139+ await flow('compare-tray', width, async (page) => {
140+ await page.goto(`${BASE}/models?sort=quality`, { waitUntil: 'networkidle' });
141+ const buttons = page.locator('button[aria-pressed]');
142+ if ((await buttons.count()) < 2) throw new Error('no compare buttons on /models');
143+ await buttons.nth(0).click();
144+ await buttons.nth(1).click();
145+ await page.waitForTimeout(300);
146+ const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('aia-compare') ?? '[]'));
147+ if (stored.length !== 2) throw new Error(`tray holds ${stored.length} items, expected 2`);
148+ await page.goto(`${BASE}/compare?ids=${stored.map((s) => s.slug).join(',')}`, { waitUntil: 'networkidle' });
149+ const cells = await page.locator('table tbody tr').count();
150+ if (cells < 3) throw new Error(`matrix has ${cells} rows`);
151+ if (!(await page.locator('table thead th').count()) >= 3) throw new Error('matrix header missing entity columns');
152+ });
153+
154+ // Hardware fit: choose presets and submit the form.
155+ await flow('hardware-fit', width, async (page) => {
156+ await page.goto(`${BASE}/hardware/fit`, { waitUntil: 'networkidle' });
157+ const form = page.locator('form[action="/hardware/fit"]').first();
158+ await form.locator('select[name="memory_gb"]').selectOption('64');
159+ await form.locator('select[name="quant"]').selectOption('8bit');
160+ await form.locator('select[name="context"]').selectOption('32768');
161+ await Promise.all([page.waitForURL(/memory_gb=64/), form.locator('button[type="submit"]').first().click()]);
162+ await page.waitForLoadState('networkidle');
163+ const text = await page.evaluate(() => document.body.innerText);
164+ if (!/estimated/i.test(text)) throw new Error('missing ESTIMATED label');
165+ if ((await page.locator('table tbody tr').count()) < 1) throw new Error('no fit rows');
166+ });
167+
168+ // Admin: login → connectors table → Run now.
169+ await flow('admin', width, async (page) => {
170+ await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' });
171+ const html = await page.content();
172+ if (html.includes(TOKEN)) throw new Error('admin token leaked into HTML before login');
173+ await page.locator('input[type="password"]').first().fill(TOKEN);
174+ await Promise.all([page.waitForURL(/\/admin\/(overview|connectors)/, { timeout: 30000 }), page.locator('form button[type="submit"]').first().click()]);
175+ await page.goto(`${BASE}/admin/connectors`, { waitUntil: 'networkidle' });
176+ if ((await page.content()).includes(TOKEN)) throw new Error('admin token leaked into connectors HTML');
177+ const rows = await page.locator('table tbody tr').count();
178+ if (rows < 1) throw new Error('connectors table empty');
179+ // Connectors with a run already pending render a disabled button — pick the first enabled one.
180+ const run = page.locator('button:not([disabled])', { hasText: /run now/i }).first();
181+ if (!(await run.count())) throw new Error('no enabled Run now button');
182+ await run.click();
183+ await page.waitForLoadState('networkidle');
184+ await page.waitForTimeout(500);
185+ const after = await page.evaluate(() => document.body.innerText);
186+ if (!/queued|run now|pending|scheduled|enqueued/i.test(after)) throw new Error('no run-now feedback');
187+ });
188+
189+ // Diff with two dates.
190+ await flow('diff', width, async (page) => {
191+ await page.goto(`${BASE}/diff`, { waitUntil: 'networkidle' });
192+ const form = page.locator('form[action="/diff"]').first();
193+ await form.locator('input[name="a"]').fill(weekAgo);
194+ await form.locator('input[name="b"]').fill(today);
195+ await Promise.all([page.waitForURL(/a=\d{4}-\d{2}-\d{2}/), form.locator('button[type="submit"]').first().click()]);
196+ await page.waitForLoadState('networkidle');
197+ const text = await page.evaluate(() => document.body.innerText);
198+ if (!/new entities/i.test(text)) throw new Error('diff sections missing');
199+ });
200+
201+ // Graph renders nodes.
202+ await flow('graph', width, async (page) => {
203+ await page.goto(`${BASE}/graph/${m1}`, { waitUntil: 'networkidle' });
204+ await page.waitForTimeout(1500);
205+ const n = await page.locator('svg [data-node], svg circle').count();
206+ if (n < 2) throw new Error(`graph has ${n} nodes`);
207+ });
208+}
209+
210+await browser.close();
211+console.log(failures ? `\n${failures} failure(s)` : '\nall wave-2 checks OK');
212+process.exit(failures ? 1 : 0);
modified apps/web/src/app/[type]/[slug]/page.tsx +5 −4
@@ -1,6 +1,6 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import { notFound, permanentRedirect } from 'next/navigation'; 2 import { notFound, permanentRedirect } from 'next/navigation';
3−import { EntityPage } from '@/components/entity/entity-page'; 3+import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page';
4 import { entityMetadata, loadEntity } from '@/components/entity/load'; 4 import { entityMetadata, loadEntity } from '@/components/entity/load';
5 import { api, safe } from '@/lib/api'; 5 import { api, safe } from '@/lib/api';
6 import { PATH_TYPES, routes } from '@/lib/site'; 6 import { PATH_TYPES, routes } from '@/lib/site';
@@ -9,7 +9,7 @@ import { PATH_TYPES, routes } from '@/lib/site';
9 * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories. 9 * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories.
10 * (/models and /companies have their own segment folders; static segments win over this dynamic one.) 10 * (/models and /companies have their own segment folders; static segments win over this dynamic one.)
11 */ 11 */
12−type Params = { params: Promise<{ type: string; slug: string }> }; 12+type Params = { params: Promise<{ type: string; slug: string }>; searchParams: Promise<EntityPageParams> };
13 13
14 export async function generateMetadata({ params }: Params): Promise<Metadata> { 14 export async function generateMetadata({ params }: Params): Promise<Metadata> {
15 const { type, slug } = await params; 15 const { type, slug } = await params;
@@ -17,12 +17,13 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
17 return entityMetadata(type, slug); 17 return entityMetadata(type, slug);
18 } 18 }
19 19
20−export default async function GenericEntityPage({ params }: Params) { 20+export default async function GenericEntityPage({ params, searchParams }: Params) {
21 const { type, slug } = await params; 21 const { type, slug } = await params;
22+ const sp = await searchParams;
22 if (!PATH_TYPES[type]) notFound(); 23 if (!PATH_TYPES[type]) notFound();
23 const d = await loadEntity(type, slug); 24 const d = await loadEntity(type, slug);
24 const canonical = routes.entity(d); 25 const canonical = routes.entity(d);
25 if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical); 26 if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical);
26 const related = await safe(api.entityRelated(d.slug, 10)); 27 const related = await safe(api.entityRelated(d.slug, 10));
27− return <EntityPage d={d} canonical={canonical} related={related?.items} />; 28+ return <EntityPage d={d} canonical={canonical} related={related?.items} asof={sp.asof} historyProperty={sp.property} />;
28 } 29 }
added apps/web/src/app/admin/cache/page.tsx +47 −0
@@ -0,0 +1,47 @@
1+import type { Metadata } from 'next';
2+import { ActionButton, AdminTitle, Notice } from '@/components/admin/ui';
3+import { flushCacheAction, recomputeQualityAction, recomputeStatsAction } from '@/lib/admin/actions';
4+import { requireAdmin } from '@/lib/admin/admin-api';
5+
6+export const metadata: Metadata = { title: 'Cache & maintenance', robots: { index: false, follow: false } };
7+export const dynamic = 'force-dynamic';
8+
9+export default async function AdminCachePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
10+ await requireAdmin();
11+ const sp = await searchParams;
12+ return (
13+ <>
14+ <AdminTitle title="Cache & maintenance" lede="Public API responses are cached in Redis (aia:api:*, 60–600 s). Flushing forces every public page to recompute on its next request." />
15+ <Notice notice={sp.notice} level={sp.level} />
16+ <ul className="divide-y divide-rule border-y border-rule">
17+ <li className="flex flex-col gap-2 py-3 md:flex-row md:items-center md:justify-between">
18+ <div>
19+ <p className="text-sm font-medium text-ink">Flush API cache</p>
20+ <p className="text-xs text-ink-3">POST /admin/cache/flush — deletes every aia:api:* key. Safe; the next requests repopulate it.</p>
21+ </div>
22+ <form action={flushCacheAction}>
23+ <ActionButton tone="accent">Flush</ActionButton>
24+ </form>
25+ </li>
26+ <li className="flex flex-col gap-2 py-3 md:flex-row md:items-center md:justify-between">
27+ <div>
28+ <p className="text-sm font-medium text-ink">Recompute stats</p>
29+ <p className="text-xs text-ink-3">POST /admin/stats/recompute — refreshes the daily stats history row for today.</p>
30+ </div>
31+ <form action={recomputeStatsAction}>
32+ <ActionButton>Recompute stats</ActionButton>
33+ </form>
34+ </li>
35+ <li className="flex flex-col gap-2 py-3 md:flex-row md:items-center md:justify-between">
36+ <div>
37+ <p className="text-sm font-medium text-ink">Recompute quality scores</p>
38+ <p className="text-xs text-ink-3">POST /admin/quality/recompute — recomputes completeness, primary-source ratio, freshness and conflicts for every entity. May take a while on a large database.</p>
39+ </div>
40+ <form action={recomputeQualityAction}>
41+ <ActionButton>Recompute quality</ActionButton>
42+ </form>
43+ </li>
44+ </ul>
45+ </>
46+ );
47+}
added apps/web/src/app/admin/connectors/page.tsx +120 −0
@@ -0,0 +1,120 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { ActionButton, AdminTitle, Bool, Mono, Notice, StatusChip, Trunc } from '@/components/admin/ui';
4+import { TierBadge } from '@/components/ui/badges';
5+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
6+import { LiveAgo } from '@/components/ui/live';
7+import { Unavailable } from '@/components/ui/unavailable';
8+import { runConnectorAction, toggleConnectorAction } from '@/lib/admin/actions';
9+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
10+import { fmtDateTime, fmtDuration, fmtInt, num } from '@/lib/format';
11+
12+export const metadata: Metadata = { title: 'Connectors', robots: { index: false, follow: false } };
13+export const dynamic = 'force-dynamic';
14+
15+const RETURN = '/admin/connectors';
16+
17+export default async function AdminConnectorsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
18+ await requireAdmin();
19+ const sp = await searchParams;
20+ const res = await load(adminApi.connectors());
21+ if (!res.ok) {
22+ return (
23+ <>
24+ <AdminTitle title="Connectors" />
25+ <Unavailable what="Connectors" reason={res.error} />
26+ </>
27+ );
28+ }
29+ const items = [...res.data.items].sort((a, b) => (num(a.priority) ?? 9) - (num(b.priority) ?? 9) || a.name.localeCompare(b.name));
30+ const counts = items.reduce<Record<string, number>>((acc, c) => ((acc[c.health] = (acc[c.health] ?? 0) + 1), acc), {});
31+ return (
32+ <>
33+ <AdminTitle title="Connectors" count={fmtInt(items.length)} lede={Object.entries(counts).map(([k, n]) => `${k} ${n}`).join(' · ')} />
34+ <Notice notice={sp.notice} level={sp.level} />
35+ <DataTable compact scroll caption="Connectors">
36+ <thead>
37+ <tr>
38+ <Th>Connector</Th>
39+ <Th>Source</Th>
40+ <Th>Health</Th>
41+ <Th>Enabled</Th>
42+ <Th num>Prio</Th>
43+ <Th num>Interval</Th>
44+ <Th>Last attempt</Th>
45+ <Th>Last success</Th>
46+ <Th>Next run</Th>
47+ <Th>Last run</Th>
48+ <Th num>Docs / snaps</Th>
49+ <Th num>Fails</Th>
50+ <Th>Circuit</Th>
51+ <Th>Actions</Th>
52+ </tr>
53+ </thead>
54+ <tbody>
55+ {items.length === 0 && <EmptyRow cols={14}>No connectors registered.</EmptyRow>}
56+ {items.map((c) => {
57+ const lr = c.last_run;
58+ return (
59+ <tr key={c.name}>
60+ <Td>
61+ <div className="flex items-center gap-1.5">
62+ <Link href={`/admin/runs?connector=${encodeURIComponent(c.name)}`} className="mono text-xs font-medium text-ink hover:text-accent">{c.name}</Link>
63+ {c.run_now_pending && <span className="text-[10px] uppercase tracking-wide text-accent" title="Run requested; waiting for the scheduler tick">queued</span>}
64+ {c.in_code === false && <span className="text-[10px] uppercase tracking-wide text-warning" title="Registered in the database but not present in code">no code</span>}
65+ </div>
66+ <span className="block max-w-[16rem] truncate text-[11px] text-ink-3" title={c.label}>{c.label}</span>
67+ </Td>
68+ <Td className="text-xs">
69+ <span className="text-ink-2">{c.source_name ?? c.source_key ?? '—'}</span> {c.source_tier !== undefined && c.source_tier !== null && <TierBadge tier={num(c.source_tier)} />}
70+ </Td>
71+ <Td><StatusChip value={c.health} /></Td>
72+ <Td><Bool v={c.enabled} /></Td>
73+ <Td num className="tnum text-xs">{fmtInt(c.priority)}</Td>
74+ <Td num className="tnum text-xs">{fmtDuration(c.interval_seconds)}</Td>
75+ <Td className="text-xs" title={fmtDateTime(c.last_attempt_at)}><LiveAgo at={c.last_attempt_at} /></Td>
76+ <Td className="text-xs" title={fmtDateTime(c.last_success_at)}><LiveAgo at={c.last_success_at} /></Td>
77+ <Td className="text-xs text-ink-2" title={fmtDateTime(c.next_run_at)}>{c.next_run_at ? fmtDateTime(c.next_run_at).replace(/ UTC$/, '') : '—'}</Td>
78+ <Td className="text-xs">
79+ {lr ? (
80+ <span className="inline-flex flex-wrap items-center gap-1.5">
81+ <StatusChip value={lr.status} />
82+ <span className="tnum text-ink-3">{num(lr.duration_ms) === null ? '' : fmtDuration(Math.round((num(lr.duration_ms) ?? 0) / 1000))}</span>
83+ <span className="tnum text-ink-3" title="docs changed / fetched">{fmtInt(lr.docs_changed)}/{fmtInt(lr.docs_fetched)}</span>
84+ {lr.error && <Trunc text={lr.error} max={40} className="text-danger" />}
85+ </span>
86+ ) : (
87+ <span className="text-ink-3">—</span>
88+ )}
89+ </Td>
90+ <Td num className="tnum text-xs text-ink-2">{c.documents === undefined && c.snapshots === undefined ? '—' : `${fmtInt(c.documents)} / ${fmtInt(c.snapshots)}`}</Td>
91+ <Td num className={`tnum text-xs ${num(c.consecutive_failures) ? 'text-danger' : 'text-ink-2'}`}>{fmtInt(c.consecutive_failures)}</Td>
92+ <Td className="text-xs">{c.circuit_open_until ? <span className="text-warning" title={fmtDateTime(c.circuit_open_until)}>open · <LiveAgo at={c.circuit_open_until} /></span> : <span className="text-ink-3">closed</span>}</Td>
93+ <Td>
94+ <div className="flex items-center gap-1.5">
95+ <form action={runConnectorAction}>
96+ <input type="hidden" name="name" value={c.name} />
97+ <input type="hidden" name="return" value={RETURN} />
98+ <ActionButton tone="accent" disabled={!!c.run_now_pending} title="Enqueue an immediate run (force)">Run now</ActionButton>
99+ </form>
100+ <form action={toggleConnectorAction}>
101+ <input type="hidden" name="name" value={c.name} />
102+ <input type="hidden" name="enabled" value={c.enabled ? 'false' : 'true'} />
103+ <input type="hidden" name="return" value={RETURN} />
104+ <ActionButton tone={c.enabled ? 'danger' : 'positive'}>{c.enabled ? 'Disable' : 'Enable'}</ActionButton>
105+ </form>
106+ </div>
107+ </Td>
108+ </tr>
109+ );
110+ })}
111+ </tbody>
112+ </DataTable>
113+ {res.data.unregistered_in_db && res.data.unregistered_in_db.length > 0 && (
114+ <p className="mt-4 text-xs text-ink-3">
115+ In code but not in the database ({res.data.unregistered_in_db.length}): {res.data.unregistered_in_db.map((n) => <Mono key={n} className="mr-1.5">{n}</Mono>)} — run <Mono>aia seed</Mono> to register them.
116+ </p>
117+ )}
118+ </>
119+ );
120+}
added apps/web/src/app/admin/documents/[id]/page.tsx +114 −0
@@ -0,0 +1,114 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { notFound } from 'next/navigation';
4+import { AdminTitle, Bool, JsonPre, Mono, StatusChip } from '@/components/admin/ui';
5+import { TierBadge } from '@/components/ui/badges';
6+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
7+import { KeyValue } from '@/components/ui/key-value';
8+import { LiveAgo } from '@/components/ui/live';
9+import { Unavailable } from '@/components/ui/unavailable';
10+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
11+import type { AdminSnapshotRow } from '@/lib/admin/types';
12+import { fmtBytes, fmtDateTime, fmtInt, num } from '@/lib/format';
13+import { routes } from '@/lib/site';
14+
15+export const metadata: Metadata = { title: 'Document', robots: { index: false, follow: false } };
16+export const dynamic = 'force-dynamic';
17+
18+export default async function AdminDocumentPage({ params }: { params: Promise<{ id: string }> }) {
19+ await requireAdmin();
20+ const { id } = await params;
21+ const res = await load(adminApi.document(id));
22+ if (!res.ok && res.error.startsWith('404')) notFound();
23+ if (!res.ok) {
24+ return (
25+ <>
26+ <AdminTitle title="Document" />
27+ <Unavailable what="Document" reason={res.error} />
28+ </>
29+ );
30+ }
31+ const d = res.data;
32+ const snaps: AdminSnapshotRow[] = Array.isArray(d.snapshots) ? d.snapshots : [];
33+ const meta = { ...(d.meta ?? {}) } as Record<string, unknown>;
34+ delete meta.raw_path;
35+ delete meta.text_path;
36+ return (
37+ <>
38+ <p className="mb-2 text-xs"><Link href="/admin/documents" className="link">← Documents</Link></p>
39+ <AdminTitle title={d.title ?? d.url.replace(/^https?:\/\/(www\.)?/, '')} lede={<Mono>{d.id}</Mono>} />
40+ <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
41+ <div className="min-w-0">
42+ <KeyValue
43+ dense
44+ rows={[
45+ { key: 'url', label: 'URL', value: <a href={d.url} target="_blank" rel="noopener noreferrer" className="link break-all">{d.url}</a> },
46+ { key: 'canonical_url', label: 'Canonical', raw: d.canonical_url },
47+ { key: 'connector', label: 'Connector', value: <Link href={`/admin/documents?connector=${encodeURIComponent(d.connector_name ?? '')}`} className="mono text-xs hover:text-accent">{d.connector_name ?? '—'}</Link> },
48+ { key: 'source', label: 'Source', value: <span className="text-sm">{d.source_name ?? d.source_id ?? '—'} {d.source_tier !== undefined && d.source_tier !== null && <TierBadge tier={num(d.source_tier)} />}</span> },
49+ { key: 'doc_type', label: 'Type', raw: d.doc_type },
50+ { key: 'entity', label: 'Entity', value: d.entity_slug && d.entity_type ? <Link href={routes.entity({ entity_type: d.entity_type, slug: d.entity_slug })} className="link">{d.entity_name ?? d.entity_slug}</Link> : <span className="text-ink-3">—</span> },
51+ { key: 'status', label: 'Status', value: <StatusChip value={d.status} /> },
52+ { key: 'processing', label: 'Processing', value: <StatusChip value={d.last_processing_status ?? null} /> },
53+ { key: 'first_seen_at', label: 'First seen', raw: d.first_seen_at ? fmtDateTime(d.first_seen_at) : null },
54+ { key: 'last_fetched_at', label: 'Last fetched', raw: d.last_fetched_at ? fmtDateTime(d.last_fetched_at) : null },
55+ { key: 'last_changed_at', label: 'Last changed', raw: d.last_changed_at ? fmtDateTime(d.last_changed_at) : null },
56+ { key: 'next_fetch_at', label: 'Next fetch', raw: d.next_fetch_at ? fmtDateTime(d.next_fetch_at) : null },
57+ { key: 'counts', label: 'Fetch / change / fail', value: <span className="tnum">{fmtInt(d.fetch_count)} / {fmtInt(d.change_count)} / {fmtInt(d.fail_count)}</span> },
58+ { key: 'last_status', label: 'Last HTTP', raw: d.last_status },
59+ { key: 'etag', label: 'ETag', value: d.etag ? <Mono>{d.etag}</Mono> : undefined },
60+ { key: 'content_hash', label: 'Content hash', value: d.content_hash ? <Mono className="break-all">{d.content_hash}</Mono> : undefined },
61+ { key: 'priority', label: 'Priority', raw: d.priority },
62+ { key: 'needs_llm', label: 'Needs LLM', value: <Bool v={d.needs_llm} /> },
63+ ]}
64+ />
65+ </div>
66+ <aside className="min-w-0">
67+ <p className="eyebrow mb-2">Meta</p>
68+ <JsonPre value={Object.keys(meta).length ? meta : null} maxHeight="20rem" />
69+ </aside>
70+ </div>
71+
72+ <section className="mt-8">
73+ <p className="eyebrow mb-2">Snapshots <span className="tnum text-ink-3">{snaps.length}</span></p>
74+ <DataTable compact scroll caption="Snapshots">
75+ <thead>
76+ <tr>
77+ <Th>Observed</Th>
78+ <Th num>HTTP</Th>
79+ <Th num>Bytes</Th>
80+ <Th>Changed</Th>
81+ <Th>Processing</Th>
82+ <Th>Parser</Th>
83+ <Th>Transport</Th>
84+ <Th>Structured</Th>
85+ <Th>Text</Th>
86+ <Th>Run</Th>
87+ </tr>
88+ </thead>
89+ <tbody>
90+ {snaps.length === 0 && <EmptyRow cols={10}>No snapshots archived for this document.</EmptyRow>}
91+ {snaps.map((s) => (
92+ <tr key={s.id}>
93+ <Td>
94+ <Link href={`/admin/snapshots/${encodeURIComponent(s.id)}`} className="text-xs text-ink hover:text-accent" title={s.id}>
95+ {fmtDateTime(s.observed_at)} · <LiveAgo at={s.observed_at} />
96+ </Link>
97+ </Td>
98+ <Td num className={`tnum text-xs ${(num(s.http_status) ?? 0) >= 400 ? 'text-danger' : ''}`}>{fmtInt(s.http_status)}</Td>
99+ <Td num className="tnum text-xs">{fmtBytes(s.byte_size)}</Td>
100+ <Td><Bool v={s.changed} /></Td>
101+ <Td><StatusChip value={s.processing_status} /></Td>
102+ <Td><Mono>v{s.parser_version ?? '?'}</Mono></Td>
103+ <Td><Mono>{s.transport ?? '—'}</Mono></Td>
104+ <Td><Bool v={s.has_structured} /></Td>
105+ <Td><Bool v={s.has_text} /></Td>
106+ <Td>{s.run_id ? <Mono>{s.run_id}</Mono> : <span className="text-ink-3">—</span>}</Td>
107+ </tr>
108+ ))}
109+ </tbody>
110+ </DataTable>
111+ </section>
112+ </>
113+ );
114+}
added apps/web/src/app/admin/documents/page.tsx +96 −0
@@ -0,0 +1,96 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { AdminFilters, AdminTitle, Mono, StatusChip } from '@/components/admin/ui';
4+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5+import { LiveAgo } from '@/components/ui/live';
6+import { Pagination, withParams } from '@/components/ui/pagination';
7+import { Unavailable } from '@/components/ui/unavailable';
8+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
9+import { fmtDateTime, fmtInt, num } from '@/lib/format';
10+import { routes } from '@/lib/site';
11+
12+export const metadata: Metadata = { title: 'Documents', robots: { index: false, follow: false } };
13+export const dynamic = 'force-dynamic';
14+
15+const LIMIT = 50;
16+const STATUSES = ['active', 'gone', 'blocked', 'error', 'paused'];
17+
18+export default async function AdminDocumentsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
19+ await requireAdmin();
20+ const sp = await searchParams;
21+ const current: Record<string, string | undefined> = {};
22+ for (const k of ['connector', 'status', 'q', 'offset']) if (sp[k]) current[k] = sp[k];
23+ const offset = Math.max(0, Number(current.offset) || 0);
24+ const [docs, connectors] = await Promise.all([load(adminApi.documents({ connector: current.connector, status: current.status, q: current.q, limit: LIMIT, offset })), load(adminApi.connectors())]);
25+ const names = connectors.ok ? connectors.data.items.map((c) => c.name).sort() : [];
26+ const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/documents', current, patch);
27+ return (
28+ <>
29+ <AdminTitle title="Documents" count={docs.ok ? fmtInt(docs.data.total) : undefined} />
30+ <AdminFilters
31+ action="/admin/documents"
32+ className="mb-4"
33+ fields={[
34+ { kind: 'select', name: 'connector', label: 'Connector', value: current.connector, options: names.map((n) => ({ value: n, label: n })) },
35+ { kind: 'select', name: 'status', label: 'Status', value: current.status, options: STATUSES.map((s) => ({ value: s, label: s })) },
36+ { kind: 'text', name: 'q', label: 'URL / title', value: current.q, placeholder: 'contains…' },
37+ ]}
38+ />
39+ {!docs.ok ? (
40+ <Unavailable what="Documents" reason={docs.error} />
41+ ) : (
42+ <>
43+ <DataTable compact scroll caption="Documents">
44+ <thead>
45+ <tr>
46+ <Th>Document</Th>
47+ <Th>Connector</Th>
48+ <Th>Type</Th>
49+ <Th>Entity</Th>
50+ <Th>Fetched</Th>
51+ <Th num>HTTP</Th>
52+ <Th num>Snaps</Th>
53+ <Th num>Changes</Th>
54+ <Th num>Fails</Th>
55+ <Th>Status</Th>
56+ <Th>Processing</Th>
57+ </tr>
58+ </thead>
59+ <tbody>
60+ {docs.data.items.length === 0 && <EmptyRow cols={11}>No documents match.</EmptyRow>}
61+ {docs.data.items.map((d) => (
62+ <tr key={d.id}>
63+ <Td>
64+ <Link href={`/admin/documents/${encodeURIComponent(d.id)}`} className="block max-w-[22rem] truncate text-xs font-medium text-ink hover:text-accent" title={d.title ?? d.url}>
65+ {d.title ?? d.url.replace(/^https?:\/\/(www\.)?/, '')}
66+ </Link>
67+ <span className="block max-w-[22rem] truncate text-[11px] text-ink-3" title={d.url}>{d.url.replace(/^https?:\/\/(www\.)?/, '')}</span>
68+ </Td>
69+ <Td><Mono>{d.connector_name ?? '—'}</Mono></Td>
70+ <Td><Mono>{d.doc_type ?? '—'}</Mono></Td>
71+ <Td className="text-xs">
72+ {d.entity_slug && d.entity_type ? (
73+ <Link href={routes.entity({ entity_type: d.entity_type, slug: d.entity_slug })} className="text-ink-2 hover:text-accent" title={d.entity_id ?? undefined}>
74+ {d.entity_name ?? d.entity_slug}
75+ </Link>
76+ ) : (
77+ <span className="text-ink-3">—</span>
78+ )}
79+ </Td>
80+ <Td className="text-xs" title={fmtDateTime(d.last_fetched_at)}><LiveAgo at={d.last_fetched_at} /></Td>
81+ <Td num className={`tnum text-xs ${(num(d.last_status) ?? 0) >= 400 ? 'text-danger' : 'text-ink-2'}`}>{fmtInt(d.last_status)}</Td>
82+ <Td num className="tnum text-xs">{typeof d.snapshots === 'number' ? fmtInt(d.snapshots) : Array.isArray(d.snapshots) ? fmtInt(d.snapshots.length) : '—'}</Td>
83+ <Td num className="tnum text-xs">{fmtInt(d.change_count)}</Td>
84+ <Td num className={`tnum text-xs ${num(d.fail_count) ? 'text-danger' : 'text-ink-2'}`}>{fmtInt(d.fail_count)}</Td>
85+ <Td><StatusChip value={d.status} /></Td>
86+ <Td><StatusChip value={d.last_processing_status ?? null} /></Td>
87+ </tr>
88+ ))}
89+ </tbody>
90+ </DataTable>
91+ <Pagination total={docs.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
92+ </>
93+ )}
94+ </>
95+ );
96+}
added apps/web/src/app/admin/entities/duplicates/page.tsx +108 −0
@@ -0,0 +1,108 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { ActionButton, AdminFilters, AdminTitle, Mono, Notice } from '@/components/admin/ui';
4+import { EntityBadge } from '@/components/ui/badges';
5+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
6+import { Unavailable } from '@/components/ui/unavailable';
7+import { mergeAction } from '@/lib/admin/actions';
8+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
9+import type { DuplicateSide } from '@/lib/admin/types';
10+import { fmtDate, fmtInt, fmtPct, num } from '@/lib/format';
11+import { routes } from '@/lib/site';
12+
13+export const metadata: Metadata = { title: 'Duplicates', robots: { index: false, follow: false } };
14+export const dynamic = 'force-dynamic';
15+
16+const TYPES = ['model', 'company', 'organization', 'lab', 'paper', 'provider', 'benchmark', 'hardware', 'framework', 'library', 'dataset', 'tool', 'repository', 'researcher'];
17+
18+function Side({ s, type }: { s: DuplicateSide; type: string }) {
19+ return (
20+ <div className="min-w-0">
21+ <Link href={routes.entity({ entity_type: type, slug: s.slug })} className="block truncate text-sm font-medium text-ink hover:text-accent" title={s.name}>
22+ {s.name}
23+ </Link>
24+ <p className="truncate text-[11px] text-ink-3">
25+ <Mono>{s.slug}</Mono>
26+ {s.organization && <span> · {s.organization}</span>}
27+ </p>
28+ <p className="tnum text-[11px] text-ink-3">
29+ {fmtInt(s.claims)} claims · first seen {fmtDate(s.first_seen_at)}
30+ </p>
31+ </div>
32+ );
33+}
34+
35+export default async function AdminDuplicatesPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
36+ await requireAdmin();
37+ const sp = await searchParams;
38+ const type = sp.type ?? 'model';
39+ const ret = `/admin/entities/duplicates?type=${encodeURIComponent(type)}`;
40+ const res = await load(adminApi.duplicates({ type, limit: 200 }));
41+ return (
42+ <>
43+ <AdminTitle title="Duplicate candidates" count={res.ok ? fmtInt(res.data.items.length) : undefined} lede={res.ok ? `pg_trgm similarity ≥ ${fmtPct((num(res.data.threshold) ?? 0) * 100, 0)} on normalized names, within one type. Merging moves aliases, identifiers, claims, relations and events into the target and marks the source merged_into.` : undefined} />
44+ <Notice notice={sp.notice} level={sp.level} />
45+ <AdminFilters action="/admin/entities/duplicates" className="mb-4" fields={[{ kind: 'select', name: 'type', label: 'Type', value: type, any: 'model', options: TYPES.map((t) => ({ value: t, label: t })) }]} />
46+ {!res.ok ? (
47+ <Unavailable what="Duplicates" reason={res.error} />
48+ ) : (
49+ <DataTable compact caption="Duplicate candidates">
50+ <thead>
51+ <tr>
52+ <Th>A</Th>
53+ <Th>B</Th>
54+ <Th num>Similarity</Th>
55+ <Th>Merge</Th>
56+ </tr>
57+ </thead>
58+ <tbody>
59+ {res.data.items.length === 0 && <EmptyRow cols={4}>No candidate pairs for this type.</EmptyRow>}
60+ {res.data.items.map((d) => {
61+ const aClaims = num(d.a.claims) ?? 0;
62+ const bClaims = num(d.b.claims) ?? 0;
63+ // Default suggestion: the record with fewer claims (or the newer one) is the source, the richer one the target.
64+ const preferAB = aClaims < bClaims || (aClaims === bClaims && (d.a.first_seen_at ?? '') > (d.b.first_seen_at ?? ''));
65+ return (
66+ <tr key={`${d.a.id}-${d.b.id}`}>
67+ <Td label="A" primary>
68+ <div className="flex items-start gap-2">
69+ <EntityBadge type={d.entity_type} small className="mt-0.5" />
70+ <Side s={d.a} type={d.entity_type} />
71+ </div>
72+ </Td>
73+ <Td label="B" primary>
74+ <div className="flex items-start gap-2">
75+ <EntityBadge type={d.entity_type} small className="mt-0.5" />
76+ <Side s={d.b} type={d.entity_type} />
77+ </div>
78+ </Td>
79+ <Td num label="Similarity" className="tnum text-xs">{fmtPct((num(d.similarity) ?? 0) * 100, 0)}</Td>
80+ <Td label="Merge" wide>
81+ <div className="flex flex-wrap items-center gap-1.5">
82+ <form action={mergeAction}>
83+ <input type="hidden" name="source_id" value={d.a.id} />
84+ <input type="hidden" name="target_id" value={d.b.id} />
85+ <input type="hidden" name="return" value={ret} />
86+ <ActionButton tone={preferAB ? 'accent' : 'neutral'} title={`Merge ${d.a.slug} into ${d.b.slug} (A disappears, B keeps everything)`}>
87+ Merge A → B{preferAB ? ' · suggested' : ''}
88+ </ActionButton>
89+ </form>
90+ <form action={mergeAction}>
91+ <input type="hidden" name="source_id" value={d.b.id} />
92+ <input type="hidden" name="target_id" value={d.a.id} />
93+ <input type="hidden" name="return" value={ret} />
94+ <ActionButton tone={preferAB ? 'neutral' : 'accent'} title={`Merge ${d.b.slug} into ${d.a.slug} (B disappears, A keeps everything)`}>
95+ Merge B → A{preferAB ? '' : ' · suggested'}
96+ </ActionButton>
97+ </form>
98+ </div>
99+ </Td>
100+ </tr>
101+ );
102+ })}
103+ </tbody>
104+ </DataTable>
105+ )}
106+ </>
107+ );
108+}
added apps/web/src/app/admin/errors/page.tsx +69 −0
@@ -0,0 +1,69 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { AdminFilters, AdminTitle, KindChip, Mono, Trunc } from '@/components/admin/ui';
4+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5+import { LiveAgo } from '@/components/ui/live';
6+import { Unavailable } from '@/components/ui/unavailable';
7+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
8+import { fmtDateTime, fmtInt } from '@/lib/format';
9+
10+export const metadata: Metadata = { title: 'Errors', robots: { index: false, follow: false } };
11+export const dynamic = 'force-dynamic';
12+
13+export default async function AdminErrorsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
14+ await requireAdmin();
15+ const sp = await searchParams;
16+ const limit = Math.min(500, Math.max(10, Number(sp.limit) || 100));
17+ const [errs, connectors] = await Promise.all([load(adminApi.errors({ connector: sp.connector, limit })), load(adminApi.connectors())]);
18+ const names = connectors.ok ? connectors.data.items.map((c) => c.name).sort() : [];
19+ return (
20+ <>
21+ <AdminTitle title="Errors" count={errs.ok ? fmtInt(errs.data.total) : undefined} />
22+ <AdminFilters
23+ action="/admin/errors"
24+ className="mb-4"
25+ fields={[
26+ { kind: 'select', name: 'connector', label: 'Connector', value: sp.connector, options: names.map((n) => ({ value: n, label: n })) },
27+ { kind: 'select', name: 'limit', label: 'Rows', value: String(limit), any: '100', options: ['50', '100', '250', '500'].map((v) => ({ value: v, label: v })) },
28+ ]}
29+ />
30+ {!errs.ok ? (
31+ <Unavailable what="Errors" reason={errs.error} />
32+ ) : (
33+ <DataTable compact scroll caption="Fetch and parse errors">
34+ <thead>
35+ <tr>
36+ <Th>Time</Th>
37+ <Th>Connector</Th>
38+ <Th>Type</Th>
39+ <Th>Message</Th>
40+ <Th>URL</Th>
41+ <Th>Run</Th>
42+ </tr>
43+ </thead>
44+ <tbody>
45+ {errs.data.items.length === 0 && <EmptyRow cols={6}>No errors recorded.</EmptyRow>}
46+ {errs.data.items.map((e) => (
47+ <tr key={String(e.id)}>
48+ <Td className="text-xs" title={fmtDateTime(e.created_at)}><LiveAgo at={e.created_at} /></Td>
49+ <Td>{e.connector_name ? <Link href={`/admin/errors?connector=${encodeURIComponent(e.connector_name)}`} className="mono text-xs text-ink hover:text-accent">{e.connector_name}</Link> : <span className="text-ink-3">—</span>}</Td>
50+ <Td>{e.error_type ? <KindChip value={e.error_type} /> : <span className="text-ink-3">—</span>}</Td>
51+ <Td className="text-xs text-ink-2"><Trunc text={e.message} max={120} /></Td>
52+ <Td className="text-xs">
53+ {e.url ? (
54+ <a href={e.url} target="_blank" rel="noopener noreferrer" className="text-ink-2 hover:text-accent" title={e.url}>
55+ {e.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 60)}
56+ </a>
57+ ) : (
58+ <span className="text-ink-3">—</span>
59+ )}
60+ </Td>
61+ <Td>{e.run_id ? <Mono>{e.run_id}</Mono> : <span className="text-ink-3">—</span>}</Td>
62+ </tr>
63+ ))}
64+ </tbody>
65+ </DataTable>
66+ )}
67+ </>
68+ );
69+}
added apps/web/src/app/admin/infrastructure/page.tsx +104 −0
@@ -0,0 +1,104 @@
1+import type { Metadata } from 'next';
2+import { AdminTitle, Bool, JsonPre, Mono } from '@/components/admin/ui';
3+import { HBars } from '@/components/charts/charts';
4+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5+import { KeyValue } from '@/components/ui/key-value';
6+import { Unavailable } from '@/components/ui/unavailable';
7+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
8+import { fmtBytes, fmtDuration, fmtInt, num } from '@/lib/format';
9+
10+export const metadata: Metadata = { title: 'Infrastructure', robots: { index: false, follow: false } };
11+export const dynamic = 'force-dynamic';
12+
13+export default async function AdminInfrastructurePage() {
14+ await requireAdmin();
15+ const res = await load(adminApi.infrastructure());
16+ if (!res.ok) {
17+ return (
18+ <>
19+ <AdminTitle title="Infrastructure" />
20+ <Unavailable what="Infrastructure" reason={res.error} />
21+ </>
22+ );
23+ }
24+ const i = res.data;
25+ const db = i.database;
26+ const tables = [...(db?.tables ?? [])].sort((a, b) => (num(b.total_bytes) ?? 0) - (num(a.total_bytes) ?? 0));
27+ const hb = Object.entries(i.heartbeats ?? {});
28+ return (
29+ <>
30+ <AdminTitle title="Infrastructure" lede={i.hostname ? <Mono>{i.hostname}</Mono> : undefined} />
31+ <div className="grid gap-8 lg:grid-cols-2">
32+ <section>
33+ <p className="eyebrow mb-2">API process</p>
34+ <KeyValue
35+ dense
36+ rows={[
37+ { key: 'hostname', label: 'Host', value: <Mono>{i.hostname ?? '—'}</Mono> },
38+ { key: 'env', label: 'Environment', raw: i.env },
39+ { key: 'python', label: 'Python', raw: i.python },
40+ { key: 'platform', label: 'Platform', raw: i.platform },
41+ { key: 'uptime', label: 'API uptime', raw: num(i.api_uptime_s) === null ? null : fmtDuration(i.api_uptime_s) },
42+ { key: 'data_dir', label: 'Data dir', value: <Bool v={i.data_dir_exists} /> },
43+ ]}
44+ />
45+ <p className="eyebrow mt-6 mb-2">Archive</p>
46+ {i.archive ? (
47+ <KeyValue
48+ dense
49+ rows={[
50+ { key: 'raw', label: 'Raw', value: <span className="tnum">{fmtBytes(i.archive.raw_bytes)} · {fmtInt(i.archive.raw_files)} files</span> },
51+ { key: 'text', label: 'Text', value: <span className="tnum">{fmtBytes(i.archive.text_bytes)} · {fmtInt(i.archive.text_files)} files</span> },
52+ ]}
53+ />
54+ ) : (
55+ <p className="text-sm text-ink-3">Archive size unavailable.</p>
56+ )}
57+ <p className="eyebrow mt-6 mb-2">Heartbeats</p>
58+ {hb.length === 0 ? <p className="text-sm text-ink-3">None reported.</p> : <JsonPre value={i.heartbeats} maxHeight="14rem" />}
59+ </section>
60+ <section>
61+ <p className="eyebrow mb-2">Database</p>
62+ {db ? (
63+ <KeyValue
64+ dense
65+ rows={[
66+ { key: 'database', label: 'Database', value: <Mono>{db.database ?? '—'}</Mono> },
67+ { key: 'size', label: 'Size', raw: fmtBytes(db.db_bytes) },
68+ { key: 'pg', label: 'Server', raw: db.pg_version?.split(',')[0] ?? db.pg_version },
69+ { key: 'conn', label: 'Connections', value: <span className="tnum">{fmtInt(db.connections)} / {fmtInt(db.max_connections)}</span> },
70+ ]}
71+ />
72+ ) : (
73+ <p className="text-sm text-ink-3">Database metrics unavailable.</p>
74+ )}
75+ <p className="eyebrow mt-6 mb-2">Table sizes</p>
76+ <HBars data={tables.slice(0, 12).map((t) => ({ label: t.table, value: num(t.total_bytes) ?? 0, sub: `${fmtInt(t.rows_estimate)} rows` }))} format={(v) => fmtBytes(v)} />
77+ </section>
78+ </div>
79+ <section className="mt-8">
80+ <DataTable compact caption="Table sizes">
81+ <thead>
82+ <tr>
83+ <Th>Table</Th>
84+ <Th num>Total</Th>
85+ <Th num>Data</Th>
86+ <Th num>Rows (est.)</Th>
87+ </tr>
88+ </thead>
89+ <tbody>
90+ {tables.length === 0 && <EmptyRow cols={4}>No table statistics.</EmptyRow>}
91+ {tables.map((t) => (
92+ <tr key={t.table}>
93+ <Td primary><Mono className="text-ink">{t.table}</Mono></Td>
94+ <Td num label="Total" className="tnum text-xs">{fmtBytes(t.total_bytes)}</Td>
95+ <Td num label="Data" className="tnum text-xs text-ink-2">{fmtBytes(t.data_bytes)}</Td>
96+ <Td num label="Rows" className="tnum text-xs">{fmtInt(t.rows_estimate)}</Td>
97+ </tr>
98+ ))}
99+ </tbody>
100+ </DataTable>
101+ </section>
102+ </>
103+ );
104+}
added apps/web/src/app/admin/jobs/page.tsx +108 −0
@@ -0,0 +1,108 @@
1+import type { Metadata } from 'next';
2+import { ActionButton, AdminFilters, AdminTitle, Mono, Notice, StatusChip, Trunc } from '@/components/admin/ui';
3+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
4+import { LiveAgo } from '@/components/ui/live';
5+import { Unavailable } from '@/components/ui/unavailable';
6+import { requeueDeadAction, retryJobAction } from '@/lib/admin/actions';
7+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
8+import { fmtDateTime, fmtInt } from '@/lib/format';
9+
10+export const metadata: Metadata = { title: 'Jobs', robots: { index: false, follow: false } };
11+export const dynamic = 'force-dynamic';
12+
13+const STATUSES = ['queued', 'running', 'done', 'failed', 'dead'];
14+
15+function payloadSummary(p: Record<string, unknown>): string {
16+ return Object.entries(p ?? {})
17+ .filter(([, v]) => v !== null && v !== undefined && v !== '')
18+ .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
19+ .join(' · ');
20+}
21+
22+export default async function AdminJobsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
23+ await requireAdmin();
24+ const sp = await searchParams;
25+ const ret = `/admin/jobs${sp.status || sp.kind ? `?${new URLSearchParams(Object.fromEntries(Object.entries({ status: sp.status, kind: sp.kind }).filter(([, v]) => v)) as Record<string, string>).toString()}` : ''}`;
26+ const res = await load(adminApi.jobs({ status: sp.status, kind: sp.kind, limit: 200 }));
27+ const kinds = res.ok ? Object.keys(res.data.depth ?? {}) : [];
28+ return (
29+ <>
30+ <AdminTitle title="Jobs" count={res.ok ? fmtInt(res.data.total) : undefined}>
31+ <form action={requeueDeadAction}>
32+ <input type="hidden" name="return" value={ret} />
33+ <ActionButton tone="accent" title="Move every dead job back to the queue">Requeue dead</ActionButton>
34+ </form>
35+ </AdminTitle>
36+ <Notice notice={sp.notice} level={sp.level} />
37+ {res.ok && res.data.depth && Object.keys(res.data.depth).length > 0 && (
38+ <ul className="mb-4 flex flex-wrap gap-x-4 gap-y-1 text-xs">
39+ {Object.entries(res.data.depth).map(([kind, byStatus]) => (
40+ <li key={kind} className="flex flex-wrap items-center gap-1.5">
41+ <Mono className="text-ink">{kind}</Mono>
42+ {Object.entries(byStatus).map(([st, n]) => (
43+ <a key={st} href={`/admin/jobs?kind=${encodeURIComponent(kind)}&status=${encodeURIComponent(st)}`} className="inline-flex items-center gap-1 hover:underline">
44+ <StatusChip value={st} /> <span className="tnum text-ink-2">{fmtInt(n)}</span>
45+ </a>
46+ ))}
47+ </li>
48+ ))}
49+ </ul>
50+ )}
51+ <AdminFilters
52+ action="/admin/jobs"
53+ className="mb-4"
54+ fields={[
55+ { kind: 'select', name: 'kind', label: 'Kind', value: sp.kind, options: (kinds.length ? kinds : ['llm_extract', 'reprocess_snapshot']).map((k) => ({ value: k, label: k })) },
56+ { kind: 'select', name: 'status', label: 'Status', value: sp.status, options: STATUSES.map((s) => ({ value: s, label: s })) },
57+ ]}
58+ />
59+ {!res.ok ? (
60+ <Unavailable what="Jobs" reason={res.error} />
61+ ) : (
62+ <DataTable compact scroll caption="Job queue">
63+ <thead>
64+ <tr>
65+ <Th>Job</Th>
66+ <Th>Kind</Th>
67+ <Th>Status</Th>
68+ <Th num>Prio</Th>
69+ <Th num>Attempts</Th>
70+ <Th>Run after</Th>
71+ <Th>Locked by</Th>
72+ <Th>Started</Th>
73+ <Th>Finished</Th>
74+ <Th>Error</Th>
75+ <Th>Action</Th>
76+ </tr>
77+ </thead>
78+ <tbody>
79+ {res.data.items.length === 0 && <EmptyRow cols={11}>No jobs match.</EmptyRow>}
80+ {res.data.items.map((j) => (
81+ <tr key={j.id}>
82+ <Td><Mono title={payloadSummary(j.payload)}>{j.id}</Mono></Td>
83+ <Td><Mono className="text-ink">{j.kind}</Mono></Td>
84+ <Td><StatusChip value={j.status} /></Td>
85+ <Td num className="tnum text-xs">{fmtInt(j.priority)}</Td>
86+ <Td num className="tnum text-xs">{fmtInt(j.attempts)} / {fmtInt(j.max_attempts)}</Td>
87+ <Td className="text-xs text-ink-2" title={fmtDateTime(j.run_after)}>{j.run_after ? <LiveAgo at={j.run_after} /> : '—'}</Td>
88+ <Td className="text-xs text-ink-2"><Trunc text={j.locked_by} max={28} /></Td>
89+ <Td className="text-xs" title={fmtDateTime(j.started_at)}>{j.started_at ? <LiveAgo at={j.started_at} /> : <span className="text-ink-3">—</span>}</Td>
90+ <Td className="text-xs" title={fmtDateTime(j.finished_at)}>{j.finished_at ? <LiveAgo at={j.finished_at} /> : <span className="text-ink-3">—</span>}</Td>
91+ <Td className="text-xs text-danger"><Trunc text={j.error} max={60} /></Td>
92+ <Td>
93+ {(j.status === 'failed' || j.status === 'dead') && (
94+ <form action={retryJobAction}>
95+ <input type="hidden" name="id" value={j.id} />
96+ <input type="hidden" name="return" value={ret} />
97+ <ActionButton tone="accent">Retry</ActionButton>
98+ </form>
99+ )}
100+ </Td>
101+ </tr>
102+ ))}
103+ </tbody>
104+ </DataTable>
105+ )}
106+ </>
107+ );
108+}
added apps/web/src/app/admin/layout.tsx +14 −0
@@ -0,0 +1,14 @@
1+import type { Metadata } from 'next';
2+import type { ReactNode } from 'react';
3+import { AdminShell } from '@/components/admin/shell';
4+import { Container } from '@/components/ui/section';
5+import { getAdminToken } from '@/lib/admin/session';
6+
7+export const metadata: Metadata = { title: { default: 'Admin', template: '%s · Admin | AI Atlas' }, robots: { index: false, follow: false, nocache: true } };
8+export const dynamic = 'force-dynamic';
9+
10+/** Admin shell when a session cookie exists; bare (login form) otherwise. Every page still validates the token on its own fetches. */
11+export default async function AdminLayout({ children }: { children: ReactNode }) {
12+ const token = await getAdminToken();
13+ return <Container wide>{token ? <AdminShell where={process.env.NODE_ENV === 'production' ? 'production' : 'development'}>{children}</AdminShell> : children}</Container>;
14+}
added apps/web/src/app/admin/llm-jobs/page.tsx +113 −0
@@ -0,0 +1,113 @@
1+import type { Metadata } from 'next';
2+import { AdminFilters, AdminTitle, Mono, StatusChip, Trunc } from '@/components/admin/ui';
3+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
4+import { LiveAgo } from '@/components/ui/live';
5+import { Unavailable } from '@/components/ui/unavailable';
6+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
7+import { fmtDateTime, fmtDuration, fmtInt, num } from '@/lib/format';
8+
9+export const metadata: Metadata = { title: 'LLM jobs', robots: { index: false, follow: false } };
10+export const dynamic = 'force-dynamic';
11+
12+export default async function AdminLlmJobsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
13+ await requireAdmin();
14+ const sp = await searchParams;
15+ const limit = Math.min(1000, Math.max(10, Number(sp.limit) || 100));
16+ const [jobs, health] = await Promise.all([load(adminApi.llmJobs({ limit })), load(adminApi.llmHealth())]);
17+ const ms = (v: unknown) => (num(v) === null ? '—' : `${fmtInt(Math.round((num(v) ?? 0) / 1000))} s`);
18+ return (
19+ <>
20+ <AdminTitle
21+ title="LLM jobs"
22+ count={jobs.ok ? fmtInt(jobs.data.total) : undefined}
23+ lede={
24+ health.ok ? (
25+ <span className="inline-flex flex-wrap items-center gap-x-2">
26+ <StatusChip value={health.data.reachable === false ? 'failing' : health.data.available ? 'ok' : 'disabled'} />
27+ <span>engine {health.data.engine ?? '—'}</span>
28+ {health.data.models && <span>· {Object.entries(health.data.models).map(([k, v]) => `${k}: ${v}`).join(' · ')}</span>}
29+ {health.data.embedding_model && <span>· embeddings {health.data.embedding_model}</span>}
30+ </span>
31+ ) : (
32+ `LLM health unavailable: ${health.error}`
33+ )
34+ }
35+ />
36+ <AdminFilters action="/admin/llm-jobs" className="mb-4" fields={[{ kind: 'select', name: 'limit', label: 'Rows', value: String(limit), any: '100', options: ['50', '100', '250', '500', '1000'].map((v) => ({ value: v, label: v })) }]} />
37+ {!jobs.ok ? (
38+ <Unavailable what="LLM jobs" reason={jobs.error} />
39+ ) : (
40+ <>
41+ {jobs.data.totals && jobs.data.totals.length > 0 && (
42+ <section className="mb-6">
43+ <p className="eyebrow mb-2">Totals by stage · model · status</p>
44+ <DataTable compact scroll caption="LLM totals">
45+ <thead>
46+ <tr>
47+ <Th>Stage</Th>
48+ <Th>Model</Th>
49+ <Th>Status</Th>
50+ <Th num>n</Th>
51+ <Th num>Input tokens</Th>
52+ <Th num>Output tokens</Th>
53+ <Th num>Avg duration</Th>
54+ </tr>
55+ </thead>
56+ <tbody>
57+ {jobs.data.totals.map((t, i) => (
58+ <tr key={i}>
59+ <Td><Mono className="text-ink">{t.stage}</Mono></Td>
60+ <Td><Mono>{t.model}</Mono></Td>
61+ <Td><StatusChip value={t.status} /></Td>
62+ <Td num className="tnum text-xs">{fmtInt(t.n)}</Td>
63+ <Td num className="tnum text-xs">{fmtInt(t.input_tokens)}</Td>
64+ <Td num className="tnum text-xs">{fmtInt(t.output_tokens)}</Td>
65+ <Td num className="tnum text-xs">{ms(t.avg_ms)}</Td>
66+ </tr>
67+ ))}
68+ </tbody>
69+ </DataTable>
70+ </section>
71+ )}
72+ <DataTable compact scroll caption="LLM job accounting">
73+ <thead>
74+ <tr>
75+ <Th>Created</Th>
76+ <Th>Task</Th>
77+ <Th>Stage</Th>
78+ <Th>Model</Th>
79+ <Th>Node</Th>
80+ <Th>Schema</Th>
81+ <Th num>In</Th>
82+ <Th num>Out</Th>
83+ <Th num>Duration</Th>
84+ <Th>Status</Th>
85+ <Th>Error</Th>
86+ <Th>Snapshot</Th>
87+ </tr>
88+ </thead>
89+ <tbody>
90+ {jobs.data.items.length === 0 && <EmptyRow cols={12}>No LLM jobs recorded.</EmptyRow>}
91+ {jobs.data.items.map((j) => (
92+ <tr key={j.id}>
93+ <Td className="text-xs" title={fmtDateTime(j.created_at)}><LiveAgo at={j.created_at} /></Td>
94+ <Td><Mono className="text-ink">{j.task_type ?? '—'}</Mono></Td>
95+ <Td><Mono>{j.stage ?? '—'}</Mono></Td>
96+ <Td><Mono>{j.model ?? '—'}</Mono></Td>
97+ <Td className="text-xs text-ink-2"><Trunc text={j.node} max={24} /></Td>
98+ <Td><Mono>{j.schema_name ?? '—'}</Mono></Td>
99+ <Td num className="tnum text-xs">{fmtInt(j.input_tokens)}</Td>
100+ <Td num className="tnum text-xs">{fmtInt(j.output_tokens)}</Td>
101+ <Td num className="tnum text-xs">{num(j.duration_ms) === null ? '—' : fmtDuration(Math.round((num(j.duration_ms) ?? 0) / 1000))}</Td>
102+ <Td><StatusChip value={j.status} /></Td>
103+ <Td className="text-xs text-danger"><Trunc text={j.error} max={50} /></Td>
104+ <Td>{j.snapshot_id ? <a href={`/admin/snapshots/${encodeURIComponent(j.snapshot_id)}`} className="mono text-[11.5px] text-ink-2 hover:text-accent">{j.snapshot_id}</a> : <span className="text-ink-3">—</span>}</Td>
105+ </tr>
106+ ))}
107+ </tbody>
108+ </DataTable>
109+ </>
110+ )}
111+ </>
112+ );
113+}
added apps/web/src/app/admin/overview/page.tsx +184 −0
@@ -0,0 +1,184 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { AdminTitle, JsonPre, Mono, StatusChip, Trunc } from '@/components/admin/ui';
4+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5+import { LiveAgo } from '@/components/ui/live';
6+import { Stat, StatGrid } from '@/components/ui/section';
7+import { Unavailable } from '@/components/ui/unavailable';
8+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
9+import { fmtBytes, fmtDuration, fmtInt, num } from '@/lib/format';
10+
11+export const metadata: Metadata = { title: 'Overview', robots: { index: false, follow: false } };
12+export const dynamic = 'force-dynamic';
13+
14+export default async function AdminOverviewPage() {
15+ await requireAdmin();
16+ const res = await load(adminApi.overview());
17+ if (!res.ok) {
18+ return (
19+ <>
20+ <AdminTitle title="Overview" />
21+ <Unavailable what="Overview" reason={res.error} />
22+ </>
23+ );
24+ }
25+ const o = res.data;
26+ const s = o.stats ?? {};
27+ const hb = Object.entries(o.heartbeats ?? {});
28+ const queue = Object.entries(o.queue ?? {});
29+ const health = Object.entries(o.connectors ?? {});
30+ return (
31+ <>
32+ <AdminTitle title="Overview" lede={s.computed_at ? <>stats computed <LiveAgo at={String(s.computed_at)} /></> : undefined} />
33+ <StatGrid cols={4}>
34+ <Stat label="Entities" value={fmtInt(s.entities_total)} />
35+ <Stat label="Documents" value={fmtInt(s.documents)} hint={`${fmtInt(s.snapshots)} snapshots`} />
36+ <Stat label="Current claims" value={fmtInt(s.claims_current)} hint={`${fmtInt(s.claims)} total`} />
37+ <Stat label="Events · 24 h" value={fmtInt(s.change_events_24h)} hint={`${fmtInt(s.change_events)} total`} />
38+ <Stat label="Current prices" value={fmtInt(s.prices_current)} />
39+ <Stat label="Benchmark results" value={fmtInt(s.benchmark_results)} />
40+ <Stat label="Review pending" value={fmtInt(o.review_pending)} href="/admin/review" />
41+ <Stat label="Recent errors" value={fmtInt(o.recent_errors)} href="/admin/errors" />
42+ </StatGrid>
43+
44+ <div className="mt-8 grid gap-8 lg:grid-cols-3">
45+ <section>
46+ <p className="eyebrow mb-2">Queue depth</p>
47+ {queue.length === 0 ? (
48+ <p className="text-sm text-ink-3">Queue empty.</p>
49+ ) : (
50+ <DataTable compact stack={false} caption="Queue depth">
51+ <thead>
52+ <tr>
53+ <Th>Kind</Th>
54+ <Th>Status</Th>
55+ <Th num>n</Th>
56+ </tr>
57+ </thead>
58+ <tbody>
59+ {queue.flatMap(([kind, byStatus]) =>
60+ Object.entries(byStatus).map(([st, n]) => (
61+ <tr key={`${kind}-${st}`}>
62+ <Td><Mono>{kind}</Mono></Td>
63+ <Td><StatusChip value={st} /></Td>
64+ <Td num className="tnum">{fmtInt(n)}</Td>
65+ </tr>
66+ )),
67+ )}
68+ </tbody>
69+ </DataTable>
70+ )}
71+ <p className="mt-2 text-xs"><Link href="/admin/jobs" className="link">Jobs →</Link></p>
72+ </section>
73+ <section>
74+ <p className="eyebrow mb-2">Connector health</p>
75+ <ul className="divide-y divide-rule border-y border-rule text-sm">
76+ {health.map(([k, n]) => (
77+ <li key={k} className="flex items-center justify-between py-1.5">
78+ <StatusChip value={k} />
79+ <span className="tnum">{fmtInt(n)}</span>
80+ </li>
81+ ))}
82+ {health.length === 0 && <li className="py-2 text-ink-3">No connectors.</li>}
83+ </ul>
84+ <p className="mt-2 text-xs"><Link href="/admin/connectors" className="link">Connectors →</Link></p>
85+ {o.review_by_kind && (
86+ <>
87+ <p className="eyebrow mt-6 mb-2">Review by kind</p>
88+ <ul className="divide-y divide-rule border-y border-rule text-sm">
89+ {Object.entries(o.review_by_kind).map(([k, n]) => (
90+ <li key={k} className="flex items-center justify-between py-1.5">
91+ <Link href={`/admin/review?kind=${encodeURIComponent(k)}`} className="mono text-xs text-ink-2 hover:text-accent">{k}</Link>
92+ <span className="tnum">{fmtInt(n)}</span>
93+ </li>
94+ ))}
95+ </ul>
96+ </>
97+ )}
98+ </section>
99+ <section>
100+ <p className="eyebrow mb-2">Heartbeats</p>
101+ {hb.length === 0 ? <p className="text-sm text-ink-3">None reported (scheduler / workers have not written a heartbeat).</p> : <JsonPre value={o.heartbeats} maxHeight="14rem" />}
102+ {o.archive && (
103+ <>
104+ <p className="eyebrow mt-6 mb-2">Archive</p>
105+ <p className="tnum text-sm text-ink-2">
106+ raw {fmtBytes(o.archive.raw_bytes)} · {fmtInt(o.archive.raw_files)} files · text {fmtBytes(o.archive.text_bytes)} · {fmtInt(o.archive.text_files)} files
107+ </p>
108+ </>
109+ )}
110+ </section>
111+ </div>
112+
113+ <section className="mt-8">
114+ <p className="eyebrow mb-2">
115+ LLM factory · 24 h {o.llm?.available === false && <StatusChip value="unavailable" className="ml-2" />}
116+ </p>
117+ <p className="tnum text-sm text-ink-2">
118+ {fmtInt(o.llm?.jobs_24h)} jobs · {fmtInt(o.llm?.tokens_24h)} tokens{o.llm?.failed_24h !== undefined && <> · <span className={num(o.llm.failed_24h) ? 'text-danger' : ''}>{fmtInt(o.llm.failed_24h)} failed</span></>}
119+ </p>
120+ {o.llm?.by_stage?.length ? (
121+ <DataTable compact stack={false} caption="LLM jobs by stage" className="mt-2 max-w-md">
122+ <thead>
123+ <tr>
124+ <Th>Stage</Th>
125+ <Th>Status</Th>
126+ <Th num>n</Th>
127+ </tr>
128+ </thead>
129+ <tbody>
130+ {o.llm.by_stage.map((r, i) => (
131+ <tr key={i}>
132+ <Td><Mono>{r.stage}</Mono></Td>
133+ <Td><StatusChip value={r.status} /></Td>
134+ <Td num className="tnum">{fmtInt(r.n)}</Td>
135+ </tr>
136+ ))}
137+ </tbody>
138+ </DataTable>
139+ ) : (
140+ <p className="mt-2 text-xs text-ink-3">No LLM jobs in the last 24 h.</p>
141+ )}
142+ <p className="mt-2 text-xs"><Link href="/admin/llm-jobs" className="link">LLM accounting →</Link></p>
143+ </section>
144+
145+ <section className="mt-8">
146+ <p className="eyebrow mb-2">Recent runs</p>
147+ <DataTable compact scroll caption="Recent runs">
148+ <thead>
149+ <tr>
150+ <Th>Connector</Th>
151+ <Th>Started</Th>
152+ <Th>Status</Th>
153+ <Th num>Duration</Th>
154+ <Th num>Fetched</Th>
155+ <Th num>Changed</Th>
156+ <Th num>Failed</Th>
157+ <Th num>Claims</Th>
158+ <Th num>Events</Th>
159+ <Th>Error</Th>
160+ </tr>
161+ </thead>
162+ <tbody>
163+ {!o.recent_runs?.length && <EmptyRow cols={10}>No runs yet.</EmptyRow>}
164+ {o.recent_runs?.map((r) => (
165+ <tr key={r.id}>
166+ <Td><Link href={`/admin/runs?connector=${encodeURIComponent(r.connector_name)}`} className="mono text-xs text-ink hover:text-accent">{r.connector_name}</Link></Td>
167+ <Td className="text-xs"><LiveAgo at={r.started_at} /></Td>
168+ <Td><StatusChip value={r.status} /></Td>
169+ <Td num className="tnum text-xs">{num(r.duration_ms) === null ? '—' : fmtDuration(Math.round((num(r.duration_ms) ?? 0) / 1000))}</Td>
170+ <Td num className="tnum text-xs">{fmtInt(r.docs_fetched)}</Td>
171+ <Td num className="tnum text-xs">{fmtInt(r.docs_changed)}</Td>
172+ <Td num className={`tnum text-xs ${num(r.docs_failed) ? 'text-danger' : ''}`}>{fmtInt(r.docs_failed)}</Td>
173+ <Td num className="tnum text-xs">{fmtInt(r.claims_written)}</Td>
174+ <Td num className="tnum text-xs">{fmtInt(r.events_emitted)}</Td>
175+ <Td className="text-xs text-ink-2"><Trunc text={r.error} max={60} /></Td>
176+ </tr>
177+ ))}
178+ </tbody>
179+ </DataTable>
180+ <p className="mt-2 text-xs"><Link href="/admin/runs" className="link">All runs →</Link></p>
181+ </section>
182+ </>
183+ );
184+}
added apps/web/src/app/admin/page.tsx +23 −0
@@ -0,0 +1,23 @@
1+import type { Metadata } from 'next';
2+import { redirect } from 'next/navigation';
3+import { LoginForm } from '@/components/admin/login-form';
4+import { getAdminToken } from '@/lib/admin/session';
5+
6+export const metadata: Metadata = { title: 'Sign in', robots: { index: false, follow: false } };
7+export const dynamic = 'force-dynamic';
8+
9+/** `/admin`: login form, or straight to the overview when a session cookie is present (the overview validates it). */
10+export default async function AdminLogin({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
11+ const sp = await searchParams;
12+ const token = await getAdminToken();
13+ if (token && !sp.expired && !sp.signed_out) redirect('/admin/overview');
14+ const hint = sp.expired ? 'Your session expired or the token was rejected — sign in again.' : sp.signed_out ? 'Signed out.' : null;
15+ return (
16+ <div className="py-16 md:py-24">
17+ <p className="eyebrow">AI Atlas</p>
18+ <h1 className="display mt-2 text-3xl md:text-4xl">Admin console</h1>
19+ <p className="mt-3 max-w-md text-sm text-ink-2">Connectors, runs, documents, jobs, review queue and infrastructure. Restricted to operators.</p>
20+ <LoginForm hint={hint} />
21+ </div>
22+ );
23+}
added apps/web/src/app/admin/review/page.tsx +182 −0
@@ -0,0 +1,182 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { ActionButton, AdminFilters, AdminTitle, JsonPre, KindChip, Mono, Notice, StatusChip, Trunc } from '@/components/admin/ui';
4+import { EntityBadge, TierBadge } from '@/components/ui/badges';
5+import { LiveAgo } from '@/components/ui/live';
6+import { Pagination, withParams } from '@/components/ui/pagination';
7+import { EmptyState, Unavailable } from '@/components/ui/unavailable';
8+import { keepConflictSideAction, reviewAction } from '@/lib/admin/actions';
9+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
10+import type { ReviewItem } from '@/lib/admin/types';
11+import { fmtDateTime, fmtInt, fmtValue, num } from '@/lib/format';
12+import { propertyLabel, routes } from '@/lib/site';
13+
14+export const metadata: Metadata = { title: 'Review queue', robots: { index: false, follow: false } };
15+export const dynamic = 'force-dynamic';
16+
17+const LIMIT = 25;
18+const STATUSES = ['pending', 'approved', 'rejected', 'edited', 'all'];
19+const KINDS = ['conflict', 'merge_candidate', 'parser_breakage', 'blocked_source'];
20+
21+function host(url: unknown): string {
22+ if (typeof url !== 'string') return '—';
23+ try {
24+ return new URL(url).hostname.replace(/^www\./, '');
25+ } catch {
26+ return url;
27+ }
28+}
29+
30+/** Conflict payload `{ property, current, claimed, current_source, claimed_source, tier }` rendered as two columns, each with a "Keep" button. */
31+function ConflictView({ item, ret }: { item: ReviewItem; ret: string }) {
32+ const p = item.payload ?? {};
33+ const property = typeof p.property === 'string' ? p.property : null;
34+ const entity = item.entities?.[0];
35+ const sides = [
36+ { key: 'current', title: 'Current value', value: p.current, source: p.current_source, tier: p.current_tier ?? null },
37+ { key: 'claimed', title: 'Claimed by new source', value: p.claimed, source: p.claimed_source, tier: p.tier ?? p.claimed_tier ?? null },
38+ ];
39+ return (
40+ <div className="mt-2 grid gap-3 md:grid-cols-2">
41+ {sides.map((s) => (
42+ <div key={s.key} className="border border-rule p-3">
43+ <p className="eyebrow">{s.title}</p>
44+ <p className="tnum mt-1 break-words text-sm font-medium text-ink">{fmtValue(s.value, property ?? undefined)}</p>
45+ <p className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-ink-3">
46+ {typeof s.source === 'string' ? (
47+ <a href={s.source} target="_blank" rel="noopener noreferrer" className="text-ink-2 hover:text-accent" title={s.source}>
48+ {host(s.source)}
49+ </a>
50+ ) : (
51+ <span>source unknown</span>
52+ )}
53+ {num(s.tier) !== null && <TierBadge tier={num(s.tier)} />}
54+ </p>
55+ {item.status === 'pending' && property && entity && (
56+ <form action={keepConflictSideAction} className="mt-2">
57+ <input type="hidden" name="id" value={item.id} />
58+ <input type="hidden" name="slug" value={entity.slug} />
59+ <input type="hidden" name="property" value={property} />
60+ <input type="hidden" name="value" value={JSON.stringify(s.value ?? null)} />
61+ <input type="hidden" name="source_url" value={typeof s.source === 'string' ? s.source : ''} />
62+ <input type="hidden" name="return" value={ret} />
63+ <ActionButton tone="positive" title="Promote this claim to current and supersede the other">Keep this</ActionButton>
64+ </form>
65+ )}
66+ </div>
67+ ))}
68+ </div>
69+ );
70+}
71+
72+export default async function AdminReviewPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
73+ await requireAdmin();
74+ const sp = await searchParams;
75+ const current: Record<string, string | undefined> = { status: sp.status ?? 'pending' };
76+ if (sp.kind) current.kind = sp.kind;
77+ if (sp.offset) current.offset = sp.offset;
78+ const offset = Math.max(0, Number(current.offset) || 0);
79+ const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/review', current, patch);
80+ const ret = href({});
81+ const res = await load(adminApi.review({ status: current.status, kind: current.kind, limit: LIMIT, offset }));
82+ const byKind = res.ok ? (res.data.by_kind ?? []).filter((k) => k.status === (current.status === 'all' ? k.status : current.status)) : [];
83+ return (
84+ <>
85+ <AdminTitle title="Review queue" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Conflicts keep both claims until an operator picks one; merges move aliases, identifiers, claims, relations and events into the target." />
86+ <Notice notice={sp.notice} level={sp.level} />
87+ {byKind.length > 0 && (
88+ <ul className="mb-4 flex flex-wrap gap-1.5 text-xs">
89+ {byKind.map((k) => (
90+ <li key={`${k.kind}-${k.status}`}>
91+ <Link href={href({ kind: current.kind === k.kind ? undefined : k.kind, offset: undefined })} className={`inline-flex h-7 items-center gap-1.5 border px-2 ${current.kind === k.kind ? 'border-accent text-accent' : 'border-rule text-ink-2 hover:border-rule-strong'}`}>
92+ <span className="mono">{k.kind}</span> <span className="tnum">{fmtInt(k.n)}</span> {current.status === 'all' && <StatusChip value={k.status} />}
93+ </Link>
94+ </li>
95+ ))}
96+ </ul>
97+ )}
98+ <AdminFilters
99+ action="/admin/review"
100+ className="mb-4"
101+ fields={[
102+ { kind: 'select', name: 'status', label: 'Status', value: current.status, any: 'pending', options: STATUSES.map((s) => ({ value: s, label: s })) },
103+ { kind: 'select', name: 'kind', label: 'Kind', value: current.kind, options: KINDS.map((k) => ({ value: k, label: k })) },
104+ ]}
105+ />
106+ {!res.ok ? (
107+ <Unavailable what="Review queue" reason={res.error} />
108+ ) : res.data.items.length === 0 ? (
109+ <EmptyState title="Nothing to review" />
110+ ) : (
111+ <>
112+ <ul className="divide-y divide-rule border-y border-rule">
113+ {res.data.items.map((item) => {
114+ const p = item.payload ?? {};
115+ const property = typeof p.property === 'string' ? p.property : null;
116+ return (
117+ <li key={item.id} className="py-3">
118+ <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
119+ <div className="min-w-0 flex-1">
120+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
121+ <KindChip value={item.kind} />
122+ <StatusChip value={item.status} />
123+ {property && <span className="text-xs text-ink-2">{propertyLabel(property)}</span>}
124+ <span className="text-xs text-ink-3" title={fmtDateTime(item.created_at)}>
125+ <LiveAgo at={item.created_at} />
126+ </span>
127+ <Mono>{item.id}</Mono>
128+ </div>
129+ {item.reason && <p className="mt-1 text-sm text-ink"><Trunc text={item.reason} max={200} /></p>}
130+ {item.entities && item.entities.length > 0 && (
131+ <ul className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-sm">
132+ {item.entities.map((e) => (
133+ <li key={e.id} className="flex items-center gap-1.5">
134+ <EntityBadge type={e.entity_type} small />
135+ <Link href={routes.entity(e)} className="text-ink hover:text-accent hover:underline">{e.name}</Link>
136+ <Mono title={e.id}>{e.slug}</Mono>
137+ {e.status !== 'active' && <StatusChip value={e.status} />}
138+ </li>
139+ ))}
140+ </ul>
141+ )}
142+ {item.kind === 'conflict' && <ConflictView item={item} ret={ret} />}
143+ {item.kind === 'merge_candidate' && item.entities && item.entities.length > 1 && item.status === 'pending' && (
144+ <p className="mt-1.5 text-xs text-ink-3">
145+ Approve merges <Mono>{item.entity_ids[0]}</Mono> into <Mono>{item.entity_ids[1]}</Mono> (first two ids); pick directions explicitly under <Link href={`/admin/entities/duplicates?type=${encodeURIComponent(item.entities[0]?.entity_type ?? '')}`} className="link">Duplicates</Link> when the order is wrong.
146+ </p>
147+ )}
148+ <details className="mt-2">
149+ <summary className="cursor-pointer text-xs text-ink-3 hover:text-ink">Payload{item.resolution ? ' · resolution' : ''}</summary>
150+ <div className="mt-1 grid gap-2 md:grid-cols-2">
151+ <JsonPre value={item.payload} maxHeight="16rem" />
152+ {item.resolution && <JsonPre value={item.resolution} maxHeight="16rem" />}
153+ </div>
154+ </details>
155+ </div>
156+ {item.status === 'pending' && (
157+ <div className="flex shrink-0 items-center gap-1.5">
158+ <form action={reviewAction}>
159+ <input type="hidden" name="id" value={item.id} />
160+ <input type="hidden" name="action" value="approve" />
161+ <input type="hidden" name="return" value={ret} />
162+ <ActionButton tone="positive" title={item.kind === 'conflict' ? 'Mark approved without promoting a claim (use "Keep this" to pick a side)' : 'Approve'}>Approve</ActionButton>
163+ </form>
164+ <form action={reviewAction}>
165+ <input type="hidden" name="id" value={item.id} />
166+ <input type="hidden" name="action" value="reject" />
167+ <input type="hidden" name="return" value={ret} />
168+ <ActionButton tone="danger">Reject</ActionButton>
169+ </form>
170+ </div>
171+ )}
172+ </div>
173+ </li>
174+ );
175+ })}
176+ </ul>
177+ <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
178+ </>
179+ )}
180+ </>
181+ );
182+}
added apps/web/src/app/admin/runs/page.tsx +79 −0
@@ -0,0 +1,79 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { AdminFilters, AdminTitle, Mono, StatusChip, Trunc } from '@/components/admin/ui';
4+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5+import { LiveAgo } from '@/components/ui/live';
6+import { Unavailable } from '@/components/ui/unavailable';
7+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
8+import { fmtDateTime, fmtDuration, fmtInt, num } from '@/lib/format';
9+
10+export const metadata: Metadata = { title: 'Runs', robots: { index: false, follow: false } };
11+export const dynamic = 'force-dynamic';
12+
13+export default async function AdminRunsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
14+ await requireAdmin();
15+ const sp = await searchParams;
16+ const limit = Math.min(500, Math.max(10, Number(sp.limit) || 100));
17+ const [runs, connectors] = await Promise.all([load(adminApi.runs({ connector: sp.connector, limit })), load(adminApi.connectors())]);
18+ const names = connectors.ok ? connectors.data.items.map((c) => c.name).sort() : [];
19+ return (
20+ <>
21+ <AdminTitle title="Runs" count={runs.ok ? fmtInt(runs.data.total) : undefined} />
22+ <AdminFilters
23+ action="/admin/runs"
24+ className="mb-4"
25+ fields={[
26+ { kind: 'select', name: 'connector', label: 'Connector', value: sp.connector, options: names.map((n) => ({ value: n, label: n })) },
27+ { kind: 'select', name: 'limit', label: 'Rows', value: String(limit), any: '100', options: ['50', '100', '250', '500'].map((v) => ({ value: v, label: v })) },
28+ ]}
29+ />
30+ {!runs.ok ? (
31+ <Unavailable what="Runs" reason={runs.error} />
32+ ) : (
33+ <DataTable compact scroll caption="Connector runs">
34+ <thead>
35+ <tr>
36+ <Th>Connector</Th>
37+ <Th>Started</Th>
38+ <Th>Status</Th>
39+ <Th num>Duration</Th>
40+ <Th num>Disc.</Th>
41+ <Th num>Fetched</Th>
42+ <Th num>Changed</Th>
43+ <Th num>Unch.</Th>
44+ <Th num>Failed</Th>
45+ <Th num>Ent. +/~</Th>
46+ <Th num>Claims</Th>
47+ <Th num>Rel.</Th>
48+ <Th num>Events</Th>
49+ <Th>Error</Th>
50+ <Th>Run id</Th>
51+ </tr>
52+ </thead>
53+ <tbody>
54+ {runs.data.items.length === 0 && <EmptyRow cols={15}>No runs match.</EmptyRow>}
55+ {runs.data.items.map((r) => (
56+ <tr key={r.id}>
57+ <Td><Link href={`/admin/runs?connector=${encodeURIComponent(r.connector_name)}`} className="mono text-xs font-medium text-ink hover:text-accent">{r.connector_name}</Link></Td>
58+ <Td className="text-xs" title={fmtDateTime(r.started_at)}><LiveAgo at={r.started_at} /></Td>
59+ <Td><StatusChip value={r.status} /></Td>
60+ <Td num className="tnum text-xs">{num(r.duration_ms) === null ? '—' : fmtDuration(Math.round((num(r.duration_ms) ?? 0) / 1000))}</Td>
61+ <Td num className="tnum text-xs">{fmtInt(r.docs_discovered)}</Td>
62+ <Td num className="tnum text-xs">{fmtInt(r.docs_fetched)}</Td>
63+ <Td num className="tnum text-xs">{fmtInt(r.docs_changed)}</Td>
64+ <Td num className="tnum text-xs text-ink-3">{fmtInt(r.docs_unchanged)}</Td>
65+ <Td num className={`tnum text-xs ${num(r.docs_failed) ? 'text-danger' : ''}`}>{fmtInt(r.docs_failed)}</Td>
66+ <Td num className="tnum text-xs">{fmtInt(r.entities_created)} / {fmtInt(r.entities_updated)}</Td>
67+ <Td num className="tnum text-xs">{fmtInt(r.claims_written)}</Td>
68+ <Td num className="tnum text-xs">{fmtInt(r.relations_written)}</Td>
69+ <Td num className="tnum text-xs">{fmtInt(r.events_emitted)}</Td>
70+ <Td className="text-xs text-danger"><Trunc text={r.error} max={50} /></Td>
71+ <Td><Mono>{r.id}</Mono></Td>
72+ </tr>
73+ ))}
74+ </tbody>
75+ </DataTable>
76+ )}
77+ </>
78+ );
79+}
added apps/web/src/app/admin/snapshots/[id]/page.tsx +123 −0
@@ -0,0 +1,123 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { notFound } from 'next/navigation';
4+import { AdminTitle, Bool, JsonPre, Mono, StatusChip } from '@/components/admin/ui';
5+import { KeyValue } from '@/components/ui/key-value';
6+import { Unavailable } from '@/components/ui/unavailable';
7+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
8+import { fmtBytes, fmtDateTime, fmtInt } from '@/lib/format';
9+import { routes } from '@/lib/site';
10+
11+export const metadata: Metadata = { title: 'Snapshot', robots: { index: false, follow: false } };
12+export const dynamic = 'force-dynamic';
13+
14+/** Render the API's `diff` as change rows when it is a map/array of changes, otherwise as JSON. */
15+function Diff({ diff }: { diff: unknown }) {
16+ if (diff === null || diff === undefined) return <p className="text-xs text-ink-3">No diff (first snapshot or unchanged).</p>;
17+ const rows: { key: string; before: unknown; after: unknown }[] = [];
18+ if (Array.isArray(diff)) {
19+ for (const [i, it] of diff.entries()) {
20+ if (it && typeof it === 'object') {
21+ const o = it as Record<string, unknown>;
22+ rows.push({ key: String(o.key ?? o.path ?? o.property ?? o.field ?? i), before: o.old ?? o.before ?? o.from ?? o.old_value, after: o.new ?? o.after ?? o.to ?? o.new_value });
23+ }
24+ }
25+ } else if (typeof diff === 'object') {
26+ const o = diff as Record<string, unknown>;
27+ const looksLikeMap = Object.values(o).every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('old' in (v as object) || 'new' in (v as object) || 'before' in (v as object) || 'after' in (v as object)));
28+ if (looksLikeMap) {
29+ for (const [k, v] of Object.entries(o)) {
30+ const c = v as Record<string, unknown>;
31+ rows.push({ key: k, before: c.old ?? c.before, after: c.new ?? c.after });
32+ }
33+ }
34+ }
35+ if (!rows.length) return <JsonPre value={diff} />;
36+ const show = (v: unknown) => (v === undefined || v === null ? <span className="text-ink-3">—</span> : <span className="break-all">{typeof v === 'string' ? v : JSON.stringify(v)}</span>);
37+ return (
38+ <ul className="divide-y divide-rule border-y border-rule text-xs">
39+ {rows.map((r) => (
40+ <li key={r.key} className="grid gap-x-3 gap-y-0.5 py-1.5 md:grid-cols-[10rem_minmax(0,1fr)_minmax(0,1fr)]">
41+ <Mono className="text-ink">{r.key}</Mono>
42+ <span className="text-danger line-through decoration-danger/50">{show(r.before)}</span>
43+ <span className="text-positive">{show(r.after)}</span>
44+ </li>
45+ ))}
46+ </ul>
47+ );
48+}
49+
50+export default async function AdminSnapshotPage({ params }: { params: Promise<{ id: string }> }) {
51+ await requireAdmin();
52+ const { id } = await params;
53+ const res = await load(adminApi.snapshot(id));
54+ if (!res.ok && res.error.startsWith('404')) notFound();
55+ if (!res.ok) {
56+ return (
57+ <>
58+ <AdminTitle title="Snapshot" />
59+ <Unavailable what="Snapshot" reason={res.error} />
60+ </>
61+ );
62+ }
63+ const s = res.data;
64+ return (
65+ <>
66+ <p className="mb-2 text-xs">
67+ <Link href="/admin/documents" className="link">← Documents</Link> · <Link href={`/admin/documents/${encodeURIComponent(s.document_id)}`} className="link">document</Link>
68+ </p>
69+ <AdminTitle title={`Snapshot ${fmtDateTime(s.observed_at)}`} lede={<Mono>{s.id}</Mono>} />
70+ <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_22rem]">
71+ <div className="min-w-0 space-y-8">
72+ <section>
73+ <p className="eyebrow mb-2">Structured extraction</p>
74+ <JsonPre value={s.structured} maxHeight="28rem" />
75+ </section>
76+ <section>
77+ <p className="eyebrow mb-2">Diff vs previous snapshot</p>
78+ <Diff diff={s.diff} />
79+ </section>
80+ <section>
81+ <p className="eyebrow mb-2">
82+ Cleaned text excerpt {s.text_chars !== undefined && <span className="tnum text-ink-3">{fmtInt(s.text_chars)} chars{s.text_truncated ? ' · truncated at 20 kB' : ''}</span>}
83+ </p>
84+ {s.text ? (
85+ <pre className="scrollbar-thin max-h-[32rem] overflow-auto whitespace-pre-wrap border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink-2">{s.text}</pre>
86+ ) : (
87+ <p className="text-xs text-ink-3">{s.text_error ? `Text unavailable: ${s.text_error}` : s.has_text === false ? 'No cleaned text for this snapshot (binary or JSON document).' : 'No text.'}</p>
88+ )}
89+ </section>
90+ </div>
91+ <aside className="min-w-0 space-y-6">
92+ <KeyValue
93+ dense
94+ rows={[
95+ { key: 'url', label: 'URL', value: <a href={s.url} target="_blank" rel="noopener noreferrer" className="link break-all text-xs">{s.url}</a> },
96+ { key: 'final_url', label: 'Final URL', value: s.final_url && s.final_url !== s.url ? <span className="break-all text-xs">{s.final_url}</span> : undefined },
97+ { key: 'connector', label: 'Connector', value: <Mono>{s.connector_name ?? '—'}</Mono> },
98+ { key: 'doc_type', label: 'Type', raw: s.doc_type },
99+ { key: 'entity', label: 'Entity', value: s.entity_slug ? <Link href={routes.entity({ entity_type: s.entity_id?.split('_')[0] ?? 'model', slug: s.entity_slug })} className="link text-xs">{s.entity_name ?? s.entity_slug}</Link> : undefined },
100+ { key: 'http_status', label: 'HTTP', raw: s.http_status },
101+ { key: 'content_type', label: 'Content type', raw: s.content_type },
102+ { key: 'byte_size', label: 'Size', raw: fmtBytes(s.byte_size) },
103+ { key: 'changed', label: 'Changed', value: <Bool v={s.changed} /> },
104+ { key: 'processing_status', label: 'Processing', value: <StatusChip value={s.processing_status} /> },
105+ { key: 'parser_version', label: 'Parser', raw: s.parser_version },
106+ { key: 'connector_version', label: 'Connector version', raw: s.connector_version },
107+ { key: 'transport', label: 'Transport', raw: s.transport },
108+ { key: 'content_hash', label: 'Content hash', value: s.content_hash ? <Mono className="break-all">{s.content_hash}</Mono> : undefined },
109+ { key: 'run_id', label: 'Run', value: s.run_id ? <Mono>{s.run_id}</Mono> : undefined },
110+ { key: 'has_raw', label: 'Raw archived', value: <Bool v={s.has_raw} /> },
111+ ]}
112+ />
113+ {s.headers && Object.keys(s.headers).length > 0 && (
114+ <div>
115+ <p className="eyebrow mb-2">Response headers</p>
116+ <JsonPre value={s.headers} maxHeight="14rem" />
117+ </div>
118+ )}
119+ </aside>
120+ </div>
121+ </>
122+ );
123+}
added apps/web/src/app/benchmarks/[slug]/page.tsx +213 −0
@@ -0,0 +1,213 @@
1+import { ExternalLink } from 'lucide-react';
2+import type { Metadata } from 'next';
3+import Link from 'next/link';
4+import { permanentRedirect } from 'next/navigation';
5+import { configChips, ConfigChips, HistoryChart, Leaderboard } from '@/components/benchmarks/leaderboard';
6+import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
7+import { RelationsBlock, SourcesTable, TimelineList } from '@/components/entity/blocks';
8+import { HistoryPanel } from '@/components/entity/history';
9+import { entityMetadata, loadEntity } from '@/components/entity/load';
10+import { ViewBeacon } from '@/components/layout/view-beacon';
11+import { EntityBadge, StatusBadge } from '@/components/ui/badges';
12+import { QualityMark } from '@/components/ui/entity';
13+import { KeyValue, type KVRow } from '@/components/ui/key-value';
14+import { Container, Note } from '@/components/ui/section';
15+import { TabPanel, Tabs, type TabDef } from '@/components/ui/tabs';
16+import { Unavailable } from '@/components/ui/unavailable';
17+import { api, safe } from '@/lib/api';
18+import { fmtAgo, fmtDate, fmtInt, fmtScore } from '@/lib/format';
19+import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
20+
21+type Params = { params: Promise<{ slug: string }>; searchParams: Promise<Record<string, string | undefined>> };
22+const LIMIT = 100;
23+
24+export async function generateMetadata({ params }: Params): Promise<Metadata> {
25+ const { slug } = await params;
26+ const m = await entityMetadata('benchmarks', slug);
27+ if (!m.title || (m.robots as { index?: boolean } | undefined)?.index === false) return m;
28+ const name = String(m.title).split(' — ')[0];
29+ const title = `${name} leaderboard — results, configs and history`;
30+ return { ...m, title, openGraph: { ...(m.openGraph ?? {}), title: `${title} | ${SITE_NAME}` }, twitter: { ...(m.twitter ?? {}), title } };
31+}
32+
33+/**
34+ * Dedicated benchmark page: definition, leaderboard (paged, config-filterable), per-model history chart,
35+ * timeline and sources. Static segment → shadows /[type]/[slug] for benchmarks only.
36+ */
37+export default async function BenchmarkPage({ params, searchParams }: Params) {
38+ const { slug } = await params;
39+ const sp = await searchParams;
40+ const d = await loadEntity('benchmarks', slug);
41+ if (d.slug !== slug) permanentRedirect(routes.benchmark(d.slug, { config: sp.config, model: sp.model }));
42+ const canonical = routes.entity(d);
43+ const offset = Math.max(0, Number(sp.offset) || 0);
44+ const config = sp.config?.trim() || undefined;
45+ const model = sp.model?.trim() || undefined;
46+ // History tab (same mode as EntityPage): full claim history always, "as of" state only when requested.
47+ const asofRaw = sp.asof?.trim() || undefined;
48+ const asofValid = asofRaw && /^\d{4}-\d{2}-\d{2}$/.test(asofRaw) ? asofRaw : undefined;
49+ const property = sp.property?.trim() || undefined;
50+ const [page, history, claimHistory, asofPayload] = await Promise.all([
51+ safe(api.benchmarkResults(d.slug, { limit: LIMIT, offset, config })),
52+ model ? safe(api.benchmarkHistory(d.slug, model)) : Promise.resolve(null),
53+ safe(api.entityHistory(d.slug, property)),
54+ asofValid ? safe(api.entityAsOf(d.slug, asofValid)) : Promise.resolve(null),
55+ ]);
56+ const claims = claimHistory?.items ?? null;
57+ // Chips come from an unfiltered sample so the filter can be changed while one is active.
58+ const sample = config || offset ? await safe(api.benchmarkResults(d.slug, { limit: LIMIT })) : page;
59+ const chips = configChips(sample?.items ?? []);
60+ const a = d.attributes ?? {};
61+ const unit = typeof a.unit === 'string' ? a.unit : (page?.items[0]?.unit ?? null);
62+ const direction = page?.items[0] ? (page.items[0].higher_is_better === false ? 'Lower is better' : 'Higher is better') : null;
63+ const link = (k: string) => (typeof a[k] === 'string' && /^https?:\/\//.test(a[k] as string) ? (a[k] as string) : null);
64+ const site = link('website') ?? link('official_url');
65+ const defRows: KVRow[] = [
66+ { key: 'category', raw: a.category },
67+ { key: 'task', raw: a.task },
68+ { key: 'metric', raw: a.metric, value: typeof a.metric === 'string' ? <span>{a.metric}{unit && <span className="text-ink-3"> · {unit}</span>}</span> : undefined },
69+ ...(direction ? [{ key: 'direction', label: 'Direction', value: <span>{direction}</span> }] : []),
70+ { key: 'creator', raw: a.creator },
71+ { key: 'paper', raw: a.paper },
72+ { key: 'paper_url', raw: a.paper_url },
73+ { key: 'methodology', raw: a.methodology, value: link('methodology') ? <a href={link('methodology') as string} className="link break-all" target="_blank" rel="noopener noreferrer">{(link('methodology') as string).replace(/^https?:\/\/(www\.)?/, '')}</a> : undefined },
74+ { key: 'known_limitations', raw: a.known_limitations },
75+ { key: 'website', raw: a.website, value: site ? <a href={site} className="link break-all" target="_blank" rel="noopener noreferrer">{site.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')}</a> : undefined },
76+ { key: 'official_url', raw: link('website') ? undefined : a.official_url },
77+ ];
78+ const tabs: TabDef[] = [
79+ { id: 'leaderboard', label: 'Leaderboard', count: page?.total ?? d.results?.length ?? undefined },
80+ { id: 'definition', label: 'Definition' },
81+ { id: 'relations', label: 'Relations', count: d.relations?.reduce((n, g) => n + g.items.length, 0) || undefined },
82+ { id: 'history', label: 'History', count: property ? undefined : claims?.length || undefined },
83+ { id: 'timeline', label: 'Timeline', count: d.timeline?.length || undefined },
84+ { id: 'sources', label: 'Sources', count: d.sources?.length || undefined },
85+ ];
86+ const ld = { '@context': 'https://schema.org', '@type': 'Dataset', name: d.name, url: `${SITE_URL}${canonical}`, description: d.description ?? (typeof a.task === 'string' ? a.task : undefined), alternateName: d.aliases?.length ? d.aliases : undefined, creator: typeof a.creator === 'string' ? { '@type': 'Organization', name: a.creator } : d.organization ? { '@type': 'Organization', name: d.organization.name } : undefined, sameAs: site ? [site] : undefined, measurementTechnique: typeof a.metric === 'string' ? a.metric : undefined };
87+ const href = (o: number) => routes.benchmark(d.slug, { config, model }) + (o ? `${config || model ? '&' : '?'}offset=${o}` : '');
88+
89+ return (
90+ <Container wide>
91+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
92+ <ViewBeacon path={canonical} />
93+ <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3">
94+ <ol className="flex flex-wrap items-center gap-1.5">
95+ <li><Link href="/" className="hover:text-ink">AI Atlas</Link></li>
96+ <li aria-hidden>/</li>
97+ <li><Link href={routes.benchmarks()} className="hover:text-ink">Benchmarks</Link></li>
98+ {typeof a.category === 'string' && (
99+ <>
100+ <li aria-hidden>/</li>
101+ <li><Link href={`/benchmarks?category=${encodeURIComponent(a.category)}`} className="hover:text-ink">{a.category}</Link></li>
102+ </>
103+ )}
104+ <li aria-hidden>/</li>
105+ <li className="text-ink-2">{d.name}</li>
106+ </ol>
107+ </nav>
108+
109+ <header className="pb-6 pt-4 md:pb-8 md:pt-5">
110+ <div className="flex flex-wrap items-center gap-2">
111+ <EntityBadge type={d.entity_type} />
112+ <StatusBadge status={d.status} />
113+ {typeof a.category === 'string' && <span className="text-xs text-ink-3">category · {a.category}</span>}
114+ </div>
115+ <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
116+ <div className="min-w-0">
117+ <h1 className="display text-[30px] md:text-[44px]">{d.name}</h1>
118+ <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2">
119+ {typeof a.creator === 'string' && <span className="font-medium text-ink">{a.creator}</span>}
120+ {d.organization && (
121+ <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="font-medium text-ink hover:text-accent">
122+ {d.organization.name}
123+ </Link>
124+ )}
125+ {site && (
126+ <a href={site} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-3 hover:text-accent">
127+ {site.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '').slice(0, 48)} <ExternalLink className="size-3.5" aria-hidden />
128+ </a>
129+ )}
130+ </p>
131+ {(d.description || typeof a.task === 'string') && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description ?? (a.task as string)}</p>}
132+ </div>
133+ <div className="shrink-0 text-xs text-ink-3 lg:text-right">
134+ <QualityMark q={d.quality?.score} label />
135+ <p className="mt-1" title={d.updated_at}>Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)}</p>
136+ <p className="mono mt-0.5 text-[11px]">{d.id}</p>
137+ </div>
138+ </div>
139+ <dl className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 border-y border-rule py-3 sm:grid-cols-4">
140+ <div><dt className="eyebrow">Metric</dt><dd className="mt-0.5 truncate text-[15px] font-medium text-ink">{typeof a.metric === 'string' ? a.metric : '—'}{unit && <span className="text-ink-3"> · {unit}</span>}</dd></div>
141+ <div><dt className="eyebrow">Direction</dt><dd className="mt-0.5 truncate text-[15px] font-medium text-ink">{direction ?? '—'}</dd></div>
142+ <div><dt className="eyebrow">Results</dt><dd className="tnum mt-0.5 text-[15px] font-medium text-ink">{sample ? fmtInt(sample.total) : '—'}{config && page && <span className="text-xs text-ink-3"> · {fmtInt(page.total)} filtered</span>}</dd></div>
143+ <div><dt className="eyebrow">Leader</dt><dd className="mt-0.5 truncate text-[15px] font-medium text-ink">{sample?.items[0] ? <Link href={routes.entity(sample.items[0].model)} className="hover:text-accent">{sample.items[0].model.name} <span className="tnum text-ink-2">{fmtScore(sample.items[0].score)}{unit === '%' ? '%' : ''}</span></Link> : '—'}</dd></div>
144+ </dl>
145+ </header>
146+
147+ <Tabs tabs={tabs} className="pb-10">
148+ <TabPanel id="leaderboard">
149+ <div className="space-y-6">
150+ {model && (
151+ <section>
152+ {history ? <HistoryChart items={history.items} model={model} unit={unit} /> : <Unavailable what="Result history" compact />}
153+ <p className="mt-2 text-xs">
154+ <Link href={routes.benchmark(d.slug, { config })} className="link">Back to the full leaderboard</Link>
155+ </p>
156+ </section>
157+ )}
158+ <section className="space-y-3">
159+ <div className="flex flex-wrap items-center justify-between gap-3">
160+ <p className="eyebrow">
161+ Leaderboard {page && <span className="tnum text-ink-3">{fmtInt(page.total)} current {page.total === 1 ? 'result' : 'results'}{config ? ` · config contains “${config}”` : ''}</span>}
162+ </p>
163+ <p className="text-xs text-ink-3">
164+ Select models with <span className="mono">+</span>, then open <Link href={routes.compare()} className="link">Compare</Link>.
165+ </p>
166+ </div>
167+ <ConfigChips slug={d.slug} chips={chips} active={config} model={model} />
168+ {!page ? <Unavailable what="Leaderboard" /> : <Leaderboard slug={d.slug} results={page.items} total={page.total} limit={LIMIT} offset={offset} config={config} model={model} unit={unit} makeHref={href} />}
169+ {chips.length > 0 && <Note>The config filter matches a value inside each result's configuration (server-side, `config=` on the API). Chips are the values shared by several rows on the first page; per-model identifiers are not offered.</Note>}
170+ </section>
171+ </div>
172+ </TabPanel>
173+ <TabPanel id="definition">
174+ <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_22rem]">
175+ <section>
176+ <p className="eyebrow mb-2">Definition</p>
177+ <KeyValue rows={defRows} provenance={d.provenance} />
178+ <Note className="mt-3">
179+ Each value shows its source, tier and observation time. Missing rows mean no source stated them. <Link href="/methodology#benchmarks" className="link">How results are recorded →</Link>
180+ </Note>
181+ </section>
182+ <aside className="space-y-8">
183+ {d.aliases?.length > 0 && (
184+ <section>
185+ <p className="eyebrow mb-2">Also known as</p>
186+ <p className="text-sm text-ink-2">{d.aliases.join(', ')}</p>
187+ </section>
188+ )}
189+ <section>
190+ <p className="eyebrow mb-2">Identity</p>
191+ <p className="mono break-all text-[11px] text-ink-3">slug {d.slug}</p>
192+ <p className="mono break-all text-[11px] text-ink-3">{d.id}</p>
193+ </section>
194+ </aside>
195+ </div>
196+ </TabPanel>
197+ <TabPanel id="relations">
198+ <RelationsBlock relations={d.relations ?? []} />
199+ </TabPanel>
200+ <TabPanel id="history">
201+ <HistoryPanel d={d} asof={asofRaw} asofPayload={asofPayload} claims={claims} property={property} />
202+ </TabPanel>
203+ <TabPanel id="timeline">
204+ <TimelineList events={d.timeline ?? []} slug={d.slug} />
205+ </TabPanel>
206+ <TabPanel id="sources">
207+ <SourcesTable sources={d.sources ?? []} />
208+ </TabPanel>
209+ </Tabs>
210+ <CompareTrayBar />
211+ </Container>
212+ );
213+}
modified apps/web/src/app/benchmarks/page.tsx +45 −12
@@ -1,23 +1,50 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2+import Link from 'next/link';
2 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; 3 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
3 import { EntityLink } from '@/components/ui/entity'; 4 import { EntityLink } from '@/components/ui/entity';
4 import { Container, Note, PageHeader } from '@/components/ui/section'; 5 import { Container, Note, PageHeader } from '@/components/ui/section';
5−import { Unavailable } from '@/components/ui/unavailable'; 6+import { EmptyState, Unavailable } from '@/components/ui/unavailable';
6 import { api, safe } from '@/lib/api'; 7 import { api, safe } from '@/lib/api';
7−import { fmtInt, fmtScore, num } from '@/lib/format'; 8+import { cn } from '@/lib/cn';
9+import { fmtAgo, fmtInt, fmtScore, num } from '@/lib/format';
10+import { routes } from '@/lib/site';
8 11
9 export const metadata: Metadata = { title: 'AI benchmarks & leaderboards', description: 'Benchmarks with their published results, evaluation configs and current leaders — never compared blindly across configs.', alternates: { canonical: '/benchmarks' } }; 12 export const metadata: Metadata = { title: 'AI benchmarks & leaderboards', description: 'Benchmarks with their published results, evaluation configs and current leaders — never compared blindly across configs.', alternates: { canonical: '/benchmarks' } };
10 export const revalidate = 300; 13 export const revalidate = 300;
11 14
12−export default async function BenchmarksPage() { 15+export default async function BenchmarksPage({ searchParams }: { searchParams: Promise<{ category?: string }> }) {
16+ const { category } = await searchParams;
13 const res = await safe(api.benchmarks()); 17 const res = await safe(api.benchmarks());
14− const items = (res?.items ?? []).slice().sort((a, b) => (num(b.result_count) ?? 0) - (num(a.result_count) ?? 0)); 18+ const all = (res?.items ?? []).slice().sort((a, b) => (num(b.result_count) ?? 0) - (num(a.result_count) ?? 0));
19+ const cats = new Map<string, number>();
20+ for (const b of all) {
21+ const c = typeof b.attributes?.category === 'string' ? b.attributes.category : null;
22+ if (c) cats.set(c, (cats.get(c) ?? 0) + 1);
23+ }
24+ const catList = [...cats.entries()].sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]));
25+ const items = category ? all.filter((b) => b.attributes?.category === category) : all;
26+ const chip = (active: boolean) => cn('inline-flex h-8 items-center gap-1.5 border px-2.5 text-xs whitespace-nowrap', active ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink');
15 return ( 27 return (
16 <Container> 28 <Container>
17− <PageHeader eyebrow="Benchmarks" title="Benchmarks" lede="Evaluation suites and the results published for them. Each result carries its configuration; leaders are shown per benchmark, not as a composite." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)} benchmarks</p> : undefined} /> 29+ <PageHeader eyebrow="Benchmarks" title="Benchmarks" lede="Evaluation suites and the results published for them. Each result carries its configuration; leaders are shown per benchmark, not as a composite." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)}{category ? ` of ${fmtInt(all.length)}` : ''} benchmarks</p> : undefined}>
30+ {catList.length > 0 && (
31+ <nav aria-label="Category" className="no-scrollbar -mx-4 mt-6 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0">
32+ <Link href={routes.benchmarks()} className={chip(!category)} aria-current={!category ? 'true' : undefined}>
33+ All <span className="tnum opacity-70">{fmtInt(all.length)}</span>
34+ </Link>
35+ {catList.map(([c, n]) => (
36+ <Link key={c} href={`/benchmarks?category=${encodeURIComponent(c)}`} className={chip(category === c)} aria-current={category === c ? 'true' : undefined}>
37+ {c} <span className="tnum opacity-70">{fmtInt(n)}</span>
38+ </Link>
39+ ))}
40+ </nav>
41+ )}
42+ </PageHeader>
18 <div className="pb-16"> 43 <div className="pb-16">
19 {!res ? ( 44 {!res ? (
20 <Unavailable what="Benchmarks" /> 45 <Unavailable what="Benchmarks" />
46+ ) : items.length === 0 ? (
47+ <EmptyState title={category ? `No benchmark in “${category}”` : 'No benchmarks recorded yet'}>{category && <Link href={routes.benchmarks()} className="link">Show all benchmarks</Link>}</EmptyState>
21 ) : ( 48 ) : (
22 <> 49 <>
23 <DataTable caption="Benchmarks"> 50 <DataTable caption="Benchmarks">
@@ -29,37 +56,43 @@ export default async function BenchmarksPage() {
29 <Th num>Results</Th> 56 <Th num>Results</Th>
30 <Th num>Models</Th> 57 <Th num>Models</Th>
31 <Th>Current leader</Th> 58 <Th>Current leader</Th>
59+ <Th>Updated</Th>
32 </tr> 60 </tr>
33 </thead> 61 </thead>
34 <tbody> 62 <tbody>
35− {items.length === 0 && <EmptyRow cols={6}>No benchmarks recorded yet.</EmptyRow>} 63+ {items.length === 0 && <EmptyRow cols={7}>No benchmarks recorded yet.</EmptyRow>}
36 {items.map((b) => { 64 {items.map((b) => {
37 const a = b.attributes ?? {}; 65 const a = b.attributes ?? {};
38 return ( 66 return (
39 <tr key={b.id}> 67 <tr key={b.id}>
40 <Td primary> 68 <Td primary>
41− <EntityLink e={b} /> 69+ <Link href={routes.benchmark(b.slug)} className="text-ink hover:text-accent hover:underline">{b.name}</Link>
42− {b.description && <span className="block max-w-md truncate text-xs text-ink-3">{b.description}</span>} 70+ {(b.description || typeof a.task === 'string') && <span className="block max-w-md truncate text-xs text-ink-3">{b.description ?? (a.task as string)}</span>}
71+ </Td>
72+ <Td label="Category" className="text-ink-2">{typeof a.category === 'string' ? <Link href={`/benchmarks?category=${encodeURIComponent(a.category)}`} className="hover:text-accent">{a.category}</Link> : <span className="text-ink-3">—</span>}</Td>
73+ <Td label="Metric" className="text-ink-2">
74+ {typeof a.metric === 'string' ? a.metric : <span className="text-ink-3">—</span>}
75+ {typeof a.unit === 'string' && a.unit && <span className="text-ink-3"> ({a.unit})</span>}
43 </Td> 76 </Td>
44− <Td label="Category" className="text-ink-2">{typeof a.category === 'string' ? a.category : <span className="text-ink-3">—</span>}</Td>
45− <Td label="Metric" className="text-ink-2">{typeof a.metric === 'string' ? a.metric : <span className="text-ink-3">—</span>}</Td>
46 <Td num label="Results" className="tnum">{fmtInt(b.result_count)}</Td> 77 <Td num label="Results" className="tnum">{fmtInt(b.result_count)}</Td>
47 <Td num label="Models" className="tnum text-ink-2">{fmtInt(b.model_count)}</Td> 78 <Td num label="Models" className="tnum text-ink-2">{fmtInt(b.model_count)}</Td>
48 <Td label="Current leader"> 79 <Td label="Current leader">
49 {b.top ? ( 80 {b.top ? (
50 <span> 81 <span>
51− <EntityLink e={b.top.model} className="font-medium" /> <span className="tnum text-ink-2">{fmtScore(b.top.score)}</span> 82+ <EntityLink e={b.top.model} className="font-medium" /> <span className="tnum text-ink-2">{fmtScore(b.top.score)}{a.unit === '%' ? '%' : ''}</span>
83+ {b.top.model.organization && <span className="block text-xs text-ink-3">{b.top.model.organization.name}</span>}
52 </span> 84 </span>
53 ) : ( 85 ) : (
54 <span className="text-ink-3">—</span> 86 <span className="text-ink-3">—</span>
55 )} 87 )}
56 </Td> 88 </Td>
89+ <Td label="Updated" className="text-ink-2" title={b.updated_at}>{fmtAgo(b.updated_at)}</Td>
57 </tr> 90 </tr>
58 ); 91 );
59 })} 92 })}
60 </tbody> 93 </tbody>
61 </DataTable> 94 </DataTable>
62− <Note className="mt-3">Leader = best current result under the benchmark's default direction (higher or lower is better). Configs differ; open the benchmark to see them.</Note> 95+ <Note className="mt-3">Leader = best current result under the benchmark's default direction (higher or lower is better). Configs differ; open a benchmark for its leaderboard, config filter and per-model history.</Note>
63 </> 96 </>
64 )} 97 )}
65 </div> 98 </div>
added apps/web/src/app/companies/[slug]/opengraph-image.tsx +44 −0
@@ -0,0 +1,44 @@
1+import { ImageResponse } from 'next/og';
2+import { Eyebrow, Facts, Fallback, Frame, INK2, Title } from '@/components/brand/og';
3+import { api, safe } from '@/lib/api';
4+import { fmtInt, num } from '@/lib/format';
5+import { routes, SITE_NAME, typeLabel } from '@/lib/site';
6+import type { EntityDetail } from '@/lib/types';
7+
8+export const runtime = 'nodejs';
9+export const alt = `Organization on ${SITE_NAME}`;
10+export const size = { width: 1200, height: 630 };
11+export const contentType = 'image/png';
12+
13+function companyFacts(d: EntityDetail): [string, string][] {
14+ const a = d.attributes ?? {};
15+ const out: [string, string][] = [];
16+ if (typeof a.country === 'string') out.push(['Country', a.country]);
17+ if (a.founded) out.push(['Founded', String(a.founded).slice(0, 4)]);
18+ const models = num(d.model_count) ?? num(d.models?.total) ?? (d.models?.items.length || null);
19+ if (models !== null) out.push(['Models', fmtInt(models)]);
20+ const papers = num(d.paper_count) ?? (d.papers?.length || null);
21+ if (papers !== null) out.push(['Papers', fmtInt(papers)]);
22+ if (typeof a.headquarters === 'string') out.push(['HQ', a.headquarters.slice(0, 22)]);
23+ return out.slice(0, 3);
24+}
25+
26+/** Per-organization Open Graph image: brand, type eyebrow, name, three key facts, canonical URL. */
27+export default async function CompanyOgImage({ params }: { params: Promise<{ slug: string }> }) {
28+ const { slug } = await params;
29+ const d = await safe(api.entityOfType('companies', slug));
30+ if (!d) return new ImageResponse(<Fallback label="Company" />, { ...size });
31+ const facts = companyFacts(d);
32+ return new ImageResponse(
33+ (
34+ <Frame footer={`www.ai-atlas.co${routes.entity(d)}`}>
35+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
36+ <Eyebrow>{typeof d.attributes?.org_kind === 'string' && d.attributes.org_kind.toLowerCase() !== typeLabel(d.entity_type).toLowerCase() ? `${typeLabel(d.entity_type)} · ${d.attributes.org_kind}` : typeLabel(d.entity_type)}</Eyebrow>
37+ <Title>{d.name}</Title>
38+ {facts.length > 0 ? <Facts items={facts} /> : <div style={{ display: 'flex', fontSize: 24, color: INK2 }}>{d.description ? d.description.slice(0, 120) : 'Models, research, providers and sources.'}</div>}
39+ </div>
40+ </Frame>
41+ ),
42+ { ...size },
43+ );
44+}
modified apps/web/src/app/companies/[slug]/page.tsx +5 −4
@@ -1,21 +1,22 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import { permanentRedirect } from 'next/navigation'; 2 import { permanentRedirect } from 'next/navigation';
3−import { EntityPage } from '@/components/entity/entity-page'; 3+import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page';
4 import { entityMetadata, loadEntity } from '@/components/entity/load'; 4 import { entityMetadata, loadEntity } from '@/components/entity/load';
5 import { api, safe } from '@/lib/api'; 5 import { api, safe } from '@/lib/api';
6 import { routes } from '@/lib/site'; 6 import { routes } from '@/lib/site';
7 7
8−type Params = { params: Promise<{ slug: string }> }; 8+type Params = { params: Promise<{ slug: string }>; searchParams: Promise<EntityPageParams> };
9 9
10 export async function generateMetadata({ params }: Params): Promise<Metadata> { 10 export async function generateMetadata({ params }: Params): Promise<Metadata> {
11 const { slug } = await params; 11 const { slug } = await params;
12 return entityMetadata('companies', slug); 12 return entityMetadata('companies', slug);
13 } 13 }
14 14
15−export default async function CompanyPage({ params }: Params) { 15+export default async function CompanyPage({ params, searchParams }: Params) {
16 const { slug } = await params; 16 const { slug } = await params;
17+ const sp = await searchParams;
17 const d = await loadEntity('companies', slug); 18 const d = await loadEntity('companies', slug);
18 if (d.slug !== slug) permanentRedirect(routes.entity(d)); 19 if (d.slug !== slug) permanentRedirect(routes.entity(d));
19 const related = await safe(api.entityRelated(d.slug, 10)); 20 const related = await safe(api.entityRelated(d.slug, 10));
20− return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />; 21+ return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} asof={sp.asof} historyProperty={sp.property} />;
21 } 22 }
modified apps/web/src/app/compare/page.tsx +73 −66
@@ -1,85 +1,92 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2−import Link from 'next/link'; 2+import { CompareMatrix, ComparePrices, SharedBenchmarks } from '@/components/compare/compare-matrix';
3+import { ComparePicker } from '@/components/compare/compare-picker';
4+import type { TrayItem } from '@/components/compare/compare-store';
3 import { EntityBadge } from '@/components/ui/badges'; 5 import { EntityBadge } from '@/components/ui/badges';
4−import { DataTable, Td, Th } from '@/components/ui/data-table';
5−import { EntityLink } from '@/components/ui/entity';
6−import { ProvenanceInline } from '@/components/ui/provenance';
7 import { Container, Note, PageHeader } from '@/components/ui/section'; 6 import { Container, Note, PageHeader } from '@/components/ui/section';
8 import { EmptyState, Unavailable } from '@/components/ui/unavailable'; 7 import { EmptyState, Unavailable } from '@/components/ui/unavailable';
9−import { api, safe } from '@/lib/api'; 8+import { api, ApiError, safe } from '@/lib/api';
10−import { fmtDate, fmtValue, num } from '@/lib/format'; 9+import { fmtInt } from '@/lib/format';
11−import { routes, typeLabel } from '@/lib/site'; 10+import { routes, SITE_NAME, typeLabel } from '@/lib/site';
11+import type { ComparePayload } from '@/lib/types';
12 12
13−export const metadata: Metadata = { title: 'Compare — models, providers, hardware side by side', description: 'Compare 2–6 entities of the same type on every recorded dimension, with the source of each value.', alternates: { canonical: '/compare' } };
14 export const revalidate = 300; 13 export const revalidate = 300;
15 14
16−export default async function ComparePage({ searchParams }: { searchParams: Promise<{ ids?: string }> }) { 15+type SP = { ids?: string };
17− const { ids: raw } = await searchParams; 16+
18− const ids = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 6); 17+function parseIds(raw: string | undefined): string[] {
18+ return [...new Set((raw ?? '').split(',').map((s) => s.trim()).filter(Boolean))].slice(0, 6);
19+}
20+
21+/** Resolve the comparison, distinguishing a semantic 400 (mixed types / unknown slug) from an outage. */
22+async function load(ids: string[]): Promise<{ res: ComparePayload | null; error: string | null }> {
23+ if (ids.length < 2) return { res: null, error: null };
24+ try {
25+ return { res: await api.compare(ids), error: null };
26+ } catch (e) {
27+ if (e instanceof ApiError && (e.status === 400 || e.status === 404 || e.status === 422)) return { res: null, error: e.detail ?? 'These entities cannot be compared.' };
28+ return { res: null, error: null };
29+ }
30+}
31+
32+export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
33+ const ids = parseIds((await searchParams).ids);
19 const res = ids.length >= 2 ? await safe(api.compare(ids)) : null; 34 const res = ids.length >= 2 ? await safe(api.compare(ids)) : null;
35+ const canonical = routes.compare(ids.length >= 2 ? ids : undefined);
36+ if (!res) return { title: 'Compare — models, providers, hardware side by side', description: 'Compare 2–6 entities of the same type on every recorded dimension, with the source of each value.', alternates: { canonical } };
37+ const names = res.items.map((i) => i.entity.name);
38+ const title = `${names.join(' vs ')} — ${typeLabel(res.entity_type).toLowerCase()} comparison`;
39+ const description = `${names.join(', ')} compared on ${res.dimensions.length} dimensions with the source of every value. ${SITE_NAME}.`.slice(0, 300);
40+ return { title, description, alternates: { canonical }, openGraph: { title, description } };
41+}
42+
43+export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {
44+ const ids = parseIds((await searchParams).ids);
45+ const [{ res, error }, examples] = await Promise.all([load(ids), ids.length < 2 ? safe(api.models({ limit: 2, sort: 'quality' })) : Promise.resolve(null)]);
46+ const initial: TrayItem[] = res ? res.items.map((it) => ({ slug: it.entity.slug, name: it.entity.name, entity_type: it.entity.entity_type, organization: it.entity.organization?.name ?? null })) : [];
47+ const exampleHref = examples && examples.items.length >= 2 ? routes.compare(examples.items.slice(0, 2).map((m) => m.slug)) : null;
48+ const isModel = res?.entity_type === 'model';
49+
20 return ( 50 return (
21 <Container wide> 51 <Container wide>
22− <PageHeader eyebrow="Compare" title={res ? `Comparing ${res.items.length} ${typeLabel(res.entity_type, true).toLowerCase()}` : 'Compare side by side'} lede="Two to six entities of one type. Each cell shows the recorded value and, on hover, where it came from."> 52+ <PageHeader
23− <form action="/compare" method="get" className="mt-6 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent"> 53+ eyebrow={res ? <><span>Compare</span><EntityBadge type={res.entity_type} small /></> : 'Compare'}
24− <label htmlFor="ids" className="sr-only">Slugs, comma separated</label> 54+ title={res ? res.items.map((i) => i.entity.name).join(' vs ') : 'Compare side by side'}
25− <input id="ids" name="ids" defaultValue={ids.join(',')} placeholder="slugs separated by commas, e.g. claude-opus-5,gpt-5,gemini-3-pro" className="h-11 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" spellCheck={false} /> 55+ lede={res ? `${fmtInt(res.items.length)} ${typeLabel(res.entity_type, true).toLowerCase()} on ${fmtInt(res.dimensions.length)} recorded dimensions. Each cell shows the value as stated by its source.` : 'Two to six entities of one type — models, providers, hardware, frameworks or companies. Each cell shows the recorded value and where it came from.'}
26− <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">Compare</button> 56+ >
27− </form> 57+ <ComparePicker initial={initial} exampleHref={exampleHref} />
28− <Note className="mt-2">Find slugs in any entity URL, or use “Add to a comparison” on entity pages. Picker UI is coming.</Note>
29 </PageHeader> 58 </PageHeader>
30− <div className="pb-16"> 59+
60+ <div className="space-y-12 pb-16">
31 {ids.length < 2 ? ( 61 {ids.length < 2 ? (
32− <EmptyState title="Enter at least two slugs"> 62+ <EmptyState title="Pick at least two entities of the same type">Search above, or press “Compare” on any model, provider or hardware listing — your selection is kept in this browser and in the URL.</EmptyState>
33− Example: <Link href={routes.compare(['claude-opus-5', 'gpt-5'])} className="link">claude-opus-5 vs gpt-5</Link> 63+ ) : error ? (
64+ <EmptyState title="These entities cannot be compared together">
65+ {error}. Comparisons work across entities of one type (all models, all providers…). Remove the odd one out in the tray above.
34 </EmptyState> 66 </EmptyState>
35 ) : !res ? ( 67 ) : !res ? (
36− <Unavailable what="Comparison" reason="One of the slugs may not exist, or the entities are of different types." /> 68+ <Unavailable what="Comparison" reason="The API did not answer. Try again in a moment." />
37 ) : ( 69 ) : (
38 <> 70 <>
39− <DataTable scroll stack={false} caption="Comparison"> 71+ <section>
40− <thead> 72+ <p className="eyebrow mb-2">Dimensions <span className="tnum text-ink-3">{res.dimensions.length}</span></p>
41− <tr> 73+ <CompareMatrix res={res} />
42− <Th>Dimension</Th> 74+ </section>
43− {res.items.map((it) => ( 75+ {isModel && (
44− <Th key={it.entity.id}> 76+ <section>
45− <span className="flex flex-col items-start gap-0.5 normal-case tracking-normal"> 77+ <p className="eyebrow mb-2">Shared benchmarks</p>
46− <EntityBadge type={it.entity.entity_type} small /> 78+ <SharedBenchmarks res={res} />
47− <EntityLink e={it.entity} className="text-sm font-semibold" /> 79+ </section>
48− {it.entity.organization && <span className="text-[11px] font-normal text-ink-3">{it.entity.organization.name}</span>} 80+ )}
49− </span> 81+ {isModel && (
50− </Th> 82+ <section>
51− ))} 83+ <p className="eyebrow mb-2">Prices · USD per 1M tokens</p>
52− </tr> 84+ <ComparePrices res={res} />
53− </thead> 85+ </section>
54− <tbody> 86+ )}
55− {res.dimensions.map((dim) => { 87+ <Note>
56− const nums = res.items.map((it) => num(it.values[dim.key])); 88+ Share this comparison with its URL. Values come from the atlas as of now; use each entity's History tab for how a value changed. <a href={routes.methodology()} className="link">How AI Atlas records facts →</a>
57− const best = dim.kind === 'number' && nums.some((n) => n !== null) ? Math.max(...(nums.filter((n): n is number => n !== null))) : null; 89+ </Note>
58− return (
59− <tr key={dim.key}>
60− <Td className="text-ink-2">
61− {dim.label}
62− {dim.unit && <span className="text-ink-3"> · {dim.unit}</span>}
63− </Td>
64− {res.items.map((it) => {
65− const v = it.values[dim.key];
66− const p = it.provenance?.[dim.key];
67− const n = num(v);
68− return (
69− <Td key={it.entity.id} className={dim.kind === 'number' ? 'tnum' : undefined}>
70− <span className={best !== null && n === best && nums.filter((x) => x !== null).length > 1 ? 'font-semibold text-ink' : undefined} title={p ? `${p.source_name ?? p.url ?? ''} · tier ${p.tier} · ${fmtDate(p.observed_at)}` : undefined}>
71− {v === null || v === undefined || v === '' ? <span className="text-ink-3">Unavailable</span> : dim.kind === 'date' ? fmtDate(String(v)) : fmtValue(v, dim.key)}
72− </span>
73− {p && <ProvenanceInline p={p} showConfidence={false} className="hidden md:flex" />}
74− </Td>
75− );
76− })}
77− </tr>
78− );
79− })}
80− </tbody>
81− </DataTable>
82− <Note className="mt-3">Bold marks the highest number in a row; it is not a verdict. Benchmark rows only appear when all compared entities have a result under a comparable configuration.</Note>
83 </> 90 </>
84 )} 91 )}
85 </div> 92 </div>
modified apps/web/src/app/datasets/page.tsx +73 −3
@@ -1,10 +1,80 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2−import { GenericListing } from '@/components/listing/generic-listing'; 2+import Link from 'next/link';
3+import { Dash, list, str, TypedListing } from '@/components/listing/typed-listing';
4+import { Chip } from '@/components/ui/badges';
5+import { EntityLink, QualityMark } from '@/components/ui/entity';
6+import { api } from '@/lib/api';
7+import { fmtAgo, fmtValue } from '@/lib/format';
8+import { routes } from '@/lib/site';
3 9
4−export const metadata: Metadata = { title: 'AI datasets', description: 'Training and evaluation datasets with modality, size, license and publisher, linked to the models trained on them.', alternates: { canonical: '/datasets' } }; 10+export const metadata: Metadata = { title: 'AI datasets — modality, size, license', description: 'Training and evaluation datasets in the atlas with modality, size, license and publisher as stated by their hosts.', alternates: { canonical: '/datasets' } };
5 export const revalidate = 300; 11 export const revalidate = 300;
6 12
7 export default async function DatasetsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { 13 export default async function DatasetsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
8 const sp = await searchParams; 14 const sp = await searchParams;
9− return <GenericListing type="dataset" basePath="/datasets" eyebrow="Datasets" title="Datasets" lede="Training and evaluation corpora as documented on Hugging Face and publisher pages." searchParams={sp} />; 15+ return (
16+ <TypedListing
17+ title="Datasets"
18+ eyebrow="Datasets"
19+ lede="Training and evaluation datasets referenced by models and benchmarks. Modality, size and license are read from dataset cards and hosting pages."
20+ basePath="/datasets"
21+ searchParams={sp}
22+ fetch={(q) => api.explore('dataset', q)}
23+ sorts={[
24+ { value: 'updated', label: 'Recently updated' },
25+ { value: 'first_seen', label: 'Recently added' },
26+ { value: 'name', label: 'Name' },
27+ { value: 'quality', label: 'Data quality' },
28+ ]}
29+ filters={sp.org ? [{ kind: 'hidden', name: 'org', value: sp.org }] : []}
30+ emptyTitle="No datasets match"
31+ columns={[
32+ {
33+ key: 'name',
34+ label: 'Dataset',
35+ primary: true,
36+ render: (e) => (
37+ <>
38+ <EntityLink e={e} />
39+ {e.description && <span className="block max-w-md truncate text-xs text-ink-3">{e.description}</span>}
40+ </>
41+ ),
42+ },
43+ {
44+ key: 'modality',
45+ label: 'Modality',
46+ render: (e) => {
47+ const m = str(e.attributes?.modality) ? [str(e.attributes.modality) as string] : list(e.attributes?.modalities);
48+ return m.length ? (
49+ <span className="flex flex-wrap gap-1">
50+ {m.slice(0, 3).map((x) => (
51+ <Chip key={x}>{x}</Chip>
52+ ))}
53+ </span>
54+ ) : (
55+ <Dash />
56+ );
57+ },
58+ },
59+ { key: 'size', label: 'Size', className: 'tnum text-ink-2', render: (e) => (e.attributes?.size ? fmtValue(e.attributes.size, 'size') : <Dash />) },
60+ { key: 'license', label: 'License', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> },
61+ {
62+ key: 'publisher',
63+ label: 'Publisher',
64+ className: 'text-ink-2',
65+ render: (e) =>
66+ e.organization ? (
67+ <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">
68+ {e.organization.name}
69+ </Link>
70+ ) : (
71+ str(e.attributes?.publisher) ?? <Dash />
72+ ),
73+ },
74+ { key: 'updated', label: 'Updated', className: 'text-ink-2 whitespace-nowrap', render: (e) => <span title={e.updated_at}>{fmtAgo(e.updated_at)}</span> },
75+ { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> },
76+ ]}
77+ note="Many datasets are known only by reference (a model card names them) and carry no attributes yet — the dash means the sources have not stated it."
78+ />
79+ );
10 } 80 }
modified apps/web/src/app/developers/page.tsx +135 −26
@@ -2,59 +2,155 @@ import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3 import { DataTable, Td, Th } from '@/components/ui/data-table'; 3 import { DataTable, Td, Th } from '@/components/ui/data-table';
4 import { Container, Note, PageHeader, Section } from '@/components/ui/section'; 4 import { Container, Note, PageHeader, Section } from '@/components/ui/section';
5+import { Unavailable } from '@/components/ui/unavailable';
5 import { api, safe } from '@/lib/api'; 6 import { api, safe } from '@/lib/api';
6 import { PUBLIC_API_BASE, routes } from '@/lib/site'; 7 import { PUBLIC_API_BASE, routes } from '@/lib/site';
8+import type { EntityDetail, Stats } from '@/lib/types';
7 9
8−export const metadata: Metadata = { title: 'Developers — public API', description: 'The AI Atlas public API: JSON over HTTPS, no key required for public routes, OpenAPI docs, examples with curl.', alternates: { canonical: '/developers' } }; 10+export const metadata: Metadata = { title: 'Developers — public API', description: 'The AI Atlas public API: JSON over HTTPS, no key required for public routes, OpenAPI docs, live example responses and curl recipes.', alternates: { canonical: '/developers' } };
9 export const revalidate = 3600; 11 export const revalidate = 3600;
10 12
11 const ENDPOINTS: { path: string; returns: string }[] = [ 13 const ENDPOINTS: { path: string; returns: string }[] = [
12 { path: 'GET /health', returns: 'Service status (db, redis, llm) and version' }, 14 { path: 'GET /health', returns: 'Service status (db, redis, llm) and version' },
13− { path: 'GET /stats', returns: 'Live counters: entities per type, sources, documents, claims, events, prices, archive size' }, 15+ { path: 'GET /stats', returns: 'Live counters: entities per type, sources, documents, claims, events, prices, archive size — always computed from the database' },
14 { path: 'GET /stats/history?days=90', returns: 'Daily counts' }, 16 { path: 'GET /stats/history?days=90', returns: 'Daily counts' },
15− { path: 'GET /search?q=&type=&limit=&offset=', returns: 'Natural-language or keyword search; returns the compiled filters and ranked entities' }, 17+ { path: 'GET /search?q=&type=&limit=&offset=', returns: 'Natural-language or keyword search → { query: compiled filters, items: ranked entities (+rank), total }' },
16− { path: 'GET /search/suggest?q=', returns: '≤ 8 prefix suggestions' }, 18+ { path: 'GET /search/suggest?q=', returns: '≤ 8 prefix suggestions { id, entity_type, slug, name, organization_name }' },
17− { path: 'GET /entities/{slug}', returns: 'Full entity detail: attributes, provenance, relations, sources, timeline and type-specific blocks (prices, results, lineage, hardware_fit, models, papers)' }, 19+ { path: 'GET /entities/{slug}', returns: 'Full entity detail: attributes, provenance per attribute, aliases, identifiers, relations (grouped), sources, timeline and type-specific blocks (prices, price_history, results, lineage, hardware_fit, models, papers, repositories)' },
18 { path: 'GET /models/{slug} · /companies/{slug} · /papers/{slug} · /providers/{slug} · /benchmarks/{slug} · /hardware/{slug} · /frameworks/{slug} · /datasets/{slug} · /tools/{slug}', returns: 'Same detail, type-checked (404 if the slug is another type)' }, 20 { path: 'GET /models/{slug} · /companies/{slug} · /papers/{slug} · /providers/{slug} · /benchmarks/{slug} · /hardware/{slug} · /frameworks/{slug} · /datasets/{slug} · /tools/{slug}', returns: 'Same detail, type-checked (404 if the slug is another type)' },
19− { path: 'GET /entities/{slug}/timeline · /history?property= · /asof?date= · /graph?depth= · /sources · /related', returns: 'Events, full claim history, state as of a date, neighbourhood graph, documents, related entities' }, 21+ { path: 'GET /entities/{slug}/timeline?limit=&before=', returns: '{ items: ChangeEvent[], next_before } — the entity’s own events (and, for companies, those of the models it develops)' },
20− { path: 'GET /models?q=&org=&openness=&modality=&status=&min_params=&min_context=&year_from=&license=&sort=&facets=1', returns: 'Paged models with facets' }, 22+ { path: 'GET /entities/{slug}/history?property=', returns: '{ items: Claim[] } — every claim ever recorded, newest first, with status current | superseded | conflicting | retracted, valid_from → valid_to, source and tier' },
21− { path: 'GET /companies?q=&country=&kind=&sort=', returns: 'Paged organizations with model and paper counts' }, 23+ { path: 'GET /entities/{slug}/asof?date=YYYY-MM-DD', returns: '{ existed, first_seen_at, date, attributes, claims } — the entity as it was known on that date' },
22− { path: 'GET /papers?q=&category=&org=&since=&until=', returns: 'Paged papers' }, 24+ { path: 'GET /entities/{slug}/graph?depth=1|2&limit=80', returns: '{ root, nodes: { id, slug, name, entity_type, organization_name }[], edges: { source, target, predicate }[] } — capped neighbourhood' },
23− { path: 'GET /providers · GET /prices?model=&provider=&current=1 · GET /prices/history · GET /prices/index?days=', returns: 'Providers, current prices, full price history, median/min price index and movers' }, 25+ { path: 'GET /entities/{slug}/sources · /related?limit=', returns: 'Documents describing the entity (deduped by URL) · related entities (same org / family / shared relations)' },
24− { path: 'GET /benchmarks · GET /benchmarks/{slug}/results · GET /benchmarks/{slug}/history?model=', returns: 'Benchmarks, leaderboards (with config), result history' }, 26+ { path: 'GET /models?q=&org=&family=&openness=&modality=&status=&min_params=&max_params=&min_context=&year_from=&year_to=&license=&sort=&order=&facets=1', returns: 'Paged models (+facets: organizations, openness, modalities, families, years, licenses, status)' },
25− { path: 'GET /hardware · GET /hardware/fit?memory_gb=&quant=&context=', returns: 'Hardware listing and the estimated fit tool' }, 27+ { path: 'GET /companies?q=&country=&kind=&sort=', returns: 'Paged organizations with model_count and paper_count (+facets)' },
26− { path: 'GET /explore/types · GET /explore/{type}', returns: 'Entity types with counts; generic listing for any type' }, 28+ { path: 'GET /papers?q=&category=&org=&since=&until=&sort=', returns: 'Paged papers' },
27− { path: 'GET /changes?category=&type=&importance_min=&since=&before= · GET /changes/daily?date= · GET /changes/categories', returns: 'Change-event feed (cursor paged), daily digest, category counts' }, 29+ { path: 'GET /providers · GET /prices?model=&provider=&sort=&current=1 · GET /prices/history?model=|provider=', returns: 'Providers with min prices · current price rows (USD / 1M tokens) · every price row incl. closed ones, oldest first' },
28− { path: 'GET /timeline?entity=&year=&category=', returns: 'Events grouped by month' }, 30+ { path: 'GET /prices/index?days=180', returns: '{ days, series: { day, median_input, median_output, min_input, max_input, models, offers }[], movers: ChangeEvent[] (PRICE_CHANGED), note }' },
29− { path: 'GET /compare?ids=a,b,c', returns: 'Side-by-side dimensions with provenance (2–6 entities of one type)' }, 31+ { path: 'GET /benchmarks · GET /benchmarks/{slug}/results?config=&history=1&limit=&offset= · GET /benchmarks/{slug}/history?model=', returns: 'Benchmarks with result_count, model_count and current top · leaderboard sorted by score (respecting higher_is_better), current rows only unless history=1, filterable by config key=value · one model’s results over time' },
30− { path: 'GET /diff?a=YYYY-MM-DD&b=YYYY-MM-DD&scope=', returns: 'What changed between two dates' }, 32+ { path: 'GET /hardware?kind=&manufacturer=&min_memory=&sort= · GET /hardware/fit?memory_gb=&quant=4bit|8bit|fp16&context=8192&openness=&limit=', returns: 'Hardware listing · ESTIMATED fit: { inputs, estimated: true, assumptions[], counts, items: { model, parameter_count, estimated_memory_gb, headroom_gb, fits, quantization, note }[] }' },
31− { path: 'GET /sources · GET /methodology · GET /trending · GET /sitemap', returns: 'Transparency, vocabularies, most viewed, sitemap feed' }, 33+ { path: 'GET /explore/types · GET /explore/{type}?q=&org=&sort=', returns: 'Entity types with live counts; generic paged listing for any type' },
34+ { path: 'GET /changes?category=&type=&entity_type=&importance_min=&since=&until=&q=&limit=&before= · GET /changes/daily?date= · GET /changes/categories?days=', returns: 'Change-event feed (cursor paged, next_before) · daily digest with sections · category counts' },
35+ { path: 'GET /timeline?entity=&year=&category=&limit=', returns: '{ items: { month, count, events[] }[], total } — grouped by month' },
36+ { path: 'GET /compare?ids=a,b[,…6]', returns: '{ entity_type, dimensions: { key, label, unit, kind: number|text|list|bool|date, source }[], items: { entity, values, provenance, prices?, results? }[] } — 2–6 entities of one type; models add best prices and the benchmarks shared by all' },
37+ { path: 'GET /diff?a=YYYY-MM-DD&b=YYYY-MM-DD&scope=all|models|org:<slug>|family:<name>', returns: '{ a, b, scope, new_entities[], gone_entities[], property_changes[], price_changes[], benchmark_changes[], counts }' },
38+ { path: 'GET /sources · GET /methodology · GET /trending?days=&limit=&type= · GET /sitemap?type=&limit=&offset=', returns: 'Transparency page data, vocabularies and tiers, most viewed entities, sitemap feed' },
32 ]; 39 ];
33 40
41+/** Compact, honest projection of /stats for the example block (the real payload has more counters). */
42+function statsExample(s: Stats) {
43+ return {
44+ entities: s.entities,
45+ entities_total: s.entities_total,
46+ sources: s.sources,
47+ connectors: s.connectors,
48+ claims_current: s.claims_current,
49+ change_events_24h: s.change_events_24h,
50+ benchmark_results: s.benchmark_results,
51+ prices_current: s.prices_current,
52+ last_event_at: s.last_event_at,
53+ computed_at: s.computed_at,
54+ '…': `${Object.keys(s).length} keys in total`,
55+ };
56+}
57+
58+function modelExample(d: EntityDetail) {
59+ const attrKeys = Object.keys(d.attributes ?? {}).slice(0, 8);
60+ const attributes: Record<string, unknown> = {};
61+ for (const k of attrKeys) attributes[k] = d.attributes[k];
62+ const provKey = attrKeys.find((k) => d.provenance?.[k]) ?? Object.keys(d.provenance ?? {})[0];
63+ const rest = Object.keys(d.attributes ?? {}).length - attrKeys.length;
64+ return {
65+ id: d.id,
66+ entity_type: d.entity_type,
67+ slug: d.slug,
68+ name: d.name,
69+ organization: d.organization,
70+ status: d.status,
71+ attributes: rest > 0 ? { ...attributes, '…': `${rest} more` } : attributes,
72+ provenance: provKey ? { [provKey]: d.provenance[provKey], '…': `one entry per attribute` } : {},
73+ quality: d.quality,
74+ counts: d.counts,
75+ relations: `${d.relations?.length ?? 0} groups`,
76+ prices: `${d.prices?.length ?? 0} current rows`,
77+ results: `${d.results?.length ?? 0} benchmark results`,
78+ timeline: `${d.timeline?.length ?? 0} events`,
79+ updated_at: d.updated_at,
80+ };
81+}
82+
83+function Code({ children }: { children: string }) {
84+ return (
85+ <pre className="scrollbar-thin mt-3 overflow-x-auto border border-rule bg-surface p-4 text-[12.5px] leading-relaxed text-ink">
86+ <code>{children}</code>
87+ </pre>
88+ );
89+}
90+
34 export default async function DevelopersPage() { 91 export default async function DevelopersPage() {
35− const health = await safe(api.health()); 92+ const [health, stats, top] = await Promise.all([safe(api.health()), safe(api.stats()), safe(api.models({ limit: 2, sort: 'quality' }))]);
93+ const slugA = top?.items[0]?.slug;
94+ const slugB = top?.items[1]?.slug;
95+ const model = slugA ? await safe(api.entityOfType('models', slugA)) : null;
96+ const exA = slugA ?? '<model-slug>';
97+ const exB = slugB ?? '<other-slug>';
98+ const today = new Date().toISOString().slice(0, 10);
99+ const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
100+
36 return ( 101 return (
37 <Container> 102 <Container>
38 <PageHeader eyebrow="Developers" title="Public API" lede="Everything on this site comes from the same JSON API. Public routes need no key; responses are cached for 1–10 minutes and search is rate-limited per IP." aside={health ? <p className="text-sm text-ink-3">API <span className={health.status === 'ok' ? 'text-positive' : 'text-warning'}>{health.status}</span> · v{health.version}</p> : <p className="text-sm text-ink-3">API status unavailable</p>} /> 103 <PageHeader eyebrow="Developers" title="Public API" lede="Everything on this site comes from the same JSON API. Public routes need no key; responses are cached for 1–10 minutes and search is rate-limited per IP." aside={health ? <p className="text-sm text-ink-3">API <span className={health.status === 'ok' ? 'text-positive' : 'text-warning'}>{health.status}</span> · v{health.version}</p> : <p className="text-sm text-ink-3">API status unavailable</p>} />
39 104
40 <Section eyebrow="Base URL" title={<span className="mono text-lg md:text-xl">{PUBLIC_API_BASE}</span>} hairline={false}> 105 <Section eyebrow="Base URL" title={<span className="mono text-lg md:text-xl">{PUBLIC_API_BASE}</span>} hairline={false}>
41 <p className="text-sm text-ink-2"> 106 <p className="text-sm text-ink-2">
42− Interactive OpenAPI documentation: <a href="/api/v1/docs" className="link mono">/api/v1/docs</a>. JSON, UTF-8, ISO-8601 UTC timestamps. Errors are <span className="mono">{'{ "detail": "…" }'}</span> with 400 / 404 / 429 / 503. 107+ Interactive OpenAPI documentation: <a href="/api/v1/docs" className="link mono">/api/v1/docs</a>. JSON, UTF-8, ISO-8601 UTC timestamps. Errors are <span className="mono">{'{ "detail": "…" }'}</span> with 400 / 404 / 429 / 503 (422 for parameters rejected by validation).
43 </p> 108 </p>
44− <pre className="scrollbar-thin mt-4 overflow-x-auto border border-rule bg-surface p-4 text-[13px] leading-relaxed text-ink"><code>{`# Live counters 109+ <Code>{`# Live counters
45 curl -s ${PUBLIC_API_BASE}/stats | jq '.entities' 110 curl -s ${PUBLIC_API_BASE}/stats | jq '.entities'
46 111
47 # Natural-language search → compiled filters + ranked results 112 # Natural-language search → compiled filters + ranked results
48 curl -s "${PUBLIC_API_BASE}/search?q=open+models+over+100B+released+in+2026" | jq '.query, .items[0:3]' 113 curl -s "${PUBLIC_API_BASE}/search?q=open+models+over+100B+released+in+2026" | jq '.query, .items[0:3]'
49 114
50 # A model with provenance, prices, results, lineage 115 # A model with provenance, prices, results, lineage
51−curl -s ${PUBLIC_API_BASE}/models/claude-opus-5 | jq '{name, attributes, provenance: (.provenance | keys)}' 116+curl -s ${PUBLIC_API_BASE}/models/${exA} | jq '{name, attributes, provenance: (.provenance | keys)}'
52 117
53−# Price history for one model across providers 118+# Compare two models on every shared dimension (+ shared benchmarks, best prices)
54−curl -s "${PUBLIC_API_BASE}/prices/history?model=claude-opus-5" | jq '.items[] | {provider: .provider.name, input_per_mtok, valid_from, valid_to}' 119+curl -s "${PUBLIC_API_BASE}/compare?ids=${exA},${exB}" | jq '.dimensions[].key, .items[].values'
120+
121+# What changed between two dates (scope: all | models | org:<slug> | family:<name>)
122+curl -s "${PUBLIC_API_BASE}/diff?a=${weekAgo}&b=${today}&scope=models" | jq '.counts, .new_entities[0:3]'
123+
124+# Claim history and the state of a record as of a date
125+curl -s "${PUBLIC_API_BASE}/entities/${exA}/history?property=context_length" | jq '.items[] | {value, status, valid_from, valid_to, source_url}'
126+curl -s "${PUBLIC_API_BASE}/entities/${exA}/asof?date=${weekAgo}" | jq '{existed, attributes}'
127+
128+# Neighbourhood graph, price index, leaderboard by config, estimated hardware fit
129+curl -s "${PUBLIC_API_BASE}/entities/${exA}/graph?depth=1&limit=80" | jq '{nodes: (.nodes|length), edges: (.edges|length)}'
130+curl -s "${PUBLIC_API_BASE}/prices/index?days=90" | jq '.series[-1], (.movers|length)'
131+curl -s "${PUBLIC_API_BASE}/benchmarks/<benchmark-slug>/results?config=variant=v2.1&limit=20" | jq '.items[] | {model: .model.name, score, config}'
132+curl -s "${PUBLIC_API_BASE}/hardware/fit?memory_gb=64&quant=4bit&context=32768&limit=20" | jq '.assumptions, .items[0]'
55 133
56 # What changed today 134 # What changed today
57−curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=20" | jq '.items[] | {event_type, summary, source_url}'`}</code></pre> 135+curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=20" | jq '.items[] | {event_type, summary, source_url}'`}</Code>
136+ </Section>
137+
138+ <Section eyebrow="Live examples" title="Example responses" lede="Fetched from the API when this page was rendered (revalidated hourly) and trimmed for display — not sample data.">
139+ <div className="grid gap-8 lg:grid-cols-2">
140+ <div className="min-w-0">
141+ <p className="mono text-sm text-ink">GET /stats</p>
142+ {stats ? <Code>{JSON.stringify(statsExample(stats), null, 2)}</Code> : <Unavailable what="Live /stats example" className="mt-3" />}
143+ </div>
144+ <div className="min-w-0">
145+ <p className="mono text-sm text-ink">GET /models/{exA}</p>
146+ {model ? <Code>{JSON.stringify(modelExample(model), null, 2)}</Code> : <Unavailable what="Live model example" className="mt-3" />}
147+ {model && (
148+ <Note className="mt-2">
149+ Trimmed: the full record has every attribute, a provenance entry per attribute, grouped relations, sources and the latest 30 events — <Link href={routes.entity(model)} className="link">see it rendered</Link> or <a href={`/api/v1/models/${encodeURIComponent(model.slug)}`} className="link">fetch the JSON</a>.
150+ </Note>
151+ )}
152+ </div>
153+ </div>
58 </Section> 154 </Section>
59 155
60 <Section eyebrow="Endpoints" title="Public routes"> 156 <Section eyebrow="Endpoints" title="Public routes">
@@ -69,7 +165,20 @@ curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=20" | jq '.items[] |
69 ))} 165 ))}
70 </tbody> 166 </tbody>
71 </DataTable> 167 </DataTable>
72− <Note className="mt-3">Shapes are documented in the OpenAPI schema. Postgres aggregates may arrive as strings; treat numeric fields as <span className="mono">number | string | null</span>.</Note> 168+ <Note className="mt-3">Shapes are documented in the OpenAPI schema. Admin routes (<span className="mono">/admin/*</span>) require the <span className="mono">x-aia-admin-token</span> header and are not public.</Note>
169+ </Section>
170+
171+ <Section eyebrow="Conventions" title="Reading responses">
172+ <ul className="max-w-3xl list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-ink-2">
173+ <li>Numeric aggregates may arrive as <span className="mono">string</span> (Postgres decimals): treat every numeric field as <span className="mono">number | string | null</span> and coerce.</li>
174+ <li><span className="mono">null</span> means the sources did not state it. Never read a missing field as zero, and never average two conflicting claims — the API returns both, flagged.</li>
175+ <li>Timestamps are ISO-8601 UTC. Dates without time (<span className="mono">release_date</span>, <span className="mono">knowledge_cutoff</span>) may be <span className="mono">YYYY</span>, <span className="mono">YYYY-MM</span> or <span className="mono">YYYY-MM-DD</span>, exactly as published.</li>
176+ <li>Units: tokens, USD per 1M tokens (<span className="mono">*_per_mtok</span>), GB, GB/s, W. Openness vocabulary: <span className="mono">open-weights | open-source | proprietary | restricted</span>.</li>
177+ <li><span className="mono">provenance</span> is an object keyed by attribute: <span className="mono">{'{ source_id, url, observed_at, tier (1 official → 4 unverified), confidence, extractor: deterministic | llm, unit? }'}</span>.</li>
178+ <li>Cursor feeds (<span className="mono">/changes</span>, <span className="mono">/entities/{'{slug}'}/timeline</span>) return <span className="mono">next_before</span>; pass it as <span className="mono">before=</span> for the next page (<span className="mono">null</span> at the end). Listings otherwise use <span className="mono">limit</span>/<span className="mono">offset</span>, capped at 200 (sitemap: 5 000).</li>
179+ <li>Parameters rejected by validation (bad type or bounds) return <span className="mono">422</span> with <span className="mono">{'{ detail, errors }'}</span>; semantic errors (unknown sort, bad date, wrong scope, ids count) return <span className="mono">400</span>.</li>
180+ <li>Anything marked <span className="mono">estimated</span> (hardware fit) is derived from published specs by a stated formula — the <span className="mono">assumptions</span> array is part of the response.</li>
181+ </ul>
73 </Section> 182 </Section>
74 183
75 <Section eyebrow="Terms" title="Fair use"> 184 <Section eyebrow="Terms" title="Fair use">
added apps/web/src/app/diff/page.tsx +193 −0
@@ -0,0 +1,193 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { ChangeRow } from '@/components/changes/change-row';
4+import { EntityRow } from '@/components/ui/entity';
5+import { Container, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';
6+import { EmptyState, Unavailable } from '@/components/ui/unavailable';
7+import { api, ApiError, safe } from '@/lib/api';
8+import { fmtDate, fmtInt, num } from '@/lib/format';
9+import { routes } from '@/lib/site';
10+import type { ChangeEvent, DiffPayload, EntitySummary } from '@/lib/types';
11+
12+type SP = { a?: string; b?: string; scope?: string; scope_custom?: string };
13+const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
14+
15+function todayUtc(): string {
16+ return new Date().toISOString().slice(0, 10);
17+}
18+function daysBefore(iso: string, days: number): string {
19+ const d = new Date(`${iso}T00:00:00Z`);
20+ d.setUTCDate(d.getUTCDate() - days);
21+ return d.toISOString().slice(0, 10);
22+}
23+function resolve(sp: SP): { a: string; b: string; scope: string; valid: boolean } {
24+ const b = sp.b && ISO_DAY.test(sp.b) ? sp.b : todayUtc();
25+ const a = sp.a && ISO_DAY.test(sp.a) ? sp.a : daysBefore(b, 7);
26+ const scope = (sp.scope_custom?.trim() || sp.scope || 'all').trim() || 'all';
27+ const valid = (!sp.a || ISO_DAY.test(sp.a)) && (!sp.b || ISO_DAY.test(sp.b)) && a < b;
28+ return { a, b, scope, valid };
29+}
30+
31+export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
32+ const { a, b, scope } = resolve(await searchParams);
33+ const title = `What changed between ${fmtDate(a)} and ${fmtDate(b)}${scope !== 'all' ? ` (${scope})` : ''}`;
34+ return { title, description: `New, gone and changed entities, prices and benchmark results in the AI ecosystem between ${a} and ${b}, straight from the AI Atlas change log.`, alternates: { canonical: routes.diff({ a, b, scope }) }, robots: { index: false } };
35+}
36+
37+function describeScope(s: DiffPayload['scope'], fallback: string): string {
38+ if (!s) return fallback === 'all' ? 'the whole atlas' : fallback;
39+ if (typeof s === 'string') return s;
40+ const kind = typeof s.kind === 'string' ? s.kind : fallback;
41+ const parts = Object.entries(s)
42+ .filter(([k, v]) => k !== 'kind' && v !== null && v !== undefined && v !== '')
43+ .map(([k, v]) => `${k} ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);
44+ if (kind === 'all') return 'the whole atlas';
45+ return parts.length ? `${kind} · ${parts.join(' · ')}` : kind;
46+}
47+
48+function Capped({ shown, total, what }: { shown: number; total: number | null; what: string }) {
49+ if (total === null || total <= shown) return null;
50+ return <Note className="mt-2">Showing the first {fmtInt(shown)} of {fmtInt(total)} {what}. Narrow the scope or the dates to see everything.</Note>;
51+}
52+
53+function EventsSection({ id, title, items, total, empty }: { id: string; title: string; items: ChangeEvent[]; total: number | null; empty: string }) {
54+ return (
55+ <Section id={id} eyebrow={title} title={<>{title} <span className="tnum text-base font-normal text-ink-3">{fmtInt(total ?? items.length)}</span></>}>
56+ {items.length === 0 ? (
57+ <EmptyState title={empty} />
58+ ) : (
59+ <>
60+ <ul className="border-t border-rule">
61+ {items.map((e) => (
62+ <ChangeRow key={e.id} e={e} showDate live={false} />
63+ ))}
64+ </ul>
65+ <Capped shown={items.length} total={total} what="events" />
66+ </>
67+ )}
68+ </Section>
69+ );
70+}
71+
72+function EntitiesSection({ id, title, items, total, empty }: { id: string; title: string; items: EntitySummary[]; total: number | null; empty: string }) {
73+ return (
74+ <Section id={id} eyebrow={title} title={<>{title} <span className="tnum text-base font-normal text-ink-3">{fmtInt(total ?? items.length)}</span></>}>
75+ {items.length === 0 ? (
76+ <EmptyState title={empty} />
77+ ) : (
78+ <>
79+ <ul className="border-t border-rule">
80+ {items.map((e) => (
81+ <EntityRow key={e.id} e={e} showType showDescription={false} />
82+ ))}
83+ </ul>
84+ <Capped shown={items.length} total={total} what="entities" />
85+ </>
86+ )}
87+ </Section>
88+ );
89+}
90+
91+export default async function DiffPage({ searchParams }: { searchParams: Promise<SP> }) {
92+ const sp = await searchParams;
93+ const { a, b, scope, valid } = resolve(sp);
94+ const orgs = await safe(api.companies({ limit: 30, sort: 'models' }));
95+ let payload: DiffPayload | null = null;
96+ let error: string | null = null;
97+ if (!valid) error = 'Dates must be YYYY-MM-DD and the first date must come before the second.';
98+ else {
99+ try {
100+ payload = await api.diff(a, b, scope);
101+ } catch (e) {
102+ error = e instanceof ApiError && e.status === 400 ? e.detail ?? 'The API rejected these parameters.' : null;
103+ }
104+ }
105+ const cls = 'h-11 w-full border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none';
106+ const orgOptions = (orgs?.items ?? []).map((o) => ({ value: `org:${o.slug}`, label: `${o.name} (${fmtInt(o.model_count)} models)` }));
107+ const knownScopes = new Set(['all', 'models', ...orgOptions.map((o) => o.value)]);
108+ const custom = knownScopes.has(scope) ? '' : scope;
109+ const c = payload?.counts ?? {};
110+ const cnt = (k: string): number | null => num(c[k]);
111+
112+ return (
113+ <Container>
114+ <PageHeader eyebrow="Diff" title={<>What changed between <span className="tnum">{fmtDate(a)}</span> and <span className="tnum">{fmtDate(b)}</span></>} lede="Two dates, one scope: the entities that appeared or disappeared, and every property, price and benchmark change recorded in between. Built from the change log — nothing is inferred." aside={payload ? <p className="text-sm text-ink-3">Scope: <span className="text-ink-2">{describeScope(payload.scope, scope)}</span></p> : undefined}>
115+ <form action="/diff" method="get" className="mt-6 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
116+ <label className="block min-w-0">
117+ <span className="eyebrow block pb-1">From (UTC)</span>
118+ <input type="date" name="a" defaultValue={a} max={todayUtc()} className={cls} required />
119+ </label>
120+ <label className="block min-w-0">
121+ <span className="eyebrow block pb-1">To (UTC)</span>
122+ <input type="date" name="b" defaultValue={b} max={todayUtc()} className={cls} required />
123+ </label>
124+ <label className="block min-w-0">
125+ <span className="eyebrow block pb-1">Scope</span>
126+ <select name="scope" defaultValue={custom ? '' : scope} className={cls}>
127+ <option value="all">Everything</option>
128+ <option value="models">Models only</option>
129+ {orgOptions.length > 0 && (
130+ <optgroup label="Organization">
131+ {orgOptions.map((o) => (
132+ <option key={o.value} value={o.value}>
133+ {o.label}
134+ </option>
135+ ))}
136+ </optgroup>
137+ )}
138+ {custom && <option value="">Custom (below)</option>}
139+ </select>
140+ </label>
141+ <label className="block min-w-0">
142+ <span className="eyebrow block pb-1">Custom scope</span>
143+ <input name="scope_custom" defaultValue={custom} placeholder="family:Claude · org:<slug>" className={cls} />
144+ </label>
145+ <div className="flex items-end gap-2">
146+ <button type="submit" className="inline-flex h-11 flex-1 items-center justify-center bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90">
147+ Compare dates
148+ </button>
149+ <Link href={routes.diff()} className="inline-flex h-11 items-center border border-rule px-3 text-sm text-ink-2 hover:text-ink">
150+ Reset
151+ </Link>
152+ </div>
153+ </form>
154+ <Note className="mt-2">
155+ Custom scope accepts <span className="mono">org:&lt;slug&gt;</span> or <span className="mono">family:&lt;name&gt;</span>; when filled it overrides the select. Quick links: <Link href={routes.diff({ a: daysBefore(todayUtc(), 1), b: todayUtc() })} className="link">yesterday → today</Link> · <Link href={routes.diff({ a: daysBefore(todayUtc(), 7), b: todayUtc(), scope: 'models' })} className="link">last 7 days, models</Link> · <Link href={routes.diff({ a: daysBefore(todayUtc(), 30), b: todayUtc() })} className="link">last 30 days</Link>
156+ </Note>
157+ </PageHeader>
158+
159+ <div className="pb-16">
160+ {error ? (
161+ <Unavailable what="Diff" reason={error} />
162+ ) : !payload ? (
163+ <Unavailable what="Diff" reason="The change log could not be read right now." />
164+ ) : (
165+ <>
166+ <StatGrid cols={8}>
167+ <Stat label="New entities" value={fmtInt(cnt('new_entities') ?? payload.new_entities.length)} href="#new" />
168+ <Stat label="Gone" value={fmtInt(cnt('gone_entities') ?? payload.gone_entities.length)} href="#gone" />
169+ <Stat label="Events" value={fmtInt(cnt('events'))} hint="all types" />
170+ <Stat label="Price changes" value={fmtInt(cnt('price_changes') ?? payload.price_changes.length)} href="#prices" accent />
171+ <Stat label="Benchmark changes" value={fmtInt(cnt('benchmark_changes') ?? payload.benchmark_changes.length)} href="#benchmarks" />
172+ <Stat label="Claims superseded" value={fmtInt(cnt('claims_superseded'))} />
173+ <Stat label={`Entities at ${fmtDate(a)}`} value={fmtInt(cnt('entities_at_a'))} />
174+ <Stat label={`Entities at ${fmtDate(b)}`} value={fmtInt(cnt('entities_at_b'))} />
175+ </StatGrid>
176+ <Note className="mt-2">
177+ Price rows opened {fmtInt(cnt('price_rows_opened'))} · closed {fmtInt(cnt('price_rows_closed'))}. Counts are totals over the period; the lists below are capped by the API.
178+ </Note>
179+
180+ <EntitiesSection id="new" title="New entities" items={payload.new_entities} total={cnt('new_entities')} empty="No new entities between these dates" />
181+ <EntitiesSection id="gone" title="Gone entities" items={payload.gone_entities} total={cnt('gone_entities')} empty="No entities disappeared between these dates" />
182+ <EventsSection id="properties" title="Property changes" items={payload.property_changes} total={cnt('property_changes')} empty="No property changes between these dates" />
183+ <EventsSection id="prices" title="Price changes" items={payload.price_changes} total={cnt('price_changes')} empty="No price changes between these dates" />
184+ <EventsSection id="benchmarks" title="Benchmark changes" items={payload.benchmark_changes} total={cnt('benchmark_changes')} empty="No benchmark changes between these dates" />
185+ <Note className="mt-6">
186+ “Gone” means an entity present at the first date is no longer current at the second (merged or retired) — its record and history are kept. Per-entity history: open any entity's History tab. <Link href="/methodology" className="link">Methodology →</Link>
187+ </Note>
188+ </>
189+ )}
190+ </div>
191+ </Container>
192+ );
193+}
modified apps/web/src/app/explore/[type]/[slug]/page.tsx +5 −4
@@ -1,12 +1,12 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import { permanentRedirect } from 'next/navigation'; 2 import { permanentRedirect } from 'next/navigation';
3−import { EntityPage } from '@/components/entity/entity-page'; 3+import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page';
4 import { buildMetadata, loadAnyEntity } from '@/components/entity/load'; 4 import { buildMetadata, loadAnyEntity } from '@/components/entity/load';
5 import { api, safe } from '@/lib/api'; 5 import { api, safe } from '@/lib/api';
6 import { routes, TYPE_PATH } from '@/lib/site'; 6 import { routes, TYPE_PATH } from '@/lib/site';
7 7
8 /** Fallback detail page for entity types without a dedicated path (regulation, incident, researcher, …). */ 8 /** Fallback detail page for entity types without a dedicated path (regulation, incident, researcher, …). */
9−type Params = { params: Promise<{ type: string; slug: string }> }; 9+type Params = { params: Promise<{ type: string; slug: string }>; searchParams: Promise<EntityPageParams> };
10 10
11 export async function generateMetadata({ params }: Params): Promise<Metadata> { 11 export async function generateMetadata({ params }: Params): Promise<Metadata> {
12 const { type, slug } = await params; 12 const { type, slug } = await params;
@@ -15,10 +15,11 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
15 return buildMetadata(d); 15 return buildMetadata(d);
16 } 16 }
17 17
18−export default async function ExploreEntityPage({ params }: Params) { 18+export default async function ExploreEntityPage({ params, searchParams }: Params) {
19 const { type, slug } = await params; 19 const { type, slug } = await params;
20+ const sp = await searchParams;
20 const d = await loadAnyEntity(type, slug); 21 const d = await loadAnyEntity(type, slug);
21 if (TYPE_PATH[d.entity_type]) permanentRedirect(routes.entity(d)); 22 if (TYPE_PATH[d.entity_type]) permanentRedirect(routes.entity(d));
22 const related = await safe(api.entityRelated(d.slug, 10)); 23 const related = await safe(api.entityRelated(d.slug, 10));
23− return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />; 24+ return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} asof={sp.asof} historyProperty={sp.property} />;
24 } 25 }
modified apps/web/src/app/frameworks/page.tsx +63 −3
@@ -1,10 +1,70 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2−import { GenericListing } from '@/components/listing/generic-listing'; 2+import Link from 'next/link';
3+import { Dash, str, TypedListing } from '@/components/listing/typed-listing';
4+import { EntityBadge } from '@/components/ui/badges';
5+import { EntityLink, QualityMark } from '@/components/ui/entity';
6+import { api } from '@/lib/api';
7+import { fmtDate, fmtInt, num } from '@/lib/format';
8+import { routes } from '@/lib/site';
3 9
4−export const metadata: Metadata = { title: 'AI frameworks, libraries and runtimes', description: 'Training, inference and agent frameworks with versions, licenses and repository metrics from their official sources.', alternates: { canonical: '/frameworks' } }; 10+export const metadata: Metadata = { title: 'AI frameworks, libraries and runtimes — versions, licenses, stars', description: 'Training, inference and agent frameworks with latest version, release date, license and repository stars read from PyPI, GitHub and release pages.', alternates: { canonical: '/frameworks' } };
5 export const revalidate = 300; 11 export const revalidate = 300;
6 12
7 export default async function FrameworksPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { 13 export default async function FrameworksPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
8 const sp = await searchParams; 14 const sp = await searchParams;
9− return <GenericListing type="framework" basePath="/frameworks" eyebrow="Frameworks" title="Frameworks & runtimes" lede="Libraries, inference engines and agent frameworks. Versions and stars are read from PyPI, GitHub and release pages." searchParams={sp} />; 15+ return (
16+ <TypedListing
17+ title="Frameworks & runtimes"
18+ eyebrow="Frameworks"
19+ lede="Libraries, inference engines and agent frameworks. Versions and stars are read from PyPI, GitHub and release pages — never estimated."
20+ basePath="/frameworks"
21+ searchParams={sp}
22+ fetch={(q) => api.explore('framework', q)}
23+ sorts={[
24+ { value: 'updated', label: 'Recently updated' },
25+ { value: 'stars', label: 'Stars' },
26+ { value: 'release', label: 'Latest release' },
27+ { value: 'name', label: 'Name' },
28+ { value: 'quality', label: 'Data quality' },
29+ ]}
30+ filters={sp.org ? [{ kind: 'hidden', name: 'org', value: sp.org }] : []}
31+ compare
32+ columns={[
33+ {
34+ key: 'name',
35+ label: 'Framework',
36+ primary: true,
37+ render: (e) => (
38+ <>
39+ <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
40+ <EntityLink e={e} />
41+ {e.entity_type !== 'framework' && <EntityBadge type={e.entity_type} small />}
42+ </span>
43+ {e.description && <span className="block max-w-md truncate text-xs text-ink-3">{e.description}</span>}
44+ </>
45+ ),
46+ },
47+ { key: 'version', label: 'Latest version', className: 'mono text-xs text-ink', render: (e) => str(e.attributes?.latest_version) ?? <Dash /> },
48+ { key: 'release', label: 'Release date', className: 'tnum text-ink-2 whitespace-nowrap', render: (e) => (str(e.attributes?.latest_release_at) ? fmtDate(str(e.attributes.latest_release_at)) : <Dash />) },
49+ { key: 'license', label: 'License', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> },
50+ { key: 'stars', label: 'Stars', num: true, className: 'tnum', render: (e) => (num(e.attributes?.['metric.stars']) === null ? <Dash /> : fmtInt(e.attributes['metric.stars'])) },
51+ { key: 'language', label: 'Language', className: 'text-ink-2', render: (e) => str(e.attributes?.language) ?? <Dash /> },
52+ {
53+ key: 'org',
54+ label: 'Organization',
55+ className: 'text-ink-2',
56+ render: (e) =>
57+ e.organization ? (
58+ <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">
59+ {e.organization.name}
60+ </Link>
61+ ) : (
62+ <Dash />
63+ ),
64+ },
65+ { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> },
66+ ]}
67+ note="Stars are a GitHub metric observed at crawl time (see each framework's Sources tab for the snapshot). Versions come from PyPI or the project's release feed."
68+ />
69+ );
10 } 70 }
added apps/web/src/app/graph/[slug]/page.tsx +163 −0
@@ -0,0 +1,163 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { notFound } from 'next/navigation';
4+import { GraphExplorer } from '@/components/graph/graph-explorer';
5+import { EntityBadge } from '@/components/ui/badges';
6+import { EntityLink } from '@/components/ui/entity';
7+import { Container, Note, PageHeader } from '@/components/ui/section';
8+import { EmptyState, Unavailable } from '@/components/ui/unavailable';
9+import { api, ApiError, safe } from '@/lib/api';
10+import { fmtInt } from '@/lib/format';
11+import { predicateLabel, routes, SITE_NAME, TYPE_COLOR_KEY, typeLabel } from '@/lib/site';
12+import type { GraphNode, GraphPayload } from '@/lib/types';
13+
14+type Params = { params: Promise<{ slug: string }>; searchParams: Promise<{ depth?: string }> };
15+const CAP = 80;
16+
17+/** 404 → notFound(); any other failure → null (the page renders an Unavailable state). */
18+async function loadGraph(slug: string, depth: 1 | 2): Promise<GraphPayload | null> {
19+ try {
20+ return await api.entityGraph(slug, depth, CAP);
21+ } catch (e) {
22+ if (e instanceof ApiError && e.notFound) notFound();
23+ return null;
24+ }
25+}
26+
27+export async function generateMetadata({ params }: Params): Promise<Metadata> {
28+ const { slug } = await params;
29+ const d = await safe(api.entity(slug));
30+ if (!d) return { title: 'Graph', robots: { index: false } };
31+ const title = `${d.name} — relationship graph`;
32+ return { title, description: `Everything AI Atlas links to ${d.name} (${typeLabel(d.entity_type).toLowerCase()}): organizations, models, benchmarks, papers and providers, with the predicate of each relation.`, alternates: { canonical: routes.graph(d.slug) }, openGraph: { title: `${title} | ${SITE_NAME}`, type: 'article' } };
33+}
34+
35+export default async function GraphPage({ params, searchParams }: Params) {
36+ const { slug } = await params;
37+ const sp = await searchParams;
38+ const depth: 1 | 2 = sp.depth === '2' ? 2 : 1;
39+ const [graph, d] = await Promise.all([loadGraph(slug, depth), safe(api.entity(slug))]);
40+ const rootId = graph?.root ?? d?.id ?? '';
41+ const nodes = graph?.nodes ?? [];
42+ const edges = graph?.edges ?? [];
43+ const capped = nodes.length >= CAP;
44+ const byId = new Map(nodes.map((n) => [n.id, n]));
45+ // Text fallback grouped by predicate (root's own edges first, then the rest).
46+ const groups = new Map<string, { node: GraphNode; direction: 'out' | 'in' }[]>();
47+ for (const e of edges) {
48+ const isOut = e.source === rootId;
49+ const isIn = e.target === rootId;
50+ if (!isOut && !isIn) continue;
51+ const other = byId.get(isOut ? e.target : e.source);
52+ if (!other) continue;
53+ const key = `${e.predicate}|${isOut ? 'out' : 'in'}`;
54+ (groups.get(key) ?? groups.set(key, []).get(key)!).push({ node: other, direction: isOut ? 'out' : 'in' });
55+ }
56+ const secondOrder = edges.filter((e) => e.source !== rootId && e.target !== rootId).length;
57+ const predicateCounts = [...edges.reduce((m, e) => m.set(e.predicate, (m.get(e.predicate) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1]);
58+ const typeCounts = [...nodes.reduce((m, n) => m.set(n.entity_type, (m.get(n.entity_type) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1]);
59+ const depthLink = (n: 1 | 2) => (n === depth ? 'bg-ink text-canvas' : 'text-ink-2 hover:text-ink');
60+
61+ return (
62+ <Container wide>
63+ <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3">
64+ <ol className="flex flex-wrap items-center gap-1.5">
65+ <li><Link href="/" className="hover:text-ink">AI Atlas</Link></li>
66+ {d && (
67+ <>
68+ <li aria-hidden>/</li>
69+ <li><Link href={routes.listing(d.entity_type)} className="hover:text-ink">{typeLabel(d.entity_type, true)}</Link></li>
70+ <li aria-hidden>/</li>
71+ <li><Link href={routes.entity(d)} className="hover:text-ink">{d.name}</Link></li>
72+ </>
73+ )}
74+ <li aria-hidden>/</li>
75+ <li className="text-ink-2">Graph</li>
76+ </ol>
77+ </nav>
78+ <PageHeader
79+ eyebrow={<>Graph explorer {d && <EntityBadge type={d.entity_type} small />}</>}
80+ title={d ? <>Around <Link href={routes.entity(d)} className="hover:text-accent">{d.name}</Link></> : `Around ${slug}`}
81+ lede={d ? `Every recorded relation of ${d.name}${depth === 2 ? ' and of its neighbours' : ''}, coloured by entity type. Click a node to open it.` : undefined}
82+ aside={
83+ <div className="flex flex-col items-start gap-2 md:items-end">
84+ <div className="inline-flex border border-rule text-sm" role="group" aria-label="Depth">
85+ <Link href={routes.graph(slug, 1)} className={`inline-flex h-11 items-center px-3 ${depthLink(1)}`} aria-current={depth === 1 ? 'true' : undefined}>Depth 1</Link>
86+ <Link href={routes.graph(slug, 2)} className={`inline-flex h-11 items-center border-l border-rule px-3 ${depthLink(2)}`} aria-current={depth === 2 ? 'true' : undefined}>Depth 2</Link>
87+ </div>
88+ {graph && <p className="tnum text-xs text-ink-3">{fmtInt(nodes.length)} nodes · {fmtInt(edges.length)} edges{capped ? ` · capped at ${CAP}` : ''}</p>}
89+ </div>
90+ }
91+ />
92+ <div className="pb-16">
93+ {!graph ? (
94+ <Unavailable what="Graph" />
95+ ) : nodes.length <= 1 || edges.length === 0 ? (
96+ <EmptyState title="No relations recorded yet">
97+ Relations are written only when a source states them (developer, provider, benchmark, paper…). {d && <Link href={routes.entity(d)} className="link">Back to {d.name} →</Link>}
98+ </EmptyState>
99+ ) : (
100+ <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]">
101+ <div className="min-w-0">
102+ <GraphExplorer nodes={nodes} edges={edges} rootId={rootId} />
103+ {capped && <Note className="mt-2">The neighbourhood is larger than {CAP} nodes; only the first {CAP} are drawn (+ more not shown). Depth 1 shows direct relations only; use the entity's Relations block for full lists.</Note>}
104+ {depth === 2 && <Note className="mt-1">{fmtInt(secondOrder)} of the edges connect neighbours to each other (second order).</Note>}
105+ </div>
106+ <aside className="min-w-0 space-y-6">
107+ <section>
108+ <p className="eyebrow mb-2">Node types</p>
109+ <ul className="space-y-1 text-sm">
110+ {typeCounts.map(([t, n]) => (
111+ <li key={t} className="flex items-center justify-between gap-2">
112+ <span className="flex items-center gap-2">
113+ <span className="inline-block size-2.5 rounded-full" style={{ background: `var(--type-${TYPE_COLOR_KEY[t] ?? 'tool'})` }} aria-hidden />
114+ {typeLabel(t, n !== 1)}
115+ </span>
116+ <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span>
117+ </li>
118+ ))}
119+ </ul>
120+ </section>
121+ <section>
122+ <p className="eyebrow mb-2">Predicates</p>
123+ <ul className="space-y-1 text-sm">
124+ {predicateCounts.map(([p, n]) => (
125+ <li key={p} className="flex items-center justify-between gap-2">
126+ <span className="text-ink-2">{predicateLabel(p, 'out')} <span className="mono text-[11px] text-ink-3">{p}</span></span>
127+ <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span>
128+ </li>
129+ ))}
130+ </ul>
131+ </section>
132+ </aside>
133+ <section className="min-w-0 lg:col-span-2">
134+ <p className="eyebrow mb-2">Direct relations, as a list</p>
135+ {groups.size === 0 ? (
136+ <p className="text-sm text-ink-3">No direct relations for the root node in this graph.</p>
137+ ) : (
138+ <dl className="kv">
139+ {[...groups.entries()].map(([key, items]) => {
140+ const [pred, dir] = key.split('|') as [string, 'out' | 'in'];
141+ return (
142+ <div key={key}>
143+ <dt>{predicateLabel(pred, dir)} <span className="tnum text-ink-3">{fmtInt(items.length)}</span></dt>
144+ <dd className="flex flex-wrap gap-x-3 gap-y-1">
145+ {items.map(({ node }) => (
146+ <span key={node.id} className="inline-flex items-center gap-1.5">
147+ <EntityBadge type={node.entity_type} small />
148+ <EntityLink e={node} />
149+ </span>
150+ ))}
151+ </dd>
152+ </div>
153+ );
154+ })}
155+ </dl>
156+ )}
157+ </section>
158+ </div>
159+ )}
160+ </div>
161+ </Container>
162+ );
163+}
added apps/web/src/app/hardware/fit/page.tsx +166 −0
@@ -0,0 +1,166 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { CompareButton } from '@/components/compare/compare-button';
4+import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
5+import { CONTEXTS, FitForm, QUANTS } from '@/components/hardware/fit-form';
6+import { Estimated, OpennessBadge } from '@/components/ui/badges';
7+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
8+import { EntityLink } from '@/components/ui/entity';
9+import { Container, Note, PageHeader } from '@/components/ui/section';
10+import { EmptyState, Unavailable } from '@/components/ui/unavailable';
11+import { api, ApiError } from '@/lib/api';
12+import { fmtGb, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format';
13+import { routes } from '@/lib/site';
14+import type { HardwareFit } from '@/lib/types';
15+
16+export const revalidate = 600;
17+
18+type SP = Record<string, string | undefined>;
19+
20+function parseInputs(sp: SP): { memory: number | null; quant: string; context: number; fitsOnly: boolean } {
21+ const custom = num(sp.memory_custom);
22+ const preset = num(sp.memory_gb);
23+ const memory = custom !== null && custom > 0 ? custom : preset !== null && preset > 0 ? preset : null;
24+ const quant = QUANTS.some((q) => q.value === sp.quant) ? (sp.quant as string) : '4bit';
25+ const ctx = num(sp.context);
26+ const context = ctx !== null && ctx > 0 ? Math.round(ctx) : 8192;
27+ return { memory, quant, context, fitsOnly: sp.fits === '1' };
28+}
29+
30+export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
31+ const { memory, quant, context } = parseInputs(await searchParams);
32+ const base = 'Hardware fit — which models run on my machine? (estimated)';
33+ const title = memory ? `Models estimated to fit ${fmtGb(memory)} at ${quant}, ${fmtTokens(context)} context` : base;
34+ return {
35+ title,
36+ description: 'Estimate which AI models fit a given amount of device memory at 4-bit, 8-bit or fp16 with a chosen context window. Estimates only — bytes per parameter plus a KV-cache allowance, never a measurement.',
37+ alternates: { canonical: memory ? routes.hardwareFit({ memory_gb: memory, quant, context }) : '/hardware/fit' },
38+ };
39+}
40+
41+export default async function HardwareFitPage({ searchParams }: { searchParams: Promise<SP> }) {
42+ const sp = await searchParams;
43+ const { memory, quant, context, fitsOnly } = parseInputs(sp);
44+ let res: HardwareFit | null = null;
45+ let error: string | null = null;
46+ if (memory) {
47+ try {
48+ res = await api.hardwareFit({ memory_gb: memory, quant, context, limit: 200 });
49+ } catch (e) {
50+ error = e instanceof ApiError ? e.detail ?? e.message : 'API unreachable';
51+ }
52+ }
53+ const items = res ? (fitsOnly ? res.items.filter((i) => i.fits) : res.items) : [];
54+ const fits = num(res?.counts?.fits) ?? res?.items.filter((i) => i.fits).length ?? null;
55+ const evaluated = num(res?.counts?.evaluated) ?? res?.items.length ?? null;
56+ const self = (patch: { fits?: boolean }) => routes.hardwareFit({ memory_gb: memory ?? undefined, quant, context }) + (patch.fits ? '&fits=1' : '');
57+ const ctxLabel = CONTEXTS.find((c) => Number(c.value) === context)?.label ?? `${fmtTokens(context)} tokens`;
58+
59+ return (
60+ <Container wide>
61+ <PageHeader
62+ eyebrow={
63+ <>
64+ <Link href={routes.hardware()} className="hover:text-ink">Hardware</Link> <span aria-hidden>/</span> Fit tool
65+ </>
66+ }
67+ title="What fits my machine?"
68+ lede="Pick a device memory size, a quantization and a context window: the atlas estimates each model's memory need from its parameter count. Every figure here is an estimate, never a measurement."
69+ aside={<Estimated />}
70+ >
71+ <FitForm memory={memory ? String(memory) : undefined} quant={quant} context={String(context)} className="mt-6" />
72+ </PageHeader>
73+
74+ <div className="pb-16">
75+ {!memory ? (
76+ <EmptyState title="Choose a memory size to start">
77+ Try <Link href={routes.hardwareFit({ memory_gb: 32, quant: '4bit', context: 8192 })} className="link">32 GB · 4-bit · 8k</Link>, <Link href={routes.hardwareFit({ memory_gb: 80, quant: '8bit', context: 32768 })} className="link">80 GB · 8-bit · 32k</Link> or <Link href={routes.hardwareFit({ memory_gb: 192, quant: 'fp16', context: 131072 })} className="link">192 GB · fp16 · 128k</Link>.
78+ </EmptyState>
79+ ) : !res ? (
80+ <Unavailable what="Hardware fit" reason={error ?? undefined} />
81+ ) : (
82+ <>
83+ <div className="mb-5 border-y border-rule py-3">
84+ <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
85+ <Estimated />
86+ <span className="font-semibold text-ink">All figures are estimates, not measurements.</span>
87+ <span className="tnum text-ink-2">
88+ {fmtGb(memory)} · {QUANTS.find((q) => q.value === quant)?.label.split(' (')[0] ?? quant} · {ctxLabel}
89+ </span>
90+ {fits !== null && evaluated !== null && (
91+ <span className="tnum text-ink-3">
92+ <span className="font-medium text-positive">{fmtInt(fits)}</span> of {fmtInt(evaluated)} models with a known parameter count fit
93+ </span>
94+ )}
95+ </p>
96+ <ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs leading-relaxed text-ink-3">
97+ {res.assumptions.map((a) => (
98+ <li key={a}>{a}</li>
99+ ))}
100+ {res.assumptions.length === 0 && <li>Assumptions unavailable from the API.</li>}
101+ </ul>
102+ <p className="mt-2 flex flex-wrap items-center gap-3 text-xs">
103+ {fitsOnly ? (
104+ <Link href={self({})} className="link">Show all evaluated models</Link>
105+ ) : (
106+ <Link href={self({ fits: true })} className="link">Show only fitting models</Link>
107+ )}
108+ <Link href="/methodology#estimates" className="text-ink-3 hover:text-ink">Method →</Link>
109+ </p>
110+ </div>
111+
112+ {items.length === 0 ? (
113+ <EmptyState title={fitsOnly ? 'No model is estimated to fit' : 'No models to evaluate'}>{fitsOnly ? 'Try a larger memory size or a lower-precision quantization.' : 'Models without a sourced parameter count are not estimated.'}</EmptyState>
114+ ) : (
115+ <DataTable caption="Estimated hardware fit">
116+ <thead>
117+ <tr>
118+ <Th>Model</Th>
119+ <Th num>Params</Th>
120+ <Th>Quant</Th>
121+ <Th num>Est. memory</Th>
122+ <Th num>Headroom</Th>
123+ <Th>Fits</Th>
124+ <Th className="w-24"><span className="sr-only">Compare</span></Th>
125+ </tr>
126+ </thead>
127+ <tbody>
128+ {items.length === 0 && <EmptyRow cols={7} />}
129+ {items.map((r) => {
130+ const openness = typeof r.model.attributes?.openness === 'string' ? r.model.attributes.openness : null;
131+ return (
132+ <tr key={`${r.model.id}-${r.quantization}`}>
133+ <Td primary>
134+ <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
135+ <EntityLink e={r.model} />
136+ {openness && <OpennessBadge openness={openness} />}
137+ </span>
138+ {r.model.organization && <span className="block text-xs text-ink-3">{r.model.organization.name}</span>}
139+ {r.note && <span className="block text-xs text-ink-3">{r.note}</span>}
140+ </Td>
141+ <Td num label="Params" className="tnum">{fmtParams(r.parameter_count)}</Td>
142+ <Td label="Quant" className="mono text-xs text-ink-2">{r.quantization}</Td>
143+ <Td num label="Est. memory" className="tnum">{fmtGb(r.estimated_memory_gb, 1)}</Td>
144+ <Td num label="Headroom" className={r.fits ? 'tnum text-positive' : 'tnum text-danger'}>
145+ {num(r.headroom_gb) === null ? '—' : `${r.headroom_gb >= 0 ? '+' : '−'}${fmtGb(Math.abs(r.headroom_gb), 1)}`}
146+ </Td>
147+ <Td label="Fits" className={r.fits ? 'font-medium text-positive' : 'text-ink-3'}>{r.fits ? '✓ fits' : '✗ too large'}</Td>
148+ <Td className="text-right">
149+ <CompareButton e={r.model} size="sm" />
150+ </Td>
151+ </tr>
152+ );
153+ })}
154+ </tbody>
155+ </DataTable>
156+ )}
157+ <Note className="mt-3">
158+ Headroom = device memory − 2 GB reserve − estimated need. Sorted by the API (fitting models first). Compare the models you shortlist with the + buttons. Devices with these memory sizes: <Link href={routes.hardware()} className="link">hardware listing</Link>.
159+ </Note>
160+ </>
161+ )}
162+ </div>
163+ <CompareTrayBar />
164+ </Container>
165+ );
166+}
modified apps/web/src/app/hardware/page.tsx +29 −2
@@ -1,7 +1,10 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3+import { CompareButton } from '@/components/compare/compare-button';
4+import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
3 import { GenericListing } from '@/components/listing/generic-listing'; 5 import { GenericListing } from '@/components/listing/generic-listing';
4 import { api } from '@/lib/api'; 6 import { api } from '@/lib/api';
7+import { routes } from '@/lib/site';
5 8
6 export const metadata: Metadata = { title: 'AI hardware — GPUs, accelerators, memory and bandwidth', description: 'Accelerators and devices for training and inference: memory, bandwidth, TDP, runtimes — and which models are estimated to fit.', alternates: { canonical: '/hardware' } }; 9 export const metadata: Metadata = { title: 'AI hardware — GPUs, accelerators, memory and bandwidth', description: 'Accelerators and devices for training and inference: memory, bandwidth, TDP, runtimes — and which models are estimated to fit.', alternates: { canonical: '/hardware' } };
7 export const revalidate = 600; 10 export const revalidate = 600;
@@ -9,10 +12,34 @@ export const revalidate = 600;
9 export default async function HardwarePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { 12 export default async function HardwarePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
10 const sp = await searchParams; 13 const sp = await searchParams;
11 return ( 14 return (
12− <GenericListing type="hardware" basePath="/hardware" eyebrow="Hardware" title="Hardware" lede="GPUs, accelerators and consumer devices with the memory and bandwidth figures published by their manufacturers." searchParams={sp} fetch={(q) => api.hardware(q)} sorts={[{ value: 'memory', label: 'Memory' }, { value: 'name', label: 'Name' }, { value: 'updated', label: 'Recently updated' }]} extraFields={[{ kind: 'text', name: 'kind', label: 'Kind', value: sp.kind, placeholder: 'gpu, accelerator, soc…' }, { kind: 'text', name: 'manufacturer', label: 'Manufacturer', value: sp.manufacturer, placeholder: 'NVIDIA, Apple…' }]}> 15+ <GenericListing
16+ type="hardware"
17+ basePath="/hardware"
18+ eyebrow="Hardware"
19+ title="Hardware"
20+ lede="GPUs, accelerators and consumer devices with the memory and bandwidth figures published by their manufacturers."
21+ searchParams={sp}
22+ fetch={(q) => api.hardware(q)}
23+ sorts={[
24+ { value: 'memory', label: 'Memory' },
25+ { value: 'name', label: 'Name' },
26+ { value: 'updated', label: 'Recently updated' },
27+ ]}
28+ extraFields={[
29+ { kind: 'text', name: 'kind', label: 'Kind', value: sp.kind, placeholder: 'gpu, accelerator, soc…' },
30+ { kind: 'text', name: 'manufacturer', label: 'Manufacturer', value: sp.manufacturer, placeholder: 'NVIDIA, Apple…' },
31+ ]}
32+ rowTrailing={(e) => <CompareButton e={e} size="sm" />}
33+ headerAside={
34+ <Link href={routes.hardwareFit()} className="inline-flex h-10 items-center border border-rule-strong px-3 text-sm font-medium text-ink hover:border-accent hover:text-accent">
35+ What fits my machine? →
36+ </Link>
37+ }
38+ >
13 <p className="mb-6 text-sm text-ink-2"> 39 <p className="mb-6 text-sm text-ink-2">
14− Each device page lists the models estimated to fit its memory per quantization — labelled as estimates, method on <Link href="/methodology#estimates" className="link">/methodology</Link>. 40+ Each device page lists the models estimated to fit its memory per quantization — labelled as estimates, method on <Link href="/methodology#estimates" className="link">/methodology</Link>. Use <Link href={routes.hardwareFit()} className="link">the fit tool</Link> for any memory size.
15 </p> 41 </p>
42+ <CompareTrayBar />
16 </GenericListing> 43 </GenericListing>
17 ); 44 );
18 } 45 }
added apps/web/src/app/icon-192.png/render.tsx +26 −0
@@ -0,0 +1,26 @@
1+import { ImageResponse } from 'next/og';
2+
3+/**
4+ * Square PNG app icon (manifest 192 / 512) drawn from the AI Atlas mark — same artwork as `apple-icon.tsx`.
5+ * Colours are the dark canvas / ink / accent tokens; raw hex is unavoidable in ImageResponse (no CSS variables).
6+ */
7+export function renderIcon(px: number): ImageResponse {
8+ const mark = Math.round(px * 0.72);
9+ return new ImageResponse(
10+ (
11+ <div style={{ width: px, height: px, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0b0d11' }}>
12+ <svg width={mark} height={mark} viewBox="0 0 32 32" fill="none">
13+ <circle cx="16" cy="16" r="13" stroke="#e9ebf0" strokeWidth="1.6" />
14+ <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13M3 16h26" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.7" />
15+ <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.55" />
16+ <circle cx="16" cy="16" r="2.4" fill="#6d95ff" />
17+ <circle cx="9.4" cy="9.5" r="1.7" fill="#e9ebf0" />
18+ <circle cx="22.6" cy="9.5" r="1.7" fill="#e9ebf0" />
19+ <circle cx="10.8" cy="24.4" r="1.7" fill="#e9ebf0" />
20+ <circle cx="23.4" cy="21.6" r="1.7" fill="#e9ebf0" />
21+ </svg>
22+ </div>
23+ ),
24+ { width: px, height: px, headers: { 'cache-control': 'public, max-age=86400, s-maxage=86400' } },
25+ );
26+}
added apps/web/src/app/icon-192.png/route.tsx +9 −0
@@ -0,0 +1,9 @@
1+import { renderIcon } from './render';
2+
3+export const runtime = 'nodejs';
4+export const dynamic = 'force-static';
5+
6+/** 192×192 PNG icon for the web app manifest. */
7+export function GET() {
8+ return renderIcon(192);
9+}
added apps/web/src/app/icon-512.png/route.tsx +9 −0
@@ -0,0 +1,9 @@
1+import { renderIcon } from '../icon-192.png/render';
2+
3+export const runtime = 'nodejs';
4+export const dynamic = 'force-static';
5+
6+/** 512×512 PNG icon for the web app manifest (also used as the maskable icon). */
7+export function GET() {
8+ return renderIcon(512);
9+}
added apps/web/src/app/manifest.ts +37 −0
@@ -0,0 +1,37 @@
1+import type { MetadataRoute } from 'next';
2+import { DESCRIPTION, SITE_NAME } from '@/lib/site';
3+
4+/**
5+ * Web app manifest (PWA basics). Colours mirror the light theme tokens in globals.css (`--canvas`), matching the
6+ * `themeColor` declared in the root layout. PNG icons are rendered on demand by `/icon-192.png` and `/icon-512.png`.
7+ */
8+export default function manifest(): MetadataRoute.Manifest {
9+ return {
10+ name: SITE_NAME,
11+ short_name: SITE_NAME,
12+ description: DESCRIPTION,
13+ id: '/',
14+ start_url: '/',
15+ scope: '/',
16+ display: 'standalone',
17+ orientation: 'any',
18+ background_color: '#f6f6f3',
19+ theme_color: '#f6f6f3',
20+ lang: 'en',
21+ dir: 'ltr',
22+ categories: ['reference', 'productivity'],
23+ icons: [
24+ { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
25+ { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
26+ { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
27+ { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
28+ { src: '/apple-icon', sizes: '180x180', type: 'image/png', purpose: 'any' },
29+ ],
30+ shortcuts: [
31+ { name: 'Models', short_name: 'Models', description: 'Every AI model with parameters, context, openness and prices', url: '/models', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
32+ { name: 'Changes', short_name: 'Changes', description: 'What changed in AI today', url: '/changes', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
33+ { name: 'Compare', short_name: 'Compare', description: 'Compare 2–6 entities side by side', url: '/compare', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
34+ { name: 'Prices', short_name: 'Prices', description: 'AI Price Index — USD per 1M tokens', url: '/prices', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
35+ ],
36+ };
37+}
modified apps/web/src/app/models/(list)/page.tsx +12 −4
@@ -1,5 +1,7 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3+import { CompareButton } from '@/components/compare/compare-button';
4+import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
3 import { ActiveFilters, type FacetGroup, Facets, FilterBar, ListingLayout } from '@/components/listing/filters'; 5 import { ActiveFilters, type FacetGroup, Facets, FilterBar, ListingLayout } from '@/components/listing/filters';
4 import { OpennessBadge, StatusBadge } from '@/components/ui/badges'; 6 import { OpennessBadge, StatusBadge } from '@/components/ui/badges';
5 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; 7 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
@@ -122,11 +124,16 @@ export default async function ModelsPage({ searchParams }: { searchParams: Promi
122 return ( 124 return (
123 <tr key={m.id}> 125 <tr key={m.id}>
124 <Td primary> 126 <Td primary>
125− <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> 127+ <div className="flex items-start justify-between gap-2">
126− <EntityLink e={m} /> 128+ <div className="min-w-0">
127− <StatusBadge status={m.status !== 'active' ? m.status : null} /> 129+ <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
130+ <EntityLink e={m} />
131+ <StatusBadge status={m.status !== 'active' ? m.status : null} />
132+ </div>
133+ {typeof a.family === 'string' && <span className="block text-xs text-ink-3">{a.family}</span>}
134+ </div>
135+ <CompareButton e={m} size="sm" className="mt-0.5" />
128 </div> 136 </div>
129− {typeof a.family === 'string' && <span className="block text-xs text-ink-3">{a.family}</span>}
130 </Td> 137 </Td>
131 <Td label="Organization" className="text-ink-2"> 138 <Td label="Organization" className="text-ink-2">
132 {m.organization ? ( 139 {m.organization ? (
@@ -156,6 +163,7 @@ export default async function ModelsPage({ searchParams }: { searchParams: Promi
156 )} 163 )}
157 </ListingLayout> 164 </ListingLayout>
158 </div> 165 </div>
166+ <CompareTrayBar />
159 </Container> 167 </Container>
160 ); 168 );
161 } 169 }
added apps/web/src/app/models/[slug]/opengraph-image.tsx +47 −0
@@ -0,0 +1,47 @@
1+import { ImageResponse } from 'next/og';
2+import { api, safe } from '@/lib/api';
3+import { fmtDate, fmtParams, fmtTokens, fmtUsdPerM, num } from '@/lib/format';
4+import { OPENNESS_LABELS, routes, SITE_NAME } from '@/lib/site';
5+import type { EntityDetail } from '@/lib/types';
6+import { Eyebrow, Facts, Fallback, Frame, INK2, Title } from '@/components/brand/og';
7+
8+export const runtime = 'nodejs';
9+export const alt = `Model on ${SITE_NAME}`;
10+export const size = { width: 1200, height: 630 };
11+export const contentType = 'image/png';
12+
13+function modelFacts(d: EntityDetail): [string, string][] {
14+ const a = d.attributes ?? {};
15+ const out: [string, string][] = [];
16+ if (num(a.parameter_count) !== null) out.push(['Parameters', fmtParams(a.parameter_count)]);
17+ if (num(a.context_length) !== null) out.push(['Context', `${fmtTokens(a.context_length)} tokens`]);
18+ const inputs = (d.prices ?? []).map((p) => num(p.input_per_mtok)).filter((v): v is number => v !== null);
19+ if (inputs.length) {
20+ const best = Math.min(...inputs);
21+ out.push(['Best input / 1M', best >= 1 ? `$${best.toFixed(2)}` : fmtUsdPerM(best)]);
22+ }
23+ if (typeof a.release_date === 'string') out.push(['Released', fmtDate(a.release_date)]);
24+ if (typeof a.openness === 'string') out.push(['Openness', OPENNESS_LABELS[a.openness] ?? a.openness]);
25+ if (typeof a.license === 'string') out.push(['License', a.license.slice(0, 18)]);
26+ return out.slice(0, 3);
27+}
28+
29+/** Per-model Open Graph image: brand, "Model" eyebrow, name, organization, three key attributes, canonical URL. */
30+export default async function ModelOgImage({ params }: { params: Promise<{ slug: string }> }) {
31+ const { slug } = await params;
32+ const d = await safe(api.entityOfType('models', slug));
33+ if (!d) return new ImageResponse(<Fallback label="Model" />, { ...size });
34+ const facts = modelFacts(d);
35+ return new ImageResponse(
36+ (
37+ <Frame footer={`www.ai-atlas.co${routes.entity(d)}`}>
38+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
39+ <Eyebrow>{d.organization ? `Model · ${d.organization.name}` : 'Model'}</Eyebrow>
40+ <Title>{d.name}</Title>
41+ {facts.length > 0 ? <Facts items={facts} /> : <div style={{ display: 'flex', fontSize: 24, color: INK2 }}>{d.description ? d.description.slice(0, 120) : 'Specifications, prices, benchmarks, lineage and sources.'}</div>}
42+ </div>
43+ </Frame>
44+ ),
45+ { ...size },
46+ );
47+}
modified apps/web/src/app/models/[slug]/page.tsx +5 −4
@@ -1,21 +1,22 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import { permanentRedirect } from 'next/navigation'; 2 import { permanentRedirect } from 'next/navigation';
3−import { EntityPage } from '@/components/entity/entity-page'; 3+import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page';
4 import { entityMetadata, loadEntity } from '@/components/entity/load'; 4 import { entityMetadata, loadEntity } from '@/components/entity/load';
5 import { api, safe } from '@/lib/api'; 5 import { api, safe } from '@/lib/api';
6 import { routes } from '@/lib/site'; 6 import { routes } from '@/lib/site';
7 7
8−type Params = { params: Promise<{ slug: string }> }; 8+type Params = { params: Promise<{ slug: string }>; searchParams: Promise<EntityPageParams> };
9 9
10 export async function generateMetadata({ params }: Params): Promise<Metadata> { 10 export async function generateMetadata({ params }: Params): Promise<Metadata> {
11 const { slug } = await params; 11 const { slug } = await params;
12 return entityMetadata('models', slug); 12 return entityMetadata('models', slug);
13 } 13 }
14 14
15−export default async function ModelPage({ params }: Params) { 15+export default async function ModelPage({ params, searchParams }: Params) {
16 const { slug } = await params; 16 const { slug } = await params;
17+ const sp = await searchParams;
17 const d = await loadEntity('models', slug); 18 const d = await loadEntity('models', slug);
18 if (d.slug !== slug) permanentRedirect(routes.entity(d)); 19 if (d.slug !== slug) permanentRedirect(routes.entity(d));
19 const related = await safe(api.entityRelated(d.slug, 10)); 20 const related = await safe(api.entityRelated(d.slug, 10));
20− return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />; 21+ return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} asof={sp.asof} historyProperty={sp.property} />;
21 } 22 }
modified apps/web/src/app/not-found.tsx +25 −11
@@ -1,22 +1,36 @@
1+import { Search } from 'lucide-react';
1 import type { Metadata } from 'next'; 2 import type { Metadata } from 'next';
2 import Link from 'next/link'; 3 import Link from 'next/link';
3−import { Container } from '@/components/ui/section'; 4+import { Container, Note } from '@/components/ui/section';
4−import { routes } from '@/lib/site'; 5+import { PUBLIC_API_BASE, routes } from '@/lib/site';
5 6
6 export const metadata: Metadata = { title: 'Not found', robots: { index: false } }; 7 export const metadata: Metadata = { title: 'Not found', robots: { index: false } };
7 8
9+/** 404 for the whole site (and for entity pages whose slug/type does not resolve). Search is the way back. */
8 export default function NotFound() { 10 export default function NotFound() {
11+ const btn = 'inline-flex h-11 items-center border border-rule px-3.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink';
9 return ( 12 return (
10− <Container className="py-20 md:py-28"> 13+ <Container className="py-16 md:py-24">
11− <p className="eyebrow">404</p> 14+ <p className="eyebrow">404 · Not on the map</p>
12− <h1 className="display mt-2 text-3xl md:text-5xl">This page is not on the map.</h1> 15+ <h1 className="display mt-2 text-3xl md:text-5xl">We have no record at this address.</h1>
13− <p className="mt-4 max-w-xl text-ink-2">The entity may have been merged into another record, renamed, or never existed. Search is the fastest way back.</p> 16+ <p className="mt-4 max-w-xl text-[15px] leading-relaxed text-ink-2">
14− <div className="mt-6 flex flex-wrap gap-3 text-sm"> 17+ The entity may have been merged into another record (its old slug then redirects), renamed by its source, or never crawled. Nothing is invented to fill the gap — search the atlas instead.
15− <Link href={routes.home()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Home</Link> 18+ </p>
16− <Link href={routes.models()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Models</Link> 19+ <form action="/search" method="get" role="search" className="mt-6 flex max-w-xl items-stretch border border-rule-strong bg-surface focus-within:border-accent">
17− <Link href={routes.explore()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Explore</Link> 20+ <label htmlFor="nf-q" className="sr-only">Search AI Atlas</label>
18− <Link href={routes.changes()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Changes</Link> 21+ <Search className="my-auto ml-3 size-4 shrink-0 text-ink-3" aria-hidden />
22+ <input id="nf-q" name="q" type="search" placeholder="Search models, companies, papers, benchmarks…" className="h-11 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" spellCheck={false} />
23+ <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">Search</button>
24+ </form>
25+ <div className="mt-6 flex flex-wrap gap-2 text-sm">
26+ <Link href={routes.home()} className={btn}>Home</Link>
27+ <Link href={routes.models()} className={btn}>Models</Link>
28+ <Link href={routes.explore()} className={btn}>Explore all types</Link>
29+ <Link href={routes.changes()} className={btn}>Changes</Link>
19 </div> 30 </div>
31+ <Note className="mt-8 max-w-xl">
32+ Canonical URLs are <span className="mono">/models/&lt;slug&gt;</span>, <span className="mono">/companies/&lt;slug&gt;</span>, <span className="mono">/papers/&lt;slug&gt;</span>… — a slug under the wrong type is a 404, not a duplicate page. Programmatic lookup: <a href={`${PUBLIC_API_BASE}/search/suggest?q=claude`} className="link mono">{PUBLIC_API_BASE.replace(/^https?:\/\/www\./, '')}/search/suggest?q=…</a>
33+ </Note>
20 </Container> 34 </Container>
21 ); 35 );
22 } 36 }
modified apps/web/src/app/papers/page.tsx +92 −8
@@ -1,23 +1,107 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2−import { GenericListing } from '@/components/listing/generic-listing'; 2+import Link from 'next/link';
3+import { Dash, list, str, TypedListing } from '@/components/listing/typed-listing';
4+import { Chip } from '@/components/ui/badges';
5+import { EntityLink, QualityMark } from '@/components/ui/entity';
3 import { api } from '@/lib/api'; 6 import { api } from '@/lib/api';
7+import { fmtDate } from '@/lib/format';
8+import { routes } from '@/lib/site';
4 9
5−export const metadata: Metadata = { title: 'AI research papers', description: 'Research papers in the atlas, linked to the models, organizations and benchmarks they describe.', alternates: { canonical: '/papers' } }; 10+export const metadata: Metadata = { title: 'AI research papers — authors, dates, categories', description: 'Research papers in the atlas, linked to the models, organizations and benchmarks they describe. Authors, arXiv categories and publication dates as stated by their sources.', alternates: { canonical: '/papers' } };
6 export const revalidate = 300; 11 export const revalidate = 300;
7 12
8 export default async function PapersPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { 13 export default async function PapersPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
9 const sp = await searchParams; 14 const sp = await searchParams;
10 return ( 15 return (
11− <GenericListing 16+ <TypedListing
12− type="paper"
13− basePath="/papers"
14− eyebrow="Research"
15 title="Papers" 17 title="Papers"
18+ eyebrow="Research"
16 lede="Publications linked to models, labs and benchmarks. Authors, venues and abstracts come from arXiv and publisher pages." 19 lede="Publications linked to models, labs and benchmarks. Authors, venues and abstracts come from arXiv and publisher pages."
20+ basePath="/papers"
17 searchParams={sp} 21 searchParams={sp}
18 fetch={(q) => api.papers(q)} 22 fetch={(q) => api.papers(q)}
19− sorts={[{ value: 'published', label: 'Recently published' }, { value: 'updated', label: 'Recently updated' }]} 23+ sorts={[
20− extraFields={[{ kind: 'text', name: 'category', label: 'arXiv category', value: sp.category, placeholder: 'cs.CL, cs.LG…' }, { kind: 'text', name: 'since', label: 'Published since', value: sp.since, placeholder: 'YYYY-MM-DD' }]} 24+ { value: 'published', label: 'Recently published' },
25+ { value: 'updated', label: 'Recently updated' },
26+ ]}
27+ filters={[
28+ { kind: 'text', name: 'category', label: 'arXiv category', value: sp.category, placeholder: 'cs.CL, cs.LG…' },
29+ { kind: 'text', name: 'since', label: 'Published since', value: sp.since, placeholder: 'YYYY-MM-DD' },
30+ ...(sp.org ? [{ kind: 'hidden' as const, name: 'org', value: sp.org }] : []),
31+ ]}
32+ columns={[
33+ {
34+ key: 'title',
35+ label: 'Title',
36+ primary: true,
37+ render: (e) => (
38+ <>
39+ <EntityLink e={e} />
40+ {str(e.attributes?.arxiv_id) && <span className="mono ml-2 text-[11px] text-ink-3">arXiv:{str(e.attributes.arxiv_id)}</span>}
41+ </>
42+ ),
43+ },
44+ {
45+ key: 'authors',
46+ label: 'Authors',
47+ className: 'max-w-[22rem] text-ink-2',
48+ render: (e) => {
49+ const a = list(e.attributes?.authors);
50+ if (!a.length) return <Dash />;
51+ return (
52+ <span className="text-sm">
53+ {a.slice(0, 3).join(', ')}
54+ {a.length > 3 && <span className="tnum text-ink-3"> +{a.length - 3}</span>}
55+ </span>
56+ );
57+ },
58+ },
59+ { key: 'published', label: 'Published', className: 'tnum text-ink-2 whitespace-nowrap', render: (e) => (str(e.attributes?.published_at) ? fmtDate(str(e.attributes.published_at)) : <Dash />) },
60+ {
61+ key: 'categories',
62+ label: 'Categories',
63+ render: (e) => {
64+ const cats = list(e.attributes?.categories);
65+ const primary = str(e.attributes?.primary_category);
66+ const all = [...new Set([...(primary ? [primary] : []), ...cats])].slice(0, 4);
67+ if (!all.length) return <Dash />;
68+ return (
69+ <span className="flex flex-wrap gap-1">
70+ {all.map((c) => (
71+ <Link key={c} href={`/papers?category=${encodeURIComponent(c)}`} className="hover:opacity-80">
72+ <Chip tone={c === primary ? 'accent' : 'neutral'} className="mono">{c}</Chip>
73+ </Link>
74+ ))}
75+ </span>
76+ );
77+ },
78+ },
79+ {
80+ key: 'org',
81+ label: 'Organization / venue',
82+ className: 'text-ink-2',
83+ render: (e) => {
84+ const venue = str(e.attributes?.venue);
85+ if (!e.organization && !venue) return <Dash />;
86+ return (
87+ <>
88+ {e.organization && (
89+ <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">
90+ {e.organization.name}
91+ </Link>
92+ )}
93+ {venue && <span className="block text-xs text-ink-3">{venue}</span>}
94+ </>
95+ );
96+ },
97+ },
98+ { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> },
99+ ]}
100+ note={
101+ <>
102+ Author lists and categories are copied from the paper's own metadata (arXiv, publisher). Each paper page shows the abstract, related models and every source snapshot.
103+ </>
104+ }
21 /> 105 />
22 ); 106 );
23 } 107 }
added apps/web/src/app/prices/loading.tsx +32 −0
@@ -0,0 +1,32 @@
1+import { Container } from '@/components/ui/section';
2+
3+export default function Loading() {
4+ return (
5+ <Container wide className="pb-16 pt-7 md:pt-10" aria-busy="true">
6+ <div className="h-4 w-16 animate-pulse bg-surface-2" />
7+ <div className="mt-3 h-9 w-64 max-w-full animate-pulse bg-surface-2" />
8+ <div className="mt-6 flex gap-1.5">
9+ {Array.from({ length: 4 }).map((_, i) => (
10+ <div key={i} className="h-9 w-20 animate-pulse bg-surface-2" />
11+ ))}
12+ </div>
13+ <div className="mt-10 grid grid-cols-2 gap-x-6 border-y border-rule md:grid-cols-5">
14+ {Array.from({ length: 5 }).map((_, i) => (
15+ <div key={i} className="py-4">
16+ <div className="h-3 w-20 animate-pulse bg-surface-2" />
17+ <div className="mt-2 h-7 w-24 animate-pulse bg-surface-2" />
18+ </div>
19+ ))}
20+ </div>
21+ <div className="mt-6 h-[240px] animate-pulse bg-surface-2" />
22+ <ul className="mt-10 border-t border-rule">
23+ {Array.from({ length: 8 }).map((_, i) => (
24+ <li key={i} className="border-b border-rule py-3">
25+ <div className="h-4 w-2/3 animate-pulse bg-surface-2" />
26+ </li>
27+ ))}
28+ </ul>
29+ <p className="sr-only">Loading prices…</p>
30+ </Container>
31+ );
32+}
added apps/web/src/app/prices/page.tsx +228 −0
@@ -0,0 +1,228 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { Legend, LineChart, type Series } from '@/components/charts/charts';
4+import { FilterBar } from '@/components/listing/filters';
5+import { PriceMovers } from '@/components/prices/movers';
6+import { ScaleToggle } from '@/components/prices/scale-toggle';
7+import { ChipRow } from '@/components/timeline/chip-row';
8+import { Chip } from '@/components/ui/badges';
9+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
10+import { EntityLink } from '@/components/ui/entity';
11+import { Pagination, withParams } from '@/components/ui/pagination';
12+import { SourceCell } from '@/components/ui/provenance';
13+import { Container, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';
14+import { EmptyState, Unavailable } from '@/components/ui/unavailable';
15+import { api, ApiError, safe } from '@/lib/api';
16+import { fmtAgo, fmtDate, fmtInt, fmtTokens, fmtUsdPerM, num } from '@/lib/format';
17+import { routes, SITE_NAME } from '@/lib/site';
18+import type { Page, Price } from '@/lib/types';
19+
20+export const revalidate = 300;
21+
22+type SP = Record<string, string | undefined>;
23+const LIMIT = 100;
24+const DAYS = [30, 90, 180, 365];
25+const SORTS = ['input', 'output', 'model', 'provider', 'observed'] as const;
26+type Sort = (typeof SORTS)[number];
27+const KEYS = ['days', 'scale', 'sort', 'provider', 'model', 'offset'] as const;
28+
29+function pick(sp: SP) {
30+ const cur: Record<string, string | undefined> = {};
31+ for (const k of KEYS) if (sp[k]) cur[k] = sp[k];
32+ const days = DAYS.includes(Number(cur.days)) ? Number(cur.days) : 180;
33+ const sort: Sort = (SORTS as readonly string[]).includes(cur.sort ?? '') ? (cur.sort as Sort) : 'input';
34+ return { cur, days, sort, offset: Math.max(0, Number(cur.offset) || 0), scale: cur.scale === 'log' ? ('log' as const) : ('linear' as const) };
35+}
36+
37+export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
38+ const { cur, days } = pick(await searchParams);
39+ const title = cur.provider || cur.model ? `AI Price Index — ${[cur.provider, cur.model].filter(Boolean).join(' · ')}` : 'AI Price Index — USD per 1M tokens across providers';
40+ return {
41+ title,
42+ description: `Median and minimum published input/output prices per 1M tokens across every provider AI Atlas tracks, over the last ${days} days, plus recent price changes and every current offer with its source.`,
43+ alternates: { canonical: routes.prices() },
44+ openGraph: { title: `${title} | ${SITE_NAME}` },
45+ robots: cur.provider || cur.model || cur.offset ? { index: false, follow: true } : undefined,
46+ };
47+}
48+
49+/** `/prices` 404s when the model/provider slug is unknown — keep that distinction instead of a generic "unavailable". */
50+async function loadPrices(query: Record<string, string | number | undefined>): Promise<{ page: Page<Price> | null; notFound: boolean }> {
51+ try {
52+ return { page: await api.prices(query), notFound: false };
53+ } catch (e) {
54+ return { page: null, notFound: e instanceof ApiError && e.notFound };
55+ }
56+}
57+
58+export default async function PricesPage({ searchParams }: { searchParams: Promise<SP> }) {
59+ const sp = await searchParams;
60+ const { cur, days, sort, offset, scale } = pick(sp);
61+ const [index, providers, priced] = await Promise.all([safe(api.priceIndex(days)), safe(api.providers()), loadPrices({ sort, provider: cur.provider, model: cur.model, limit: LIMIT, offset })]);
62+ const href = (patch: Record<string, string | number | undefined | null>) => withParams('/prices', cur, patch);
63+
64+ // ---- index: latest populated day + chart series (null days skipped)
65+ const series = index?.series ?? [];
66+ const populated = series.filter((p) => num(p.median_input) !== null || num(p.median_output) !== null);
67+ const latest = populated.at(-1) ?? null;
68+ const first = populated[0] ?? null;
69+ const toSeries = (name: string, field: 'median_input' | 'median_output' | 'min_input', color: string): Series => ({ name, color, points: series.flatMap((p) => (num(p[field]) === null ? [] : [{ x: new Date(`${p.day}T00:00:00Z`), y: num(p[field]) as number }])) });
70+ const chartSeries = [toSeries('Median input', 'median_input', 'var(--series-1)'), toSeries('Median output', 'median_output', 'var(--series-2)'), toSeries('Cheapest input', 'min_input', 'var(--series-7)')].filter((s) => s.points.length > 0);
71+ const chartPoints = chartSeries.reduce((n, s) => n + s.points.length, 0);
72+ const delta = (field: 'median_input' | 'median_output') => {
73+ const a = num(first?.[field]);
74+ const b = num(latest?.[field]);
75+ if (a === null || b === null || a === 0 || first === latest) return undefined;
76+ const pct = ((b - a) / a) * 100;
77+ return { value: `${pct > 0 ? '+' : ''}${pct.toFixed(1)}%`, tone: pct < 0 ? ('positive' as const) : pct > 0 ? ('negative' as const) : ('neutral' as const) };
78+ };
79+
80+ // ---- table: cheapest input per model within the shown rows
81+ const page = priced.page;
82+ const rows = page?.items ?? [];
83+ const minByModel = new Map<string, number>();
84+ for (const r of rows) {
85+ const v = num(r.input_per_mtok);
86+ if (v === null) continue;
87+ const m = minByModel.get(r.model.slug);
88+ if (m === undefined || v < m) minByModel.set(r.model.slug, v);
89+ }
90+ // "cheapest" is only meaningful when a model is offered by more than one provider among the shown rows
91+ const providersByModel = new Map<string, Set<string>>();
92+ for (const r of rows) {
93+ const s = providersByModel.get(r.model.slug) ?? new Set<string>();
94+ s.add(r.provider.slug);
95+ providersByModel.set(r.model.slug, s);
96+ }
97+ const providerOptions = (providers?.items ?? []).slice().sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: p.slug, label: p.name }));
98+ const providerName = providerOptions.find((o) => o.value === cur.provider)?.label;
99+ const SortTh = ({ s, children, num: n }: { s: Sort; children: React.ReactNode; num?: boolean }) => (
100+ <Th num={n} aria-sort={sort === s ? 'ascending' : undefined}>
101+ <Link href={href({ sort: s === 'input' ? undefined : s, offset: undefined })} className={sort === s ? 'text-ink' : 'hover:text-ink'}>
102+ {children}
103+ {sort === s && <span aria-hidden> ↑</span>}
104+ </Link>
105+ </Th>
106+ );
107+
108+ return (
109+ <Container wide>
110+ <PageHeader eyebrow="Prices" title="AI Price Index" lede="What a million tokens costs, day by day: the median and cheapest published input/output prices across every provider offer AI Atlas tracks. Prices are quoted as stated on providers' pricing pages; every change is kept." aside={latest ? <p className="tnum text-sm text-ink-3">{fmtInt(latest.offers)} offers · {fmtInt(latest.models)} models · {fmtDate(latest.day)}</p> : undefined}>
111+ <div className="mt-6">
112+ <p className="eyebrow mb-1.5">Window</p>
113+ <ChipRow label="Window" items={DAYS.map((d) => ({ href: href({ days: d === 180 ? undefined : d }), label: `${d} days`, active: days === d }))} />
114+ </div>
115+ </PageHeader>
116+
117+ {/* ------------------------------------------------------------------------------------------------ index */}
118+ <Section eyebrow="Index" title={<>Median price per 1M tokens · last {days} days</>} hairline={false}>
119+ {!index ? (
120+ <Unavailable what="Price index" />
121+ ) : !latest ? (
122+ <EmptyState title="No priced offers in this window">Prices appear once a provider pricing page has been crawled. Try a longer window.</EmptyState>
123+ ) : (
124+ <>
125+ <StatGrid cols={5}>
126+ <Stat label="Median input" value={<span className="text-accent-2">{fmtUsdPerM(latest.median_input)}</span>} hint={`per 1M · ${fmtDate(latest.day)}`} delta={delta('median_input')} />
127+ <Stat label="Median output" value={<span className="text-accent-2">{fmtUsdPerM(latest.median_output)}</span>} hint={`per 1M · ${fmtDate(latest.day)}`} delta={delta('median_output')} />
128+ <Stat label="Cheapest input" value={fmtUsdPerM(latest.min_input)} hint={num(latest.max_input) !== null ? `dearest ${fmtUsdPerM(latest.max_input)}` : 'per 1M tokens'} />
129+ <Stat label="Models priced" value={fmtInt(latest.models)} hint="with a current offer" />
130+ <Stat label="Offers" value={fmtInt(latest.offers)} hint="model × provider rows" />
131+ </StatGrid>
132+ <div className="mt-6">
133+ {chartPoints < 2 || populated.length < 2 ? (
134+ <Note>The index has {fmtInt(populated.length)} day{populated.length === 1 ? '' : 's'} of observations so far — a line needs at least two. The chart fills in as daily snapshots accumulate.</Note>
135+ ) : (
136+ <>
137+ <ScaleToggle initial={scale} linear={<LineChart series={chartSeries} height={240} yFormat={(v) => fmtUsdPerM(v)} yLabel="USD per 1M tokens" showDots />} log={<LineChart series={chartSeries} height={240} yFormat={(v) => fmtUsdPerM(v)} yLabel="USD per 1M tokens (log scale)" showDots yScale="log" />} />
138+ <Legend series={chartSeries} className="mt-2" />
139+ </>
140+ )}
141+ </div>
142+ {index.note && <Note className="mt-3">{index.note} {delta('median_input') ? 'Deltas compare the first and last populated day of the window.' : ''}</Note>}
143+ </>
144+ )}
145+ </Section>
146+
147+ {/* ------------------------------------------------------------------------------------------------ movers */}
148+ <Section eyebrow="Movers" title="Recent price changes" lede="Provider offers whose published price moved, newest first." action={{ href: `${routes.changes()}?category=price`, label: 'All pricing events' }}>
149+ {!index ? <Unavailable what="Price changes" /> : <PriceMovers movers={index.movers ?? []} />}
150+ </Section>
151+
152+ {/* ------------------------------------------------------------------------------------------------ table */}
153+ <Section eyebrow="Current prices" title={providerName ? `Current offers from ${providerName}` : cur.model ? `Current offers for ${cur.model}` : 'Every current offer'} lede="One row per model × provider, as published. Sort by any column; filter by provider or model slug.">
154+ <FilterBar
155+ action="/prices"
156+ className="mb-5 lg:grid-cols-5"
157+ resetHref={href({ provider: undefined, model: undefined, offset: undefined, sort: undefined })}
158+ fields={[
159+ { kind: 'select', name: 'provider', label: 'Provider', value: cur.provider, options: providerOptions },
160+ { kind: 'text', name: 'model', label: 'Model slug', value: cur.model, placeholder: 'e.g. claude-opus-5' },
161+ ...(cur.days ? [{ kind: 'hidden' as const, name: 'days', value: cur.days }] : []),
162+ ...(cur.scale ? [{ kind: 'hidden' as const, name: 'scale', value: cur.scale }] : []),
163+ ]}
164+ sort={{ value: sort, options: [{ value: 'input', label: 'Cheapest input' }, { value: 'output', label: 'Cheapest output' }, { value: 'model', label: 'Model' }, { value: 'provider', label: 'Provider' }, { value: 'observed', label: 'Recently observed' }] }}
165+ />
166+ {priced.notFound ? (
167+ <EmptyState title={`No ${cur.model ? 'model' : 'provider'} with slug “${cur.model ?? cur.provider}”`}>
168+ Slugs are the last part of an entity URL (<span className="mono">/models/&lt;slug&gt;</span>). <Link href={cur.model ? routes.search(cur.model, 'model') : routes.providers()} className="link">Search instead →</Link>
169+ </EmptyState>
170+ ) : !page ? (
171+ <Unavailable what="Current prices" />
172+ ) : (
173+ <>
174+ <DataTable caption="Current prices per 1M tokens">
175+ <thead>
176+ <tr>
177+ <SortTh s="model">Model</SortTh>
178+ <SortTh s="provider">Provider</SortTh>
179+ <SortTh s="input" num>Input / 1M</SortTh>
180+ <SortTh s="output" num>Output / 1M</SortTh>
181+ <Th num>Cached in</Th>
182+ <Th num>Context</Th>
183+ <SortTh s="observed">Observed</SortTh>
184+ <Th>Source</Th>
185+ </tr>
186+ </thead>
187+ <tbody>
188+ {rows.length === 0 && <EmptyRow cols={8}>No current offers match these filters.</EmptyRow>}
189+ {rows.map((p) => {
190+ const v = num(p.input_per_mtok);
191+ const cheapest = v !== null && minByModel.get(p.model.slug) === v && (providersByModel.get(p.model.slug)?.size ?? 0) > 1;
192+ return (
193+ <tr key={p.id}>
194+ <Td primary>
195+ <EntityLink e={p.model} />
196+ {p.model.organization && <span className="ml-2 text-xs text-ink-3">{p.model.organization.name}</span>}
197+ {p.provider_model_id && p.provider_model_id !== p.model.slug && <span className="mono block text-[11px] text-ink-3">{p.provider_model_id}</span>}
198+ </Td>
199+ <Td label="Provider" className="text-ink-2">
200+ <Link href={href({ provider: p.provider.slug, offset: undefined })} className="hover:text-accent" title="Filter by this provider">
201+ {p.provider.name}
202+ </Link>
203+ <EntityLink e={p.provider} className="ml-1.5 text-xs text-ink-3">↗</EntityLink>
204+ </Td>
205+ <Td num label="Input / 1M" className={cheapest ? 'tnum font-semibold text-accent-2' : 'tnum text-accent-2'}>
206+ {fmtUsdPerM(p.input_per_mtok)}
207+ {cheapest && <Chip tone="accent" className="ml-1.5 align-middle">cheapest</Chip>}
208+ </Td>
209+ <Td num label="Output / 1M" className="tnum text-accent-2">{fmtUsdPerM(p.output_per_mtok)}</Td>
210+ <Td num label="Cached in" className="tnum text-ink-2">{fmtUsdPerM(p.cached_input_per_mtok)}</Td>
211+ <Td num label="Context" className="tnum text-ink-2">{num(p.context_length) === null ? <span className="text-ink-3">—</span> : fmtTokens(p.context_length)}</Td>
212+ <Td label="Observed" className="text-ink-2" title={p.observed_at}>{fmtAgo(p.observed_at)}</Td>
213+ <Td label="Source"><SourceCell url={p.source_url} tier={p.tier} /></Td>
214+ </tr>
215+ );
216+ })}
217+ </tbody>
218+ </DataTable>
219+ <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
220+ <Note className="mt-3">
221+ “Cheapest” marks the lowest input price for a model among the rows shown on this page (models served by several providers). USD per 1M tokens as published; <Link href={routes.providers()} className="link">providers overview</Link> · <Link href="/methodology" className="link">methodology</Link>.
222+ </Note>
223+ </>
224+ )}
225+ </Section>
226+ </Container>
227+ );
228+}
modified apps/web/src/app/providers/page.tsx +10 −2
@@ -1,5 +1,7 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3+import { CompareButton } from '@/components/compare/compare-button';
4+import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
3 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; 5 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
4 import { EntityLink, QualityMark } from '@/components/ui/entity'; 6 import { EntityLink, QualityMark } from '@/components/ui/entity';
5 import { Container, Note, PageHeader } from '@/components/ui/section'; 7 import { Container, Note, PageHeader } from '@/components/ui/section';
@@ -42,8 +44,13 @@ export default async function ProvidersPage() {
42 {items.map((p) => ( 44 {items.map((p) => (
43 <tr key={p.id}> 45 <tr key={p.id}>
44 <Td primary> 46 <Td primary>
45− <EntityLink e={p} /> 47+ <div className="flex items-start justify-between gap-2">
46− {p.organization && <span className="ml-2 text-xs text-ink-3">{p.organization.name}</span>} 48+ <div className="min-w-0">
49+ <EntityLink e={p} />
50+ {p.organization && <span className="ml-2 text-xs text-ink-3">{p.organization.name}</span>}
51+ </div>
52+ <CompareButton e={p} size="sm" className="mt-0.5" />
53+ </div>
47 </Td> 54 </Td>
48 <Td num label="Models" className="tnum">{fmtInt(p.model_count)}</Td> 55 <Td num label="Models" className="tnum">{fmtInt(p.model_count)}</Td>
49 <Td num label="Price rows" className="tnum text-ink-2">{fmtInt(p.price_count)}</Td> 56 <Td num label="Price rows" className="tnum text-ink-2">{fmtInt(p.price_count)}</Td>
@@ -59,6 +66,7 @@ export default async function ProvidersPage() {
59 </> 66 </>
60 )} 67 )}
61 </div> 68 </div>
69+ <CompareTrayBar />
62 </Container> 70 </Container>
63 ); 71 );
64 } 72 }
modified apps/web/src/app/robots.ts +1 −1
@@ -3,7 +3,7 @@ import { SITE_URL } from '@/lib/site';
3 3
4 export default function robots(): MetadataRoute.Robots { 4 export default function robots(): MetadataRoute.Robots {
5 return { 5 return {
6− rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin', '/search?'] }], 6+ rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin', '/admin/', '/search?'] }],
7 sitemap: `${SITE_URL}/sitemap.xml`, 7 sitemap: `${SITE_URL}/sitemap.xml`,
8 host: SITE_URL, 8 host: SITE_URL,
9 }; 9 };
added apps/web/src/app/timeline/loading.tsx +25 −0
@@ -0,0 +1,25 @@
1+import { Container } from '@/components/ui/section';
2+
3+export default function Loading() {
4+ return (
5+ <Container className="pb-16 pt-7 md:pt-10" aria-busy="true">
6+ <div className="h-4 w-20 animate-pulse bg-surface-2" />
7+ <div className="mt-3 h-9 w-72 max-w-full animate-pulse bg-surface-2" />
8+ <div className="mt-6 flex gap-1.5">
9+ {Array.from({ length: 5 }).map((_, i) => (
10+ <div key={i} className="h-9 w-16 animate-pulse bg-surface-2" />
11+ ))}
12+ </div>
13+ <div className="mt-8 h-[90px] animate-pulse bg-surface-2" />
14+ <ul className="mt-8 border-t border-rule">
15+ {Array.from({ length: 8 }).map((_, i) => (
16+ <li key={i} className="border-b border-rule py-3">
17+ <div className="h-4 w-1/2 animate-pulse bg-surface-2" />
18+ <div className="mt-2 h-3 w-3/4 animate-pulse bg-surface-2" />
19+ </li>
20+ ))}
21+ </ul>
22+ <p className="sr-only">Loading timeline…</p>
23+ </Container>
24+ );
25+}
modified apps/web/src/app/timeline/page.tsx +136 −49
@@ -1,73 +1,160 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3 import { ChangeRow } from '@/components/changes/change-row'; 3 import { ChangeRow } from '@/components/changes/change-row';
4−import { FilterBar } from '@/components/listing/filters'; 4+import { Bars } from '@/components/charts/charts';
5−import { withParams } from '@/components/ui/pagination'; 5+import { ChipRow, type ChipItem } from '@/components/timeline/chip-row';
6−import { Container, PageHeader } from '@/components/ui/section'; 6+import { EntityBadge } from '@/components/ui/badges';
7+import { Container, Note, PageHeader } from '@/components/ui/section';
7 import { EmptyState, Unavailable } from '@/components/ui/unavailable'; 8 import { EmptyState, Unavailable } from '@/components/ui/unavailable';
8 import { api, safe } from '@/lib/api'; 9 import { api, safe } from '@/lib/api';
9−import { fmtInt, fmtMonth } from '@/lib/format'; 10+import { fmtInt, fmtMonth, fmtYear } from '@/lib/format';
10−import { CATEGORY_LABELS, routes } from '@/lib/site'; 11+import { CATEGORY_LABELS, categoryLabel, routes, SITE_NAME } from '@/lib/site';
11 12
12−export const metadata: Metadata = { title: 'Timeline — the AI ecosystem month by month', description: 'Change events grouped by month: releases, price moves, deprecations and benchmark results across the whole atlas or for one entity.', alternates: { canonical: '/timeline' } };
13 export const revalidate = 600; 13 export const revalidate = 600;
14 14
15 type SP = Record<string, string | undefined>; 15 type SP = Record<string, string | undefined>;
16−const YEARS = Array.from({ length: 8 }, (_, i) => String(new Date().getUTCFullYear() - i)); 16+const LIMIT = 400;
17+
18+function current(sp: SP) {
19+ const out: { entity?: string; year?: string; category?: string } = {};
20+ if (sp.entity) out.entity = sp.entity;
21+ if (sp.year && /^\d{4}$/.test(sp.year)) out.year = sp.year;
22+ if (sp.category) out.category = sp.category;
23+ return out;
24+}
25+
26+export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
27+ const cur = current(await searchParams);
28+ const bits: string[] = [];
29+ if (cur.entity) bits.push(cur.entity);
30+ if (cur.category) bits.push(categoryLabel(cur.category).toLowerCase());
31+ if (cur.year) bits.push(cur.year);
32+ const title = bits.length ? `Timeline — ${bits.join(' · ')}` : 'Timeline — the AI ecosystem month by month';
33+ return {
34+ title,
35+ description: 'Change events grouped by month: releases, price moves, deprecations and benchmark results across the whole atlas or for one entity, each linked to its source.',
36+ alternates: { canonical: routes.timeline(cur) },
37+ openGraph: { title: `${title} | ${SITE_NAME}` },
38+ robots: cur.entity ? { index: false, follow: true } : undefined,
39+ };
40+}
17 41
18 export default async function TimelinePage({ searchParams }: { searchParams: Promise<SP> }) { 42 export default async function TimelinePage({ searchParams }: { searchParams: Promise<SP> }) {
19 const sp = await searchParams; 43 const sp = await searchParams;
20− const current: Record<string, string | undefined> = {}; 44+ const cur = current(sp);
21− for (const k of ['entity', 'year', 'category']) if (sp[k]) current[k] = sp[k]; 45+ const [res, cats, stats, entity] = await Promise.all([safe(api.timeline({ ...cur, limit: LIMIT })), safe(api.changesCategories(365)), safe(api.stats()), cur.entity ? safe(api.entity(cur.entity)) : Promise.resolve(null)]);
22− const res = await safe(api.timeline({ ...current, limit: 400 }));
23 const months = res?.items ?? []; 46 const months = res?.items ?? [];
24− const total = months.reduce((n, m) => n + m.events.length, 0); 47+ const shown = months.reduce((n, m) => n + m.events.length, 0);
25− const entity = current.entity ? await safe(api.entity(current.entity)) : null; 48+ const total = res?.total ?? shown;
49+
50+ // Years: current UTC year back to the earliest year the atlas knows (first entity or earliest month returned).
51+ const thisYear = new Date().getUTCFullYear();
52+ const earliestCandidates = [stats?.first_entity_at, ...months.map((m) => m.month), cur.year].filter((v): v is string => !!v).map((v) => Number(fmtYear(v))).filter((n) => Number.isFinite(n) && n > 1990);
53+ const earliest = earliestCandidates.length ? Math.min(...earliestCandidates) : thisYear;
54+ const years: string[] = [];
55+ for (let y = thisYear; y >= earliest; y--) years.push(String(y));
56+
57+ // Categories: live counts over the last year, summed per category; fall back to the vocabulary when unavailable.
58+ const catCounts = new Map<string, number>();
59+ for (const it of cats?.items ?? []) catCounts.set(it.category, (catCounts.get(it.category) ?? 0) + (Number(it.count) || 0));
60+ const catKeys = catCounts.size ? [...catCounts.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k) : Object.keys(CATEGORY_LABELS);
61+ if (cur.category && !catKeys.includes(cur.category)) catKeys.push(cur.category);
62+
63+ const yearChips: ChipItem[] = [{ href: routes.timeline({ ...cur, year: undefined }), label: 'All years', active: !cur.year }, ...years.map((y) => ({ href: routes.timeline({ ...cur, year: y }), label: y, active: cur.year === y }))];
64+ const catChips: ChipItem[] = [{ href: routes.timeline({ ...cur, category: undefined }), label: 'All categories', active: !cur.category }, ...catKeys.map((c) => ({ href: routes.timeline({ ...cur, category: c }), label: categoryLabel(c), count: catCounts.size ? catCounts.get(c) ?? 0 : undefined, active: cur.category === c }))];
65+
66+ // Density strip, oldest → newest (the list below is newest first).
67+ const density = [...months].reverse().map((m) => ({ label: fmtMonth(m.month), value: m.count ?? m.events.length }));
68+
26 return ( 69 return (
27 <Container> 70 <Container>
28− <PageHeader eyebrow="Timeline" title={entity ? <>Timeline of <Link href={routes.entity(entity)} className="text-ink-2 hover:text-accent">{entity.name}</Link></> : 'The ecosystem, month by month'} lede="Events grouped by month, newest first. Every entry links to the entity and to the source that stated the change." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(total)} events shown</p> : undefined}> 71+ <PageHeader
29− <FilterBar 72+ eyebrow={
30− action="/timeline" 73+ entity ? (
31− className="mt-6 lg:grid-cols-4" 74+ <>
32− resetHref={routes.timeline()} 75+ <EntityBadge type={entity.entity_type} small /> Timeline
33− fields={[ 76+ </>
34− { kind: 'text', name: 'entity', label: 'Entity slug', value: current.entity, placeholder: 'e.g. claude-opus-5' }, 77+ ) : (
35− { kind: 'select', name: 'year', label: 'Year', value: current.year, options: YEARS.map((y) => ({ value: y, label: y })) }, 78+ 'Timeline'
36− { kind: 'select', name: 'category', label: 'Category', value: current.category, options: Object.entries(CATEGORY_LABELS).map(([value, label]) => ({ value, label })) }, 79+ )
37− ]} 80+ }
38− /> 81+ title={
82+ entity ? (
83+ <>
84+ Timeline of <Link href={routes.entity(entity)} className="text-ink-2 hover:text-accent">{entity.name}</Link>
85+ </>
86+ ) : cur.entity ? (
87+ <>Timeline of <span className="mono text-ink-2">{cur.entity}</span></>
88+ ) : (
89+ 'The ecosystem, month by month'
90+ )
91+ }
92+ lede={entity ? <>Every recorded change for this {entity.entity_type.replace(/_/g, ' ')}{entity.organization ? ` by ${entity.organization.name}` : ''}, newest first. <Link href={routes.entity(entity)} className="link">Back to the entity page →</Link></> : 'Events grouped by month, newest first. Every entry links to the entity and to the source that stated the change.'}
93+ aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(shown)}{total > shown ? ` of ${fmtInt(total)}` : ''} events</p> : undefined}
94+ >
95+ <div className="mt-6 space-y-3">
96+ <div>
97+ <p className="eyebrow mb-1.5">Year</p>
98+ <ChipRow label="Year" items={yearChips} />
99+ </div>
100+ <div>
101+ <p className="eyebrow mb-1.5">Category {cats ? <span className="normal-case tracking-normal text-ink-3">· events in the last 365 days</span> : null}</p>
102+ <ChipRow label="Category" items={catChips} />
103+ </div>
104+ {cur.entity && (
105+ <p className="text-xs text-ink-3">
106+ Filtered to one entity. <Link href={routes.timeline({ year: cur.year, category: cur.category })} className="link">Show the whole atlas</Link>
107+ </p>
108+ )}
109+ </div>
39 </PageHeader> 110 </PageHeader>
111+
40 <div className="pb-16"> 112 <div className="pb-16">
41 {!res ? ( 113 {!res ? (
42− <Unavailable what="Timeline" /> 114+ <Unavailable what="Timeline" reason={cur.entity ? 'The entity slug may not exist.' : undefined} />
43 ) : months.length === 0 ? ( 115 ) : months.length === 0 ? (
44− <EmptyState title="No events for this selection">Try another year or category, or open <Link href={routes.changes()} className="link">the live feed</Link>.</EmptyState> 116+ <EmptyState title="No events for this selection">
117+ Try another year or category, or open <Link href={routes.changes()} className="link">the live feed</Link>.
118+ </EmptyState>
45 ) : ( 119 ) : (
46− <div className="grid gap-x-10 lg:grid-cols-[10rem_minmax(0,1fr)]"> 120+ <>
47− <nav aria-label="Months" className="no-scrollbar -mx-4 mb-4 flex gap-1 overflow-x-auto px-4 lg:sticky lg:top-[calc(var(--header-h)+1rem)] lg:mx-0 lg:mb-0 lg:block lg:self-start lg:space-y-0.5 lg:px-0"> 121+ {density.length > 1 && (
48− {months.map((m) => ( 122+ <section aria-label="Events per month" className="mb-8">
49− <a key={m.month} href={`#m-${m.month}`} className="tnum flex shrink-0 items-center justify-between gap-2 border border-rule px-2 py-1 text-xs text-ink-2 hover:text-ink lg:border-0 lg:px-1"> 123+ <p className="eyebrow mb-2">Events per month <span className="tnum normal-case tracking-normal text-ink-3">· {fmtInt(density.length)} months shown</span></p>
50− <span>{fmtMonth(m.month)}</span> 124+ <Bars data={density} height={90} />
51− <span className="text-ink-3">{m.events.length}</span> 125+ </section>
52− </a> 126+ )}
53− ))} 127+ <div className="grid gap-x-10 lg:grid-cols-[10rem_minmax(0,1fr)]">
54− </nav> 128+ <nav aria-label="Months" className="no-scrollbar -mx-4 mb-4 flex gap-1 overflow-x-auto px-4 lg:sticky lg:top-[calc(var(--header-h)+1rem)] lg:mx-0 lg:mb-0 lg:block lg:self-start lg:space-y-0.5 lg:px-0">
55− <div className="min-w-0"> 129+ {months.map((m) => (
56− {months.map((m) => ( 130+ <a key={m.month} href={`#m-${m.month}`} className="tnum flex h-9 shrink-0 items-center justify-between gap-2 border border-rule px-2 text-xs text-ink-2 hover:text-ink lg:h-8 lg:border-0 lg:px-1">
57− <section key={m.month} id={`m-${m.month}`} className="scroll-mt-20 pb-8"> 131+ <span>{fmtMonth(m.month)}</span>
58− <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0"> 132+ <span className="text-ink-3">{fmtInt(m.count ?? m.events.length)}</span>
59− {fmtMonth(m.month)} <span className="tnum text-ink-3">{m.events.length}</span> 133+ </a>
60− </h2> 134+ ))}
61− <ul className="border-t border-rule"> 135+ </nav>
62− {m.events.map((e) => ( 136+ <div className="min-w-0">
63− <ChangeRow key={e.id} e={e} showDate live={false} /> 137+ {months.map((m) => (
64− ))} 138+ <section key={m.month} id={`m-${m.month}`} className="scroll-mt-20 pb-8">
65− </ul> 139+ <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 flex items-baseline justify-between bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0">
66− </section> 140+ <span>
67− ))} 141+ {fmtMonth(m.month)} <span className="tnum text-ink-3">{fmtInt(m.count ?? m.events.length)}</span>
68− <p className="text-xs text-ink-3">Showing up to 400 events. Narrow by year, category or entity{current.entity ? '' : `, or use ${withParams('/changes', {}, {})} for the cursor-paged feed`}.</p> 142+ </span>
143+ {(m.count ?? m.events.length) > m.events.length && <span className="tnum normal-case tracking-normal text-ink-3">{fmtInt(m.events.length)} shown</span>}
144+ </h2>
145+ <ul className="border-t border-rule">
146+ {m.events.map((e) => (
147+ <ChangeRow key={e.id} e={e} showDate live={false} />
148+ ))}
149+ </ul>
150+ </section>
151+ ))}
152+ <Note>
153+ Showing up to {fmtInt(LIMIT)} events{total > shown ? ` of ${fmtInt(total)} matching` : ''}. Narrow by year or category, or use <Link href={routes.changes()} className="link">the changes feed</Link> for cursor-paged history. Times are UTC.
154+ </Note>
155+ </div>
69 </div> 156 </div>
70− </div> 157+ </>
71 )} 158 )}
72 </div> 159 </div>
73 </Container> 160 </Container>
modified apps/web/src/app/tools/page.tsx +63 −3
@@ -1,10 +1,70 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2−import { GenericListing } from '@/components/listing/generic-listing'; 2+import Link from 'next/link';
3+import { Dash, str, TypedListing } from '@/components/listing/typed-listing';
4+import { EntityBadge } from '@/components/ui/badges';
5+import { EntityLink, QualityMark } from '@/components/ui/entity';
6+import { api } from '@/lib/api';
7+import { fmtAgo, fmtInt, num } from '@/lib/format';
8+import { routes } from '@/lib/site';
3 9
4−export const metadata: Metadata = { title: 'AI tools, agents and products', description: 'Developer tools, agents, MCP servers and products in the AI ecosystem, with sources and history.', alternates: { canonical: '/tools' } }; 10+export const metadata: Metadata = { title: 'AI tools, agents and MCP servers', description: 'Developer tools, agents, products and MCP servers in the atlas with language, license, stars and publisher from their official sources.', alternates: { canonical: '/tools' } };
5 export const revalidate = 300; 11 export const revalidate = 300;
6 12
7 export default async function ToolsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { 13 export default async function ToolsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
8 const sp = await searchParams; 14 const sp = await searchParams;
9− return <GenericListing type="tool" basePath="/tools" eyebrow="Tools" title="Tools, agents & products" lede="Developer tools, agents and MCP servers — as described by their own documentation." searchParams={sp} />; 15+ return (
16+ <TypedListing
17+ title="Tools"
18+ eyebrow="Tools"
19+ lede="Agents, coding tools, MCP servers and products built on the models above. Metadata comes from repositories, package registries and official pages."
20+ basePath="/tools"
21+ searchParams={sp}
22+ fetch={(q) => api.explore('tool', q)}
23+ sorts={[
24+ { value: 'updated', label: 'Recently updated' },
25+ { value: 'stars', label: 'Stars' },
26+ { value: 'name', label: 'Name' },
27+ { value: 'quality', label: 'Data quality' },
28+ ]}
29+ filters={sp.org ? [{ kind: 'hidden', name: 'org', value: sp.org }] : []}
30+ emptyTitle={sp.q ? 'No tools match' : 'No tools recorded yet'}
31+ emptyHint={sp.q ? 'Try another name.' : <>Connectors are populating this type. Related software lives under <Link href={routes.frameworks()} className="link">Frameworks</Link> and <Link href={routes.exploreType('repository')} className="link">Repositories</Link> in the meantime.</>}
32+ columns={[
33+ {
34+ key: 'name',
35+ label: 'Tool',
36+ primary: true,
37+ render: (e) => (
38+ <>
39+ <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
40+ <EntityLink e={e} />
41+ <EntityBadge type={e.entity_type} small />
42+ </span>
43+ {e.description && <span className="block max-w-md truncate text-xs text-ink-3">{e.description}</span>}
44+ </>
45+ ),
46+ },
47+ { key: 'kind', label: 'Category', className: 'text-ink-2', render: (e) => str(e.attributes?.category) ?? str(e.attributes?.kind) ?? <Dash /> },
48+ { key: 'language', label: 'Language', className: 'text-ink-2', render: (e) => str(e.attributes?.language) ?? <Dash /> },
49+ { key: 'stars', label: 'Stars', num: true, className: 'tnum', render: (e) => (num(e.attributes?.['metric.stars']) === null ? <Dash /> : fmtInt(e.attributes['metric.stars'])) },
50+ { key: 'license', label: 'License', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> },
51+ {
52+ key: 'org',
53+ label: 'Organization',
54+ className: 'text-ink-2',
55+ render: (e) =>
56+ e.organization ? (
57+ <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">
58+ {e.organization.name}
59+ </Link>
60+ ) : (
61+ <Dash />
62+ ),
63+ },
64+ { key: 'updated', label: 'Updated', className: 'text-ink-2 whitespace-nowrap', render: (e) => <span title={e.updated_at}>{fmtAgo(e.updated_at)}</span> },
65+ { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> },
66+ ]}
67+ note="Tools cover several entity types (tool, agent, MCP server, product) — the badge on each row says which."
68+ />
69+ );
10 } 70 }
added apps/web/src/components/admin/login-form.tsx +26 −0
@@ -0,0 +1,26 @@
1+'use client';
2+import { useActionState } from 'react';
3+import { loginAction } from '@/lib/admin/actions';
4+
5+/** Login form: posts the token to a server action; the token only ever travels in this request body and the httpOnly cookie. */
6+export function LoginForm({ hint }: { hint?: string | null }) {
7+ const [state, action, pending] = useActionState(loginAction, { error: null });
8+ return (
9+ <form action={action} className="mt-6 max-w-sm space-y-3">
10+ <label className="block">
11+ <span className="eyebrow block pb-1">Admin token</span>
12+ <input type="password" name="token" required autoComplete="off" autoFocus spellCheck={false} className="h-11 w-full border border-rule-strong bg-surface px-3 text-[16px] text-ink focus:border-accent focus:outline-none" />
13+ </label>
14+ <button type="submit" disabled={pending} className="inline-flex h-11 w-full items-center justify-center bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90 disabled:opacity-60">
15+ {pending ? 'Checking…' : 'Sign in'}
16+ </button>
17+ {state?.error && (
18+ <p role="alert" className="text-sm text-danger">
19+ {state.error}
20+ </p>
21+ )}
22+ {!state?.error && hint && <p className="text-xs text-ink-3">{hint}</p>}
23+ <p className="text-xs text-ink-3">The token is validated against the API and kept in an httpOnly cookie for 12 hours. It is never sent to the browser.</p>
24+ </form>
25+ );
26+}
added apps/web/src/components/admin/nav-select.tsx +21 −0
@@ -0,0 +1,21 @@
1+'use client';
2+import { usePathname, useRouter } from 'next/navigation';
3+
4+/** Mobile section switcher for the admin shell (< lg): a native select that navigates. */
5+export function AdminNavSelect({ items }: { items: { href: string; label: string }[] }) {
6+ const router = useRouter();
7+ const pathname = usePathname();
8+ const current = items.find((i) => pathname === i.href || pathname.startsWith(i.href + '/'))?.href ?? items[0]?.href ?? '';
9+ return (
10+ <label className="block lg:hidden">
11+ <span className="sr-only">Admin section</span>
12+ <select value={current} onChange={(e) => router.push(e.target.value)} className="h-11 w-full border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none">
13+ {items.map((i) => (
14+ <option key={i.href} value={i.href}>
15+ {i.label}
16+ </option>
17+ ))}
18+ </select>
19+ </label>
20+ );
21+}
added apps/web/src/components/admin/shell.tsx +59 −0
@@ -0,0 +1,59 @@
1+import Link from 'next/link';
2+import type { ReactNode } from 'react';
3+import { logoutAction } from '@/lib/admin/actions';
4+import { AdminNavSelect } from './nav-select';
5+
6+export const ADMIN_NAV = [
7+ { href: '/admin/overview', label: 'Overview' },
8+ { href: '/admin/connectors', label: 'Connectors' },
9+ { href: '/admin/runs', label: 'Runs' },
10+ { href: '/admin/errors', label: 'Errors' },
11+ { href: '/admin/documents', label: 'Documents' },
12+ { href: '/admin/jobs', label: 'Jobs' },
13+ { href: '/admin/llm-jobs', label: 'LLM jobs' },
14+ { href: '/admin/review', label: 'Review queue' },
15+ { href: '/admin/entities/duplicates', label: 'Duplicates' },
16+ { href: '/admin/infrastructure', label: 'Infrastructure' },
17+ { href: '/admin/cache', label: 'Cache' },
18+];
19+
20+/** Admin shell: header line + left nav (≥ lg) / select (mobile). Server component; the nav highlight is done by the client select and CSS-less links. */
21+export function AdminShell({ children, where }: { children: ReactNode; where?: string | null }) {
22+ return (
23+ <div className="py-5 md:py-6">
24+ <div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-rule pb-3">
25+ <p className="mono text-xs text-ink-3">
26+ <span className="text-ink">AI Atlas admin</span>
27+ {where && <span> · {where}</span>}
28+ </p>
29+ <div className="flex items-center gap-3 text-xs">
30+ <Link href="/" className="text-ink-3 hover:text-ink">
31+ ← public site
32+ </Link>
33+ <form action={logoutAction}>
34+ <button type="submit" className="h-8 border border-rule px-2.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">
35+ Sign out
36+ </button>
37+ </form>
38+ </div>
39+ </div>
40+ <div className="grid gap-6 lg:grid-cols-[11rem_minmax(0,1fr)]">
41+ <aside className="min-w-0">
42+ <AdminNavSelect items={ADMIN_NAV} />
43+ <nav aria-label="Admin sections" className="hidden lg:block lg:sticky lg:top-[calc(var(--header-h)+1rem)]">
44+ <ul className="space-y-px">
45+ {ADMIN_NAV.map((n) => (
46+ <li key={n.href}>
47+ <Link href={n.href} className="flex min-h-8 items-center px-1 text-sm text-ink-2 hover:bg-surface-2 hover:text-ink">
48+ {n.label}
49+ </Link>
50+ </li>
51+ ))}
52+ </ul>
53+ </nav>
54+ </aside>
55+ <div className="min-w-0">{children}</div>
56+ </div>
57+ </div>
58+ );
59+}
added apps/web/src/components/admin/ui.tsx +161 −0
@@ -0,0 +1,161 @@
1+import Link from 'next/link';
2+import type { ReactNode } from 'react';
3+import { cn } from '@/lib/cn';
4+import { DASH } from '@/lib/format';
5+
6+/* Dense admin primitives on the shared tokens. No marketing, no cards. */
7+
8+const chip = 'inline-flex items-center whitespace-nowrap rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium leading-4 tracking-wide';
9+
10+const HEALTH: Record<string, string> = {
11+ ok: 'text-positive bg-positive-soft',
12+ success: 'text-positive bg-positive-soft',
13+ done: 'text-positive bg-positive-soft',
14+ approved: 'text-positive bg-positive-soft',
15+ extracted: 'text-positive bg-positive-soft',
16+ active: 'text-positive bg-positive-soft',
17+ degraded: 'text-warning bg-warning-soft',
18+ partial: 'text-warning bg-warning-soft',
19+ skipped: 'text-warning bg-warning-soft',
20+ queued: 'text-accent bg-accent-soft',
21+ running: 'text-accent bg-accent-soft',
22+ pending: 'text-accent bg-accent-soft',
23+ failing: 'text-danger bg-danger-soft',
24+ failed: 'text-danger bg-danger-soft',
25+ error: 'text-danger bg-danger-soft',
26+ dead: 'text-danger bg-danger-soft',
27+ rejected: 'text-danger bg-danger-soft',
28+ schema_error: 'text-danger bg-danger-soft',
29+ blocked: 'text-danger bg-danger-soft',
30+ disabled: 'text-ink-3 bg-surface-2',
31+ unknown: 'text-ink-3 bg-surface-2',
32+};
33+
34+/** Health / status chip: ok → positive · degraded → warning · failing → danger · disabled/unknown → muted. */
35+export function StatusChip({ value, className }: { value: string | null | undefined; className?: string }) {
36+ if (!value) return <span className="text-ink-3">{DASH}</span>;
37+ return <span className={cn(chip, 'mono', HEALTH[value] ?? 'text-ink-2 bg-surface-2', className)}>{value}</span>;
38+}
39+
40+export function KindChip({ value, className }: { value: string; className?: string }) {
41+ return <span className={cn(chip, 'mono bg-surface-2 text-ink-2', className)}>{value}</span>;
42+}
43+
44+export function Bool({ v }: { v: boolean | null | undefined }) {
45+ if (v === null || v === undefined) return <span className="text-ink-3">{DASH}</span>;
46+ return <span className={v ? 'text-positive' : 'text-ink-3'}>{v ? 'yes' : 'no'}</span>;
47+}
48+
49+/** Inline notice from `?notice=&level=` after a server action. */
50+export function Notice({ notice, level }: { notice?: string; level?: string }) {
51+ if (!notice) return null;
52+ const err = level === 'error';
53+ return (
54+ <p role="status" className={cn('mb-4 border-l-2 px-3 py-2 text-sm', err ? 'border-danger bg-danger-soft text-danger' : 'border-positive bg-positive-soft text-ink')}>
55+ {notice}
56+ </p>
57+ );
58+}
59+
60+/** Pretty JSON in a scrollable block; never dumps undefined/null as text. */
61+export function JsonPre({ value, className, maxHeight = '24rem' }: { value: unknown; className?: string; maxHeight?: string }) {
62+ if (value === null || value === undefined) return <p className="text-xs text-ink-3">none</p>;
63+ const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
64+ return (
65+ <pre className={cn('scrollbar-thin overflow-auto border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink', className)} style={{ maxHeight }}>
66+ <code>{text}</code>
67+ </pre>
68+ );
69+}
70+
71+/** Small action button used inside server-action forms. */
72+export function ActionButton({ children, tone = 'neutral', className, disabled, title }: { children: ReactNode; tone?: 'neutral' | 'accent' | 'danger' | 'positive'; className?: string; disabled?: boolean; title?: string }) {
73+ return (
74+ <button
75+ type="submit"
76+ disabled={disabled}
77+ title={title}
78+ className={cn(
79+ 'inline-flex h-8 items-center justify-center whitespace-nowrap border px-2.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40',
80+ tone === 'neutral' && 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink',
81+ tone === 'accent' && 'border-accent text-accent hover:bg-accent-soft',
82+ tone === 'positive' && 'border-positive text-positive hover:bg-positive-soft',
83+ tone === 'danger' && 'border-danger text-danger hover:bg-danger-soft',
84+ className,
85+ )}
86+ >
87+ {children}
88+ </button>
89+ );
90+}
91+
92+/** Title line for an admin section. */
93+export function AdminTitle({ title, count, children, lede }: { title: string; count?: ReactNode; children?: ReactNode; lede?: ReactNode }) {
94+ return (
95+ <div className="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
96+ <div className="min-w-0">
97+ <h1 className="text-xl font-semibold tracking-tight">
98+ {title} {count !== undefined && <span className="tnum text-base font-normal text-ink-3">{count}</span>}
99+ </h1>
100+ {lede && <p className="mt-1 text-xs text-ink-3">{lede}</p>}
101+ </div>
102+ {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
103+ </div>
104+ );
105+}
106+
107+/** Compact GET filter form (selects/text) for admin listings. */
108+export function AdminFilters({ action, fields, className }: { action: string; fields: ({ kind: 'select'; name: string; label: string; value?: string; options: { value: string; label: string }[]; any?: string } | { kind: 'text'; name: string; label: string; value?: string; placeholder?: string } | { kind: 'hidden'; name: string; value: string })[]; className?: string }) {
109+ const cls = 'h-9 w-full border border-rule bg-surface px-2 text-xs text-ink focus:border-accent focus:outline-none';
110+ return (
111+ <form action={action} method="get" className={cn('grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6', className)}>
112+ {fields.map((f) =>
113+ f.kind === 'hidden' ? (
114+ <input key={f.name} type="hidden" name={f.name} value={f.value} />
115+ ) : (
116+ <label key={f.name} className="block min-w-0">
117+ <span className="eyebrow block pb-1">{f.label}</span>
118+ {f.kind === 'select' ? (
119+ <select name={f.name} defaultValue={f.value ?? ''} className={cls}>
120+ <option value="">{f.any ?? 'Any'}</option>
121+ {f.options.map((o) => (
122+ <option key={o.value} value={o.value}>
123+ {o.label}
124+ </option>
125+ ))}
126+ </select>
127+ ) : (
128+ <input name={f.name} defaultValue={f.value ?? ''} placeholder={f.placeholder} className={cls} />
129+ )}
130+ </label>
131+ ),
132+ )}
133+ <div className="flex items-end gap-2">
134+ <button type="submit" className="h-9 flex-1 bg-ink px-3 text-xs font-medium text-canvas hover:opacity-90">
135+ Apply
136+ </button>
137+ <Link href={action} className="inline-flex h-9 items-center border border-rule px-3 text-xs text-ink-2 hover:text-ink">
138+ Reset
139+ </Link>
140+ </div>
141+ </form>
142+ );
143+}
144+
145+export function Mono({ children, className, title }: { children: ReactNode; className?: string; title?: string }) {
146+ return (
147+ <span className={cn('mono text-[11.5px] text-ink-2', className)} title={title}>
148+ {children}
149+ </span>
150+ );
151+}
152+
153+/** Truncate long text with the full text as title. */
154+export function Trunc({ text, max = 80, className }: { text: string | null | undefined; max?: number; className?: string }) {
155+ if (!text) return <span className="text-ink-3">{DASH}</span>;
156+ return (
157+ <span className={className} title={text.length > max ? text : undefined}>
158+ {text.length > max ? `${text.slice(0, max)}…` : text}
159+ </span>
160+ );
161+}
added apps/web/src/components/benchmarks/leaderboard.tsx +191 −0
@@ -0,0 +1,191 @@
1+import Link from 'next/link';
2+import { Legend, LineChart } from '@/components/charts/charts';
3+import { CompareButton } from '@/components/compare/compare-button';
4+import { ConfidenceBadge } from '@/components/ui/badges';
5+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
6+import { EntityLink } from '@/components/ui/entity';
7+import { Pagination } from '@/components/ui/pagination';
8+import { SourceCell } from '@/components/ui/provenance';
9+import { Note } from '@/components/ui/section';
10+import { EmptyState } from '@/components/ui/unavailable';
11+import { cn } from '@/lib/cn';
12+import { fmtDate, fmtInt, fmtScore } from '@/lib/format';
13+import { routes } from '@/lib/site';
14+import type { BenchmarkResult } from '@/lib/types';
15+
16+/** Per-model identifiers (one row each) are useless as filters; everything else becomes a chip candidate. */
17+const PER_ROW_KEYS = new Set(['aa_slug', 'model_tag', 'model_id', 'run_id', 'submission_id', 'date', 'submitted_at', 'url']);
18+
19+/** Distinct config values seen on the page (value → count), skipping per-row identifiers and singletons. */
20+export function configChips(results: BenchmarkResult[], max = 10): { value: string; key: string; count: number }[] {
21+ const counts = new Map<string, { key: string; count: number }>();
22+ for (const r of results) {
23+ for (const [k, v] of Object.entries(r.config ?? {})) {
24+ if (PER_ROW_KEYS.has(k) || v === null || v === undefined || v === '') continue;
25+ const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
26+ if (s.length > 40) continue;
27+ const cur = counts.get(s);
28+ if (cur) cur.count += 1;
29+ else counts.set(s, { key: k, count: 1 });
30+ }
31+ }
32+ return [...counts.entries()]
33+ .map(([value, x]) => ({ value, key: x.key, count: x.count }))
34+ .filter((c) => c.count > 1 && c.count < results.length)
35+ .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value))
36+ .slice(0, max);
37+}
38+
39+export function configSummary(c: Record<string, unknown>): string {
40+ return Object.entries(c ?? {})
41+ .filter(([, v]) => v !== null && v !== undefined && v !== '')
42+ .slice(0, 4)
43+ .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
44+ .join(' · ');
45+}
46+
47+export function ConfigChips({ slug, chips, active, model }: { slug: string; chips: ReturnType<typeof configChips>; active?: string; model?: string }) {
48+ if (!chips.length && !active) return null;
49+ const cls = (on: boolean) => cn('inline-flex h-8 items-center gap-1.5 border px-2.5 text-xs whitespace-nowrap', on ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink');
50+ return (
51+ <nav aria-label="Configuration filter" className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0">
52+ <Link href={routes.benchmark(slug, { model })} className={cls(!active)} aria-current={!active ? 'true' : undefined}>
53+ All configs
54+ </Link>
55+ {active && !chips.some((c) => c.value === active) && (
56+ <Link href={routes.benchmark(slug, { model })} className={cls(true)} aria-current="true" title="Remove this filter">
57+ <span className="mono">{active}</span> ×
58+ </Link>
59+ )}
60+ {chips.map((c) => (
61+ <Link key={c.value} href={routes.benchmark(slug, { config: active === c.value ? undefined : c.value, model })} className={cls(active === c.value)} aria-current={active === c.value ? 'true' : undefined} title={`config.${c.key} contains “${c.value}” (${c.count} rows on this page)`}>
62+ <span className="text-ink-3 opacity-80">{c.key}</span> <span className="mono">{c.value}</span>
63+ </Link>
64+ ))}
65+ </nav>
66+ );
67+}
68+
69+/**
70+ * Leaderboard table: rank · model (+org) · score with a relative bar · config · evaluated · source · compare/history.
71+ * Bars are relative to the best score of the page (per direction) — a reading aid, not a normalisation.
72+ */
73+export function Leaderboard({ slug, results, total, limit, offset, config, model, unit, makeHref }: { slug: string; results: BenchmarkResult[]; total: number; limit: number; offset: number; config?: string; model?: string; unit?: string | null; makeHref: (offset: number) => string }) {
74+ if (!results.length) return <EmptyState title={config ? 'No result matches this configuration filter' : 'No benchmark results recorded'}>{config ? <Link href={routes.benchmark(slug, { model })} className="link">Clear the filter</Link> : 'Results appear when a tier 1–3 source publishes them; we never copy scores without a source.'}</EmptyState>;
75+ const hib = results[0]?.higher_is_better !== false;
76+ const scores = results.map((r) => r.score).filter((s) => Number.isFinite(s));
77+ const max = Math.max(...scores);
78+ const min = Math.min(...scores);
79+ const width = (s: number) => {
80+ if (!Number.isFinite(s) || max <= 0) return 0;
81+ const v = hib ? s / max : min > 0 ? min / s : 0;
82+ return Math.max(2, Math.min(100, v * 100));
83+ };
84+ const u = unit ?? results[0]?.unit ?? null;
85+ return (
86+ <>
87+ <DataTable caption="Leaderboard">
88+ <thead>
89+ <tr>
90+ <Th className="w-10">#</Th>
91+ <Th>Model</Th>
92+ <Th num>Score</Th>
93+ <Th>Config</Th>
94+ <Th>Evaluated</Th>
95+ <Th>Source</Th>
96+ <Th className="w-40"><span className="sr-only">Actions</span></Th>
97+ </tr>
98+ </thead>
99+ <tbody>
100+ {results.length === 0 && <EmptyRow cols={7} />}
101+ {results.map((r, i) => {
102+ const rank = offset + i + 1;
103+ const isModel = model && r.model.slug === model;
104+ return (
105+ <tr key={r.id} className={isModel ? 'bg-accent-soft/40' : undefined}>
106+ <Td className="tnum text-ink-3" hideStack>{fmtInt(rank)}</Td>
107+ <Td primary>
108+ <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
109+ <span className="tnum text-xs text-ink-3 md:hidden">#{fmtInt(rank)}</span>
110+ <EntityLink e={r.model} />
111+ </span>
112+ {r.model.organization && <span className="block text-xs text-ink-3">{r.model.organization.name}</span>}
113+ </Td>
114+ <Td num label="Score" className="tnum font-medium">
115+ <span className="inline-flex flex-col items-end gap-1">
116+ <span>
117+ {fmtScore(r.score)}
118+ {u === '%' ? '%' : u ? <span className="text-ink-3"> {u}</span> : null}
119+ </span>
120+ <span className="block h-1 w-24 overflow-hidden rounded-sm bg-surface-2" aria-hidden>
121+ <span className="block h-full" style={{ width: `${width(r.score)}%`, background: 'var(--type-benchmark)' }} />
122+ </span>
123+ </span>
124+ </Td>
125+ <Td label="Config" className="mono max-w-[18rem] truncate text-xs text-ink-3" title={JSON.stringify(r.config)}>{configSummary(r.config) || '—'}</Td>
126+ <Td label="Evaluated" className="tnum text-ink-2 whitespace-nowrap" title={r.evaluated_at ? undefined : `Observed ${fmtDate(r.observed_at)}; the source gave no evaluation date`}>
127+ {r.evaluated_at ? fmtDate(r.evaluated_at) : <span className="text-ink-3">— <span className="text-[11px]">(obs. {fmtDate(r.observed_at)})</span></span>}
128+ </Td>
129+ <Td label="Source">
130+ <SourceCell url={r.source_url} tier={r.tier} /> <ConfidenceBadge confidence={r.confidence !== 'high' && r.confidence !== 'medium' ? r.confidence : null} />
131+ </Td>
132+ <Td className="text-right">
133+ <span className="inline-flex flex-wrap items-center justify-end gap-1.5">
134+ <Link href={routes.benchmark(slug, { config, model: isModel ? undefined : r.model.slug })} className={cn('inline-flex h-7 items-center border px-1.5 text-xs whitespace-nowrap', isModel ? 'border-accent bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')} aria-pressed={!!isModel}>
135+ History
136+ </Link>
137+ <CompareButton e={r.model} size="sm" />
138+ </span>
139+ </Td>
140+ </tr>
141+ );
142+ })}
143+ </tbody>
144+ </DataTable>
145+ <Pagination total={total} limit={limit} offset={offset} makeHref={makeHref} className="mt-4" />
146+ <Note className="mt-3">
147+ Scores are reported as published, with their evaluation configuration (harness, prompting, judge). The bar is relative to the best score on this page{hib ? '' : ' (lower is better)'}. Results with different configs are not directly comparable — see <Link href="/methodology#benchmarks" className="link">methodology</Link>.
148+ </Note>
149+ </>
150+ );
151+}
152+
153+/** Score history for one model on this benchmark (all rows incl. superseded), as a time series. */
154+export function HistoryChart({ items, model, unit }: { items: BenchmarkResult[]; model: string; unit?: string | null }) {
155+ const name = items[0]?.model.name ?? model;
156+ const pts = items
157+ .map((r) => ({ x: new Date(r.evaluated_at ?? r.observed_at), y: r.score, r }))
158+ .filter((p) => !Number.isNaN(p.x.getTime()) && Number.isFinite(p.y))
159+ .sort((a, b) => a.x.getTime() - b.x.getTime());
160+ const u = unit ?? items[0]?.unit ?? null;
161+ const fmt = (v: number) => `${fmtScore(v)}${u === '%' ? '%' : ''}`;
162+ const days = new Set(pts.map((p) => p.x.toISOString().slice(0, 10)));
163+ return (
164+ <div>
165+ <p className="eyebrow mb-2">
166+ Score history · {name} <span className="tnum text-ink-3">{fmtInt(items.length)} {items.length === 1 ? 'row' : 'rows'}</span>
167+ </p>
168+ {pts.length < 2 || days.size < 2 ? (
169+ <Note>
170+ Not enough history to chart — {pts.length === 0 ? 'no result recorded for this model' : pts.length === 1 ? `a single observation (${fmt(pts[0]?.y ?? 0)} on ${fmtDate(pts[0]?.r.evaluated_at ?? pts[0]?.r.observed_at)})` : `${fmtInt(pts.length)} observations, all dated ${fmtDate(pts[0]?.r.evaluated_at ?? pts[0]?.r.observed_at)}`}. Rows under different configurations count separately; the list below shows each one.
171+ </Note>
172+ ) : (
173+ <>
174+ <LineChart series={[{ name, color: 'var(--type-benchmark)', points: pts.map((p) => ({ x: p.x, y: p.y })) }]} height={200} yFormat={fmt} showDots yLabel={`Score history for ${name}`} />
175+ <Legend series={[{ name, color: 'var(--type-benchmark)' }]} className="mt-2" />
176+ </>
177+ )}
178+ {pts.length > 0 && (
179+ <ul className="mt-3 divide-y divide-rule border-y border-rule text-sm">
180+ {[...pts].reverse().slice(0, 12).map((p) => (
181+ <li key={p.r.id} className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-baseline gap-x-3 py-1.5">
182+ <span className="tnum font-medium">{fmt(p.y)}</span>
183+ <span className="mono truncate text-xs text-ink-3" title={JSON.stringify(p.r.config)}>{configSummary(p.r.config) || '—'}</span>
184+ <span className="tnum text-xs text-ink-2">{fmtDate(p.r.evaluated_at ?? p.r.observed_at)}</span>
185+ </li>
186+ ))}
187+ </ul>
188+ )}
189+ </div>
190+ );
191+}
added apps/web/src/components/brand/og.tsx +79 −0
@@ -0,0 +1,79 @@
1+/** Shared chrome for per-entity Open Graph images (ImageResponse cannot read CSS variables → token hex values inlined). */
2+export const INK = '#e9ebf0';
3+export const INK2 = '#a3a9b8';
4+export const INK3 = '#6f7688';
5+export const ACCENT = '#6d95ff';
6+
7+function Mark({ px = 56 }: { px?: number }) {
8+ return (
9+ <svg width={px} height={px} viewBox="0 0 32 32" fill="none">
10+ <circle cx="16" cy="16" r="13" stroke={INK} strokeWidth="1.6" />
11+ <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13M3 16h26" stroke={INK} strokeWidth="1.2" opacity="0.7" />
12+ <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke={INK} strokeWidth="1.2" opacity="0.55" />
13+ <circle cx="16" cy="16" r="2.4" fill={ACCENT} />
14+ <circle cx="9.4" cy="9.5" r="1.7" fill={INK} />
15+ <circle cx="22.6" cy="9.5" r="1.7" fill={INK} />
16+ <circle cx="10.8" cy="24.4" r="1.7" fill={INK} />
17+ <circle cx="23.4" cy="21.6" r="1.7" fill={INK} />
18+ </svg>
19+ );
20+}
21+
22+export function Frame({ children, footer }: { children: React.ReactNode; footer: string }) {
23+ return (
24+ <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '56px 72px', background: 'linear-gradient(160deg, #12151b 0%, #0b0d11 55%, #0b0d11 100%)', color: INK, fontFamily: 'sans-serif', position: 'relative' }}>
25+ <div style={{ position: 'absolute', right: -140, top: -160, width: 640, height: 640, borderRadius: 999, background: 'radial-gradient(circle at 40% 40%, rgba(109,149,255,0.18), rgba(11,13,17,0) 70%)', display: 'flex' }} />
26+ <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
27+ <Mark />
28+ <div style={{ display: 'flex', fontSize: 32, fontWeight: 600, letterSpacing: -1 }}>
29+ <span>AI</span>
30+ <span style={{ color: INK2, marginLeft: 9, fontWeight: 500 }}>Atlas</span>
31+ </div>
32+ </div>
33+ {children}
34+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', color: INK3, fontSize: 21 }}>
35+ <span>{footer}</span>
36+ <span>source-attributed · with history</span>
37+ </div>
38+ </div>
39+ );
40+}
41+
42+export function Eyebrow({ children, color = ACCENT }: { children: string; color?: string }) {
43+ return <div style={{ display: 'flex', fontSize: 18, letterSpacing: 3, textTransform: 'uppercase', color, fontWeight: 600 }}>{children}</div>;
44+}
45+
46+export function Title({ children }: { children: string }) {
47+ const long = children.length > 26;
48+ return (
49+ <div style={{ display: 'flex', fontSize: long ? 58 : 74, fontWeight: 600, lineHeight: 1.04, letterSpacing: -2.2, maxWidth: 1040, overflow: 'hidden', maxHeight: long ? 128 : 160 }}>
50+ <span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis' }}>{children}</span>
51+ </div>
52+ );
53+}
54+
55+export function Facts({ items }: { items: [string, string][] }) {
56+ if (!items.length) return null;
57+ return (
58+ <div style={{ display: 'flex', gap: 52, marginTop: 10 }}>
59+ {items.map(([label, value]) => (
60+ <div key={label} style={{ display: 'flex', flexDirection: 'column' }}>
61+ <span style={{ fontSize: 15, letterSpacing: 2.5, textTransform: 'uppercase', color: INK3, fontWeight: 600 }}>{label}</span>
62+ <span style={{ fontSize: 34, fontWeight: 600, color: INK, letterSpacing: -1, marginTop: 6 }}>{value}</span>
63+ </div>
64+ ))}
65+ </div>
66+ );
67+}
68+
69+export function Fallback({ label }: { label: string }) {
70+ return (
71+ <Frame footer="www.ai-atlas.co">
72+ <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
73+ <Eyebrow>{label}</Eyebrow>
74+ <Title>Explore the entire AI ecosystem.</Title>
75+ <div style={{ fontSize: 24, color: INK2, display: 'flex' }}>Models · companies · research · providers & pricing · benchmarks · hardware</div>
76+ </div>
77+ </Frame>
78+ );
79+}
modified apps/web/src/components/charts/charts.tsx +26 −8
@@ -1,5 +1,5 @@
1 import { extent, max } from 'd3-array'; 1 import { extent, max } from 'd3-array';
2−import { scaleLinear, scaleTime } from 'd3-scale'; 2+import { scaleLinear, scaleLog, scaleTime } from 'd3-scale';
3 import { area, curveMonotoneX, line } from 'd3-shape'; 3 import { area, curveMonotoneX, line } from 'd3-shape';
4 import { cn } from '@/lib/cn'; 4 import { cn } from '@/lib/cn';
5 import { fmtCompact } from '@/lib/format'; 5 import { fmtCompact } from '@/lib/format';
@@ -97,9 +97,15 @@ export function HBars({ data, className, color = 'var(--series-1)', format = fmt
97 97
98 export type Series = { name: string; color?: string; points: Point[] }; 98 export type Series = { name: string; color?: string; points: Point[] };
99 99
100−/** Multi-series line chart with time or linear x axis, light grid, end labels. */ 100+/**
101−export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain }: { series: Series[]; height?: number; className?: string; yFormat?: (v: number) => string; xTime?: boolean; yLabel?: string; showDots?: boolean; yDomain?: [number, number] }) { 101+ * Multi-series line chart with time or linear x axis, light grid, end labels.
102− const all = series.flatMap((s) => s.points).filter((p) => Number.isFinite(p.y)); 102+ * `yScale="log"` uses a log₁₀ y axis (points ≤ 0 are dropped; domain padded around the positive extent) — for prices
103+ * spanning several orders of magnitude. Default `linear` keeps the historical behaviour (domain anchored at 0).
104+ */
105+export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain, yScale = 'linear' }: { series: Series[]; height?: number; className?: string; yFormat?: (v: number) => string; xTime?: boolean; yLabel?: string; showDots?: boolean; yDomain?: [number, number]; yScale?: 'linear' | 'log' }) {
106+ const log = yScale === 'log';
107+ const keep = (p: Point) => Number.isFinite(p.y) && (!log || p.y > 0);
108+ const all = series.flatMap((s) => s.points).filter(keep);
103 if (all.length < 2) return <p className={cn('text-xs text-ink-3', className)}>Not enough history</p>; 109 if (all.length < 2) return <p className={cn('text-xs text-ink-3', className)}>Not enough history</p>;
104 const w = 720; 110 const w = 720;
105 const pad = { l: 44, r: 16, t: 12, b: 24 }; 111 const pad = { l: 44, r: 16, t: 12, b: 24 };
@@ -112,11 +118,15 @@ export function LineChart({ series, height = 220, className, yFormat = fmtCompac
112 y0 = y0 * 0.9; 118 y0 = y0 * 0.9;
113 y1 = y1 * 1.1 || 1; 119 y1 = y1 * 1.1 || 1;
114 } 120 }
115− if (!yDomain) y0 = Math.min(0, y0); 121+ if (!yDomain && !log) y0 = Math.min(0, y0);
116− const y = scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]); 122+ if (log) {
123+ y0 = Math.max(Number.EPSILON, y0 <= 0 ? Math.min(...ys.filter((v) => v > 0)) : y0) / 1.25;
124+ y1 = y1 * 1.25;
125+ }
126+ const y = log ? scaleLog().domain([y0, y1]).range([height - pad.b, pad.t]) : scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]);
117 const toX = (p: Point) => x(p.x instanceof Date ? p.x : xTime ? new Date(Number(p.x)) : Number(p.x)) as number; 127 const toX = (p: Point) => x(p.x instanceof Date ? p.x : xTime ? new Date(Number(p.x)) : Number(p.x)) as number;
118 const l = line<Point>().x(toX).y((p) => y(p.y)).curve(curveMonotoneX); 128 const l = line<Point>().x(toX).y((p) => y(p.y)).curve(curveMonotoneX);
119− const ticks = y.ticks(4); 129+ const ticks = log ? logTicks(y0, y1) : y.ticks(4);
120 const xt = (x as { ticks: (n: number) => (Date | number)[] }).ticks(5); 130 const xt = (x as { ticks: (n: number) => (Date | number)[] }).ticks(5);
121 const fmtX = (v: Date | number) => (v instanceof Date ? v.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v)); 131 const fmtX = (v: Date | number) => (v instanceof Date ? v.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v));
122 return ( 132 return (
@@ -135,7 +145,7 @@ export function LineChart({ series, height = 220, className, yFormat = fmtCompac
135 </text> 145 </text>
136 ))} 146 ))}
137 {series.map((s, i) => { 147 {series.map((s, i) => {
138− const pts = s.points.filter((p) => Number.isFinite(p.y)).sort((a, b) => (a.x instanceof Date ? a.x.getTime() : Number(a.x)) - (b.x instanceof Date ? b.x.getTime() : Number(b.x))); 148+ const pts = s.points.filter(keep).sort((a, b) => (a.x instanceof Date ? a.x.getTime() : Number(a.x)) - (b.x instanceof Date ? b.x.getTime() : Number(b.x)));
139 if (pts.length === 0) return null; 149 if (pts.length === 0) return null;
140 const color = s.color ?? `var(--series-${(i % 8) + 1})`; 150 const color = s.color ?? `var(--series-${(i % 8) + 1})`;
141 const last = pts[pts.length - 1] as Point; 151 const last = pts[pts.length - 1] as Point;
@@ -151,6 +161,14 @@ export function LineChart({ series, height = 220, className, yFormat = fmtCompac
151 ); 161 );
152 } 162 }
153 163
164+/** Powers of ten inside [lo, hi] (at least the two bounds when the range spans < 1 decade). */
165+function logTicks(lo: number, hi: number): number[] {
166+ const out: number[] = [];
167+ for (let e = Math.ceil(Math.log10(lo)); e <= Math.floor(Math.log10(hi)); e++) out.push(10 ** e);
168+ if (out.length < 2) return [lo, hi];
169+ return out;
170+}
171+
154 export function Legend({ series, className }: { series: { name: string; color?: string }[]; className?: string }) { 172 export function Legend({ series, className }: { series: { name: string; color?: string }[]; className?: string }) {
155 return ( 173 return (
156 <ul className={cn('flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2', className)}> 174 <ul className={cn('flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2', className)}>
added apps/web/src/components/compare/compare-button.tsx +39 −0
@@ -0,0 +1,39 @@
1+'use client';
2+import { Check, Plus } from 'lucide-react';
3+import { cn } from '@/lib/cn';
4+import { COMPARE_MAX, type TrayItem, trayType, useCompareTray } from './compare-store';
5+
6+/**
7+ * Small toggle that adds/removes an entity from the compare tray (localStorage-backed, mirrored into `/compare?ids=`).
8+ * `size="sm"` for table rows (still a ≥ 44 px hit area through padding on touch), default for entity headers.
9+ * Renders a neutral placeholder before hydration so server and client markup match.
10+ */
11+export function CompareButton({ e, size = 'md', className, label = 'Compare' }: { e: TrayItem | { slug: string; name: string; entity_type: string; organization?: { name: string } | null }; size?: 'sm' | 'md'; className?: string; label?: string }) {
12+ const tray = useCompareTray();
13+ const item: TrayItem = { slug: e.slug, name: e.name, entity_type: e.entity_type, organization: typeof e.organization === 'string' || e.organization === null || e.organization === undefined ? (e.organization as string | null | undefined) : e.organization.name };
14+ const on = tray.ready && tray.has(item.slug);
15+ const otherType = tray.ready && tray.type !== null && tray.type !== trayType(item.entity_type) && !on;
16+ const full = tray.ready && tray.full && !on;
17+ const title = on ? 'Remove from comparison' : otherType ? `Start a new ${item.entity_type} comparison (replaces the current tray)` : full ? `The tray holds at most ${COMPARE_MAX} items` : 'Add to comparison';
18+ return (
19+ <button
20+ type="button"
21+ onClick={() => {
22+ if (full) return;
23+ tray.toggle(item);
24+ }}
25+ aria-pressed={on}
26+ title={title}
27+ disabled={full}
28+ className={cn(
29+ 'inline-flex shrink-0 items-center gap-1 border text-xs font-medium whitespace-nowrap transition-colors disabled:cursor-not-allowed disabled:opacity-40',
30+ size === 'sm' ? 'h-7 px-1.5' : 'h-9 px-2.5 text-sm',
31+ on ? 'border-accent bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink',
32+ className,
33+ )}
34+ >
35+ {on ? <Check className={size === 'sm' ? 'size-3' : 'size-3.5'} aria-hidden /> : <Plus className={size === 'sm' ? 'size-3' : 'size-3.5'} aria-hidden />}
36+ <span>{on ? 'Comparing' : label}</span>
37+ </button>
38+ );
39+}
added apps/web/src/components/compare/compare-matrix.tsx +312 −0
@@ -0,0 +1,312 @@
1+import { Chip, TierBadge } from '@/components/ui/badges';
2+import { DataTable, Td, Th } from '@/components/ui/data-table';
3+import { EntityLink } from '@/components/ui/entity';
4+import { Note } from '@/components/ui/section';
5+import { cn } from '@/lib/cn';
6+import { fmtAgo, fmtDate, fmtScore, fmtUsdPerM, fmtValue, num } from '@/lib/format';
7+import { routes } from '@/lib/site';
8+import type { BenchmarkResult, CompareDimension, ComparePayload, Price, ProvenanceEntry } from '@/lib/types';
9+import { CompareButton } from './compare-button';
10+
11+/* ------------------------------------------------------------------------------------------------------ helpers */
12+
13+const PRICE_KEYS = new Set(['best_input_per_mtok', 'best_output_per_mtok', 'min_input_per_mtok', 'min_output_per_mtok']);
14+/** Numeric dimensions where lower is better. */
15+function lowerIsBetter(key: string): boolean {
16+ return PRICE_KEYS.has(key) || /tdp|price|latency/.test(key);
17+}
18+
19+function host(url: string | null | undefined): string | null {
20+ if (!url) return null;
21+ try {
22+ return new URL(url).hostname.replace(/^www\./, '');
23+ } catch {
24+ return null;
25+ }
26+}
27+
28+/** Compact per-cell provenance: T1 · host · 3 h ago (full text in title). */
29+function CellProvenance({ p }: { p: ProvenanceEntry }) {
30+ const h = p.source_name ?? host(p.url) ?? 'source';
31+ return (
32+ <span className="mt-0.5 hidden items-center gap-1 text-[11px] leading-4 text-ink-3 md:flex">
33+ <TierBadge tier={p.tier} />
34+ {p.url ? (
35+ <a href={p.url} target="_blank" rel="noopener noreferrer" className="max-w-[10rem] truncate hover:text-accent">
36+ {h}
37+ </a>
38+ ) : (
39+ <span className="max-w-[10rem] truncate">{h}</span>
40+ )}
41+ <span>· {fmtAgo(p.observed_at)}</span>
42+ </span>
43+ );
44+}
45+
46+function CellValue({ v, dim }: { v: unknown; dim: CompareDimension }) {
47+ if (v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) return <span className="text-ink-3">Unavailable</span>;
48+ switch (dim.kind) {
49+ case 'date':
50+ return <>{fmtDate(String(v))}</>;
51+ case 'bool':
52+ return <>{v ? 'Yes' : 'No'}</>;
53+ case 'list': {
54+ const arr = Array.isArray(v) ? v : [v];
55+ return (
56+ <span className="flex flex-wrap gap-1">
57+ {arr.map((x, i) => (
58+ <Chip key={i}>{typeof x === 'object' && x ? JSON.stringify(x) : String(x)}</Chip>
59+ ))}
60+ </span>
61+ );
62+ }
63+ case 'number':
64+ if (PRICE_KEYS.has(dim.key)) return <span className="text-accent-2">{fmtUsdPerM(v)}</span>;
65+ return <>{fmtValue(v, dim.key)}</>;
66+ default:
67+ return <>{typeof v === 'string' ? v : fmtValue(v, dim.key)}</>;
68+ }
69+}
70+
71+const STICKY = 'sticky left-0 z-10 bg-canvas';
72+/** First-column width: table layout ignores max-width, so set width + min-width explicitly. */
73+const FIRST = 'w-[9rem] min-w-[9rem] md:w-[12rem] md:min-w-[12rem]';
74+/** `.table-scroll td { white-space: nowrap }` out-specifies utilities — wrap the first column inline. */
75+const WRAP = { whiteSpace: 'normal' } as const;
76+
77+/** Horizontal scroll wrapper without the negative page margins (so the sticky column pins at the very left edge). */
78+function ScrollTable({ children, caption, className }: { children: React.ReactNode; caption: string; className?: string }) {
79+ return (
80+ <div className="table-scroll">
81+ <DataTable stack={false} caption={caption} className={className}>
82+ {children}
83+ </DataTable>
84+ </div>
85+ );
86+}
87+
88+function EntityHead({ e }: { e: ComparePayload['items'][number]['entity'] }) {
89+ return (
90+ <span className="flex flex-col items-start gap-1 normal-case tracking-normal">
91+ <EntityLink e={e} className="text-sm font-semibold whitespace-normal" />
92+ {e.organization && <span className="text-[11px] font-normal text-ink-3">{e.organization.name}</span>}
93+ <CompareButton e={e} size="sm" label="Add" />
94+ </span>
95+ );
96+}
97+
98+/* ------------------------------------------------------------------------------------------------------ matrix */
99+
100+/** Dimension × entity matrix: sticky first column, horizontal scroll, kind-aware formatting, per-cell provenance. */
101+export function CompareMatrix({ res }: { res: ComparePayload }) {
102+ return (
103+ <>
104+ <ScrollTable caption="Comparison matrix" className="compare-matrix">
105+ <thead>
106+ <tr>
107+ <Th className={cn(STICKY, FIRST)} style={WRAP}>Dimension</Th>
108+ {res.items.map((it) => (
109+ <Th key={it.entity.id} className="min-w-[10rem] align-top">
110+ <EntityHead e={it.entity} />
111+ </Th>
112+ ))}
113+ </tr>
114+ </thead>
115+ <tbody>
116+ {res.dimensions.map((dim) => {
117+ const nums = res.items.map((it) => num(it.values[dim.key]));
118+ const present = nums.filter((n): n is number => n !== null);
119+ const best = dim.kind === 'number' && present.length > 1 ? (lowerIsBetter(dim.key) ? Math.min(...present) : Math.max(...present)) : null;
120+ return (
121+ <tr key={dim.key}>
122+ <Td className={cn(STICKY, FIRST, 'text-ink-2')} style={WRAP}>
123+ {dim.label}
124+ {dim.unit && <span className="block text-[11px] text-ink-3">{dim.unit}</span>}
125+ </Td>
126+ {res.items.map((it, i) => {
127+ const v = it.values[dim.key];
128+ const p = it.provenance?.[dim.key];
129+ const isBest = best !== null && nums[i] === best;
130+ return (
131+ <Td key={it.entity.id} className={cn('align-top', dim.kind === 'number' && 'tnum', dim.kind === 'list' && 'whitespace-normal')} title={p ? `${p.source_name ?? host(p.url) ?? ''} · tier ${p.tier} · ${fmtDate(p.observed_at)}` : undefined}>
132+ <span className={cn('block', isBest && 'font-semibold text-ink')}>
133+ <CellValue v={v} dim={dim} />
134+ </span>
135+ {p && <CellProvenance p={p} />}
136+ </Td>
137+ );
138+ })}
139+ </tr>
140+ );
141+ })}
142+ </tbody>
143+ </ScrollTable>
144+ <Note className="mt-3">Bold marks the best number in a row (highest, or lowest for prices); it is not a verdict. Each value carries its own source and tier; hover a cell for the observation date. Missing values are shown as unavailable, never estimated.</Note>
145+ </>
146+ );
147+}
148+
149+/* ------------------------------------------------------------------------------------------------------ shared benchmarks */
150+
151+function configSummary(c: Record<string, unknown>): string {
152+ return Object.entries(c ?? {})
153+ .filter(([, v]) => v !== null && v !== undefined && v !== '')
154+ .slice(0, 4)
155+ .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
156+ .join(' · ');
157+}
158+
159+/** Benchmarks with a current result for every compared model (best result per model per benchmark). */
160+export function SharedBenchmarks({ res }: { res: ComparePayload }) {
161+ const perItem = res.items.map((it) => {
162+ const m = new Map<string, BenchmarkResult>();
163+ for (const r of it.results ?? []) {
164+ const prev = m.get(r.benchmark.slug);
165+ if (!prev || (r.higher_is_better === false ? r.score < prev.score : r.score > prev.score)) m.set(r.benchmark.slug, r);
166+ }
167+ return m;
168+ });
169+ const first = perItem[0];
170+ const shared = first ? [...first.values()].filter((r) => perItem.every((m) => m.has(r.benchmark.slug))) : [];
171+ const anyResults = res.items.some((it) => (it.results?.length ?? 0) > 0);
172+ if (shared.length === 0)
173+ return (
174+ <p className="text-sm text-ink-3">
175+ {anyResults ? 'No benchmark has a published result for every compared model.' : 'No benchmark results recorded for these models.'} Per-model results are on each model page (Benchmarks tab).
176+ </p>
177+ );
178+ shared.sort((a, b) => a.benchmark.name.localeCompare(b.benchmark.name));
179+ return (
180+ <>
181+ <ScrollTable caption="Shared benchmark results">
182+ <thead>
183+ <tr>
184+ <Th className={cn(STICKY, FIRST)} style={WRAP}>Benchmark</Th>
185+ {res.items.map((it) => (
186+ <Th key={it.entity.id} className="min-w-[8rem]">
187+ <EntityLink e={it.entity} className="text-sm font-semibold normal-case tracking-normal whitespace-normal" />
188+ </Th>
189+ ))}
190+ </tr>
191+ </thead>
192+ <tbody>
193+ {shared.map((row) => {
194+ const cells = perItem.map((m) => m.get(row.benchmark.slug) ?? null);
195+ const scores = cells.map((c) => (c ? c.score : null)).filter((s): s is number => s !== null);
196+ const best = scores.length > 1 ? (row.higher_is_better === false ? Math.min(...scores) : Math.max(...scores)) : null;
197+ return (
198+ <tr key={row.benchmark.slug}>
199+ <Td className={cn(STICKY, FIRST, 'text-ink-2')} style={WRAP}>
200+ <EntityLink e={row.benchmark} />
201+ <span className="block text-[11px] text-ink-3">
202+ {row.metric ?? '—'}
203+ {row.higher_is_better === false ? ' · lower is better' : ''}
204+ </span>
205+ </Td>
206+ {cells.map((c, i) => (
207+ <Td key={res.items[i]!.entity.id} className="tnum align-top" title={c ? configSummary(c.config) || undefined : undefined}>
208+ {c ? (
209+ <>
210+ <span className={cn('block', best !== null && c.score === best && 'font-semibold text-ink')}>
211+ {fmtScore(c.score)}
212+ {c.unit === '%' ? '%' : c.unit ? <span className="text-ink-3"> {c.unit}</span> : ''}
213+ </span>
214+ <span className="mt-0.5 hidden items-center gap-1 text-[11px] text-ink-3 md:flex">
215+ <TierBadge tier={c.tier} />
216+ <span className="max-w-[10rem] truncate">{configSummary(c.config) || (c.evaluated_at ? fmtDate(c.evaluated_at) : '')}</span>
217+ </span>
218+ </>
219+ ) : (
220+ <span className="text-ink-3">—</span>
221+ )}
222+ </Td>
223+ ))}
224+ </tr>
225+ );
226+ })}
227+ </tbody>
228+ </ScrollTable>
229+ <Note className="mt-3">Only benchmarks with a result for every compared model are shown (best current result per model). Configurations may differ — hover a score for its config; scores under different configs are not strictly comparable.</Note>
230+ </>
231+ );
232+}
233+
234+/* ------------------------------------------------------------------------------------------------------ prices */
235+
236+type Best = { input: number | null; output: number | null; observed: string; url: string | null; tier: number };
237+
238+/** Provider × entity: cheapest current input / output per provider for each compared model. */
239+export function ComparePrices({ res }: { res: ComparePayload }) {
240+ const providers = new Map<string, Price['provider']>();
241+ const perItem = res.items.map((it) => {
242+ const m = new Map<string, Best>();
243+ for (const p of it.prices ?? []) {
244+ providers.set(p.provider.slug, p.provider);
245+ const cur = m.get(p.provider.slug) ?? { input: null, output: null, observed: p.observed_at, url: p.source_url, tier: p.tier };
246+ const inp = num(p.input_per_mtok);
247+ const out = num(p.output_per_mtok);
248+ if (inp !== null && (cur.input === null || inp < cur.input)) cur.input = inp;
249+ if (out !== null && (cur.output === null || out < cur.output)) cur.output = out;
250+ if (p.observed_at > cur.observed) cur.observed = p.observed_at;
251+ m.set(p.provider.slug, cur);
252+ }
253+ return m;
254+ });
255+ if (providers.size === 0) return <p className="text-sm text-ink-3">No current prices recorded for these models.</p>;
256+ const rows = [...providers.values()].sort((a, b) => a.name.localeCompare(b.name));
257+ const bestOverall = res.items.map((_, i) => {
258+ let best: number | null = null;
259+ for (const b of perItem[i]!.values()) if (b.input !== null && (best === null || b.input < best)) best = b.input;
260+ return best;
261+ });
262+ return (
263+ <>
264+ <ScrollTable caption="Best current prices per provider (USD per 1M tokens)">
265+ <thead>
266+ <tr>
267+ <Th className={cn(STICKY, FIRST)} style={WRAP}>Provider</Th>
268+ {res.items.map((it) => (
269+ <Th key={it.entity.id} className="min-w-[9rem]">
270+ <EntityLink e={it.entity} className="text-sm font-semibold normal-case tracking-normal whitespace-normal" />
271+ <span className="block text-[10px] font-normal text-ink-3">in / out per 1M</span>
272+ </Th>
273+ ))}
274+ </tr>
275+ </thead>
276+ <tbody>
277+ {rows.map((prov) => (
278+ <tr key={prov.slug}>
279+ <Td className={cn(STICKY, FIRST)} style={WRAP}>
280+ <EntityLink e={prov} />
281+ </Td>
282+ {perItem.map((m, i) => {
283+ const b = m.get(prov.slug);
284+ if (!b) return <Td key={res.items[i]!.entity.id} className="text-ink-3">—</Td>;
285+ const cheapest = bestOverall[i] !== null && b.input === bestOverall[i];
286+ return (
287+ <Td key={res.items[i]!.entity.id} className="tnum align-top" title={`observed ${fmtDate(b.observed)}`}>
288+ <span className={cn('block text-accent-2', cheapest && 'font-semibold')}>
289+ {fmtUsdPerM(b.input)} <span className="text-ink-3">/</span> {fmtUsdPerM(b.output)}
290+ </span>
291+ <span className="mt-0.5 hidden items-center gap-1 text-[11px] text-ink-3 md:flex">
292+ <TierBadge tier={b.tier} />
293+ {b.url ? (
294+ <a href={b.url} target="_blank" rel="noopener noreferrer" className="max-w-[10rem] truncate hover:text-accent">
295+ {host(b.url)}
296+ </a>
297+ ) : null}
298+ <span>· {fmtAgo(b.observed)}</span>
299+ </span>
300+ </Td>
301+ );
302+ })}
303+ </tr>
304+ ))}
305+ </tbody>
306+ </ScrollTable>
307+ <Note className="mt-3">
308+ Cheapest current input / output price of each provider for each model, as published on the provider's pricing page (USD per 1M tokens). Bold = the model's cheapest provider. Full tables and history on each model page, or the <a href={routes.prices()} className="link">price index</a>.
309+ </Note>
310+ </>
311+ );
312+}
added apps/web/src/components/compare/compare-picker.tsx +218 −0
@@ -0,0 +1,218 @@
1+'use client';
2+import { Search, X } from 'lucide-react';
3+import Link from 'next/link';
4+import { usePathname, useRouter } from 'next/navigation';
5+import { useEffect, useRef, useState } from 'react';
6+import { EntityBadge } from '@/components/ui/badges';
7+import { clientApi } from '@/lib/client-api';
8+import { cn } from '@/lib/cn';
9+import { typeLabel } from '@/lib/site';
10+import type { Suggestion } from '@/lib/types';
11+import { COMPARE_MAX, COMPARE_MIN, compareHref, type TrayItem, trayType, useCompareTray } from './compare-store';
12+
13+/**
14+ * Compare picker + tray for /compare. Suggestions come from /search/suggest; once the tray holds an item, only
15+ * entities of the same (normalised) type are selectable — the API compares 2–6 entities of one type.
16+ * `initial` (from `?ids=` resolved server-side) seeds the tray on load; tray changes are mirrored into `?ids=`.
17+ */
18+export function ComparePicker({ initial, exampleHref }: { initial: TrayItem[]; exampleHref?: string | null }) {
19+ const tray = useCompareTray();
20+ const router = useRouter();
21+ const pathname = usePathname();
22+ const [q, setQ] = useState('');
23+ const [items, setItems] = useState<Suggestion[]>([]);
24+ const [loading, setLoading] = useState(false);
25+ const [failed, setFailed] = useState(false);
26+ const seeded = useRef(false);
27+ /** Slugs the tray must show before we start mirroring it into the URL (avoids stripping ?ids= during the seed commit). */
28+ const awaiting = useRef<string | null>(null);
29+ const inputRef = useRef<HTMLInputElement>(null);
30+
31+ // Seed the tray from the URL once hydrated (URL wins over localStorage when present).
32+ useEffect(() => {
33+ if (!tray.ready || seeded.current) return;
34+ seeded.current = true;
35+ if (initial.length) {
36+ const same = initial.every((i) => trayType(i.entity_type) === trayType(initial[0]!.entity_type));
37+ const want = initial.map((i) => i.slug).join(',');
38+ const current = tray.items.map((i) => i.slug).join(',');
39+ if (same && current !== want) {
40+ awaiting.current = want;
41+ tray.replace(initial);
42+ }
43+ }
44+ }, [tray.ready, tray.items, tray.replace, initial]);
45+
46+ // Mirror the tray into ?ids= (no scroll, replace) — only once the seeded items are in state.
47+ useEffect(() => {
48+ if (!tray.ready || !seeded.current) return;
49+ const ids = tray.items.map((i) => i.slug);
50+ if (awaiting.current !== null) {
51+ if (ids.join(',') !== awaiting.current) return;
52+ awaiting.current = null;
53+ }
54+ const url = new URL(window.location.href);
55+ const cur = url.searchParams.get('ids') ?? '';
56+ const next = ids.length >= COMPARE_MIN ? ids.join(',') : '';
57+ if (cur === next) return;
58+ if (next) url.searchParams.set('ids', next);
59+ else url.searchParams.delete('ids');
60+ router.replace(`${pathname}${url.search}`, { scroll: false });
61+ }, [tray.items, tray.ready, router, pathname]);
62+
63+ useEffect(() => {
64+ const term = q.trim();
65+ if (term.length < 1) {
66+ setItems([]);
67+ return;
68+ }
69+ const ctrl = new AbortController();
70+ const t = setTimeout(async () => {
71+ setLoading(true);
72+ try {
73+ const res = await clientApi.suggest(term, ctrl.signal);
74+ setItems(res.items ?? []);
75+ setFailed(false);
76+ } catch (e) {
77+ if ((e as Error).name !== 'AbortError') setFailed(true);
78+ } finally {
79+ setLoading(false);
80+ }
81+ }, 120);
82+ return () => {
83+ clearTimeout(t);
84+ ctrl.abort();
85+ };
86+ }, [q]);
87+
88+ const type = tray.type;
89+ const full = tray.full;
90+ const pick = (s: Suggestion) => {
91+ tray.add({ slug: s.slug, name: s.name, entity_type: s.entity_type, organization: s.organization_name });
92+ setQ('');
93+ setItems([]);
94+ inputRef.current?.focus();
95+ };
96+ const term = q.trim();
97+ const selectable = items.filter((s) => !tray.has(s.slug));
98+ const matching = type ? selectable.filter((s) => trayType(s.entity_type) === type) : selectable;
99+ const others = type ? selectable.filter((s) => trayType(s.entity_type) !== type) : [];
100+
101+ return (
102+ <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
103+ {/* ---------------------------------------------------------------------------------------------- search */}
104+ <div className="min-w-0">
105+ <label htmlFor="compare-q" className="eyebrow block pb-1">
106+ {type ? `Add another ${typeLabel(type).toLowerCase()}` : 'Add an entity'}
107+ </label>
108+ <div className={cn('flex items-center gap-2 border border-rule-strong bg-surface px-3 focus-within:border-accent', full && 'opacity-60')}>
109+ <Search className="size-4 shrink-0 text-ink-3" aria-hidden />
110+ <input
111+ id="compare-q"
112+ ref={inputRef}
113+ value={q}
114+ onChange={(e) => setQ(e.target.value)}
115+ disabled={full}
116+ placeholder={full ? `Tray full (${COMPARE_MAX}) — remove one to add another` : type ? `Search ${typeLabel(type, true).toLowerCase()}…` : 'Search models, providers, hardware, companies…'}
117+ className="h-11 min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none"
118+ autoComplete="off"
119+ spellCheck={false}
120+ aria-autocomplete="list"
121+ aria-controls="compare-suggestions"
122+ />
123+ {q && (
124+ <button type="button" onClick={() => setQ('')} className="flex size-9 items-center justify-center text-ink-3 hover:text-ink" aria-label="Clear">
125+ <X className="size-4" aria-hidden />
126+ </button>
127+ )}
128+ </div>
129+ {type && <p className="mt-1.5 text-xs text-ink-3">Comparing {typeLabel(type, true).toLowerCase()} — pick another {typeLabel(type).toLowerCase()}. Entities of another type would start a new comparison.</p>}
130+ {term && (
131+ <ul id="compare-suggestions" role="listbox" className="mt-1 border border-rule bg-surface">
132+ {failed && <li className="px-3 py-2 text-xs text-warning">Suggestions unavailable — try again in a moment.</li>}
133+ {!loading && !failed && matching.length === 0 && others.length === 0 && <li className="px-3 py-3 text-sm text-ink-3">{selectable.length === 0 && items.length > 0 ? 'Already in the tray.' : 'No match.'}</li>}
134+ {matching.map((s) => (
135+ <li key={s.id} role="option" aria-selected={false}>
136+ <button type="button" onClick={() => pick(s)} className="flex min-h-[46px] w-full items-center gap-3 px-3 py-2 text-left hover:bg-surface-2">
137+ <EntityBadge type={s.entity_type} small />
138+ <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{s.name}</span>
139+ {s.organization_name && <span className="hidden truncate text-xs text-ink-3 sm:block">{s.organization_name}</span>}
140+ </button>
141+ </li>
142+ ))}
143+ {others.length > 0 && (
144+ <li className="border-t border-rule px-3 py-1.5 text-[11px] text-ink-3">
145+ Other types (selecting one starts a new {' '}comparison)
146+ </li>
147+ )}
148+ {others.map((s) => (
149+ <li key={s.id} role="option" aria-selected={false}>
150+ <button type="button" onClick={() => pick(s)} className="flex min-h-[46px] w-full items-center gap-3 px-3 py-2 text-left text-ink-3 hover:bg-surface-2">
151+ <EntityBadge type={s.entity_type} small className="opacity-70" />
152+ <span className="min-w-0 flex-1 truncate text-[15px]">{s.name}</span>
153+ {s.organization_name && <span className="hidden truncate text-xs sm:block">{s.organization_name}</span>}
154+ </button>
155+ </li>
156+ ))}
157+ </ul>
158+ )}
159+ </div>
160+
161+ {/* ---------------------------------------------------------------------------------------------- tray */}
162+ <div className="min-w-0">
163+ <p className="eyebrow pb-1">
164+ Tray <span className="tnum text-ink-3">{tray.ready ? tray.items.length : initial.length} / {COMPARE_MAX}</span>
165+ </p>
166+ <CompareTray items={tray.ready ? tray.items : initial} onRemove={tray.remove} onClear={tray.clear} ready={tray.ready} exampleHref={exampleHref} />
167+ </div>
168+ </div>
169+ );
170+}
171+
172+export function CompareTray({ items, onRemove, onClear, ready, exampleHref }: { items: TrayItem[]; onRemove: (slug: string) => void; onClear: () => void; ready: boolean; exampleHref?: string | null }) {
173+ const can = items.length >= COMPARE_MIN;
174+ return (
175+ <div className="border-t border-rule">
176+ {items.length === 0 ? (
177+ <p className="py-3 text-sm text-ink-3">
178+ Nothing selected yet. Use the search on the left, or the “Compare” buttons on listings and entity pages.
179+ {exampleHref && (
180+ <>
181+ {' '}
182+ Example: <Link href={exampleHref} className="link">two well-documented models</Link>.
183+ </>
184+ )}
185+ </p>
186+ ) : (
187+ <ul className="divide-y divide-rule">
188+ {items.map((it) => (
189+ <li key={it.slug} className="flex min-h-11 items-center gap-2 py-1.5">
190+ <EntityBadge type={it.entity_type} small />
191+ <span className="min-w-0 flex-1 truncate text-sm text-ink">
192+ {it.name}
193+ {it.organization && <span className="ml-2 text-xs text-ink-3">{it.organization}</span>}
194+ </span>
195+ <button type="button" onClick={() => onRemove(it.slug)} disabled={!ready} className="flex size-9 shrink-0 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Remove ${it.name}`}>
196+ <X className="size-4" aria-hidden />
197+ </button>
198+ </li>
199+ ))}
200+ </ul>
201+ )}
202+ <div className="mt-3 flex flex-wrap items-center gap-2">
203+ {can ? (
204+ <Link href={compareHref(items)} className="inline-flex h-10 items-center bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">
205+ Compare {items.length} →
206+ </Link>
207+ ) : (
208+ <span className="inline-flex h-10 items-center border border-rule px-4 text-sm text-ink-3">Pick at least {COMPARE_MIN}</span>
209+ )}
210+ {items.length > 0 && (
211+ <button type="button" onClick={onClear} disabled={!ready} className="inline-flex h-10 items-center border border-rule px-3 text-sm text-ink-2 hover:text-ink">
212+ Clear
213+ </button>
214+ )}
215+ </div>
216+ </div>
217+ );
218+}
added apps/web/src/components/compare/compare-store.ts +111 −0
@@ -0,0 +1,111 @@
1+'use client';
2+import { useCallback, useEffect, useState } from 'react';
3+
4+/**
5+ * Compare tray — the list of entity slugs the visitor is collecting for `/compare?ids=`.
6+ * Source of truth: `localStorage['aia-compare']` (mirrored across tabs through the `storage` event and a same-tab
7+ * custom event). The tray is type-homogeneous: the first item fixes the entity type; adding another type replaces
8+ * the tray (the API compares 2–6 entities of one type).
9+ */
10+export const COMPARE_KEY = 'aia-compare';
11+export const COMPARE_MAX = 6;
12+export const COMPARE_MIN = 2;
13+const EVENT = 'aia-compare-change';
14+
15+export type TrayItem = { slug: string; name: string; entity_type: string; organization?: string | null };
16+
17+function read(): TrayItem[] {
18+ if (typeof window === 'undefined') return [];
19+ try {
20+ const raw = window.localStorage.getItem(COMPARE_KEY);
21+ if (!raw) return [];
22+ const arr = JSON.parse(raw) as unknown;
23+ if (!Array.isArray(arr)) return [];
24+ return arr.filter((x): x is TrayItem => !!x && typeof x === 'object' && typeof (x as TrayItem).slug === 'string' && typeof (x as TrayItem).entity_type === 'string').slice(0, COMPARE_MAX);
25+ } catch {
26+ return [];
27+ }
28+}
29+
30+function write(items: TrayItem[]) {
31+ if (typeof window === 'undefined') return;
32+ try {
33+ window.localStorage.setItem(COMPARE_KEY, JSON.stringify(items.slice(0, COMPARE_MAX)));
34+ } catch {
35+ /* storage full / disabled */
36+ }
37+ window.dispatchEvent(new CustomEvent(EVENT));
38+}
39+
40+/** Normalises the API's type aliases so companies/labs/orgs compare together (the API accepts them under `company`). */
41+export function trayType(entityType: string): string {
42+ if (['company', 'organization', 'lab', 'university'].includes(entityType)) return 'company';
43+ if (['framework', 'library', 'runtime'].includes(entityType)) return 'framework';
44+ if (entityType === 'quantization') return 'model';
45+ return entityType;
46+}
47+
48+export function readTray(): TrayItem[] {
49+ return read();
50+}
51+export function setTray(items: TrayItem[]) {
52+ write(items);
53+}
54+export function clearTray() {
55+ write([]);
56+}
57+
58+/** Add one item. Returns the new tray; a type change replaces the tray. */
59+export function addToTray(item: TrayItem): TrayItem[] {
60+ const cur = read();
61+ const t = trayType(item.entity_type);
62+ const same = cur.filter((c) => trayType(c.entity_type) === t);
63+ if (same.some((c) => c.slug === item.slug)) return cur;
64+ const next = same.length === cur.length ? [...cur, item] : [item];
65+ const capped = next.slice(0, COMPARE_MAX);
66+ write(capped);
67+ return capped;
68+}
69+export function removeFromTray(slug: string): TrayItem[] {
70+ const next = read().filter((c) => c.slug !== slug);
71+ write(next);
72+ return next;
73+}
74+export function toggleTray(item: TrayItem): TrayItem[] {
75+ return read().some((c) => c.slug === item.slug) ? removeFromTray(item.slug) : addToTray(item);
76+}
77+
78+/** React binding: returns the tray and mutators, subscribed to storage changes. `ready` is false during SSR/hydration. */
79+export function useCompareTray() {
80+ const [items, setItems] = useState<TrayItem[]>([]);
81+ const [ready, setReady] = useState(false);
82+ useEffect(() => {
83+ const sync = () => setItems(read());
84+ sync();
85+ setReady(true);
86+ window.addEventListener(EVENT, sync);
87+ window.addEventListener('storage', sync);
88+ return () => {
89+ window.removeEventListener(EVENT, sync);
90+ window.removeEventListener('storage', sync);
91+ };
92+ }, []);
93+ const add = useCallback((item: TrayItem) => setItems(addToTray(item)), []);
94+ const remove = useCallback((slug: string) => setItems(removeFromTray(slug)), []);
95+ const toggle = useCallback((item: TrayItem) => setItems(toggleTray(item)), []);
96+ const clear = useCallback(() => {
97+ clearTray();
98+ setItems([]);
99+ }, []);
100+ const replace = useCallback((next: TrayItem[]) => {
101+ write(next);
102+ setItems(next.slice(0, COMPARE_MAX));
103+ }, []);
104+ const has = useCallback((slug: string) => items.some((c) => c.slug === slug), [items]);
105+ const type = items[0] ? trayType(items[0].entity_type) : null;
106+ return { items, ready, add, remove, toggle, clear, replace, has, type, full: items.length >= COMPARE_MAX, canCompare: items.length >= COMPARE_MIN };
107+}
108+
109+export function compareHref(items: { slug: string }[]): string {
110+ return items.length ? `/compare?ids=${items.map((i) => encodeURIComponent(i.slug)).join(',')}` : '/compare';
111+}
added apps/web/src/components/compare/compare-tray-bar.tsx +37 −0
@@ -0,0 +1,37 @@
1+'use client';
2+import { X } from 'lucide-react';
3+import Link from 'next/link';
4+import { usePathname } from 'next/navigation';
5+import { typeLabel } from '@/lib/site';
6+import { COMPARE_MAX, COMPARE_MIN, compareHref, useCompareTray } from './compare-store';
7+
8+/**
9+ * Floating tray summary shown on listings while the visitor collects entities: "n selected · Compare →".
10+ * Fixed above the mobile tab bar (< md) and bottom-right on desktop. Hidden on /compare itself and when empty.
11+ */
12+export function CompareTrayBar() {
13+ const tray = useCompareTray();
14+ const pathname = usePathname();
15+ if (!tray.ready || tray.items.length === 0 || pathname === '/compare') return null;
16+ const can = tray.items.length >= COMPARE_MIN;
17+ return (
18+ <div role="status" aria-live="polite" className="fixed inset-x-2 bottom-[calc(var(--tabbar-h)+env(safe-area-inset-bottom,0px)+8px)] z-[44] md:inset-x-auto md:bottom-6 md:right-6">
19+ <div className="panel flex items-center gap-2 p-1.5 pl-3 shadow-lg">
20+ <p className="min-w-0 flex-1 text-sm text-ink-2">
21+ <span className="tnum font-medium text-ink">{tray.items.length}</span> / {COMPARE_MAX} {tray.type ? typeLabel(tray.type, true).toLowerCase() : 'selected'}
22+ <span className="hidden text-ink-3 sm:inline"> · {tray.items.map((i) => i.name).slice(0, 3).join(', ')}{tray.items.length > 3 ? ` +${tray.items.length - 3}` : ''}</span>
23+ </p>
24+ {can ? (
25+ <Link href={compareHref(tray.items)} className="inline-flex h-10 items-center bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90">
26+ Compare →
27+ </Link>
28+ ) : (
29+ <span className="inline-flex h-10 items-center px-2 text-xs text-ink-3">Pick {COMPARE_MIN - tray.items.length} more</span>
30+ )}
31+ <button type="button" onClick={tray.clear} className="flex size-10 items-center justify-center text-ink-3 hover:text-ink" aria-label="Clear comparison tray">
32+ <X className="size-4" aria-hidden />
33+ </button>
34+ </div>
35+ </div>
36+ );
37+}
added apps/web/src/components/entity/asof-picker.tsx +53 −0
@@ -0,0 +1,53 @@
1+'use client';
2+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
3+import { useState } from 'react';
4+
5+/**
6+ * "As of" date picker for the History tab. Writes `?tab=history&asof=YYYY-MM-DD` (keeping other params) so the
7+ * server renders the entity's attributes as known at that date (`GET /entities/{slug}/asof`).
8+ */
9+export function AsOfPicker({ value }: { value?: string }) {
10+ const router = useRouter();
11+ const pathname = usePathname();
12+ const sp = useSearchParams();
13+ const [date, setDate] = useState(value ?? '');
14+ const today = new Date().toISOString().slice(0, 10);
15+
16+ const go = (d: string | null) => {
17+ const next = new URLSearchParams(sp.toString());
18+ next.set('tab', 'history');
19+ if (d) next.set('asof', d);
20+ else next.delete('asof');
21+ router.replace(`${pathname}?${next.toString()}`, { scroll: false });
22+ };
23+
24+ return (
25+ <form
26+ className="flex flex-wrap items-end gap-2"
27+ onSubmit={(e) => {
28+ e.preventDefault();
29+ if (date) go(date);
30+ }}
31+ >
32+ <label className="block min-w-0">
33+ <span className="eyebrow block pb-1">As of date (UTC)</span>
34+ <input type="date" name="asof" value={date} max={today} onChange={(e) => setDate(e.target.value)} className="h-11 w-full min-w-[11rem] border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none" />
35+ </label>
36+ <button type="submit" disabled={!date} className="inline-flex h-11 items-center bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90 disabled:opacity-40">
37+ View as of
38+ </button>
39+ {value && (
40+ <button
41+ type="button"
42+ onClick={() => {
43+ setDate('');
44+ go(null);
45+ }}
46+ className="inline-flex h-11 items-center border border-rule px-3 text-sm text-ink-2 hover:text-ink"
47+ >
48+ Back to today
49+ </button>
50+ )}
51+ </form>
52+ );
53+}
modified apps/web/src/components/entity/entity-page.tsx +37 −8
@@ -1,14 +1,21 @@
1−import { ExternalLink } from 'lucide-react'; 1+import { ExternalLink, GitFork, MemoryStick } from 'lucide-react';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3+import { CompareButton } from '@/components/compare/compare-button';
3 import { ViewBeacon } from '@/components/layout/view-beacon'; 4 import { ViewBeacon } from '@/components/layout/view-beacon';
4 import { EntityBadge, OpennessBadge, StatusBadge } from '@/components/ui/badges'; 5 import { EntityBadge, OpennessBadge, StatusBadge } from '@/components/ui/badges';
5 import { EntityLink, QualityMark } from '@/components/ui/entity'; 6 import { EntityLink, QualityMark } from '@/components/ui/entity';
6 import { Container, Note } from '@/components/ui/section'; 7 import { Container, Note } from '@/components/ui/section';
7 import { TabPanel, Tabs, type TabDef } from '@/components/ui/tabs'; 8 import { TabPanel, Tabs, type TabDef } from '@/components/ui/tabs';
9+import { api, safe } from '@/lib/api';
8 import { fmtAgo, fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; 10 import { fmtAgo, fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format';
9 import { routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site'; 11 import { routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site';
10 import type { EntityDetail, EntitySummary } from '@/lib/types'; 12 import type { EntityDetail, EntitySummary } from '@/lib/types';
11 import { Capabilities, EntityList, HardwareFitTable, Identity, LineageBlock, ModelsTable, PriceHistory, PricesTable, ProvenanceSummary, RelationsBlock, ResultsTable, SourcesTable, SpecTable, TimelineList } from './blocks'; 13 import { Capabilities, EntityList, HardwareFitTable, Identity, LineageBlock, ModelsTable, PriceHistory, PricesTable, ProvenanceSummary, RelationsBlock, ResultsTable, SourcesTable, SpecTable, TimelineList } from './blocks';
14+import { HistoryPanel } from './history';
15+
16+/** Search params an entity page understands (History tab). */
17+export type EntityPageParams = { asof?: string; property?: string };
18+const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
12 19
13 /* ---------------------------------------------------------------------------------------------------------- header chips */ 20 /* ---------------------------------------------------------------------------------------------------------- header chips */
14 21
@@ -93,15 +100,16 @@ function primaryUrl(d: EntityDetail): string | null {
93 100
94 /* ---------------------------------------------------------------------------------------------------------- tabs per type */ 101 /* ---------------------------------------------------------------------------------------------------------- tabs per type */
95 102
96−type TabKey = 'overview' | 'capabilities' | 'benchmarks' | 'providers' | 'hardware' | 'lineage' | 'research' | 'models' | 'repositories' | 'leaderboard' | 'runnable' | 'relations' | 'timeline' | 'sources'; 103+type TabKey = 'overview' | 'capabilities' | 'benchmarks' | 'providers' | 'hardware' | 'lineage' | 'research' | 'models' | 'repositories' | 'leaderboard' | 'runnable' | 'relations' | 'history' | 'timeline' | 'sources';
97 104
98−function tabsFor(d: EntityDetail): TabDef[] { 105+function tabsFor(d: EntityDetail, claimCount?: number | null): TabDef[] {
99 const t = d.entity_type; 106 const t = d.entity_type;
100 const c = (n: number | undefined | null) => (n ? n : undefined); 107 const c = (n: number | undefined | null) => (n ? n : undefined);
101 const rel = d.relations?.reduce((n, g) => n + g.items.length, 0) ?? 0; 108 const rel = d.relations?.reduce((n, g) => n + g.items.length, 0) ?? 0;
102 const sources = d.sources?.length ?? 0; 109 const sources = d.sources?.length ?? 0;
103 const timeline = d.timeline?.length ?? 0; 110 const timeline = d.timeline?.length ?? 0;
104 const tail: TabDef[] = [ 111 const tail: TabDef[] = [
112+ { id: 'history', label: 'History', count: c(claimCount ?? d.counts?.claims) },
105 { id: 'timeline', label: 'Timeline', count: c(timeline) }, 113 { id: 'timeline', label: 'Timeline', count: c(timeline) },
106 { id: 'sources', label: 'Sources', count: c(sources) }, 114 { id: 'sources', label: 'Sources', count: c(sources) },
107 ]; 115 ];
@@ -195,12 +203,18 @@ function jsonLd(d: EntityDetail, canonical: string) {
195 * Shared entity page for every type. Header (badges, name, org, description, key chips) + URL-driven tabs whose set 203 * Shared entity page for every type. Header (badges, name, org, description, key chips) + URL-driven tabs whose set
196 * depends on the type. Panels are all server-rendered (SEO); the client Tabs only toggles visibility. 204 * depends on the type. Panels are all server-rendered (SEO); the client Tabs only toggles visibility.
197 */ 205 */
198−export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonical: string; related?: EntitySummary[] | null }) { 206+export async function EntityPage({ d, canonical, related, asof: asofRaw, historyProperty }: { d: EntityDetail; canonical: string; related?: EntitySummary[] | null; asof?: string; historyProperty?: string }) {
199 const a = d.attributes ?? {}; 207 const a = d.attributes ?? {};
200 const chips = headerChips(d); 208 const chips = headerChips(d);
201 const openness = typeof a.openness === 'string' ? a.openness : null; 209 const openness = typeof a.openness === 'string' ? a.openness : null;
202 const link = primaryUrl(d); 210 const link = primaryUrl(d);
203− const tabs = tabsFor(d); 211+ // History tab data: full claim history always (it is the tab's content); the "as of" state only when requested.
212+ const asof = asofRaw && ISO_DAY.test(asofRaw) ? asofRaw : asofRaw ? 'invalid' : undefined;
213+ const property = historyProperty?.trim() || undefined;
214+ const [history, asofPayload] = await Promise.all([safe(api.entityHistory(d.slug, property)), asof && asof !== 'invalid' ? safe(api.entityAsOf(d.slug, asof)) : Promise.resolve(null)]);
215+ const claims = history?.items ?? null;
216+ const tabs = tabsFor(d, property ? null : claims?.length);
217+ const memoryGb = d.entity_type === 'hardware' ? num(a.memory_gb) : null;
204 const isModel = d.entity_type === 'model' || d.entity_type === 'quantization'; 218 const isModel = d.entity_type === 'model' || d.entity_type === 'quantization';
205 const isCompany = ['company', 'organization', 'lab', 'university'].includes(d.entity_type); 219 const isCompany = ['company', 'organization', 'lab', 'university'].includes(d.entity_type);
206 const ld = jsonLd(d, canonical); 220 const ld = jsonLd(d, canonical);
@@ -251,6 +265,17 @@ export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonic
251 )} 265 )}
252 </p> 266 </p>
253 {d.description && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>} 267 {d.description && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>}
268+ <div className="mt-4 flex flex-wrap items-center gap-2" aria-label="Actions">
269+ <CompareButton e={d} />
270+ <Link href={routes.graph(d.slug)} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">
271+ <GitFork className="size-3.5" aria-hidden /> Explore graph
272+ </Link>
273+ {memoryGb !== null && (
274+ <Link href={routes.hardwareFit({ memory_gb: memoryGb, quant: '4bit', context: 8192 })} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">
275+ <MemoryStick className="size-3.5" aria-hidden /> What can this run?
276+ </Link>
277+ )}
278+ </div>
254 </div> 279 </div>
255 <div className="shrink-0 text-xs text-ink-3 lg:text-right"> 280 <div className="shrink-0 text-xs text-ink-3 lg:text-right">
256 <QualityMark q={d.quality?.score} label /> 281 <QualityMark q={d.quality?.score} label />
@@ -329,9 +354,10 @@ export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonic
329 )} 354 )}
330 <section> 355 <section>
331 <p className="eyebrow mb-2">Compare</p> 356 <p className="eyebrow mb-2">Compare</p>
332− <p className="text-sm text-ink-2"> 357+ <div className="flex flex-wrap items-center gap-3 text-sm">
333− <Link href={routes.compare([d.slug])} className="link">Add {d.name} to a comparison →</Link> 358+ <CompareButton e={d} />
334− </p> 359+ <Link href={routes.compare([d.slug])} className="link py-2">Open compare →</Link>
360+ </div>
335 </section> 361 </section>
336 </aside> 362 </aside>
337 </div> 363 </div>
@@ -446,6 +472,9 @@ export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonic
446 </TabPanel> 472 </TabPanel>
447 )} 473 )}
448 474
475+ <TabPanel id="history">
476+ <HistoryPanel d={d} asof={asof === 'invalid' ? asofRaw : asof} asofPayload={asof === 'invalid' ? null : asofPayload} claims={claims} property={property} />
477+ </TabPanel>
449 <TabPanel id="timeline"> 478 <TabPanel id="timeline">
450 <TimelineList events={d.timeline ?? []} slug={d.slug} /> 479 <TimelineList events={d.timeline ?? []} slug={d.slug} />
451 </TabPanel> 480 </TabPanel>
added apps/web/src/components/entity/history.tsx +170 −0
@@ -0,0 +1,170 @@
1+import Link from 'next/link';
2+import { DataTable, Td, Th } from '@/components/ui/data-table';
3+import { KeyValue, type KVRow } from '@/components/ui/key-value';
4+import { SourceCell } from '@/components/ui/provenance';
5+import { Note } from '@/components/ui/section';
6+import { EmptyState, Unavailable } from '@/components/ui/unavailable';
7+import { cn } from '@/lib/cn';
8+import { fmtDate, fmtDateTime, fmtInt, fmtValue } from '@/lib/format';
9+import { PROSE_KEYS, propertyLabel, routes } from '@/lib/site';
10+import type { AsOfPayload, Claim, EntityDetail } from '@/lib/types';
11+import { AsOfPicker } from './asof-picker';
12+
13+/* Canonical order for model properties (mirrors SpecTable in blocks.tsx; kept local to avoid touching that file). */
14+const MODEL_ORDER = ['family', 'version', 'release_date', 'status', 'openness', 'license', 'architecture', 'parameter_count', 'active_parameter_count', 'is_moe', 'context_length', 'max_output_tokens', 'knowledge_cutoff', 'training_data_cutoff', 'modalities', 'modalities_input', 'modalities_output', 'languages', 'tokenizer', 'api_model_id', 'api_alias', 'base_model', 'quantization', 'quant_format', 'file_size_gb', 'deprecation_date', 'retirement_date', 'retirement_tentative', 'hf_repo', 'pipeline_tag', 'official_url', 'model_card_url', 'paper_url', 'repository_url', 'metric.downloads', 'metric.likes'];
15+const HIDDEN = new Set(['name', 'slug', 'id', 'entity_type']);
16+const ORDER = new Map(MODEL_ORDER.map((k, i) => [k, i]));
17+const byCanonical = (a: string, b: string) => (ORDER.get(a) ?? 999) - (ORDER.get(b) ?? 999) || a.localeCompare(b);
18+
19+const STATUS_CLS: Record<string, string> = {
20+ current: 'text-positive bg-positive-soft',
21+ superseded: 'text-ink-3 bg-surface-2',
22+ conflicting: 'text-danger bg-danger-soft',
23+ retracted: 'text-warning bg-warning-soft',
24+};
25+function ClaimStatus({ status }: { status: string }) {
26+ return <span className={cn('inline-flex items-center whitespace-nowrap rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium leading-4 tracking-wide', STATUS_CLS[status] ?? 'text-ink-2 bg-surface-2')}>{status}</span>;
27+}
28+
29+/* ------------------------------------------------------------------------------------------------------ as of */
30+
31+export function AsOfBlock({ d, asof, payload }: { d: EntityDetail; asof: string; payload: AsOfPayload | null }) {
32+ const back = `${routes.entity(d)}?tab=history`;
33+ if (!payload) {
34+ return (
35+ <div className="hairline pt-4">
36+ <Unavailable what={`State as of ${asof}`} reason="Could not load the state for that date — check the date (YYYY-MM-DD, not in the future)." />
37+ <p className="mt-2 text-sm"><Link href={back} className="link">Back to today →</Link></p>
38+ </div>
39+ );
40+ }
41+ const attrs = payload.attributes ?? {};
42+ const keys = Object.keys(attrs).filter((k) => !HIDDEN.has(k) && !PROSE_KEYS.has(k) && attrs[k] !== null && attrs[k] !== undefined && attrs[k] !== '' && !(Array.isArray(attrs[k]) && (attrs[k] as unknown[]).length === 0));
43+ keys.sort(byCanonical);
44+ const rows: KVRow[] = keys.map((k) => ({ key: k, raw: attrs[k] }));
45+ return (
46+ <div className="hairline pt-4">
47+ <div className="flex flex-wrap items-center justify-between gap-2 bg-accent-soft px-3 py-2.5 text-sm">
48+ <p className="text-ink">
49+ <span className="font-medium">Viewing AI Atlas as of {fmtDate(payload.date)}</span>
50+ <span className="text-ink-2"> — attributes exactly as the atlas knew them on that day; later corrections are not shown.</span>
51+ </p>
52+ <Link href={back} className="link shrink-0 py-2">Back to today →</Link>
53+ </div>
54+ {!payload.existed ? (
55+ <EmptyState title={`${d.name} was not yet in AI Atlas on ${fmtDate(payload.date)}`} className="mt-4">
56+ First seen {fmtDate(payload.first_seen_at)}. Nothing is inferred backwards: no attribute is shown for dates before the first observation.
57+ </EmptyState>
58+ ) : (
59+ <div className="mt-4">
60+ <p className="eyebrow mb-2">
61+ Attributes as of {fmtDate(payload.date)} <span className="tnum text-ink-3">{fmtInt(payload.claims?.length ?? 0)} claims in force</span>
62+ </p>
63+ <KeyValue rows={rows} />
64+ </div>
65+ )}
66+ </div>
67+ );
68+}
69+
70+/* ------------------------------------------------------------------------------------------------------ claim history */
71+
72+export function ClaimHistory({ d, claims, property }: { d: EntityDetail; claims: Claim[] | null; property?: string }) {
73+ const base = `${routes.entity(d)}?tab=history`;
74+ if (!claims) return <Unavailable what="Claim history" />;
75+ if (!claims.length)
76+ return (
77+ <EmptyState title={property ? `No claims recorded for ${propertyLabel(property)}` : 'No claims recorded yet'}>
78+ {property ? <Link href={base} className="link">Show all properties →</Link> : 'Claims appear when a source states a fact; every later change is kept as a new claim.'}
79+ </EmptyState>
80+ );
81+ const groups = new Map<string, Claim[]>();
82+ for (const c of claims) {
83+ const arr = groups.get(c.property);
84+ if (arr) arr.push(c);
85+ else groups.set(c.property, [c]);
86+ }
87+ const keys = [...groups.keys()].sort(byCanonical);
88+ const conflicting = claims.filter((c) => c.status === 'conflicting').length;
89+ return (
90+ <div className="space-y-8">
91+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-ink-3">
92+ <span className="tnum">{fmtInt(claims.length)} claims · {fmtInt(keys.length)} properties</span>
93+ {conflicting > 0 && <span className="font-medium text-danger">{fmtInt(conflicting)} conflicting</span>}
94+ {property && (
95+ <Link href={base} className="link">
96+ Show all properties
97+ </Link>
98+ )}
99+ </div>
100+ {keys.map((k) => {
101+ const rows = groups.get(k) ?? [];
102+ return (
103+ <section key={k} id={`h-${k}`} className="scroll-mt-20">
104+ <h3 className="mb-2 flex flex-wrap items-baseline gap-x-2">
105+ <Link href={`${base}&property=${encodeURIComponent(k)}`} className="text-sm font-semibold text-ink hover:text-accent" aria-current={property === k ? 'true' : undefined}>
106+ {propertyLabel(k)}
107+ </Link>
108+ <span className="mono text-[11px] text-ink-3">{k}</span>
109+ <span className="tnum text-xs text-ink-3">{fmtInt(rows.length)}</span>
110+ {rows.some((c) => c.status === 'conflicting') && <span className="text-xs font-medium text-danger">conflicting claims</span>}
111+ </h3>
112+ <DataTable compact caption={`Claim history for ${propertyLabel(k)}`}>
113+ <thead>
114+ <tr>
115+ <Th>Value</Th>
116+ <Th>Valid from → to</Th>
117+ <Th>Status</Th>
118+ <Th>Source</Th>
119+ <Th>Confidence</Th>
120+ <Th>Extractor</Th>
121+ </tr>
122+ </thead>
123+ <tbody>
124+ {rows.map((c) => (
125+ <tr key={c.id} className={c.status === 'conflicting' ? 'border-l-2 border-danger' : undefined}>
126+ <Td primary className={c.status === 'conflicting' ? 'pl-2 md:pl-2' : undefined}>
127+ <span className="tnum">{fmtValue(c.value, c.property)}</span>
128+ {c.unit && !/parameter_count|context_length|max_output_tokens|memory_gb|file_size_gb/.test(c.property) && <span className="text-xs text-ink-3"> {c.unit}</span>}
129+ </Td>
130+ <Td label="Valid" className="tnum text-xs text-ink-2">
131+ <time dateTime={c.valid_from}>{fmtDateTime(c.valid_from)}</time>
132+ <span className="text-ink-3"> → </span>
133+ {c.valid_to ? <time dateTime={c.valid_to}>{fmtDateTime(c.valid_to)}</time> : <span className="font-medium text-positive">current</span>}
134+ </Td>
135+ <Td label="Status"><ClaimStatus status={c.status} /></Td>
136+ <Td label="Source"><SourceCell url={c.source_url} tier={c.tier} name={c.source_name} /></Td>
137+ <Td label="Confidence" className={cn('text-xs', c.confidence === 'conflicted' && 'text-danger', c.confidence === 'low' && 'text-warning', !['conflicted', 'low'].includes(c.confidence) && 'text-ink-2')}>{c.confidence}</Td>
138+ <Td label="Extractor" className="mono text-xs text-ink-3">{c.extractor}</Td>
139+ </tr>
140+ ))}
141+ </tbody>
142+ </DataTable>
143+ </section>
144+ );
145+ })}
146+ <Note>
147+ Claims are temporal and append-only: a new observation closes the previous claim (<span className="mono">valid_to</span>) instead of overwriting it. Conflicting claims from different sources are kept side by side and flagged — never averaged. <Link href="/methodology" className="link">Methodology →</Link>
148+ </Note>
149+ </div>
150+ );
151+}
152+
153+/* ------------------------------------------------------------------------------------------------------ panel */
154+
155+export function HistoryPanel({ d, asof, asofPayload, claims, property }: { d: EntityDetail; asof?: string; asofPayload: AsOfPayload | null; claims: Claim[] | null; property?: string }) {
156+ return (
157+ <div className="space-y-10">
158+ <section>
159+ <p className="eyebrow mb-2">As of</p>
160+ <p className="mb-3 max-w-2xl text-sm text-ink-2">Rewind the record: see this entity's attributes exactly as AI Atlas knew them on a given day.</p>
161+ <AsOfPicker value={asof} />
162+ {asof && <div className="mt-4"><AsOfBlock d={d} asof={asof} payload={asofPayload} /></div>}
163+ </section>
164+ <section>
165+ <p className="eyebrow mb-2">Claim history{property ? <> · {propertyLabel(property)}</> : null}</p>
166+ <ClaimHistory d={d} claims={claims} property={property} />
167+ </section>
168+ </div>
169+ );
170+}
added apps/web/src/components/graph/graph-explorer.tsx +167 −0
@@ -0,0 +1,167 @@
1+'use client';
2+import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from 'd3-force';
3+import { useRouter } from 'next/navigation';
4+import { useEffect, useMemo, useState } from 'react';
5+import { cn } from '@/lib/cn';
6+import { predicateLabel, routes, TYPE_COLOR_KEY, typeLabel } from '@/lib/site';
7+import type { GraphEdge, GraphNode } from '@/lib/types';
8+
9+type SimNode = SimulationNodeDatum & GraphNode & { degree: number; isRoot: boolean };
10+type SimLink = SimulationLinkDatum<SimNode> & { predicate: string };
11+
12+const W = 960;
13+const H = 640;
14+const LABEL_LIMIT = 40;
15+const EDGE_LABEL_LIMIT = 40;
16+
17+function colorOf(type: string): string {
18+ return `var(--type-${TYPE_COLOR_KEY[type] ?? 'tool'})`;
19+}
20+function short(s: string, n = 26): string {
21+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
22+}
23+
24+/**
25+ * Force-directed neighbourhood graph (d3-force run for a fixed number of ticks, deterministic seed, no animation).
26+ * Server render: the SVG frame + legend only; the layout is computed on the client after mount so markup stays stable.
27+ * Click / Enter → the entity page; hover / focus highlights the neighbourhood and reveals edge labels.
28+ */
29+export function GraphExplorer({ nodes, edges, rootId }: { nodes: GraphNode[]; edges: GraphEdge[]; rootId: string }) {
30+ const router = useRouter();
31+ const [mounted, setMounted] = useState(false);
32+ const [hover, setHover] = useState<string | null>(null);
33+ useEffect(() => setMounted(true), []);
34+
35+ const layout = useMemo(() => {
36+ if (!mounted) return null;
37+ const ids = new Set(nodes.map((n) => n.id));
38+ const degree = new Map<string, number>();
39+ const links: SimLink[] = [];
40+ for (const e of edges) {
41+ if (!ids.has(e.source) || !ids.has(e.target)) continue;
42+ degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
43+ degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
44+ links.push({ source: e.source, target: e.target, predicate: e.predicate });
45+ }
46+ // Deterministic seed: nodes on a ring ordered by type, root at the centre.
47+ const sim: SimNode[] = nodes.map((n, i) => {
48+ const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;
49+ const isRoot = n.id === rootId;
50+ return { ...n, degree: degree.get(n.id) ?? 0, isRoot, x: isRoot ? W / 2 : W / 2 + Math.cos(ang) * 220, y: isRoot ? H / 2 : H / 2 + Math.sin(ang) * 220, fx: isRoot ? W / 2 : undefined, fy: isRoot ? H / 2 : undefined };
51+ });
52+ const byId = new Map(sim.map((n) => [n.id, n]));
53+ const radius = (n: SimNode) => (n.isRoot ? 14 : 5 + Math.min(6, Math.sqrt(n.degree)));
54+ const s = forceSimulation<SimNode>(sim)
55+ .force('link', forceLink<SimNode, SimLink>(links).id((d) => d.id).distance((l) => {
56+ const a = l.source as SimNode;
57+ const b = l.target as SimNode;
58+ const hub = Math.max(a.degree, b.degree);
59+ return 70 + Math.min(90, hub * 3) + (a.isRoot || b.isRoot ? 60 : 0);
60+ }).strength(0.6))
61+ .force('charge', forceManyBody<SimNode>().strength((d) => (d.isRoot ? -900 : -220)))
62+ .force('center', forceCenter(W / 2, H / 2))
63+ .force('collide', forceCollide<SimNode>().radius((d) => radius(d) + 14).iterations(2))
64+ .stop();
65+ for (let i = 0; i < 300; i++) s.tick();
66+ // Clamp into the viewBox.
67+ for (const n of sim) {
68+ n.x = Math.max(24, Math.min(W - 24, n.x ?? W / 2));
69+ n.y = Math.max(24, Math.min(H - 24, n.y ?? H / 2));
70+ }
71+ const neighbours = new Map<string, Set<string>>();
72+ for (const l of links) {
73+ const a = (l.source as SimNode).id;
74+ const b = (l.target as SimNode).id;
75+ (neighbours.get(a) ?? neighbours.set(a, new Set()).get(a)!).add(b);
76+ (neighbours.get(b) ?? neighbours.set(b, new Set()).get(b)!).add(a);
77+ }
78+ return { sim, links, byId, radius, neighbours };
79+ }, [mounted, nodes, edges, rootId]);
80+
81+ const showAllLabels = nodes.length <= LABEL_LIMIT;
82+ const showAllEdgeLabels = edges.length <= EDGE_LABEL_LIMIT;
83+
84+ const go = (n: GraphNode) => router.push(routes.entity(n));
85+ const active = (id: string) => !hover || hover === id || (layout?.neighbours.get(hover)?.has(id) ?? false);
86+
87+ return (
88+ <div className="relative">
89+ <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full border border-rule bg-surface" role="img" aria-label="Relationship graph" onMouseLeave={() => setHover(null)}>
90+ {!layout && (
91+ <text x={W / 2} y={H / 2} textAnchor="middle" fontSize={14} fill="var(--ink-3)">
92+ Laying out {nodes.length} nodes…
93+ </text>
94+ )}
95+ {layout && (
96+ <>
97+ <g>
98+ {layout.links.map((l, i) => {
99+ const a = l.source as SimNode;
100+ const b = l.target as SimNode;
101+ const on = !hover || hover === a.id || hover === b.id;
102+ const label = showAllEdgeLabels || (hover && (hover === a.id || hover === b.id));
103+ const mx = ((a.x ?? 0) + (b.x ?? 0)) / 2;
104+ const my = ((a.y ?? 0) + (b.y ?? 0)) / 2;
105+ return (
106+ <g key={i} opacity={on ? 1 : 0.12}>
107+ <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={on && hover ? 'var(--accent)' : 'var(--rule-strong)'} strokeWidth={on && hover ? 1.4 : 1} />
108+ {label && (
109+ <text x={mx} y={my - 3} textAnchor="middle" fontSize={9} fill="var(--ink-3)" className="pointer-events-none select-none">
110+ {predicateLabel(l.predicate, 'out')}
111+ </text>
112+ )}
113+ </g>
114+ );
115+ })}
116+ </g>
117+ <g>
118+ {layout.sim.map((n) => {
119+ const r = layout.radius(n);
120+ const on = active(n.id);
121+ const label = n.isRoot || showAllLabels || (hover && (hover === n.id || layout.neighbours.get(hover)?.has(n.id)));
122+ const color = colorOf(n.entity_type);
123+ return (
124+ <g
125+ key={n.id}
126+ transform={`translate(${n.x},${n.y})`}
127+ opacity={on ? 1 : 0.2}
128+ tabIndex={0}
129+ role="link"
130+ aria-label={`${n.name} (${typeLabel(n.entity_type)})`}
131+ className="cursor-pointer outline-none focus-visible:[&>circle]:stroke-accent"
132+ onMouseEnter={() => setHover(n.id)}
133+ onFocus={() => setHover(n.id)}
134+ onBlur={() => setHover(null)}
135+ onClick={() => go(n)}
136+ onKeyDown={(e) => {
137+ if (e.key === 'Enter' || e.key === ' ') {
138+ e.preventDefault();
139+ go(n);
140+ }
141+ }}
142+ >
143+ <circle r={Math.max(16, r + 10)} fill="transparent" data-hit="" />
144+ {n.isRoot && <circle r={r + 6} fill="none" stroke={color} strokeWidth={1} opacity={0.5} />}
145+ <circle r={r} fill={color} stroke="var(--canvas)" strokeWidth={1.5} />
146+ {label && (
147+ <text x={0} y={r + 11} textAnchor="middle" fontSize={n.isRoot ? 12 : 10} fontWeight={n.isRoot ? 600 : 400} fill={n.isRoot ? 'var(--ink)' : 'var(--ink-2)'} className="pointer-events-none select-none" style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>
148+ {short(n.name, n.isRoot ? 40 : 26)}
149+ </text>
150+ )}
151+ </g>
152+ );
153+ })}
154+ </g>
155+ </>
156+ )}
157+ </svg>
158+ {layout && hover && layout.byId.get(hover) && (
159+ <p className="pointer-events-none absolute left-2 top-2 max-w-[70%] truncate bg-canvas/90 px-2 py-1 text-xs text-ink-2 backdrop-blur">
160+ <span className="font-medium text-ink">{layout.byId.get(hover)!.name}</span> · {typeLabel(layout.byId.get(hover)!.entity_type)}
161+ {layout.byId.get(hover)!.organization_name ? ` · ${layout.byId.get(hover)!.organization_name}` : ''} · {layout.byId.get(hover)!.degree} link{layout.byId.get(hover)!.degree === 1 ? '' : 's'}
162+ </p>
163+ )}
164+ {!showAllLabels && <p className={cn('mt-1 text-xs text-ink-3')}>Labels are shown on hover or focus beyond {LABEL_LIMIT} nodes; the list below names every node.</p>}
165+ </div>
166+ );
167+}
added apps/web/src/components/hardware/fit-form.tsx +79 −0
@@ -0,0 +1,79 @@
1+import { Cpu } from 'lucide-react';
2+import { cn } from '@/lib/cn';
3+
4+/**
5+ * GET form for /hardware/fit — no JavaScript needed. Memory presets are grouped by platform; a free numeric field
6+ * overrides the preset when filled. Quantization and context are plain selects (the API accepts 4bit|8bit|fp16 and
7+ * any positive context; we offer the three common windows).
8+ */
9+export const APPLE_PRESETS = [16, 24, 32, 36, 48, 64, 96, 128, 192, 256, 512];
10+export const NVIDIA_PRESETS = [24, 32, 80, 141, 192];
11+export const QUANTS = [
12+ { value: '4bit', label: '4-bit (≈ 0.5 byte / param)' },
13+ { value: '8bit', label: '8-bit (1 byte / param)' },
14+ { value: 'fp16', label: 'fp16 / bf16 (2 bytes / param)' },
15+];
16+export const CONTEXTS = [
17+ { value: '8192', label: '8k tokens' },
18+ { value: '32768', label: '32k tokens' },
19+ { value: '131072', label: '128k tokens' },
20+];
21+
22+export function FitForm({ memory, quant, context, className }: { memory?: string; quant?: string; context?: string; className?: string }) {
23+ const cls = 'h-11 w-full border border-rule bg-surface px-2.5 text-[15px] text-ink focus:border-accent focus:outline-none';
24+ const presetSelected = memory && [...APPLE_PRESETS, ...NVIDIA_PRESETS].includes(Number(memory)) ? memory : '';
25+ return (
26+ <form action="/hardware/fit" method="get" className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-[1.4fr_1fr_1.1fr_1fr_auto] lg:items-end', className)}>
27+ <label className="block min-w-0">
28+ <span className="eyebrow block pb-1">Device memory (preset)</span>
29+ <select name="memory_gb" defaultValue={presetSelected} className={cls} aria-describedby="fit-memory-hint">
30+ <option value="">Choose a preset…</option>
31+ <optgroup label="Apple silicon (unified memory)">
32+ {APPLE_PRESETS.map((g) => (
33+ <option key={`a${g}`} value={g}>
34+ {g} GB
35+ </option>
36+ ))}
37+ </optgroup>
38+ <optgroup label="NVIDIA (VRAM)">
39+ {NVIDIA_PRESETS.map((g) => (
40+ <option key={`n${g}`} value={g}>
41+ {g} GB
42+ </option>
43+ ))}
44+ </optgroup>
45+ </select>
46+ </label>
47+ <label className="block min-w-0">
48+ <span className="eyebrow block pb-1">…or any memory (GB)</span>
49+ <input name="memory_custom" inputMode="decimal" pattern="[0-9]*\.?[0-9]*" defaultValue={presetSelected ? '' : memory ?? ''} placeholder="e.g. 20" className={cls} />
50+ </label>
51+ <label className="block min-w-0">
52+ <span className="eyebrow block pb-1">Quantization</span>
53+ <select name="quant" defaultValue={quant ?? '4bit'} className={cls}>
54+ {QUANTS.map((q) => (
55+ <option key={q.value} value={q.value}>
56+ {q.label}
57+ </option>
58+ ))}
59+ </select>
60+ </label>
61+ <label className="block min-w-0">
62+ <span className="eyebrow block pb-1">Context window</span>
63+ <select name="context" defaultValue={context ?? '8192'} className={cls}>
64+ {CONTEXTS.map((c) => (
65+ <option key={c.value} value={c.value}>
66+ {c.label}
67+ </option>
68+ ))}
69+ </select>
70+ </label>
71+ <button type="submit" className="inline-flex h-11 items-center justify-center gap-1.5 bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90 sm:col-span-2 lg:col-span-1">
72+ <Cpu className="size-4" aria-hidden /> Estimate fit
73+ </button>
74+ <p id="fit-memory-hint" className="text-xs text-ink-3 sm:col-span-2 lg:col-span-5">
75+ The custom field wins over the preset when both are filled. Estimates reserve 2 GB for the OS and framework.
76+ </p>
77+ </form>
78+ );
79+}
modified apps/web/src/components/listing/generic-listing.tsx +3 −3
@@ -14,7 +14,7 @@ const LIMIT = 40;
14 * Generic paged listing for a type with light filters (q, org, sort). `fetch` defaults to /explore/<type>; 14 * Generic paged listing for a type with light filters (q, org, sort). `fetch` defaults to /explore/<type>;
15 * pass a custom fetcher for typed endpoints (/papers, /hardware). Renders `EntityRow`s — dense, mobile-safe. 15 * pass a custom fetcher for typed endpoints (/papers, /hardware). Renders `EntityRow`s — dense, mobile-safe.
16 */ 16 */
17−export async function GenericListing({ type, basePath, title, eyebrow, lede, searchParams, fetch, sorts = [{ value: 'updated', label: 'Recently updated' }, { value: 'name', label: 'Name' }], extraFields = [], children }: { type: string; basePath: string; title: string; eyebrow: string; lede?: string; searchParams: Record<string, string | undefined>; fetch?: (q: Record<string, string | number | undefined>) => Promise<Page<EntitySummary>>; sorts?: { value: string; label: string }[]; extraFields?: Parameters<typeof FilterBar>[0]['fields']; children?: ReactNode }) { 17+export async function GenericListing({ type, basePath, title, eyebrow, lede, searchParams, fetch, sorts = [{ value: 'updated', label: 'Recently updated' }, { value: 'name', label: 'Name' }], extraFields = [], children, rowTrailing, headerAside }: { type: string; basePath: string; title: string; eyebrow: string; lede?: string; searchParams: Record<string, string | undefined>; fetch?: (q: Record<string, string | number | undefined>) => Promise<Page<EntitySummary>>; sorts?: { value: string; label: string }[]; extraFields?: Parameters<typeof FilterBar>[0]['fields']; children?: ReactNode; /** Optional per-row trailing node (e.g. a CompareButton). */ rowTrailing?: (e: EntitySummary) => ReactNode; /** Extra content in the page header aside, next to the total. */ headerAside?: ReactNode }) {
18 const current: Record<string, string | undefined> = {}; 18 const current: Record<string, string | undefined> = {};
19 for (const k of ['q', 'org', 'sort', 'category', 'kind', 'manufacturer', 'since', 'offset']) if (searchParams[k]) current[k] = searchParams[k]; 19 for (const k of ['q', 'org', 'sort', 'category', 'kind', 'manufacturer', 'since', 'offset']) if (searchParams[k]) current[k] = searchParams[k];
20 const offset = Math.max(0, Number(current.offset) || 0); 20 const offset = Math.max(0, Number(current.offset) || 0);
@@ -23,7 +23,7 @@ export async function GenericListing({ type, basePath, title, eyebrow, lede, sea
23 const href = (patch: Record<string, string | number | undefined | null>) => withParams(basePath, current, patch); 23 const href = (patch: Record<string, string | number | undefined | null>) => withParams(basePath, current, patch);
24 return ( 24 return (
25 <Container> 25 <Container>
26− <PageHeader eyebrow={eyebrow} title={title} lede={lede} aside={page ? <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} total</p> : undefined}> 26+ <PageHeader eyebrow={eyebrow} title={title} lede={lede} aside={page || headerAside ? <div className="flex flex-col items-start gap-2 md:items-end">{page && <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} total</p>}{headerAside}</div> : undefined}>
27 <FilterBar action={basePath} className="mt-6" resetHref={basePath} fields={[{ kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'Search by name' }, ...extraFields]} sort={{ value: current.sort ?? sorts[0]?.value, options: sorts }} /> 27 <FilterBar action={basePath} className="mt-6" resetHref={basePath} fields={[{ kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'Search by name' }, ...extraFields]} sort={{ value: current.sort ?? sorts[0]?.value, options: sorts }} />
28 </PageHeader> 28 </PageHeader>
29 {children} 29 {children}
@@ -36,7 +36,7 @@ export async function GenericListing({ type, basePath, title, eyebrow, lede, sea
36 <> 36 <>
37 <ul className="border-t border-rule"> 37 <ul className="border-t border-rule">
38 {page.items.map((e) => ( 38 {page.items.map((e) => (
39− <EntityRow key={e.id} e={e} showType={e.entity_type !== type} /> 39+ <EntityRow key={e.id} e={e} showType={e.entity_type !== type} trailing={rowTrailing?.(e)} />
40 ))} 40 ))}
41 </ul> 41 </ul>
42 <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" /> 42 <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
added apps/web/src/components/listing/typed-listing.tsx +133 −0
@@ -0,0 +1,133 @@
1+import type { ReactNode } from 'react';
2+import { CompareButton } from '@/components/compare/compare-button';
3+import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
4+import { FilterBar, type FilterField } from '@/components/listing/filters';
5+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
6+import { Pagination, withParams } from '@/components/ui/pagination';
7+import { Container, Note, PageHeader } from '@/components/ui/section';
8+import { EmptyState, Unavailable } from '@/components/ui/unavailable';
9+import { safe } from '@/lib/api';
10+import { fmtInt } from '@/lib/format';
11+import type { EntitySummary, Page } from '@/lib/types';
12+
13+/**
14+ * Type-specific paged table listing (papers, frameworks, datasets, tools…). Server component: a GET `FilterBar` drives
15+ * the URL, the page fetches once, rows render through column definitions. Stacks under 768 px via `.data-table.stack`
16+ * (every cell carries its label). `compare` adds a per-row CompareButton.
17+ */
18+export type Column = {
19+ key: string;
20+ label: string;
21+ num?: boolean;
22+ primary?: boolean;
23+ wide?: boolean;
24+ hideStack?: boolean;
25+ className?: string;
26+ render: (e: EntitySummary) => ReactNode;
27+};
28+
29+const LIMIT = 40;
30+const PARAM_KEYS = ['q', 'org', 'sort', 'order', 'category', 'kind', 'since', 'until', 'manufacturer', 'offset'];
31+
32+export async function TypedListing({
33+ title,
34+ eyebrow,
35+ lede,
36+ basePath,
37+ searchParams,
38+ fetch,
39+ columns,
40+ sorts,
41+ filters = [],
42+ emptyTitle,
43+ emptyHint,
44+ note,
45+ compare = false,
46+ headerAside,
47+ children,
48+}: {
49+ title: string;
50+ eyebrow: string;
51+ lede?: string;
52+ basePath: string;
53+ searchParams: Record<string, string | undefined>;
54+ fetch: (q: Record<string, string | number | undefined>) => Promise<Page<EntitySummary>>;
55+ columns: Column[];
56+ sorts: { value: string; label: string }[];
57+ filters?: FilterField[];
58+ emptyTitle?: string;
59+ emptyHint?: ReactNode;
60+ note?: ReactNode;
61+ compare?: boolean;
62+ headerAside?: ReactNode;
63+ children?: ReactNode;
64+}) {
65+ const current: Record<string, string | undefined> = {};
66+ for (const k of PARAM_KEYS) if (searchParams[k]) current[k] = searchParams[k];
67+ const offset = Math.max(0, Number(current.offset) || 0);
68+ const sort = current.sort ?? sorts[0]?.value;
69+ const page = await safe(fetch({ ...current, sort, limit: LIMIT, offset }));
70+ const href = (patch: Record<string, string | number | undefined | null>) => withParams(basePath, current, patch);
71+ const cols = columns.length + (compare ? 1 : 0);
72+ return (
73+ <Container wide>
74+ <PageHeader eyebrow={eyebrow} title={title} lede={lede} aside={page || headerAside ? <div className="flex flex-col items-start gap-2 md:items-end">{page && <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} total</p>}{headerAside}</div> : undefined}>
75+ <FilterBar action={basePath} className="mt-6" resetHref={basePath} fields={[{ kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'Search by name' }, ...filters]} sort={{ value: sort, options: sorts }} />
76+ </PageHeader>
77+ {children}
78+ <div className="pb-16">
79+ {!page ? (
80+ <Unavailable what={title} />
81+ ) : page.items.length === 0 && !offset ? (
82+ <EmptyState title={emptyTitle ?? `No ${title.toLowerCase()} match`}>{emptyHint ?? 'Try another name or remove filters.'}</EmptyState>
83+ ) : (
84+ <>
85+ <DataTable caption={title}>
86+ <thead>
87+ <tr>
88+ {columns.map((c) => (
89+ <Th key={c.key} num={c.num}>
90+ {c.label}
91+ </Th>
92+ ))}
93+ {compare && <Th className="w-24"><span className="sr-only">Compare</span></Th>}
94+ </tr>
95+ </thead>
96+ <tbody>
97+ {page.items.length === 0 && <EmptyRow cols={cols}>No rows on this page.</EmptyRow>}
98+ {page.items.map((e) => (
99+ <tr key={e.id}>
100+ {columns.map((c) => (
101+ <Td key={c.key} label={c.primary ? undefined : c.label} num={c.num} primary={c.primary} wide={c.wide} hideStack={c.hideStack} className={c.className}>
102+ {c.render(e)}
103+ </Td>
104+ ))}
105+ {compare && (
106+ <Td className="text-right">
107+ <CompareButton e={e} size="sm" />
108+ </Td>
109+ )}
110+ </tr>
111+ ))}
112+ </tbody>
113+ </DataTable>
114+ <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
115+ {note && <Note className="mt-3">{note}</Note>}
116+ </>
117+ )}
118+ </div>
119+ {compare && <CompareTrayBar />}
120+ </Container>
121+ );
122+}
123+
124+/** Shared cell helpers. */
125+export function Dash() {
126+ return <span className="text-ink-3">—</span>;
127+}
128+export function str(v: unknown): string | null {
129+ return typeof v === 'string' && v.trim() ? v : null;
130+}
131+export function list(v: unknown): string[] {
132+ return Array.isArray(v) ? v.filter((x) => x !== null && x !== undefined).map(String) : [];
133+}
added apps/web/src/components/prices/movers.tsx +86 −0
@@ -0,0 +1,86 @@
1+import { ExternalLink } from 'lucide-react';
2+import { EntityLink } from '@/components/ui/entity';
3+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
4+import { Note } from '@/components/ui/section';
5+import { fmtDateTime, fmtAgo, fmtUsdPerM, num } from '@/lib/format';
6+import type { ChangeEvent } from '@/lib/types';
7+
8+type PriceValue = { input_per_mtok?: unknown; output_per_mtok?: unknown } | null | undefined;
9+
10+function pv(v: unknown): { input: number | null; output: number | null } {
11+ const o = (v && typeof v === 'object' ? v : {}) as PriceValue;
12+ return { input: num(o?.input_per_mtok), output: num(o?.output_per_mtok) };
13+}
14+
15+function host(url: string): string {
16+ try {
17+ return new URL(url).hostname.replace(/^www\./, '');
18+ } catch {
19+ return 'source';
20+ }
21+}
22+
23+/** Old → new price cell: strike-through old, bold new, direction sign. Amber = money. */
24+function Move({ from, to }: { from: number | null; to: number | null }) {
25+ if (from === null && to === null) return <span className="text-ink-3">—</span>;
26+ const dir = from !== null && to !== null ? (to < from ? '↓' : to > from ? '↑' : '=') : '';
27+ return (
28+ <span className="tnum inline-flex items-center gap-1.5">
29+ <span className="text-ink-3 line-through decoration-ink-3/60">{fmtUsdPerM(from)}</span>
30+ <span aria-hidden className="text-ink-3">→</span>
31+ <span className={dir === '=' ? 'text-ink-2' : 'font-semibold text-accent-2'}>{fmtUsdPerM(to)}</span>
32+ {dir && dir !== '=' && <span className={dir === '↓' ? 'text-positive' : 'text-danger'} aria-label={dir === '↓' ? 'decrease' : 'increase'}>{dir}</span>}
33+ </span>
34+ );
35+}
36+
37+/** PRICE_CHANGED events as a dense table: model · provider · input move · output move · when · source. */
38+export function PriceMovers({ movers }: { movers: ChangeEvent[] }) {
39+ const rows = movers.filter((e) => e.event_type === 'PRICE_CHANGED' || e.category === 'price');
40+ return (
41+ <>
42+ <DataTable caption="Recent price changes">
43+ <thead>
44+ <tr>
45+ <Th>Model</Th>
46+ <Th>Provider</Th>
47+ <Th>Input / 1M</Th>
48+ <Th>Output / 1M</Th>
49+ <Th>Observed</Th>
50+ <Th>Source</Th>
51+ </tr>
52+ </thead>
53+ <tbody>
54+ {rows.length === 0 && <EmptyRow cols={6}>No price change recorded in this window. The first observation of a price is not a change.</EmptyRow>}
55+ {rows.map((e) => {
56+ const o = pv(e.old_value);
57+ const n = pv(e.new_value);
58+ const provider = typeof e.meta?.provider === 'string' ? e.meta.provider : null;
59+ return (
60+ <tr key={e.id}>
61+ <Td primary>
62+ {e.entity ? <EntityLink e={e.entity} /> : <span className="text-ink-3">—</span>}
63+ {e.entity?.organization && <span className="ml-2 text-xs text-ink-3">{e.entity.organization.name}</span>}
64+ </Td>
65+ <Td label="Provider" className="text-ink-2">{provider ?? <span className="text-ink-3">—</span>}</Td>
66+ <Td label="Input / 1M"><Move from={o.input} to={n.input} /></Td>
67+ <Td label="Output / 1M"><Move from={o.output} to={n.output} /></Td>
68+ <Td label="Observed" className="text-ink-2" title={fmtDateTime(e.observed_at)}>{fmtAgo(e.observed_at)}</Td>
69+ <Td label="Source">
70+ {e.source_url ? (
71+ <a href={e.source_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs text-ink-2 hover:text-accent">
72+ {host(e.source_url)} <ExternalLink className="size-3" aria-hidden />
73+ </a>
74+ ) : (
75+ <span className="text-ink-3">—</span>
76+ )}
77+ </Td>
78+ </tr>
79+ );
80+ })}
81+ </tbody>
82+ </DataTable>
83+ {rows.length > 0 && <Note className="mt-3">Movers are PRICE_CHANGED events emitted when a provider's published price for a model differs from the previous observation. Green arrow = cheaper, red = dearer.</Note>}
84+ </>
85+ );
86+}
added apps/web/src/components/prices/scale-toggle.tsx +38 −0
@@ -0,0 +1,38 @@
1+'use client';
2+import { type ReactNode, useState } from 'react';
3+import { cn } from '@/lib/cn';
4+
5+/**
6+ * Linear / log axis switch for a server-rendered chart: both variants are rendered by the server (SEO, no client
7+ * fetch); this only toggles which one is visible and mirrors the choice into `?scale=` with `replaceState`
8+ * (no navigation, no re-render of the page).
9+ */
10+export function ScaleToggle({ linear, log, initial = 'linear', className }: { linear: ReactNode; log: ReactNode; initial?: 'linear' | 'log'; className?: string }) {
11+ const [scale, setScale] = useState<'linear' | 'log'>(initial);
12+ const set = (s: 'linear' | 'log') => {
13+ setScale(s);
14+ try {
15+ const url = new URL(window.location.href);
16+ if (s === 'log') url.searchParams.set('scale', 'log');
17+ else url.searchParams.delete('scale');
18+ window.history.replaceState(null, '', url.toString());
19+ } catch {
20+ /* ignore */
21+ }
22+ };
23+ const btn = (s: 'linear' | 'log', label: string) => (
24+ <button type="button" onClick={() => set(s)} aria-pressed={scale === s} className={cn('h-9 border px-3 text-xs font-medium transition-colors', scale === s ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
25+ {label}
26+ </button>
27+ );
28+ return (
29+ <div className={className}>
30+ <div className="mb-2 flex items-center gap-1" role="group" aria-label="Y axis scale">
31+ {btn('linear', 'Linear')}
32+ {btn('log', 'Log scale')}
33+ </div>
34+ <div hidden={scale !== 'linear'}>{linear}</div>
35+ <div hidden={scale !== 'log'}>{log}</div>
36+ </div>
37+ );
38+}
added apps/web/src/components/timeline/chip-row.tsx +23 −0
@@ -0,0 +1,23 @@
1+import Link from 'next/link';
2+import { cn } from '@/lib/cn';
3+import { fmtInt } from '@/lib/format';
4+
5+export type ChipItem = { href: string; label: string; count?: number | string | null; active?: boolean };
6+
7+/**
8+ * Horizontal row of link chips (years, categories, ranges). Scrolls on mobile without a visible scrollbar,
9+ * wraps on desktop. Server component — selection lives in the URL.
10+ */
11+export function ChipRow({ items, label, className }: { items: ChipItem[]; label: string; className?: string }) {
12+ if (!items.length) return null;
13+ return (
14+ <nav aria-label={label} className={cn('no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0', className)}>
15+ {items.map((it) => (
16+ <Link key={it.href + it.label} href={it.href} aria-current={it.active ? 'true' : undefined} className={cn('inline-flex h-9 shrink-0 items-center gap-1.5 border px-2.5 text-sm transition-colors', it.active ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
17+ <span>{it.label}</span>
18+ {it.count !== undefined && it.count !== null && <span className={cn('tnum text-[11px]', it.active ? 'text-canvas/70' : 'text-ink-3')}>{fmtInt(it.count)}</span>}
19+ </Link>
20+ ))}
21+ </nav>
22+ );
23+}
added apps/web/src/lib/admin/actions.ts +150 −0
@@ -0,0 +1,150 @@
1+'use server';
2+import { revalidatePath } from 'next/cache';
3+import { redirect } from 'next/navigation';
4+import { ApiError } from '@/lib/api';
5+import { AdminAuthError, adminApi, claimHistory } from './admin-api';
6+import { clearAdminToken, setAdminToken } from './session';
7+
8+/**
9+ * Server actions for the admin console. Each mutating action calls the API with the cookie token, revalidates the
10+ * section and redirects back with a `?notice=` message (no client state, works without JS).
11+ */
12+
13+function describe(e: unknown): string {
14+ if (e instanceof AdminAuthError) return 'Session expired — sign in again.';
15+ if (e instanceof ApiError) return e.detail ? `API ${e.status}: ${e.detail}` : e.message;
16+ return (e as Error)?.message ?? 'Unknown error';
17+}
18+
19+function back(path: string, notice: string, ok = true): never {
20+ const p = new URLSearchParams();
21+ p.set('notice', notice);
22+ if (!ok) p.set('level', 'error');
23+ redirect(`${path}${path.includes('?') ? '&' : '?'}${p.toString()}`);
24+}
25+
26+async function run(path: string, okMsg: (r: unknown) => string, fn: () => Promise<unknown>): Promise<never> {
27+ let msg: string;
28+ let ok = true;
29+ try {
30+ const r = await fn();
31+ msg = okMsg(r);
32+ } catch (e) {
33+ if (e instanceof AdminAuthError) redirect('/admin?expired=1');
34+ msg = describe(e);
35+ ok = false;
36+ }
37+ revalidatePath(path.split('?')[0] ?? path);
38+ back(path, msg, ok);
39+}
40+
41+// ------------------------------------------------------------------------------------------------------------ auth
42+export async function loginAction(_prev: { error: string | null } | undefined, formData: FormData): Promise<{ error: string | null }> {
43+ const token = String(formData.get('token') ?? '').trim();
44+ if (!token) return { error: 'Enter the admin token.' };
45+ try {
46+ await adminApi.overview(token);
47+ } catch (e) {
48+ if (e instanceof AdminAuthError) return { error: 'Token rejected by the API.' };
49+ return { error: `API unreachable: ${describe(e)}` };
50+ }
51+ await setAdminToken(token);
52+ redirect('/admin/overview');
53+}
54+
55+export async function logoutAction(): Promise<void> {
56+ await clearAdminToken();
57+ redirect('/admin?signed_out=1');
58+}
59+
60+// ------------------------------------------------------------------------------------------------------------ connectors
61+export async function runConnectorAction(formData: FormData): Promise<void> {
62+ const name = String(formData.get('name') ?? '');
63+ const ret = String(formData.get('return') ?? '/admin/connectors');
64+ await run(ret, () => `Run queued for ${name} — the scheduler picks it up on its next tick.`, () => adminApi.runConnector(name, true));
65+}
66+
67+export async function toggleConnectorAction(formData: FormData): Promise<void> {
68+ const name = String(formData.get('name') ?? '');
69+ const enabled = String(formData.get('enabled') ?? '') === 'true';
70+ const ret = String(formData.get('return') ?? '/admin/connectors');
71+ await run(ret, () => `${name} ${enabled ? 'enabled' : 'disabled'}.`, () => adminApi.patchConnector(name, { enabled }));
72+}
73+
74+// ------------------------------------------------------------------------------------------------------------ jobs
75+export async function retryJobAction(formData: FormData): Promise<void> {
76+ const id = String(formData.get('id') ?? '');
77+ const ret = String(formData.get('return') ?? '/admin/jobs');
78+ await run(ret, () => `Job ${id} re-queued.`, () => adminApi.retryJob(id));
79+}
80+
81+export async function requeueDeadAction(formData: FormData): Promise<void> {
82+ const ret = String(formData.get('return') ?? '/admin/jobs');
83+ await run(
84+ ret,
85+ (r) => {
86+ const n = (r as { requeued?: number; count?: number } | null)?.requeued ?? (r as { count?: number } | null)?.count;
87+ return n === undefined ? 'Dead jobs re-queued.' : `${n} dead job${n === 1 ? '' : 's'} re-queued.`;
88+ },
89+ () => adminApi.requeueDead(),
90+ );
91+}
92+
93+// ------------------------------------------------------------------------------------------------------------ review
94+export async function reviewAction(formData: FormData): Promise<void> {
95+ const id = String(formData.get('id') ?? '');
96+ const action = String(formData.get('action') ?? '') as 'approve' | 'reject';
97+ const ret = String(formData.get('return') ?? '/admin/review');
98+ if (action !== 'approve' && action !== 'reject') back(ret, 'Unknown action.', false);
99+ await run(ret, (r) => `Review ${id} ${(r as { status?: string })?.status ?? action}.`, () => adminApi.reviewAction(id, action));
100+}
101+
102+/**
103+ * Conflict resolution: the payload only carries the two values and their source URLs, not claim ids. We look the
104+ * claim up in the entity's claim history (same property, same value, same source URL — newest first) and approve
105+ * with `resolution.keep_claim_id` so the API promotes it and supersedes the other.
106+ */
107+export async function keepConflictSideAction(formData: FormData): Promise<void> {
108+ const id = String(formData.get('id') ?? '');
109+ const slug = String(formData.get('slug') ?? '');
110+ const property = String(formData.get('property') ?? '');
111+ const valueJson = String(formData.get('value') ?? 'null');
112+ const sourceUrl = String(formData.get('source_url') ?? '');
113+ const ret = String(formData.get('return') ?? '/admin/review');
114+ await run(
115+ ret,
116+ () => `Conflict ${id} resolved — kept ${property} = ${valueJson}.`,
117+ async () => {
118+ let value: unknown = null;
119+ try {
120+ value = JSON.parse(valueJson);
121+ } catch {
122+ value = valueJson;
123+ }
124+ const hist = await claimHistory(slug, property);
125+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
126+ const match = hist.items.find((c) => same(c.value, value) && (!sourceUrl || c.source_url === sourceUrl)) ?? hist.items.find((c) => same(c.value, value));
127+ if (!match) throw new Error(`No claim with ${property} = ${valueJson} found in the history of ${slug}; approve or reject without promotion instead.`);
128+ return adminApi.reviewAction(id, 'approve', { keep_claim_id: match.id, kept_value: value, kept_source: sourceUrl || null });
129+ },
130+ );
131+}
132+
133+// ------------------------------------------------------------------------------------------------------------ duplicates
134+export async function mergeAction(formData: FormData): Promise<void> {
135+ const source = String(formData.get('source_id') ?? '');
136+ const target = String(formData.get('target_id') ?? '');
137+ const ret = String(formData.get('return') ?? '/admin/entities/duplicates');
138+ await run(ret, () => `Merged ${source} → ${target}.`, () => adminApi.merge(source, target));
139+}
140+
141+// ------------------------------------------------------------------------------------------------------------ maintenance
142+export async function flushCacheAction(): Promise<void> {
143+ await run('/admin/cache', (r) => `Cache flushed (${(r as { flushed?: number })?.flushed ?? '?'} keys).`, () => adminApi.flushCache());
144+}
145+export async function recomputeStatsAction(): Promise<void> {
146+ await run('/admin/cache', () => 'Stats recompute triggered.', () => adminApi.recomputeStats());
147+}
148+export async function recomputeQualityAction(): Promise<void> {
149+ await run('/admin/cache', () => 'Quality recompute triggered.', () => adminApi.recomputeQuality());
150+}
added apps/web/src/lib/admin/admin-api.ts +110 −0
@@ -0,0 +1,110 @@
1+import 'server-only';
2+import { redirect } from 'next/navigation';
3+import { API_URL, ApiError, type Query } from '@/lib/api';
4+import { getAdminToken } from './session';
5+import type { AdminConnectorsPayload, AdminDocument, AdminError, AdminJobsPayload, AdminLlmHealth, AdminLlmJobsPayload, AdminOverview, AdminPage, AdminRun, AdminSnapshot, DuplicatesPayload, Infrastructure, ReviewPayload } from './types';
6+
7+/**
8+ * Server-only admin client. Every call carries `x-aia-admin-token` from the httpOnly cookie (or an explicit token
9+ * during login) and is `no-store`. A 401/403 raises `AdminAuthError`; pages turn it into a redirect to the login form.
10+ */
11+const BASE = `${API_URL}/api/v1/admin`;
12+
13+export class AdminAuthError extends Error {
14+ constructor() {
15+ super('admin token missing or rejected');
16+ this.name = 'AdminAuthError';
17+ }
18+}
19+
20+function qs(query?: Query): string {
21+ if (!query) return '';
22+ const p = new URLSearchParams();
23+ for (const [k, v] of Object.entries(query)) {
24+ if (v === undefined || v === null || v === '') continue;
25+ p.set(k, String(v));
26+ }
27+ const s = p.toString();
28+ return s ? `?${s}` : '';
29+}
30+
31+export async function adminRequest<T>(path: string, opts: { method?: 'GET' | 'POST' | 'PATCH'; query?: Query; body?: unknown; token?: string } = {}): Promise<T> {
32+ const token = opts.token ?? (await getAdminToken());
33+ if (!token) throw new AdminAuthError();
34+ const url = `${BASE}${path}${qs(opts.query)}`;
35+ const init: RequestInit = { method: opts.method ?? 'GET', cache: 'no-store', headers: { accept: 'application/json', 'x-aia-admin-token': token } };
36+ if (opts.body !== undefined) {
37+ init.body = JSON.stringify(opts.body);
38+ (init.headers as Record<string, string>)['content-type'] = 'application/json';
39+ }
40+ let res: Response;
41+ try {
42+ res = await fetch(url, init);
43+ } catch (e) {
44+ throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`);
45+ }
46+ if (res.status === 401 || res.status === 403) throw new AdminAuthError();
47+ if (!res.ok) {
48+ let detail: string | null = null;
49+ try {
50+ const body = (await res.json()) as { detail?: unknown };
51+ detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null;
52+ } catch {
53+ /* non-JSON error body */
54+ }
55+ throw new ApiError(res.status, path, detail);
56+ }
57+ return (await res.json()) as T;
58+}
59+
60+/** Ensure a session cookie exists; otherwise go to the login form. */
61+export async function requireAdmin(): Promise<string> {
62+ const token = await getAdminToken();
63+ if (!token) redirect('/admin');
64+ return token;
65+}
66+
67+export type Loaded<T> = { ok: true; data: T } | { ok: false; error: string };
68+
69+/** Resolve an admin fetch for a page panel: auth failure → login redirect; other failures → `{ ok: false, error }`. */
70+export async function load<T>(p: Promise<T>): Promise<Loaded<T>> {
71+ try {
72+ return { ok: true, data: await p };
73+ } catch (e) {
74+ if (e instanceof AdminAuthError) redirect('/admin?expired=1');
75+ const msg = e instanceof ApiError ? (e.detail ? `${e.status}: ${e.detail}` : e.message) : (e as Error).message;
76+ return { ok: false, error: msg };
77+ }
78+}
79+
80+export const adminApi = {
81+ overview: (token?: string) => adminRequest<AdminOverview>('/overview', { token }),
82+ connectors: () => adminRequest<AdminConnectorsPayload>('/connectors'),
83+ runConnector: (name: string, force = true) => adminRequest<{ queued: boolean }>(`/connectors/${encodeURIComponent(name)}/run`, { method: 'POST', body: { force } }),
84+ patchConnector: (name: string, patch: { enabled?: boolean; interval_seconds?: number; priority?: number }) => adminRequest<unknown>(`/connectors/${encodeURIComponent(name)}`, { method: 'PATCH', body: patch }),
85+ runs: (query: Query) => adminRequest<AdminPage<AdminRun>>('/runs', { query }),
86+ errors: (query: Query) => adminRequest<AdminPage<AdminError>>('/errors', { query }),
87+ documents: (query: Query) => adminRequest<AdminPage<AdminDocument>>('/documents', { query }),
88+ document: (id: string) => adminRequest<AdminDocument>(`/documents/${encodeURIComponent(id)}`),
89+ snapshot: (id: string) => adminRequest<AdminSnapshot>(`/snapshots/${encodeURIComponent(id)}`),
90+ jobs: (query: Query) => adminRequest<AdminJobsPayload>('/jobs', { query }),
91+ retryJob: (id: string) => adminRequest<unknown>(`/jobs/${encodeURIComponent(id)}/retry`, { method: 'POST', body: {} }),
92+ requeueDead: () => adminRequest<{ requeued?: number } & Record<string, unknown>>('/jobs/requeue-dead', { method: 'POST', body: {} }),
93+ llmJobs: (query: Query) => adminRequest<AdminLlmJobsPayload>('/llm-jobs', { query }),
94+ llmHealth: () => adminRequest<AdminLlmHealth>('/llm/health'),
95+ review: (query: Query) => adminRequest<ReviewPayload>('/review', { query }),
96+ reviewAction: (id: string, action: 'approve' | 'reject' | 'edit', resolution?: Record<string, unknown>) => adminRequest<{ ok: boolean; status: string; effect?: unknown }>(`/review/${encodeURIComponent(id)}`, { method: 'POST', body: { action, resolution } }),
97+ duplicates: (query: Query) => adminRequest<DuplicatesPayload>('/entities/duplicates', { query }),
98+ merge: (source_id: string, target_id: string) => adminRequest<unknown>('/entities/merge', { method: 'POST', body: { source_id, target_id } }),
99+ infrastructure: () => adminRequest<Infrastructure>('/infrastructure'),
100+ flushCache: () => adminRequest<{ flushed: number }>('/cache/flush', { method: 'POST', body: {} }),
101+ recomputeStats: () => adminRequest<unknown>('/stats/recompute', { method: 'POST', body: {} }),
102+ recomputeQuality: () => adminRequest<unknown>('/quality/recompute', { method: 'POST', body: {} }),
103+};
104+
105+/** Public (non-admin) history lookup used to map a conflict payload to concrete claim ids. */
106+export async function claimHistory(slug: string, property: string): Promise<{ items: { id: string; property: string; value: unknown; source_url: string | null; status: string; observed_at: string }[] }> {
107+ const res = await fetch(`${API_URL}/api/v1/entities/${encodeURIComponent(slug)}/history?property=${encodeURIComponent(property)}`, { cache: 'no-store', headers: { accept: 'application/json' } });
108+ if (!res.ok) throw new ApiError(res.status, '/history', null);
109+ return (await res.json()) as { items: { id: string; property: string; value: unknown; source_url: string | null; status: string; observed_at: string }[] };
110+}
added apps/web/src/lib/admin/session.ts +31 −0
@@ -0,0 +1,31 @@
1+import 'server-only';
2+import { cookies } from 'next/headers';
3+
4+/**
5+ * Admin session = the admin token itself, stored in an httpOnly cookie set by the login server action.
6+ * It is read only on the server (`cookies()`), forwarded as `x-aia-admin-token`, and never rendered.
7+ */
8+export const ADMIN_COOKIE = 'aia-admin';
9+export const ADMIN_COOKIE_MAX_AGE = 12 * 3600; // 12 h
10+
11+export async function getAdminToken(): Promise<string | null> {
12+ const store = await cookies();
13+ const v = store.get(ADMIN_COOKIE)?.value;
14+ return v && v.length > 0 ? v : null;
15+}
16+
17+export async function setAdminToken(token: string): Promise<void> {
18+ const store = await cookies();
19+ store.set(ADMIN_COOKIE, token, {
20+ httpOnly: true,
21+ sameSite: 'lax',
22+ secure: process.env.NODE_ENV === 'production',
23+ path: '/admin',
24+ maxAge: ADMIN_COOKIE_MAX_AGE,
25+ });
26+}
27+
28+export async function clearAdminToken(): Promise<void> {
29+ const store = await cookies();
30+ store.set(ADMIN_COOKIE, '', { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/admin', maxAge: 0 });
31+}
added apps/web/src/lib/admin/types.ts +260 −0
@@ -0,0 +1,260 @@
1+/** Admin API shapes (docs/API.md "Admin routes" + live observations 2026-09-11). Loose on purpose: we render what we know. */
2+import type { Num, Stats } from '@/lib/types';
3+
4+export interface AdminRun {
5+ id: string;
6+ connector_name: string;
7+ started_at: string | null;
8+ finished_at: string | null;
9+ status: string;
10+ duration_ms: Num;
11+ docs_discovered: Num;
12+ docs_fetched: Num;
13+ docs_changed: Num;
14+ docs_unchanged: Num;
15+ docs_failed: Num;
16+ entities_created: Num;
17+ entities_updated: Num;
18+ claims_written: Num;
19+ relations_written: Num;
20+ events_emitted: Num;
21+ error: string | null;
22+ meta: Record<string, unknown>;
23+}
24+
25+export interface AdminOverview {
26+ stats: Partial<Stats> & Record<string, unknown>;
27+ queue: Record<string, Record<string, Num>>;
28+ heartbeats: Record<string, unknown>;
29+ connectors: Record<string, Num>;
30+ review_pending: Num;
31+ review_by_kind?: Record<string, Num>;
32+ recent_errors: Num;
33+ llm: { available?: boolean; jobs_24h: Num; tokens_24h: Num; failed_24h?: Num; by_stage: { stage: string; status: string; n: Num }[] };
34+ recent_runs?: AdminRun[];
35+ archive?: Record<string, Num>;
36+}
37+
38+export interface AdminConnector {
39+ name: string;
40+ source_id: string | null;
41+ label: string;
42+ description: string | null;
43+ enabled: boolean;
44+ priority: Num;
45+ interval_seconds: Num;
46+ min_interval_seconds?: Num;
47+ max_interval_seconds?: Num;
48+ parser_version: string | null;
49+ rate_limit_per_min?: Num;
50+ owner?: string;
51+ last_attempt_at: string | null;
52+ last_success_at: string | null;
53+ last_change_at: string | null;
54+ next_run_at: string | null;
55+ last_duration_ms: Num;
56+ consecutive_failures: Num;
57+ consecutive_unchanged?: Num;
58+ circuit_open_until: string | null;
59+ health: string;
60+ meta: Record<string, unknown>;
61+ source_key?: string;
62+ source_name?: string | null;
63+ source_tier?: Num;
64+ source_domain?: string | null;
65+ last_run?: AdminRun | null;
66+ documents?: Num;
67+ snapshots?: Num;
68+ run_now_pending?: boolean;
69+ in_code?: boolean;
70+}
71+export interface AdminConnectorsPayload {
72+ items: AdminConnector[];
73+ unregistered_in_db?: string[];
74+}
75+
76+export interface AdminPage<T> {
77+ items: T[];
78+ total: number;
79+ limit: number;
80+ offset: number;
81+}
82+
83+export interface AdminError {
84+ id: number | string;
85+ connector_name: string | null;
86+ run_id: string | null;
87+ url: string | null;
88+ error_type: string | null;
89+ message: string;
90+ created_at: string;
91+}
92+
93+export interface AdminDocument {
94+ id: string;
95+ source_id: string | null;
96+ connector_name: string | null;
97+ url: string;
98+ canonical_url: string | null;
99+ doc_type: string | null;
100+ title: string | null;
101+ entity_id: string | null;
102+ status: string;
103+ first_seen_at: string | null;
104+ last_fetched_at: string | null;
105+ last_changed_at: string | null;
106+ last_status: Num;
107+ etag?: string | null;
108+ last_modified?: string | null;
109+ content_hash?: string | null;
110+ fetch_count: Num;
111+ change_count: Num;
112+ fail_count: Num;
113+ fetch_interval_s?: Num;
114+ next_fetch_at?: string | null;
115+ priority?: Num;
116+ needs_llm?: boolean;
117+ meta?: Record<string, unknown>;
118+ entity_slug?: string | null;
119+ entity_name?: string | null;
120+ entity_type?: string | null;
121+ snapshots?: number | AdminSnapshotRow[];
122+ last_processing_status?: string | null;
123+ source_name?: string | null;
124+ source_tier?: Num;
125+}
126+
127+export interface AdminSnapshotRow {
128+ id: string;
129+ run_id: string | null;
130+ url: string;
131+ final_url: string | null;
132+ observed_at: string;
133+ http_status: Num;
134+ content_type: string | null;
135+ content_hash: string | null;
136+ byte_size: Num;
137+ text_hash: string | null;
138+ parser_version: string | null;
139+ connector_version: string | null;
140+ transport: string | null;
141+ changed: boolean;
142+ processing_status: string | null;
143+ created_at: string;
144+ has_structured?: boolean;
145+ has_text?: boolean;
146+ has_raw?: boolean;
147+}
148+
149+export interface AdminSnapshot extends AdminSnapshotRow {
150+ document_id: string;
151+ headers?: Record<string, string>;
152+ structured: unknown;
153+ diff: unknown;
154+ document_url?: string;
155+ doc_type?: string | null;
156+ connector_name?: string | null;
157+ entity_id?: string | null;
158+ entity_slug?: string | null;
159+ entity_name?: string | null;
160+ text: string | null;
161+ text_chars?: Num;
162+ text_truncated?: boolean;
163+ text_error?: string | null;
164+}
165+
166+export interface AdminJob {
167+ id: string;
168+ kind: string;
169+ payload: Record<string, unknown>;
170+ priority: Num;
171+ status: string;
172+ attempts: Num;
173+ max_attempts: Num;
174+ run_after: string | null;
175+ locked_by: string | null;
176+ locked_at: string | null;
177+ started_at: string | null;
178+ finished_at: string | null;
179+ error: string | null;
180+ batch_id: string | null;
181+ dedupe_key: string | null;
182+ created_at: string;
183+}
184+export type AdminJobsPayload = AdminPage<AdminJob> & { depth?: Record<string, Record<string, Num>> };
185+
186+export interface AdminLlmJob {
187+ id: string;
188+ job_id: string | null;
189+ task_type: string | null;
190+ stage: string | null;
191+ engine: string | null;
192+ model: string | null;
193+ node: string | null;
194+ schema_name: string | null;
195+ snapshot_id: string | null;
196+ entity_id: string | null;
197+ input_tokens: Num;
198+ output_tokens: Num;
199+ duration_ms: Num;
200+ status: string;
201+ error: string | null;
202+ created_at: string;
203+}
204+export type AdminLlmJobsPayload = AdminPage<AdminLlmJob> & { totals?: { stage: string; model: string; status: string; n: Num; input_tokens: Num; output_tokens: Num; avg_ms: Num }[] };
205+export interface AdminLlmHealth {
206+ engine?: string;
207+ available: boolean;
208+ reachable?: boolean;
209+ models?: Record<string, string>;
210+ embedding_model?: string;
211+ base_url_configured?: boolean;
212+ error?: string;
213+}
214+
215+export interface ReviewEntity {
216+ id: string;
217+ name: string;
218+ slug: string;
219+ status: string;
220+ entity_type: string;
221+}
222+export interface ReviewItem {
223+ id: string;
224+ kind: string;
225+ entity_ids: string[];
226+ payload: Record<string, unknown>;
227+ reason: string | null;
228+ status: string;
229+ resolution: Record<string, unknown> | null;
230+ created_at: string;
231+ resolved_at: string | null;
232+ dedupe_key?: string | null;
233+ entities: ReviewEntity[] | null;
234+}
235+export type ReviewPayload = AdminPage<ReviewItem> & { by_kind?: { kind: string; status: string; n: Num }[] };
236+
237+export interface DuplicateSide {
238+ id: string;
239+ slug: string;
240+ name: string;
241+ organization: string | null;
242+ first_seen_at: string | null;
243+ claims: Num;
244+}
245+export interface DuplicatesPayload {
246+ threshold: Num;
247+ items: { entity_type: string; similarity: Num; a: DuplicateSide; b: DuplicateSide }[];
248+}
249+
250+export interface Infrastructure {
251+ hostname?: string;
252+ python?: string;
253+ platform?: string;
254+ api_uptime_s?: Num;
255+ env?: string;
256+ heartbeats?: Record<string, unknown>;
257+ archive?: { raw_bytes: Num; raw_files: Num; text_bytes: Num; text_files: Num };
258+ data_dir_exists?: boolean;
259+ database?: { db_bytes: Num; database?: string; pg_version?: string; connections?: Num; max_connections?: Num; tables?: { table: string; total_bytes: Num; data_bytes: Num; rows_estimate: Num }[] };
260+}
modified apps/web/src/lib/site.ts +39 −1
@@ -158,8 +158,43 @@ export const routes = {
158 tools: () => '/tools', 158 tools: () => '/tools',
159 changes: () => '/changes', 159 changes: () => '/changes',
160 changesDay: (date: string) => `/changes/${date}`, 160 changesDay: (date: string) => `/changes/${date}`,
161− timeline: () => '/timeline', 161+ timeline: (q?: { entity?: string; year?: string; category?: string }) => {
162+ const p = new URLSearchParams();
163+ if (q?.entity) p.set('entity', q.entity);
164+ if (q?.year) p.set('year', q.year);
165+ if (q?.category) p.set('category', q.category);
166+ const s = p.toString();
167+ return s ? `/timeline?${s}` : '/timeline';
168+ },
162 compare: (ids?: string[]) => (ids?.length ? `/compare?ids=${ids.map(encodeURIComponent).join(',')}` : '/compare'), 169 compare: (ids?: string[]) => (ids?.length ? `/compare?ids=${ids.map(encodeURIComponent).join(',')}` : '/compare'),
170+ prices: () => '/prices',
171+ hardwareFit: (q?: { memory_gb?: number | string; quant?: string; context?: number | string }) => {
172+ const p = new URLSearchParams();
173+ if (q?.memory_gb !== undefined && q.memory_gb !== '') p.set('memory_gb', String(q.memory_gb));
174+ if (q?.quant) p.set('quant', q.quant);
175+ if (q?.context !== undefined && q.context !== '') p.set('context', String(q.context));
176+ const s = p.toString();
177+ return s ? `/hardware/fit?${s}` : '/hardware/fit';
178+ },
179+ benchmark: (slug: string, q?: { config?: string; model?: string }) => {
180+ const p = new URLSearchParams();
181+ if (q?.config) p.set('config', q.config);
182+ if (q?.model) p.set('model', q.model);
183+ const s = p.toString();
184+ return `/benchmarks/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;
185+ },
186+ diff: (q?: { a?: string; b?: string; scope?: string }) => {
187+ const p = new URLSearchParams();
188+ if (q?.a) p.set('a', q.a);
189+ if (q?.b) p.set('b', q.b);
190+ if (q?.scope && q.scope !== 'all') p.set('scope', q.scope);
191+ const s = p.toString();
192+ return s ? `/diff?${s}` : '/diff';
193+ },
194+ graph: (slug: string, depth?: 1 | 2) => `/graph/${encodeURIComponent(slug)}${depth === 2 ? '?depth=2' : ''}`,
195+ /** Entity page "as of" view (History tab). */
196+ entityAsOf: (e: { entity_type: string; slug: string }, date: string) => `${routes.entity(e)}?tab=history&asof=${encodeURIComponent(date)}`,
197+ admin: (section?: string) => (section ? `/admin/${section}` : '/admin'),
163 explore: () => '/explore', 198 explore: () => '/explore',
164 exploreType: (t: string) => `/explore/${encodeURIComponent(t)}`, 199 exploreType: (t: string) => `/explore/${encodeURIComponent(t)}`,
165 methodology: () => '/methodology', 200 methodology: () => '/methodology',
@@ -192,6 +227,7 @@ export const exploreNav = [
192 export const primaryNav = [ 227 export const primaryNav = [
193 { href: '/changes', label: 'Changes' }, 228 { href: '/changes', label: 'Changes' },
194 { href: '/timeline', label: 'Timeline' }, 229 { href: '/timeline', label: 'Timeline' },
230+ { href: '/prices', label: 'Prices' },
195 { href: '/compare', label: 'Compare' }, 231 { href: '/compare', label: 'Compare' },
196 { href: '/developers', label: 'Developers' }, 232 { href: '/developers', label: 'Developers' },
197 ]; 233 ];
@@ -199,6 +235,8 @@ export const moreNav = [
199 { href: '/about', label: 'About' }, 235 { href: '/about', label: 'About' },
200 { href: '/methodology', label: 'Methodology' }, 236 { href: '/methodology', label: 'Methodology' },
201 { href: '/sources', label: 'Sources' }, 237 { href: '/sources', label: 'Sources' },
238+ { href: '/diff', label: 'Diff two dates' },
239+ { href: '/hardware/fit', label: 'Hardware fit' },
202 { href: '/developers', label: 'API & Developers' }, 240 { href: '/developers', label: 'API & Developers' },
203 { href: '/bot', label: 'AIAtlasBot' }, 241 { href: '/bot', label: 'AIAtlasBot' },
204 ]; 242 ];
modified apps/web/src/lib/types.ts +48 −5
@@ -265,15 +265,37 @@ export type CompaniesPage = Page<CompanyRow> & { facets?: CompanyFacets };
265 export type ProviderRow = EntitySummary & { model_count: Num; price_count: Num; min_input_per_mtok: Num; min_output_per_mtok: Num }; 265 export type ProviderRow = EntitySummary & { model_count: Num; price_count: Num; min_input_per_mtok: Num; min_output_per_mtok: Num };
266 export type BenchmarkRow = EntitySummary & { result_count: Num; model_count: Num; top: { model: EntitySummary; score: number } | null }; 266 export type BenchmarkRow = EntitySummary & { result_count: Num; model_count: Num; top: { model: EntitySummary; score: number } | null };
267 267
268+export interface PriceIndexPoint {
269+ day: string;
270+ median_input: Num;
271+ median_output: Num;
272+ min_input: Num;
273+ max_input?: Num;
274+ models: number;
275+ offers?: number;
276+}
268 export interface PriceIndex { 277 export interface PriceIndex {
269− series: { day: string; median_input: Num; median_output: Num; min_input: Num; models: number }[]; 278+ days?: number;
279+ note?: string;
280+ series: PriceIndexPoint[];
270 movers: ChangeEvent[]; 281 movers: ChangeEvent[];
271 } 282 }
272 283
284+export interface HardwareFitItem {
285+ model: EntitySummary;
286+ parameter_count: number;
287+ estimated_memory_gb: number;
288+ fits: boolean;
289+ headroom_gb: number;
290+ quantization: string;
291+ note: string;
292+}
273 export interface HardwareFit { 293 export interface HardwareFit {
274 inputs: Record<string, unknown>; 294 inputs: Record<string, unknown>;
295+ estimated?: boolean;
296+ counts?: { fits?: Num; evaluated?: Num };
275 assumptions: string[]; 297 assumptions: string[];
276− items: { model: EntitySummary; parameter_count: number; estimated_memory_gb: number; fits: boolean; headroom_gb: number; quantization: string; note: string }[]; 298+ items: HardwareFitItem[];
277 } 299 }
278 300
279 export interface ExploreType { 301 export interface ExploreType {
@@ -294,7 +316,8 @@ export interface ChangeCategories {
294 } 316 }
295 317
296 export interface TimelinePayload { 318 export interface TimelinePayload {
297− items: { month: string; events: ChangeEvent[] }[]; 319+ items: { month: string; count?: number; events: ChangeEvent[] }[];
320+ total?: number;
298 } 321 }
299 322
300 export interface CompareDimension { 323 export interface CompareDimension {
@@ -302,6 +325,8 @@ export interface CompareDimension {
302 label: string; 325 label: string;
303 unit?: string; 326 unit?: string;
304 kind: 'number' | 'text' | 'list' | 'bool' | 'date'; 327 kind: 'number' | 'text' | 'list' | 'bool' | 'date';
328+ /** Where the value comes from: `attr` (claims) · `prices` · `results` (benchmark) — informational. */
329+ source?: string;
305 } 330 }
306 export interface ComparePayload { 331 export interface ComparePayload {
307 entity_type: string; 332 entity_type: string;
@@ -312,6 +337,7 @@ export interface ComparePayload {
312 export interface DiffPayload { 337 export interface DiffPayload {
313 a: string; 338 a: string;
314 b: string; 339 b: string;
340+ scope?: Record<string, unknown> | string;
315 new_entities: EntitySummary[]; 341 new_entities: EntitySummary[];
316 gone_entities: EntitySummary[]; 342 gone_entities: EntitySummary[];
317 property_changes: ChangeEvent[]; 343 property_changes: ChangeEvent[];
@@ -366,12 +392,29 @@ export interface SitemapPayload {
366 total: number; 392 total: number;
367 } 393 }
368 394
395+export interface GraphNode {
396+ id: string;
397+ slug: string;
398+ name: string;
399+ entity_type: string;
400+ organization_name?: string | null;
401+}
402+export interface GraphEdge {
403+ source: string;
404+ target: string;
405+ predicate: string;
406+}
369 export interface GraphPayload { 407 export interface GraphPayload {
370− nodes: { id: string; slug: string; name: string; entity_type: string; organization_name?: string }[]; 408+ root?: string;
371− edges: { source: string; target: string; predicate: string }[]; 409+ nodes: GraphNode[];
410+ edges: GraphEdge[];
372 } 411 }
373 412
374 export interface AsOfPayload { 413 export interface AsOfPayload {
414+ id?: string;
415+ slug?: string;
416+ name?: string;
417+ entity_type?: string;
375 existed: boolean; 418 existed: boolean;
376 first_seen_at: string | null; 419 first_seen_at: string | null;
377 date: string; 420 date: string;
modified docs/FRONTEND.md +88 −22
@@ -102,33 +102,99 @@ Do not fork these; extend with props or add new components in your own folder.
102 102
103 ## 5. Routes built 103 ## 5. Routes built
104 104
105−`/` · `/search` · `/models` (+ facets, sort) · `/models/[slug]` · `/companies` · `/companies/[slug]` · `/[type]/[slug]` (providers, benchmarks, 105+`/` · `/search` · `/models` (+ facets, sort, Compare buttons) · `/models/[slug]` (+ `opengraph-image`) · `/companies` · `/companies/[slug]` (+ `opengraph-image`)
106−hardware, papers, frameworks, datasets, tools, repositories) · `/explore` · `/explore/[type]` · `/explore/[type]/[slug]` (types without a path) 106+· `/[type]/[slug]` (providers, hardware, papers, frameworks, datasets, tools, repositories) · `/benchmarks/[slug]` (dedicated leaderboard — static segment shadows
107−· `/papers /providers /benchmarks /hardware /frameworks /datasets /tools` listings · `/changes` · `/changes/[date]` · `/timeline` (basic) 107+`[type]/[slug]`) · `/explore` · `/explore/[type]` · `/explore/[type]/[slug]` · `/papers /providers /benchmarks /hardware /frameworks /datasets /tools` (typed tables)
108−· `/compare?ids=` (basic table) · `/methodology` · `/sources` · `/about` · `/developers` · `/bot` · `robots.ts` · `/sitemap.xml` + `/sitemap/[shard].xml` 108+· `/changes` · `/changes/[date]` · `/timeline?year=&category=&entity=` · `/prices?days=&sort=&provider=&model=&scale=` · `/compare?ids=` (picker + tray + matrix)
109−· `not-found.tsx` · `error.tsx` · `icon.svg` · `apple-icon` · `opengraph-image`. 109+· `/hardware/fit?memory_gb=&quant=&context=&fits=` · `/diff?a=&b=&scope=` · `/graph/[slug]?depth=` · `/admin/**` (token-gated, noindex) · `/methodology` · `/sources`
110− 110+· `/about` · `/developers` (live example responses) · `/bot` · `robots.ts` (disallows `/admin/`) · `/sitemap.xml` + `/sitemap/[shard].xml` · `manifest.ts`
111−## 6. Left for the next agent (use the components above) 111+· `icon-192.png` / `icon-512.png` (ImageResponse routes) · `not-found.tsx` · `error.tsx` · `icon.svg` · `apple-icon` · `opengraph-image`.
112− 112+
113−- **Compare** picker UI (suggest-driven multi-select, pinned "compare tray", shareable URL), per-type dimension rendering, shared-benchmark rows. 113+## 6. Wave 2 (2026-09-11) — components and page patterns added
114−- **Timeline** — richer `/timeline` (year scrubber, category chips, entity autocomplete, month density bars via `Bars`). 114+
115−- **Hardware fit tool** `/hardware/fit` (form memory_gb/quant/context → `GET /hardware/fit`, table with `Estimated`, assumptions list). 115+All numbers still come from the API; nothing below hardcodes counts, slugs or dates. Ownership of shared files stays as in §4 — extend, don't fork.
116−- **Price index** `/prices` (`GET /prices/index` → `LineChart` median/min, movers via `ChangeRow`) and per-model price sparkline in `/models` rows. 116+
117−- **Benchmarks** — leaderboard page per benchmark with config filter (`?config=`), `history=1`, result history chart per model. 117+### Compare (`components/compare/`)
118−- **Diff / history mode** — `/diff?a=&b=&scope=` and an "as of" toggle on entity pages (`GET /entities/{slug}/asof`, `/history?property=`) showing superseded claims. 118+- `compare-store.ts` (client) — the **compare tray**: `localStorage['aia-compare']` (≤ 6 `TrayItem { slug, name, entity_type, organization? }`), same-tab
119−- **Entity graph** (`GET /entities/{slug}/graph`) as an SVG neighbourhood on the Overview aside. 119+ `aia-compare-change` event + cross-tab `storage`. `useCompareTray()` → `{ items, ready, add, remove, toggle, clear, replace, has, type, full, canCompare }`;
120−- **Admin** (`/admin`, token in a cookie, `x-aia-admin-token` from a server action; never expose it client-side): connectors, runs, errors, review queue, duplicates, LLM jobs, infrastructure. 120+ helpers `addToTray/removeFromTray/toggleTray/readTray/clearTray`, `trayType(t)` (company-like → `company`, library/runtime → `framework`, quantization → `model`),
121−- Listings for papers/frameworks/datasets/tools could gain type-specific tables (currently `EntityRow` lists via `GenericListing`). 121+ `compareHref(items)`. The tray is type-homogeneous: adding another type **replaces** it (the API compares 2–6 entities of one type).
122−- `manifest.ts`, per-entity `opengraph-image` (optional). 122+- `CompareButton({ e, size: 'sm' | 'md' })` (client) — toggle with `aria-pressed`; used in entity headers, `/models`, `/providers`, `/hardware`, `/frameworks`,
123+ leaderboards and hardware-fit rows. `CompareTrayBar` (client) — fixed bar above the mobile tab bar / bottom-right on desktop, hidden when empty and on `/compare`;
124+ mount it **once per page** that shows Compare buttons.
125+- `ComparePicker` + `CompareTray` (client, `compare-picker.tsx`) — `/search/suggest` autocomplete filtered to the tray's type, chips with remove/clear, "Compare n →";
126+ seeds the tray from `?ids=` (URL wins) and mirrors tray changes back with `router.replace`.
127+- `CompareMatrix`, `SharedBenchmarks`, `ComparePrices` (server, `compare-matrix.tsx`) — dimension × entity table (sticky first column inside `.table-scroll`,
128+ `kind`-aware cells via `lib/format`, best-per-row bold — lower is better for `*_per_mtok`, per-cell `T{n} · source · ago` provenance), benchmarks present for
129+ every model, cheapest input/output per provider × entity. Gotcha: `.table-scroll .data-table td { white-space: nowrap }` out-specifies Tailwind
130+ `whitespace-normal` — use an inline style for a wrapping sticky column.
131+
132+### Timeline & prices
133+- `components/timeline/chip-row.tsx` — `ChipRow` (server link chips: years, categories, windows). Years derive from `stats.first_entity_at` and the months returned.
134+- `/timeline` — `Bars` month-density strip, sticky month headings, `ChangeRow showDate live={false}`, dynamic metadata (entity views are `noindex`).
135+- `components/prices/` — `ScaleToggle` (client: linear/log, `aria-pressed`, mirrors `?scale=log` with `replaceState`; the page renders both charts server-side),
136+ `PriceMovers` (PRICE_CHANGED events as a table; their `old_value/new_value` are objects, so `Delta` would JSON-dump them — format them yourself).
137+- `LineChart` gained `yScale?: 'linear' | 'log'` (d3 `scaleLog`, points ≤ 0 dropped). Existing calls are unchanged.
138+- `/prices?model=` and `provider=` take **slugs** (API 404s on free text) — label fields accordingly.
139+
140+### Benchmarks & hardware
141+- `components/benchmarks/leaderboard.tsx` — `Leaderboard` (rank, model, score + relative bar in `var(--type-benchmark)`, config summary, evaluated, source,
142+ "History" link, `CompareButton`, `Pagination`), `ConfigChips` + `configChips(rows)` (`?config=`), `HistoryChart` (per-model `LineChart`, honest when < 2 points).
143+ API note: `results?config=` is a **value substring match** (`config=v2.1`), not `key=value` or JSON. `evaluated_at` is often null (falls back to observed date).
144+- `/benchmarks/[slug]` tabs: Leaderboard · Definition (`KeyValue` with provenance) · Relations · History (`HistoryPanel`) · Timeline · Sources.
145+- `components/hardware/fit-form.tsx` — `FitForm` (GET form: memory preset `<select>` with Apple/NVIDIA optgroups + free numeric field, `quant`, `context`;
146+ exports `APPLE_PRESETS NVIDIA_PRESETS QUANTS CONTEXTS`). `/hardware/fit` shows `Estimated` prominently, the API `assumptions`, `counts.fits/evaluated`,
147+ `?fits=1` (filtered in the page — the API has no such param). Hardware entity headers link "What can this run?" prefilled with `memory_gb`.
148+
149+### Typed listings (`components/listing/typed-listing.tsx`)
150+- `TypedListing({ title, eyebrow, lede, basePath, searchParams, fetch, columns: Column[], sorts, filters, emptyTitle, emptyHint, note, compare, headerAside })` — column-driven paged table (FilterBar + Pagination +
151+ Unavailable/EmptyState) used by `/papers /frameworks /datasets /tools`; helpers `Dash str list`. `/explore/{type}` supports `sort=updated|name|quality|first_seen|release|stars`.
152+ Keep `GenericListing` for row-style lists; it now accepts `rowTrailing(e)` and `headerAside`.
153+
154+### History mode, diff, graph
155+- `EntityPage` is now `async` and takes `asof?` and `historyProperty?` (pages pass `searchParams.asof/property`). Header actions row: `CompareButton`,
156+ "Explore graph" → `routes.graph(slug)`, hardware-only "What can this run?". New **History** tab (before Timeline) → `components/entity/history.tsx`:
157+ `HistoryPanel({ d, asof, asofPayload, claims, property })` = `AsOfPicker` (client date input, `router.replace` to `?tab=history&asof=`) + `AsOfBlock`
158+ (banner "Viewing AI Atlas as of …" on `bg-accent-soft`, `existed: false` state, attributes as known then via `KeyValue`) + `ClaimHistory` (claims grouped by
159+ property: value, valid_from → valid_to/"current", source, tier, confidence, extractor, status chip — `conflicting` rows in danger with `border-l-2`;
160+ `?property=` filters via `api.entityHistory(slug, property)`). Fetch history always for the tab; as-of only when `?asof`.
161+- `/diff` — GET form (from/to dates, scope select `all | models | org:<slug>` from `/companies?sort=models`, custom `family:<name>`), `StatGrid` from `counts`,
162+ sections New / Gone (`EntityRow`) and Property / Price / Benchmark changes (`ChangeRow`); API lists are capped at 200 — say "first N of total". a ≥ b → API 400 → honest message.
163+- `/graph/[slug]` + `components/graph/graph-explorer.tsx` (client) — d3-force layout run after mount (server renders the frame only → stable SSR), nodes coloured
164+ `var(--type-*)`, root pinned, edge labels always ≤ 40 edges else on hover, hover/focus highlights the neighbourhood, click/Enter → `routes.entity`, depth 1/2 links,
165+ cap 80 nodes + note, type and predicate legends, accessible text fallback list. `routes.graph(slug, depth)`.
166+
167+### Admin (`/admin`, `lib/admin/`, `components/admin/`)
168+- Cookie `aia-admin` (httpOnly, sameSite=lax, secure in prod, `path=/admin`, 12 h) set by the `loginAction` server action after validating the token against
169+ `GET /admin/overview`. `lib/admin/admin-api.ts` (`server-only`): `adminRequest` adds `x-aia-admin-token` from the cookie, `no-store`; 401/403 → `AdminAuthError`
170+ → `redirect('/admin?expired=1')`; `requireAdmin()`; typed `adminApi.*` for every admin route. **The token never reaches the client** (grep the HTML for it in QA).
171+- `lib/admin/actions.ts` (`'use server'`): login/logout, run/toggle connector, retry job, requeue dead, review approve/reject/keep-side, merge, flush cache,
172+ recompute stats/quality — each revalidates and redirects back with `?notice=&level=` (works without JS).
173+- `components/admin/`: `shell.tsx` (header + sticky left nav ≥ lg), `nav-select.tsx` (mobile select), `login-form.tsx` (`useActionState`), `ui.tsx`
174+ (`StatusChip KindChip Bool Notice JsonPre ActionButton AdminTitle AdminFilters Mono Trunc`). Pages: overview, connectors, runs, errors, documents(+[id]),
175+ snapshots/[id], jobs, llm-jobs, review, entities/duplicates, infrastructure, cache — all `force-dynamic`, `robots: noindex`, `DataTable compact` (+ `scroll`).
176+- Conflict review items carry no claim ids; "Keep this" resolves the claim through `/entities/{slug}/history?property=` before approving with `resolution.keep_claim_id`.
177+
178+### Polish
179+- `manifest.ts` (standalone, shortcuts, svg + 192/512 PNG icons from `icon-192.png/route.tsx` + `icon-512.png/route.tsx`), `components/brand/og.tsx`
180+ (`Frame Eyebrow Title Facts Fallback` — shared OG chrome; hex constants are allowed there like in the other ImageResponse files), per-type `opengraph-image.tsx`
181+ for models and companies (3 key facts, brand-only fallback when the slug is unknown — returns 200), `not-found.tsx` with a search form, `/developers` with live trimmed `/stats`
182+ and one model response plus Compare/Diff/history/asof/graph/fit/price-index rows and response conventions.
183+
184+### Left for the next agent
185+- Graph: no pan/zoom; edge labels can overlap on near-collinear edges. Timeline: `total` from the API equals the returned count, so "N of M" is impossible.
186+- `/prices/index` needs ≥ 2 daily snapshots before the chart draws (the DB is one day old); movers and the offers table already work.
187+- Per-model price sparkline in `/models` rows (`PriceSpark` exists) and pan/zoom on the graph remain optional.
123 188
124 ## 7. Verification before you report 189 ## 7. Verification before you report
125 190
126 1. `cd apps/web && pnpm typecheck` — zero errors. `pnpm build` must pass (Turbopack works; fall back to `next build --webpack` only if needed and say so). 191 1. `cd apps/web && pnpm typecheck` — zero errors. `pnpm build` must pass (Turbopack works; fall back to `next build --webpack` only if needed and say so).
127−2. Dev server on :8330 (`pnpm dev`). API on :8331 (`.venv/bin/aia api`). Run `node qa/screens.mjs` (add your routes to `PAGES`): 192+2. Dev server on :8330 (`pnpm dev`). API on :8331 (`.venv/bin/aia api`). Run `node qa/screens.mjs` (wave-1 routes; add yours to `PAGES`) **and**
128− it checks HTTP status (404 for missing slugs), zero console errors, `scrollWidth <= clientWidth` at 390 and 1440 px, dark **and** light, 193+ `node qa/screens-wave2.mjs [BASE] [API] [ADMIN_TOKEN]` (wave-2 routes + flows: compare tray, hardware-fit submit, admin login → connectors → Run now,
129− and that homepage counters equal `GET /api/v1/stats`. Screenshots land in `apps/web/qa/screens/`. 194+ diff with two dates, graph nodes; slugs discovered live). Both check HTTP status (404 for missing slugs), zero console errors, `scrollWidth <= clientWidth`
195+ at 390 and 1440 px, dark **and** light; screens.mjs also checks homepage counters against `GET /api/v1/stats`. Screenshots: `qa/screens/` and `qa/screens/wave2/`.
130 3. Look at the screenshots. Dense but readable; no card walls; numbers tabular; provenance visible; empty states honest. 196 3. Look at the screenshots. Dense but readable; no card walls; numbers tabular; provenance visible; empty states honest.
131−4. `curl -sI localhost:8330/<type>/does-not-exist` → 404. 197+4. `curl -sI localhost:8330/<type>/does-not-exist` → 404. `curl -s localhost:8330/admin | grep -c <token>` → 0.
132 198
133 ## 8. API notes observed live (2026-09-11) — keep in mind 199 ## 8. API notes observed live (2026-09-11) — keep in mind
134 200
modified pnpm-lock.yaml +39 −0
@@ -13,6 +13,9 @@ importers:
13 d3-array: 13 d3-array:
14 specifier: ^3.2.4 14 specifier: ^3.2.4
15 version: 3.2.4 15 version: 3.2.4
16+ d3-force:
17+ specifier: ^3.0.0
18+ version: 3.0.0
16 d3-scale: 19 d3-scale:
17 specifier: ^4.0.2 20 specifier: ^4.0.2
18 version: 4.0.2 21 version: 4.0.2
@@ -44,6 +47,9 @@ importers:
44 '@types/d3-array': 47 '@types/d3-array':
45 specifier: ^3.2.1 48 specifier: ^3.2.1
46 version: 3.2.2 49 version: 3.2.2
50+ '@types/d3-force':
51+ specifier: ^3.0.10
52+ version: 3.0.10
47 '@types/d3-scale': 53 '@types/d3-scale':
48 specifier: ^4.0.9 54 specifier: ^4.0.9
49 version: 4.0.9 55 version: 4.0.9
@@ -406,6 +412,9 @@ packages:
406 '@types/d3-array@3.2.2': 412 '@types/d3-array@3.2.2':
407 resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} 413 resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
408 414
415+ '@types/d3-force@3.0.10':
416+ resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
417+
409 '@types/d3-path@3.1.1': 418 '@types/d3-path@3.1.1':
410 resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} 419 resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
411 420
@@ -451,6 +460,14 @@ packages:
451 resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} 460 resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
452 engines: {node: '>=12'} 461 engines: {node: '>=12'}
453 462
463+ d3-dispatch@3.0.1:
464+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
465+ engines: {node: '>=12'}
466+
467+ d3-force@3.0.0:
468+ resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
469+ engines: {node: '>=12'}
470+
454 d3-format@3.1.2: 471 d3-format@3.1.2:
455 resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} 472 resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
456 engines: {node: '>=12'} 473 engines: {node: '>=12'}
@@ -463,6 +480,10 @@ packages:
463 resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} 480 resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
464 engines: {node: '>=12'} 481 engines: {node: '>=12'}
465 482
483+ d3-quadtree@3.0.1:
484+ resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
485+ engines: {node: '>=12'}
486+
466 d3-scale@4.0.2: 487 d3-scale@4.0.2:
467 resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} 488 resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
468 engines: {node: '>=12'} 489 engines: {node: '>=12'}
@@ -479,6 +500,10 @@ packages:
479 resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} 500 resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
480 engines: {node: '>=12'} 501 engines: {node: '>=12'}
481 502
503+ d3-timer@3.0.1:
504+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
505+ engines: {node: '>=12'}
506+
482 detect-libc@2.1.2: 507 detect-libc@2.1.2:
483 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} 508 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
484 engines: {node: '>=8'} 509 engines: {node: '>=8'}
@@ -922,6 +947,8 @@ snapshots:
922 947
923 '@types/d3-array@3.2.2': {} 948 '@types/d3-array@3.2.2': {}
924 949
950+ '@types/d3-force@3.0.10': {}
951+
925 '@types/d3-path@3.1.1': {} 952 '@types/d3-path@3.1.1': {}
926 953
927 '@types/d3-scale@4.0.9': 954 '@types/d3-scale@4.0.9':
@@ -960,6 +987,14 @@ snapshots:
960 987
961 d3-color@3.1.0: {} 988 d3-color@3.1.0: {}
962 989
990+ d3-dispatch@3.0.1: {}
991+
992+ d3-force@3.0.0:
993+ dependencies:
994+ d3-dispatch: 3.0.1
995+ d3-quadtree: 3.0.1
996+ d3-timer: 3.0.1
997+
963 d3-format@3.1.2: {} 998 d3-format@3.1.2: {}
964 999
965 d3-interpolate@3.0.1: 1000 d3-interpolate@3.0.1:
@@ -968,6 +1003,8 @@ snapshots:
968 1003
969 d3-path@3.1.0: {} 1004 d3-path@3.1.0: {}
970 1005
1006+ d3-quadtree@3.0.1: {}
1007+
971 d3-scale@4.0.2: 1008 d3-scale@4.0.2:
972 dependencies: 1009 dependencies:
973 d3-array: 3.2.4 1010 d3-array: 3.2.4
@@ -988,6 +1025,8 @@ snapshots:
988 dependencies: 1025 dependencies:
989 d3-array: 3.2.4 1026 d3-array: 3.2.4
990 1027
1028+ d3-timer@3.0.1: {}
1029+
991 detect-libc@2.1.2: {} 1030 detect-libc@2.1.2: {}
992 1031
993 enhanced-resolve@5.24.5: 1032 enhanced-resolve@5.24.5:
994 1033
Menu

Theme