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>
77 changed files +6,501 −253
modified
apps/web/package.json
+2 −0
@@ -11,6 +11,7 @@ | ||
| 11 | 11 | }, |
| 12 | 12 | "dependencies": { |
| 13 | 13 | "d3-array": "^3.2.4", |
| 14 | + "d3-force": "^3.0.0", | |
| 14 | 15 | "d3-scale": "^4.0.2", |
| 15 | 16 | "d3-shape": "^3.2.0", |
| 16 | 17 | "geist": "^1.5.1", |
@@ -23,6 +24,7 @@ | ||
| 23 | 24 | "devDependencies": { |
| 24 | 25 | "@tailwindcss/postcss": "^4", |
| 25 | 26 | "@types/d3-array": "^3.2.1", |
| 27 | + "@types/d3-force": "^3.0.10", | |
| 26 | 28 | "@types/d3-scale": "^4.0.9", |
| 27 | 29 | "@types/d3-shape": "^3.1.7", |
| 28 | 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 | 1 | import type { Metadata } from 'next'; |
| 2 | 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 | 4 | import { entityMetadata, loadEntity } from '@/components/entity/load'; |
| 5 | 5 | import { api, safe } from '@/lib/api'; |
| 6 | 6 | import { PATH_TYPES, routes } from '@/lib/site'; |
@@ -9,7 +9,7 @@ import { PATH_TYPES, routes } from '@/lib/site'; | ||
| 9 | 9 | * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories. |
| 10 | 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 | 14 | export async function generateMetadata({ params }: Params): Promise<Metadata> { |
| 15 | 15 | const { type, slug } = await params; |
@@ -17,12 +17,13 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> { | ||
| 17 | 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 | 21 | const { type, slug } = await params; |
| 22 | + const sp = await searchParams; | |
| 22 | 23 | if (!PATH_TYPES[type]) notFound(); |
| 23 | 24 | const d = await loadEntity(type, slug); |
| 24 | 25 | const canonical = routes.entity(d); |
| 25 | 26 | if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical); |
| 26 | 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 | 1 | import type { Metadata } from 'next'; |
| 2 | +import Link from 'next/link'; | |
| 2 | 3 | import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; |
| 3 | 4 | import { EntityLink } from '@/components/ui/entity'; |
| 4 | 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 | 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 | 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 | 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 | 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 | 27 | return ( |
| 16 | 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 | 43 | <div className="pb-16"> |
| 19 | 44 | {!res ? ( |
| 20 | 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 | 50 | <DataTable caption="Benchmarks"> |
@@ -29,37 +56,43 @@ export default async function BenchmarksPage() { | ||
| 29 | 56 | <Th num>Results</Th> |
| 30 | 57 | <Th num>Models</Th> |
| 31 | 58 | <Th>Current leader</Th> |
| 59 | + <Th>Updated</Th> | |
| 32 | 60 | </tr> |
| 33 | 61 | </thead> |
| 34 | 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 | 64 | {items.map((b) => { |
| 37 | 65 | const a = b.attributes ?? {}; |
| 38 | 66 | return ( |
| 39 | 67 | <tr key={b.id}> |
| 40 | 68 | <Td primary> |
| 41 | − <EntityLink e={b} /> | |
| 42 | − {b.description && <span className="block max-w-md truncate text-xs text-ink-3">{b.description}</span>} | |
| 69 | + <Link href={routes.benchmark(b.slug)} className="text-ink hover:text-accent hover:underline">{b.name}</Link> | |
| 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 | 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 | 77 | <Td num label="Results" className="tnum">{fmtInt(b.result_count)}</Td> |
| 47 | 78 | <Td num label="Models" className="tnum text-ink-2">{fmtInt(b.model_count)}</Td> |
| 48 | 79 | <Td label="Current leader"> |
| 49 | 80 | {b.top ? ( |
| 50 | 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 | 84 | </span> |
| 53 | 85 | ) : ( |
| 54 | 86 | <span className="text-ink-3">—</span> |
| 55 | 87 | )} |
| 56 | 88 | </Td> |
| 89 | + <Td label="Updated" className="text-ink-2" title={b.updated_at}>{fmtAgo(b.updated_at)}</Td> | |
| 57 | 90 | </tr> |
| 58 | 91 | ); |
| 59 | 92 | })} |
| 60 | 93 | </tbody> |
| 61 | 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 | 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 | 1 | import type { Metadata } from 'next'; |
| 2 | 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 | 4 | import { entityMetadata, loadEntity } from '@/components/entity/load'; |
| 5 | 5 | import { api, safe } from '@/lib/api'; |
| 6 | 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 | 10 | export async function generateMetadata({ params }: Params): Promise<Metadata> { |
| 11 | 11 | const { slug } = await params; |
| 12 | 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 | 16 | const { slug } = await params; |
| 17 | + const sp = await searchParams; | |
| 17 | 18 | const d = await loadEntity('companies', slug); |
| 18 | 19 | if (d.slug !== slug) permanentRedirect(routes.entity(d)); |
| 19 | 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 | 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 | 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 | 6 | import { Container, Note, PageHeader } from '@/components/ui/section'; |
| 8 | 7 | import { EmptyState, Unavailable } from '@/components/ui/unavailable'; |
| 9 | −import { api, safe } from '@/lib/api'; | |
| 10 | −import { fmtDate, fmtValue, num } from '@/lib/format'; | |
| 11 | −import { routes, typeLabel } from '@/lib/site'; | |
| 8 | +import { api, ApiError, safe } from '@/lib/api'; | |
| 9 | +import { fmtInt } from '@/lib/format'; | |
| 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 | 13 | export const revalidate = 300; |
| 15 | 14 | |
| 16 | −export default async function ComparePage({ searchParams }: { searchParams: Promise<{ ids?: string }> }) { | |
| 17 | − const { ids: raw } = await searchParams; | |
| 18 | − const ids = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 6); | |
| 15 | +type SP = { ids?: string }; | |
| 16 | + | |
| 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 | 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 | 50 | return ( |
| 21 | 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."> | |
| 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"> | |
| 24 | − <label htmlFor="ids" className="sr-only">Slugs, comma separated</label> | |
| 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} /> | |
| 26 | − <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">Compare</button> | |
| 27 | − </form> | |
| 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> | |
| 52 | + <PageHeader | |
| 53 | + eyebrow={res ? <><span>Compare</span><EntityBadge type={res.entity_type} small /></> : 'Compare'} | |
| 54 | + title={res ? res.items.map((i) => i.entity.name).join(' vs ') : 'Compare side by side'} | |
| 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.'} | |
| 56 | + > | |
| 57 | + <ComparePicker initial={initial} exampleHref={exampleHref} /> | |
| 29 | 58 | </PageHeader> |
| 30 | − <div className="pb-16"> | |
| 59 | + | |
| 60 | + <div className="space-y-12 pb-16"> | |
| 31 | 61 | {ids.length < 2 ? ( |
| 32 | − <EmptyState title="Enter at least two slugs"> | |
| 33 | − Example: <Link href={routes.compare(['claude-opus-5', 'gpt-5'])} className="link">claude-opus-5 vs gpt-5</Link> | |
| 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> | |
| 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 | 66 | </EmptyState> |
| 35 | 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"> | |
| 40 | − <thead> | |
| 41 | − <tr> | |
| 42 | − <Th>Dimension</Th> | |
| 43 | − {res.items.map((it) => ( | |
| 44 | − <Th key={it.entity.id}> | |
| 45 | − <span className="flex flex-col items-start gap-0.5 normal-case tracking-normal"> | |
| 46 | − <EntityBadge type={it.entity.entity_type} small /> | |
| 47 | − <EntityLink e={it.entity} className="text-sm font-semibold" /> | |
| 48 | − {it.entity.organization && <span className="text-[11px] font-normal text-ink-3">{it.entity.organization.name}</span>} | |
| 49 | − </span> | |
| 50 | − </Th> | |
| 51 | − ))} | |
| 52 | − </tr> | |
| 53 | − </thead> | |
| 54 | − <tbody> | |
| 55 | − {res.dimensions.map((dim) => { | |
| 56 | − const nums = res.items.map((it) => num(it.values[dim.key])); | |
| 57 | − const best = dim.kind === 'number' && nums.some((n) => n !== null) ? Math.max(...(nums.filter((n): n is number => n !== null))) : null; | |
| 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> | |
| 71 | + <section> | |
| 72 | + <p className="eyebrow mb-2">Dimensions <span className="tnum text-ink-3">{res.dimensions.length}</span></p> | |
| 73 | + <CompareMatrix res={res} /> | |
| 74 | + </section> | |
| 75 | + {isModel && ( | |
| 76 | + <section> | |
| 77 | + <p className="eyebrow mb-2">Shared benchmarks</p> | |
| 78 | + <SharedBenchmarks res={res} /> | |
| 79 | + </section> | |
| 80 | + )} | |
| 81 | + {isModel && ( | |
| 82 | + <section> | |
| 83 | + <p className="eyebrow mb-2">Prices · USD per 1M tokens</p> | |
| 84 | + <ComparePrices res={res} /> | |
| 85 | + </section> | |
| 86 | + )} | |
| 87 | + <Note> | |
| 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> | |
| 89 | + </Note> | |
| 83 | 90 | </> |
| 84 | 91 | )} |
| 85 | 92 | </div> |
modified
apps/web/src/app/datasets/page.tsx
+73 −3
@@ -1,10 +1,80 @@ | ||
| 1 | 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 | 11 | export const revalidate = 300; |
| 6 | 12 | |
| 7 | 13 | export default async function DatasetsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { |
| 8 | 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 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { DataTable, Td, Th } from '@/components/ui/data-table'; |
| 4 | 4 | import { Container, Note, PageHeader, Section } from '@/components/ui/section'; |
| 5 | +import { Unavailable } from '@/components/ui/unavailable'; | |
| 5 | 6 | import { api, safe } from '@/lib/api'; |
| 6 | 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 | 11 | export const revalidate = 3600; |
| 10 | 12 | |
| 11 | 13 | const ENDPOINTS: { path: string; returns: string }[] = [ |
| 12 | 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 | 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' }, | |
| 16 | − { path: 'GET /search/suggest?q=', returns: '≤ 8 prefix suggestions' }, | |
| 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)' }, | |
| 17 | + { path: 'GET /search?q=&type=&limit=&offset=', returns: 'Natural-language or keyword search → { query: compiled filters, items: ranked entities (+rank), total }' }, | |
| 18 | + { path: 'GET /search/suggest?q=', returns: '≤ 8 prefix suggestions { id, entity_type, slug, name, organization_name }' }, | |
| 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 | 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' }, | |
| 20 | − { path: 'GET /models?q=&org=&openness=&modality=&status=&min_params=&min_context=&year_from=&license=&sort=&facets=1', returns: 'Paged models with facets' }, | |
| 21 | − { path: 'GET /companies?q=&country=&kind=&sort=', returns: 'Paged organizations with model and paper counts' }, | |
| 22 | − { path: 'GET /papers?q=&category=&org=&since=&until=', returns: 'Paged papers' }, | |
| 23 | − { path: 'GET /providers · GET /prices?model=&provider=¤t=1 · GET /prices/history · GET /prices/index?days=', returns: 'Providers, current prices, full price history, median/min price index and movers' }, | |
| 24 | − { path: 'GET /benchmarks · GET /benchmarks/{slug}/results · GET /benchmarks/{slug}/history?model=', returns: 'Benchmarks, leaderboards (with config), result history' }, | |
| 25 | − { path: 'GET /hardware · GET /hardware/fit?memory_gb=&quant=&context=', returns: 'Hardware listing and the estimated fit tool' }, | |
| 26 | − { path: 'GET /explore/types · GET /explore/{type}', returns: 'Entity types with counts; generic listing for any type' }, | |
| 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' }, | |
| 28 | − { path: 'GET /timeline?entity=&year=&category=', returns: 'Events grouped by month' }, | |
| 29 | − { path: 'GET /compare?ids=a,b,c', returns: 'Side-by-side dimensions with provenance (2–6 entities of one type)' }, | |
| 30 | − { path: 'GET /diff?a=YYYY-MM-DD&b=YYYY-MM-DD&scope=', returns: 'What changed between two dates' }, | |
| 31 | − { path: 'GET /sources · GET /methodology · GET /trending · GET /sitemap', returns: 'Transparency, vocabularies, most viewed, sitemap feed' }, | |
| 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)' }, | |
| 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' }, | |
| 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' }, | |
| 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' }, | |
| 25 | + { path: 'GET /entities/{slug}/sources · /related?limit=', returns: 'Documents describing the entity (deduped by URL) · related entities (same org / family / shared relations)' }, | |
| 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)' }, | |
| 27 | + { path: 'GET /companies?q=&country=&kind=&sort=', returns: 'Paged organizations with model_count and paper_count (+facets)' }, | |
| 28 | + { path: 'GET /papers?q=&category=&org=&since=&until=&sort=', returns: 'Paged papers' }, | |
| 29 | + { path: 'GET /providers · GET /prices?model=&provider=&sort=¤t=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' }, | |
| 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 }' }, | |
| 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' }, | |
| 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 }[] }' }, | |
| 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 | 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 | 101 | return ( |
| 37 | 102 | <Container> |
| 38 | 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 | 105 | <Section eyebrow="Base URL" title={<span className="mono text-lg md:text-xl">{PUBLIC_API_BASE}</span>} hairline={false}> |
| 41 | 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 | 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 | 110 | curl -s ${PUBLIC_API_BASE}/stats | jq '.entities' |
| 46 | 111 | |
| 47 | 112 | # Natural-language search → compiled filters + ranked results |
| 48 | 113 | curl -s "${PUBLIC_API_BASE}/search?q=open+models+over+100B+released+in+2026" | jq '.query, .items[0:3]' |
| 49 | 114 | |
| 50 | 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 | |
| 54 | −curl -s "${PUBLIC_API_BASE}/prices/history?model=claude-opus-5" | jq '.items[] | {provider: .provider.name, input_per_mtok, valid_from, valid_to}' | |
| 118 | +# Compare two models on every shared dimension (+ shared benchmarks, best prices) | |
| 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 | 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 | 154 | </Section> |
| 59 | 155 | |
| 60 | 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 | 166 | </tbody> |
| 71 | 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 | 182 | </Section> |
| 74 | 183 | |
| 75 | 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:<slug></span> or <span className="mono">family:<name></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 | 1 | import type { Metadata } from 'next'; |
| 2 | 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 | 4 | import { buildMetadata, loadAnyEntity } from '@/components/entity/load'; |
| 5 | 5 | import { api, safe } from '@/lib/api'; |
| 6 | 6 | import { routes, TYPE_PATH } from '@/lib/site'; |
| 7 | 7 | |
| 8 | 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 | 11 | export async function generateMetadata({ params }: Params): Promise<Metadata> { |
| 12 | 12 | const { type, slug } = await params; |
@@ -15,10 +15,11 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> { | ||
| 15 | 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 | 19 | const { type, slug } = await params; |
| 20 | + const sp = await searchParams; | |
| 20 | 21 | const d = await loadAnyEntity(type, slug); |
| 21 | 22 | if (TYPE_PATH[d.entity_type]) permanentRedirect(routes.entity(d)); |
| 22 | 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 | 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 | 11 | export const revalidate = 300; |
| 6 | 12 | |
| 7 | 13 | export default async function FrameworksPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { |
| 8 | 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 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 4 | +import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; | |
| 3 | 5 | import { GenericListing } from '@/components/listing/generic-listing'; |
| 4 | 6 | import { api } from '@/lib/api'; |
| 7 | +import { routes } from '@/lib/site'; | |
| 5 | 8 | |
| 6 | 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 | 10 | export const revalidate = 600; |
@@ -9,10 +12,34 @@ export const revalidate = 600; | ||
| 9 | 12 | export default async function HardwarePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { |
| 10 | 13 | const sp = await searchParams; |
| 11 | 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 | 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 | 41 | </p> |
| 42 | + <CompareTrayBar /> | |
| 16 | 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 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 4 | +import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; | |
| 3 | 5 | import { ActiveFilters, type FacetGroup, Facets, FilterBar, ListingLayout } from '@/components/listing/filters'; |
| 4 | 6 | import { OpennessBadge, StatusBadge } from '@/components/ui/badges'; |
| 5 | 7 | import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; |
@@ -122,11 +124,16 @@ export default async function ModelsPage({ searchParams }: { searchParams: Promi | ||
| 122 | 124 | return ( |
| 123 | 125 | <tr key={m.id}> |
| 124 | 126 | <Td primary> |
| 125 | − <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 126 | − <EntityLink e={m} /> | |
| 127 | − <StatusBadge status={m.status !== 'active' ? m.status : null} /> | |
| 127 | + <div className="flex items-start justify-between gap-2"> | |
| 128 | + <div className="min-w-0"> | |
| 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 | 136 | </div> |
| 129 | − {typeof a.family === 'string' && <span className="block text-xs text-ink-3">{a.family}</span>} | |
| 130 | 137 | </Td> |
| 131 | 138 | <Td label="Organization" className="text-ink-2"> |
| 132 | 139 | {m.organization ? ( |
@@ -156,6 +163,7 @@ export default async function ModelsPage({ searchParams }: { searchParams: Promi | ||
| 156 | 163 | )} |
| 157 | 164 | </ListingLayout> |
| 158 | 165 | </div> |
| 166 | + <CompareTrayBar /> | |
| 159 | 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 | 1 | import type { Metadata } from 'next'; |
| 2 | 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 | 4 | import { entityMetadata, loadEntity } from '@/components/entity/load'; |
| 5 | 5 | import { api, safe } from '@/lib/api'; |
| 6 | 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 | 10 | export async function generateMetadata({ params }: Params): Promise<Metadata> { |
| 11 | 11 | const { slug } = await params; |
| 12 | 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 | 16 | const { slug } = await params; |
| 17 | + const sp = await searchParams; | |
| 17 | 18 | const d = await loadEntity('models', slug); |
| 18 | 19 | if (d.slug !== slug) permanentRedirect(routes.entity(d)); |
| 19 | 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 | 2 | import type { Metadata } from 'next'; |
| 2 | 3 | import Link from 'next/link'; |
| 3 | −import { Container } from '@/components/ui/section'; | |
| 4 | −import { routes } from '@/lib/site'; | |
| 4 | +import { Container, Note } from '@/components/ui/section'; | |
| 5 | +import { PUBLIC_API_BASE, routes } from '@/lib/site'; | |
| 5 | 6 | |
| 6 | 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 | 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 | 12 | return ( |
| 10 | − <Container className="py-20 md:py-28"> | |
| 11 | − <p className="eyebrow">404</p> | |
| 12 | − <h1 className="display mt-2 text-3xl md:text-5xl">This page is not on the map.</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> | |
| 14 | − <div className="mt-6 flex flex-wrap gap-3 text-sm"> | |
| 15 | − <Link href={routes.home()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Home</Link> | |
| 16 | − <Link href={routes.models()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Models</Link> | |
| 17 | − <Link href={routes.explore()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Explore</Link> | |
| 18 | − <Link href={routes.changes()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Changes</Link> | |
| 13 | + <Container className="py-16 md:py-24"> | |
| 14 | + <p className="eyebrow">404 · Not on the map</p> | |
| 15 | + <h1 className="display mt-2 text-3xl md:text-5xl">We have no record at this address.</h1> | |
| 16 | + <p className="mt-4 max-w-xl text-[15px] leading-relaxed text-ink-2"> | |
| 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. | |
| 18 | + </p> | |
| 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"> | |
| 20 | + <label htmlFor="nf-q" className="sr-only">Search AI Atlas</label> | |
| 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 | 30 | </div> |
| 31 | + <Note className="mt-8 max-w-xl"> | |
| 32 | + Canonical URLs are <span className="mono">/models/<slug></span>, <span className="mono">/companies/<slug></span>, <span className="mono">/papers/<slug></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 | 34 | </Container> |
| 21 | 35 | ); |
| 22 | 36 | } |
modified
apps/web/src/app/papers/page.tsx
+92 −8
@@ -1,23 +1,107 @@ | ||
| 1 | 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 | 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 | 11 | export const revalidate = 300; |
| 7 | 12 | |
| 8 | 13 | export default async function PapersPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { |
| 9 | 14 | const sp = await searchParams; |
| 10 | 15 | return ( |
| 11 | − <GenericListing | |
| 12 | − type="paper" | |
| 13 | − basePath="/papers" | |
| 14 | − eyebrow="Research" | |
| 16 | + <TypedListing | |
| 15 | 17 | title="Papers" |
| 18 | + eyebrow="Research" | |
| 16 | 19 | lede="Publications linked to models, labs and benchmarks. Authors, venues and abstracts come from arXiv and publisher pages." |
| 20 | + basePath="/papers" | |
| 17 | 21 | searchParams={sp} |
| 18 | 22 | fetch={(q) => api.papers(q)} |
| 19 | − sorts={[{ value: 'published', label: 'Recently published' }, { value: 'updated', label: 'Recently updated' }]} | |
| 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' }]} | |
| 23 | + sorts={[ | |
| 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/<slug></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 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 4 | +import { CompareTrayBar } from '@/components/compare/compare-tray-bar'; | |
| 3 | 5 | import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table'; |
| 4 | 6 | import { EntityLink, QualityMark } from '@/components/ui/entity'; |
| 5 | 7 | import { Container, Note, PageHeader } from '@/components/ui/section'; |
@@ -42,8 +44,13 @@ export default async function ProvidersPage() { | ||
| 42 | 44 | {items.map((p) => ( |
| 43 | 45 | <tr key={p.id}> |
| 44 | 46 | <Td primary> |
| 45 | − <EntityLink e={p} /> | |
| 46 | − {p.organization && <span className="ml-2 text-xs text-ink-3">{p.organization.name}</span>} | |
| 47 | + <div className="flex items-start justify-between gap-2"> | |
| 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 | 54 | </Td> |
| 48 | 55 | <Td num label="Models" className="tnum">{fmtInt(p.model_count)}</Td> |
| 49 | 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 | 68 | </div> |
| 69 | + <CompareTrayBar /> | |
| 62 | 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 | 4 | export default function robots(): MetadataRoute.Robots { |
| 5 | 5 | return { |
| 6 | − rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin', '/search?'] }], | |
| 6 | + rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin', '/admin/', '/search?'] }], | |
| 7 | 7 | sitemap: `${SITE_URL}/sitemap.xml`, |
| 8 | 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 | 1 | import type { Metadata } from 'next'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { ChangeRow } from '@/components/changes/change-row'; |
| 4 | −import { FilterBar } from '@/components/listing/filters'; | |
| 5 | −import { withParams } from '@/components/ui/pagination'; | |
| 6 | −import { Container, PageHeader } from '@/components/ui/section'; | |
| 4 | +import { Bars } from '@/components/charts/charts'; | |
| 5 | +import { ChipRow, type ChipItem } from '@/components/timeline/chip-row'; | |
| 6 | +import { EntityBadge } from '@/components/ui/badges'; | |
| 7 | +import { Container, Note, PageHeader } from '@/components/ui/section'; | |
| 7 | 8 | import { EmptyState, Unavailable } from '@/components/ui/unavailable'; |
| 8 | 9 | import { api, safe } from '@/lib/api'; |
| 9 | −import { fmtInt, fmtMonth } from '@/lib/format'; | |
| 10 | −import { CATEGORY_LABELS, routes } from '@/lib/site'; | |
| 10 | +import { fmtInt, fmtMonth, fmtYear } from '@/lib/format'; | |
| 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 | 13 | export const revalidate = 600; |
| 14 | 14 | |
| 15 | 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 | 42 | export default async function TimelinePage({ searchParams }: { searchParams: Promise<SP> }) { |
| 19 | 43 | const sp = await searchParams; |
| 20 | − const current: Record<string, string | undefined> = {}; | |
| 21 | − for (const k of ['entity', 'year', 'category']) if (sp[k]) current[k] = sp[k]; | |
| 22 | − const res = await safe(api.timeline({ ...current, limit: 400 })); | |
| 44 | + const cur = current(sp); | |
| 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)]); | |
| 23 | 46 | const months = res?.items ?? []; |
| 24 | − const total = months.reduce((n, m) => n + m.events.length, 0); | |
| 25 | − const entity = current.entity ? await safe(api.entity(current.entity)) : null; | |
| 47 | + const shown = months.reduce((n, m) => n + m.events.length, 0); | |
| 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 | 69 | return ( |
| 27 | 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}> | |
| 29 | − <FilterBar | |
| 30 | − action="/timeline" | |
| 31 | − className="mt-6 lg:grid-cols-4" | |
| 32 | − resetHref={routes.timeline()} | |
| 33 | − fields={[ | |
| 34 | − { kind: 'text', name: 'entity', label: 'Entity slug', value: current.entity, placeholder: 'e.g. claude-opus-5' }, | |
| 35 | − { kind: 'select', name: 'year', label: 'Year', value: current.year, options: YEARS.map((y) => ({ value: y, label: y })) }, | |
| 36 | − { kind: 'select', name: 'category', label: 'Category', value: current.category, options: Object.entries(CATEGORY_LABELS).map(([value, label]) => ({ value, label })) }, | |
| 37 | − ]} | |
| 38 | − /> | |
| 71 | + <PageHeader | |
| 72 | + eyebrow={ | |
| 73 | + entity ? ( | |
| 74 | + <> | |
| 75 | + <EntityBadge type={entity.entity_type} small /> Timeline | |
| 76 | + </> | |
| 77 | + ) : ( | |
| 78 | + 'Timeline' | |
| 79 | + ) | |
| 80 | + } | |
| 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 | 110 | </PageHeader> |
| 111 | + | |
| 40 | 112 | <div className="pb-16"> |
| 41 | 113 | {!res ? ( |
| 42 | − <Unavailable what="Timeline" /> | |
| 114 | + <Unavailable what="Timeline" reason={cur.entity ? 'The entity slug may not exist.' : undefined} /> | |
| 43 | 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)]"> | |
| 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"> | |
| 48 | − {months.map((m) => ( | |
| 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"> | |
| 50 | − <span>{fmtMonth(m.month)}</span> | |
| 51 | − <span className="text-ink-3">{m.events.length}</span> | |
| 52 | − </a> | |
| 53 | − ))} | |
| 54 | − </nav> | |
| 55 | − <div className="min-w-0"> | |
| 56 | − {months.map((m) => ( | |
| 57 | − <section key={m.month} id={`m-${m.month}`} className="scroll-mt-20 pb-8"> | |
| 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"> | |
| 59 | − {fmtMonth(m.month)} <span className="tnum text-ink-3">{m.events.length}</span> | |
| 60 | − </h2> | |
| 61 | − <ul className="border-t border-rule"> | |
| 62 | − {m.events.map((e) => ( | |
| 63 | − <ChangeRow key={e.id} e={e} showDate live={false} /> | |
| 64 | − ))} | |
| 65 | − </ul> | |
| 66 | − </section> | |
| 67 | − ))} | |
| 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> | |
| 120 | + <> | |
| 121 | + {density.length > 1 && ( | |
| 122 | + <section aria-label="Events per month" className="mb-8"> | |
| 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> | |
| 124 | + <Bars data={density} height={90} /> | |
| 125 | + </section> | |
| 126 | + )} | |
| 127 | + <div className="grid gap-x-10 lg:grid-cols-[10rem_minmax(0,1fr)]"> | |
| 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"> | |
| 129 | + {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"> | |
| 131 | + <span>{fmtMonth(m.month)}</span> | |
| 132 | + <span className="text-ink-3">{fmtInt(m.count ?? m.events.length)}</span> | |
| 133 | + </a> | |
| 134 | + ))} | |
| 135 | + </nav> | |
| 136 | + <div className="min-w-0"> | |
| 137 | + {months.map((m) => ( | |
| 138 | + <section key={m.month} id={`m-${m.month}`} className="scroll-mt-20 pb-8"> | |
| 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"> | |
| 140 | + <span> | |
| 141 | + {fmtMonth(m.month)} <span className="tnum text-ink-3">{fmtInt(m.count ?? m.events.length)}</span> | |
| 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 | 156 | </div> |
| 70 | − </div> | |
| 157 | + </> | |
| 71 | 158 | )} |
| 72 | 159 | </div> |
| 73 | 160 | </Container> |
modified
apps/web/src/app/tools/page.tsx
+63 −3
@@ -1,10 +1,70 @@ | ||
| 1 | 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 | 11 | export const revalidate = 300; |
| 6 | 12 | |
| 7 | 13 | export default async function ToolsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { |
| 8 | 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 | 1 | import { extent, max } from 'd3-array'; |
| 2 | −import { scaleLinear, scaleTime } from 'd3-scale'; | |
| 2 | +import { scaleLinear, scaleLog, scaleTime } from 'd3-scale'; | |
| 3 | 3 | import { area, curveMonotoneX, line } from 'd3-shape'; |
| 4 | 4 | import { cn } from '@/lib/cn'; |
| 5 | 5 | import { fmtCompact } from '@/lib/format'; |
@@ -97,9 +97,15 @@ export function HBars({ data, className, color = 'var(--series-1)', format = fmt | ||
| 97 | 97 | |
| 98 | 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. */ | |
| 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] }) { | |
| 102 | − const all = series.flatMap((s) => s.points).filter((p) => Number.isFinite(p.y)); | |
| 100 | +/** | |
| 101 | + * Multi-series line chart with time or linear x axis, light grid, end labels. | |
| 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 | 109 | if (all.length < 2) return <p className={cn('text-xs text-ink-3', className)}>Not enough history</p>; |
| 104 | 110 | const w = 720; |
| 105 | 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 | 118 | y0 = y0 * 0.9; |
| 113 | 119 | y1 = y1 * 1.1 || 1; |
| 114 | 120 | } |
| 115 | − if (!yDomain) y0 = Math.min(0, y0); | |
| 116 | − const y = scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]); | |
| 121 | + if (!yDomain && !log) y0 = Math.min(0, y0); | |
| 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 | 127 | const toX = (p: Point) => x(p.x instanceof Date ? p.x : xTime ? new Date(Number(p.x)) : Number(p.x)) as number; |
| 118 | 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 | 130 | const xt = (x as { ticks: (n: number) => (Date | number)[] }).ticks(5); |
| 121 | 131 | const fmtX = (v: Date | number) => (v instanceof Date ? v.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v)); |
| 122 | 132 | return ( |
@@ -135,7 +145,7 @@ export function LineChart({ series, height = 220, className, yFormat = fmtCompac | ||
| 135 | 145 | </text> |
| 136 | 146 | ))} |
| 137 | 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 | 149 | if (pts.length === 0) return null; |
| 140 | 150 | const color = s.color ?? `var(--series-${(i % 8) + 1})`; |
| 141 | 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 | 172 | export function Legend({ series, className }: { series: { name: string; color?: string }[]; className?: string }) { |
| 155 | 173 | return ( |
| 156 | 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 | 2 | import Link from 'next/link'; |
| 3 | +import { CompareButton } from '@/components/compare/compare-button'; | |
| 3 | 4 | import { ViewBeacon } from '@/components/layout/view-beacon'; |
| 4 | 5 | import { EntityBadge, OpennessBadge, StatusBadge } from '@/components/ui/badges'; |
| 5 | 6 | import { EntityLink, QualityMark } from '@/components/ui/entity'; |
| 6 | 7 | import { Container, Note } from '@/components/ui/section'; |
| 7 | 8 | import { TabPanel, Tabs, type TabDef } from '@/components/ui/tabs'; |
| 9 | +import { api, safe } from '@/lib/api'; | |
| 8 | 10 | import { fmtAgo, fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format'; |
| 9 | 11 | import { routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site'; |
| 10 | 12 | import type { EntityDetail, EntitySummary } from '@/lib/types'; |
| 11 | 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 | 20 | /* ---------------------------------------------------------------------------------------------------------- header chips */ |
| 14 | 21 | |
@@ -93,15 +100,16 @@ function primaryUrl(d: EntityDetail): string | null { | ||
| 93 | 100 | |
| 94 | 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 | 106 | const t = d.entity_type; |
| 100 | 107 | const c = (n: number | undefined | null) => (n ? n : undefined); |
| 101 | 108 | const rel = d.relations?.reduce((n, g) => n + g.items.length, 0) ?? 0; |
| 102 | 109 | const sources = d.sources?.length ?? 0; |
| 103 | 110 | const timeline = d.timeline?.length ?? 0; |
| 104 | 111 | const tail: TabDef[] = [ |
| 112 | + { id: 'history', label: 'History', count: c(claimCount ?? d.counts?.claims) }, | |
| 105 | 113 | { id: 'timeline', label: 'Timeline', count: c(timeline) }, |
| 106 | 114 | { id: 'sources', label: 'Sources', count: c(sources) }, |
| 107 | 115 | ]; |
@@ -195,12 +203,18 @@ function jsonLd(d: EntityDetail, canonical: string) { | ||
| 195 | 203 | * Shared entity page for every type. Header (badges, name, org, description, key chips) + URL-driven tabs whose set |
| 196 | 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 | 207 | const a = d.attributes ?? {}; |
| 200 | 208 | const chips = headerChips(d); |
| 201 | 209 | const openness = typeof a.openness === 'string' ? a.openness : null; |
| 202 | 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 | 218 | const isModel = d.entity_type === 'model' || d.entity_type === 'quantization'; |
| 205 | 219 | const isCompany = ['company', 'organization', 'lab', 'university'].includes(d.entity_type); |
| 206 | 220 | const ld = jsonLd(d, canonical); |
@@ -251,6 +265,17 @@ export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonic | ||
| 251 | 265 | )} |
| 252 | 266 | </p> |
| 253 | 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 | 279 | </div> |
| 255 | 280 | <div className="shrink-0 text-xs text-ink-3 lg:text-right"> |
| 256 | 281 | <QualityMark q={d.quality?.score} label /> |
@@ -329,9 +354,10 @@ export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonic | ||
| 329 | 354 | )} |
| 330 | 355 | <section> |
| 331 | 356 | <p className="eyebrow mb-2">Compare</p> |
| 332 | − <p className="text-sm text-ink-2"> | |
| 333 | − <Link href={routes.compare([d.slug])} className="link">Add {d.name} to a comparison →</Link> | |
| 334 | − </p> | |
| 357 | + <div className="flex flex-wrap items-center gap-3 text-sm"> | |
| 358 | + <CompareButton e={d} /> | |
| 359 | + <Link href={routes.compare([d.slug])} className="link py-2">Open compare →</Link> | |
| 360 | + </div> | |
| 335 | 361 | </section> |
| 336 | 362 | </aside> |
| 337 | 363 | </div> |
@@ -446,6 +472,9 @@ export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonic | ||
| 446 | 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 | 478 | <TabPanel id="timeline"> |
| 450 | 479 | <TimelineList events={d.timeline ?? []} slug={d.slug} /> |
| 451 | 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 | 14 | * Generic paged listing for a type with light filters (q, org, sort). `fetch` defaults to /explore/<type>; |
| 15 | 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 | 18 | const current: Record<string, string | undefined> = {}; |
| 19 | 19 | for (const k of ['q', 'org', 'sort', 'category', 'kind', 'manufacturer', 'since', 'offset']) if (searchParams[k]) current[k] = searchParams[k]; |
| 20 | 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 | 23 | const href = (patch: Record<string, string | number | undefined | null>) => withParams(basePath, current, patch); |
| 24 | 24 | return ( |
| 25 | 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 | 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 | 28 | </PageHeader> |
| 29 | 29 | {children} |
@@ -36,7 +36,7 @@ export async function GenericListing({ type, basePath, title, eyebrow, lede, sea | ||
| 36 | 36 | <> |
| 37 | 37 | <ul className="border-t border-rule"> |
| 38 | 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 | 41 | </ul> |
| 42 | 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 | 158 | tools: () => '/tools', |
| 159 | 159 | changes: () => '/changes', |
| 160 | 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 | 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 | 198 | explore: () => '/explore', |
| 164 | 199 | exploreType: (t: string) => `/explore/${encodeURIComponent(t)}`, |
| 165 | 200 | methodology: () => '/methodology', |
@@ -192,6 +227,7 @@ export const exploreNav = [ | ||
| 192 | 227 | export const primaryNav = [ |
| 193 | 228 | { href: '/changes', label: 'Changes' }, |
| 194 | 229 | { href: '/timeline', label: 'Timeline' }, |
| 230 | + { href: '/prices', label: 'Prices' }, | |
| 195 | 231 | { href: '/compare', label: 'Compare' }, |
| 196 | 232 | { href: '/developers', label: 'Developers' }, |
| 197 | 233 | ]; |
@@ -199,6 +235,8 @@ export const moreNav = [ | ||
| 199 | 235 | { href: '/about', label: 'About' }, |
| 200 | 236 | { href: '/methodology', label: 'Methodology' }, |
| 201 | 237 | { href: '/sources', label: 'Sources' }, |
| 238 | + { href: '/diff', label: 'Diff two dates' }, | |
| 239 | + { href: '/hardware/fit', label: 'Hardware fit' }, | |
| 202 | 240 | { href: '/developers', label: 'API & Developers' }, |
| 203 | 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 | 265 | export type ProviderRow = EntitySummary & { model_count: Num; price_count: Num; min_input_per_mtok: Num; min_output_per_mtok: Num }; |
| 266 | 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 | 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 | 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 | 293 | export interface HardwareFit { |
| 274 | 294 | inputs: Record<string, unknown>; |
| 295 | + estimated?: boolean; | |
| 296 | + counts?: { fits?: Num; evaluated?: Num }; | |
| 275 | 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 | 301 | export interface ExploreType { |
@@ -294,7 +316,8 @@ export interface ChangeCategories { | ||
| 294 | 316 | } |
| 295 | 317 | |
| 296 | 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 | 323 | export interface CompareDimension { |
@@ -302,6 +325,8 @@ export interface CompareDimension { | ||
| 302 | 325 | label: string; |
| 303 | 326 | unit?: string; |
| 304 | 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 | 331 | export interface ComparePayload { |
| 307 | 332 | entity_type: string; |
@@ -312,6 +337,7 @@ export interface ComparePayload { | ||
| 312 | 337 | export interface DiffPayload { |
| 313 | 338 | a: string; |
| 314 | 339 | b: string; |
| 340 | + scope?: Record<string, unknown> | string; | |
| 315 | 341 | new_entities: EntitySummary[]; |
| 316 | 342 | gone_entities: EntitySummary[]; |
| 317 | 343 | property_changes: ChangeEvent[]; |
@@ -366,12 +392,29 @@ export interface SitemapPayload { | ||
| 366 | 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 | 407 | export interface GraphPayload { |
| 370 | − nodes: { id: string; slug: string; name: string; entity_type: string; organization_name?: string }[]; | |
| 371 | − edges: { source: string; target: string; predicate: string }[]; | |
| 408 | + root?: string; | |
| 409 | + nodes: GraphNode[]; | |
| 410 | + edges: GraphEdge[]; | |
| 372 | 411 | } |
| 373 | 412 | |
| 374 | 413 | export interface AsOfPayload { |
| 414 | + id?: string; | |
| 415 | + slug?: string; | |
| 416 | + name?: string; | |
| 417 | + entity_type?: string; | |
| 375 | 418 | existed: boolean; |
| 376 | 419 | first_seen_at: string | null; |
| 377 | 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 | 103 | ## 5. Routes built |
| 104 | 104 | |
| 105 | −`/` · `/search` · `/models` (+ facets, sort) · `/models/[slug]` · `/companies` · `/companies/[slug]` · `/[type]/[slug]` (providers, benchmarks, | |
| 106 | −hardware, papers, frameworks, datasets, tools, repositories) · `/explore` · `/explore/[type]` · `/explore/[type]/[slug]` (types without a path) | |
| 107 | −· `/papers /providers /benchmarks /hardware /frameworks /datasets /tools` listings · `/changes` · `/changes/[date]` · `/timeline` (basic) | |
| 108 | −· `/compare?ids=` (basic table) · `/methodology` · `/sources` · `/about` · `/developers` · `/bot` · `robots.ts` · `/sitemap.xml` + `/sitemap/[shard].xml` | |
| 109 | −· `not-found.tsx` · `error.tsx` · `icon.svg` · `apple-icon` · `opengraph-image`. | |
| 110 | − | |
| 111 | −## 6. Left for the next agent (use the components above) | |
| 112 | − | |
| 113 | −- **Compare** picker UI (suggest-driven multi-select, pinned "compare tray", shareable URL), per-type dimension rendering, shared-benchmark rows. | |
| 114 | −- **Timeline** — richer `/timeline` (year scrubber, category chips, entity autocomplete, month density bars via `Bars`). | |
| 115 | −- **Hardware fit tool** `/hardware/fit` (form memory_gb/quant/context → `GET /hardware/fit`, table with `Estimated`, assumptions list). | |
| 116 | −- **Price index** `/prices` (`GET /prices/index` → `LineChart` median/min, movers via `ChangeRow`) and per-model price sparkline in `/models` rows. | |
| 117 | −- **Benchmarks** — leaderboard page per benchmark with config filter (`?config=`), `history=1`, result history chart per model. | |
| 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. | |
| 119 | −- **Entity graph** (`GET /entities/{slug}/graph`) as an SVG neighbourhood on the Overview aside. | |
| 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. | |
| 121 | −- Listings for papers/frameworks/datasets/tools could gain type-specific tables (currently `EntityRow` lists via `GenericListing`). | |
| 122 | −- `manifest.ts`, per-entity `opengraph-image` (optional). | |
| 105 | +`/` · `/search` · `/models` (+ facets, sort, Compare buttons) · `/models/[slug]` (+ `opengraph-image`) · `/companies` · `/companies/[slug]` (+ `opengraph-image`) | |
| 106 | +· `/[type]/[slug]` (providers, hardware, papers, frameworks, datasets, tools, repositories) · `/benchmarks/[slug]` (dedicated leaderboard — static segment shadows | |
| 107 | +`[type]/[slug]`) · `/explore` · `/explore/[type]` · `/explore/[type]/[slug]` · `/papers /providers /benchmarks /hardware /frameworks /datasets /tools` (typed tables) | |
| 108 | +· `/changes` · `/changes/[date]` · `/timeline?year=&category=&entity=` · `/prices?days=&sort=&provider=&model=&scale=` · `/compare?ids=` (picker + tray + matrix) | |
| 109 | +· `/hardware/fit?memory_gb=&quant=&context=&fits=` · `/diff?a=&b=&scope=` · `/graph/[slug]?depth=` · `/admin/**` (token-gated, noindex) · `/methodology` · `/sources` | |
| 110 | +· `/about` · `/developers` (live example responses) · `/bot` · `robots.ts` (disallows `/admin/`) · `/sitemap.xml` + `/sitemap/[shard].xml` · `manifest.ts` | |
| 111 | +· `icon-192.png` / `icon-512.png` (ImageResponse routes) · `not-found.tsx` · `error.tsx` · `icon.svg` · `apple-icon` · `opengraph-image`. | |
| 112 | + | |
| 113 | +## 6. Wave 2 (2026-09-11) — components and page patterns added | |
| 114 | + | |
| 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 | + | |
| 117 | +### Compare (`components/compare/`) | |
| 118 | +- `compare-store.ts` (client) — the **compare tray**: `localStorage['aia-compare']` (≤ 6 `TrayItem { slug, name, entity_type, organization? }`), same-tab | |
| 119 | + `aia-compare-change` event + cross-tab `storage`. `useCompareTray()` → `{ items, ready, add, remove, toggle, clear, replace, has, type, full, canCompare }`; | |
| 120 | + helpers `addToTray/removeFromTray/toggleTray/readTray/clearTray`, `trayType(t)` (company-like → `company`, library/runtime → `framework`, quantization → `model`), | |
| 121 | + `compareHref(items)`. The tray is type-homogeneous: adding another type **replaces** it (the API compares 2–6 entities of one type). | |
| 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 | 189 | ## 7. Verification before you report |
| 125 | 190 | |
| 126 | 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`): | |
| 128 | − it checks HTTP status (404 for missing slugs), zero console errors, `scrollWidth <= clientWidth` at 390 and 1440 px, dark **and** light, | |
| 129 | − and that homepage counters equal `GET /api/v1/stats`. Screenshots land in `apps/web/qa/screens/`. | |
| 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** | |
| 193 | + `node qa/screens-wave2.mjs [BASE] [API] [ADMIN_TOKEN]` (wave-2 routes + flows: compare tray, hardware-fit submit, admin login → connectors → Run now, | |
| 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 | 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 | 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 | 13 | d3-array: |
| 14 | 14 | specifier: ^3.2.4 |
| 15 | 15 | version: 3.2.4 |
| 16 | + d3-force: | |
| 17 | + specifier: ^3.0.0 | |
| 18 | + version: 3.0.0 | |
| 16 | 19 | d3-scale: |
| 17 | 20 | specifier: ^4.0.2 |
| 18 | 21 | version: 4.0.2 |
@@ -44,6 +47,9 @@ importers: | ||
| 44 | 47 | '@types/d3-array': |
| 45 | 48 | specifier: ^3.2.1 |
| 46 | 49 | version: 3.2.2 |
| 50 | + '@types/d3-force': | |
| 51 | + specifier: ^3.0.10 | |
| 52 | + version: 3.0.10 | |
| 47 | 53 | '@types/d3-scale': |
| 48 | 54 | specifier: ^4.0.9 |
| 49 | 55 | version: 4.0.9 |
@@ -406,6 +412,9 @@ packages: | ||
| 406 | 412 | '@types/d3-array@3.2.2': |
| 407 | 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 | 418 | '@types/d3-path@3.1.1': |
| 410 | 419 | resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} |
| 411 | 420 | |
@@ -451,6 +460,14 @@ packages: | ||
| 451 | 460 | resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} |
| 452 | 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 | 471 | d3-format@3.1.2: |
| 455 | 472 | resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} |
| 456 | 473 | engines: {node: '>=12'} |
@@ -463,6 +480,10 @@ packages: | ||
| 463 | 480 | resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} |
| 464 | 481 | engines: {node: '>=12'} |
| 465 | 482 | |
| 483 | + d3-quadtree@3.0.1: | |
| 484 | + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} | |
| 485 | + engines: {node: '>=12'} | |
| 486 | + | |
| 466 | 487 | d3-scale@4.0.2: |
| 467 | 488 | resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} |
| 468 | 489 | engines: {node: '>=12'} |
@@ -479,6 +500,10 @@ packages: | ||
| 479 | 500 | resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} |
| 480 | 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 | 507 | detect-libc@2.1.2: |
| 483 | 508 | resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} |
| 484 | 509 | engines: {node: '>=8'} |
@@ -922,6 +947,8 @@ snapshots: | ||
| 922 | 947 | |
| 923 | 948 | '@types/d3-array@3.2.2': {} |
| 924 | 949 | |
| 950 | + '@types/d3-force@3.0.10': {} | |
| 951 | + | |
| 925 | 952 | '@types/d3-path@3.1.1': {} |
| 926 | 953 | |
| 927 | 954 | '@types/d3-scale@4.0.9': |
@@ -960,6 +987,14 @@ snapshots: | ||
| 960 | 987 | |
| 961 | 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 | 998 | d3-format@3.1.2: {} |
| 964 | 999 | |
| 965 | 1000 | d3-interpolate@3.0.1: |
@@ -968,6 +1003,8 @@ snapshots: | ||
| 968 | 1003 | |
| 969 | 1004 | d3-path@3.1.0: {} |
| 970 | 1005 | |
| 1006 | + d3-quadtree@3.0.1: {} | |
| 1007 | + | |
| 971 | 1008 | d3-scale@4.0.2: |
| 972 | 1009 | dependencies: |
| 973 | 1010 | d3-array: 3.2.4 |
@@ -988,6 +1025,8 @@ snapshots: | ||
| 988 | 1025 | dependencies: |
| 989 | 1026 | d3-array: 3.2.4 |
| 990 | 1027 | |
| 1028 | + d3-timer@3.0.1: {} | |
| 1029 | + | |
| 991 | 1030 | detect-libc@2.1.2: {} |
| 992 | 1031 | |
| 993 | 1032 | enhanced-resolve@5.24.5: |
| 994 | 1033 | |