HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2import { LayoutList, Table2 } from 'lucide-react';3import Link from 'next/link';4import { useMemo, useState } from 'react';5import { Evidence } from '@/components/evidence/evidence';6import { ComparabilityBadge, OpennessChip, TrustBadge } from '@/components/models/badges';7import { fmtScoreUnit, opennessLabel } from '@/components/models/shared';8import { Chip, TierBadge } from '@/components/ui/badges';9import { EntityLink } from '@/components/ui/entity';10import { Hint } from '@/components/ui/hint';11import { Note } from '@/components/ui/section';12import { cn } from '@/lib/cn';13import { DASH, fmtAgo, fmtDate, fmtTokens, fmtUsdPerM, fmtValue, hostOf, num } from '@/lib/format';14import { routes } from '@/lib/site';15import type { CompareDimension11, ComparePayload11, ProvenanceEntry } from '@/lib/types';16import { CompareButton } from './compare-button';1718/*19 Compare 3.0 (client): row groups × sticky entity headers, "hide identical rows" (default on when ≥ 3 entities), use-case chips that20 reorder and emphasise relevant rows (never a winner), per-cell evidence, mobile horizontal scroll with sticky first column or stacked cards.21 Data is fetched server-side (`/compare`, `diff_only` is a URL parameter); this component only arranges it.22*/2324type Group = { id: string; label: string; test: (d: CompareDimension11) => boolean };25const GROUPS: Group[] = [26 { id: 'overview', label: 'Overview', test: (d) => ['release_date', 'status', 'family', 'version', 'openness', 'organization', 'kind', 'website', 'pricing_url', 'model_count', 'category', 'manufacturer', 'latest_version', 'language'].includes(d.key) },27 { id: 'architecture', label: 'Architecture', test: (d) => /parameter_count|architecture|is_moe|num_experts|tokenizer|memory_gb|memory_type|bandwidth|tdp|tflops|compute/.test(d.key) },28 { id: 'context', label: 'Context', test: (d) => /context_length|max_output_tokens|knowledge_cutoff|training_data_cutoff/.test(d.key) },29 { id: 'capabilities', label: 'Capabilities', test: (d) => /modalit|reasoning|tool_calling|structured_output|vision|audio|fine_tuning|features|languages|runtimes/.test(d.key) },30 { id: 'benchmarks', label: 'Benchmarks', test: (d) => d.key.startsWith('bench:') || d.source === 'results' },31 { id: 'pricing', label: 'Pricing', test: (d) => d.source === 'prices' || /per_mtok|provider_count|price/.test(d.key) },32 { id: 'license', label: 'License', test: (d) => /license|licence/.test(d.key) },33 { id: 'history', label: 'History', test: () => false },34];35const USE_CASES: { id: string; label: string; match: RegExp; groups: string[] }[] = [36 { id: 'coding', label: 'Coding', match: /swe-bench|aider|livebench-coding|livebench-agentic|scicode|humaneval|livecodebench|terminal-bench|tool_calling|structured_output/i, groups: ['benchmarks', 'capabilities'] },37 { id: 'reasoning', label: 'Reasoning', match: /gpqa|humanitys-last-exam|aime|math|arc-agi|livebench-reasoning|livebench-math|reasoning|intelligence-index/i, groups: ['benchmarks', 'capabilities'] },38 { id: 'agentic', label: 'Agentic', match: /tau|terminal-bench|agentic|tool_calling|max_output_tokens/i, groups: ['benchmarks', 'capabilities', 'context'] },39 { id: 'long_context', label: 'Long context', match: /context_length|max_output_tokens|knowledge_cutoff/i, groups: ['context'] },40 { id: 'vision', label: 'Vision', match: /mmmu|vision|modalit/i, groups: ['capabilities', 'benchmarks'] },41 { id: 'low_cost', label: 'Low-cost', match: /per_mtok|provider_count|active_parameter/i, groups: ['pricing', 'architecture'] },42 { id: 'local', label: 'Local', match: /parameter_count|openness|license|is_moe|memory/i, groups: ['architecture', 'license', 'overview'] },43 { id: 'embeddings', label: 'Embeddings', match: /mteb|embedding|dimension/i, groups: ['benchmarks', 'capabilities'] },44];4546function groupOf(d: CompareDimension11): string {47 return GROUPS.find((g) => g.test(d))?.id ?? 'overview';48}49const LOWER_BETTER = /per_mtok|tdp|price|latency/;5051function fmtCell(v: unknown, dim: CompareDimension11): string {52 if (v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) return 'Unavailable';53 switch (dim.kind) {54 case 'date':55 return fmtDate(String(v));56 case 'bool':57 return v ? 'Yes' : 'No';58 case 'list':59 return (Array.isArray(v) ? v : [v]).map((x) => (typeof x === 'object' && x ? JSON.stringify(x) : String(x))).join(', ');60 case 'number':61 if (/per_mtok/.test(dim.key)) return fmtUsdPerM(v);62 if (dim.key.startsWith('bench:')) return fmtScoreUnit(num(v), dim.unit ?? null);63 if (/context_length|max_output_tokens/.test(dim.key)) return `${fmtTokens(v)} tokens`;64 return fmtValue(v, dim.key);65 default:66 if (dim.key === 'openness') return opennessLabel(v);67 return typeof v === 'string' ? v : fmtValue(v, dim.key);68 }69}70const sameValue = (vals: unknown[]) => {71 const norm = vals.map((v) => JSON.stringify(v ?? null));72 return norm.every((n) => n === norm[0]);73};7475export function CompareTerminal({ res, diffOnly }: { res: ComparePayload11; diffOnly: boolean }) {76 const n = res.items.length;77 const [hideIdentical, setHideIdentical] = useState(n >= 3);78 const [useCase, setUseCase] = useState<string | null>(null);79 const [layout, setLayout] = useState<'table' | 'stack'>('table');80 const uc = USE_CASES.find((u) => u.id === useCase) ?? null;81 const isModel = res.entity_type === 'model';8283 const rows = useMemo(() => {84 const byGroup = new Map<string, CompareDimension11[]>();85 for (const d of res.dimensions) {86 const g = groupOf(d);87 byGroup.set(g, [...(byGroup.get(g) ?? []), d]);88 }89 // History rows synthesised from the entity summaries (first seen · release · last change).90 const historyDims: CompareDimension11[] = [91 { key: '_first_seen', label: 'First seen in AI Atlas', kind: 'date', source: 'entity' },92 { key: '_last_change', label: 'Last change', kind: 'date', source: 'entity' },93 ];94 byGroup.set('history', historyDims);95 let order = GROUPS.map((g) => g.id);96 if (uc) order = [...uc.groups, ...order.filter((g) => !uc.groups.includes(g))];97 return order.map((id) => ({ id, label: GROUPS.find((g) => g.id === id)?.label ?? id, dims: (byGroup.get(id) ?? []).slice().sort((a, b) => (uc ? Number(uc.match.test(b.key) || uc.match.test(b.label)) - Number(uc.match.test(a.key) || uc.match.test(a.label)) : 0)) })).filter((g) => g.dims.length);98 }, [res.dimensions, uc]);99100 const valueOf = (d: CompareDimension11, it: ComparePayload11['items'][number]) => {101 if (d.key === '_first_seen') return it.entity.first_seen_at || null;102 if (d.key === '_last_change') return it.entity.updated_at || null;103 return it.values[d.key];104 };105 const provenanceOf = (d: CompareDimension11, it: ComparePayload11['items'][number]): ProvenanceEntry | null => {106 const p = it.provenance?.[d.key];107 if (p) return p;108 if (d.key.startsWith('bench:') && d.benchmark) {109 const r = (it.results ?? []).find((x) => x.benchmark.slug === d.benchmark && (!d.metric || x.metric === d.metric));110 if (r) return { source_id: null, source_name: hostOf(r.source_url) ?? undefined, url: r.source_url, observed_at: r.observed_at, tier: r.tier, confidence: r.confidence, extractor: 'deterministic', unit: r.unit ?? undefined };111 }112 if (d.source === 'prices') {113 const best = [...(it.prices ?? [])].sort((a, b) => (num(a.output_per_mtok) ?? Infinity) - (num(b.output_per_mtok) ?? Infinity))[0];114 if (best) return { source_id: null, source_name: hostOf(best.source_url) ?? best.provider.name, url: best.source_url, observed_at: best.observed_at, tier: best.tier, confidence: 'high', extractor: 'deterministic', unit: 'USD / 1M tokens' };115 }116 return null;117 };118 const propertyOf = (d: CompareDimension11) => (d.key.startsWith('bench:') && d.benchmark ? `benchmark.${d.benchmark}.${d.metric ?? ''}` : d.key.replace(/^_/, ''));119120 let shownRows = 0;121 let hiddenRows = 0;122 const body = rows.map((g) => {123 const dims = g.dims.filter((d) => {124 const vals = res.items.map((it) => valueOf(d, it));125 const identical = sameValue(vals);126 if (hideIdentical && identical && g.id !== 'history') {127 hiddenRows++;128 return false;129 }130 return true;131 });132 shownRows += dims.length;133 return { ...g, dims };134 }).filter((g) => g.dims.length);135136 const cellFor = (d: CompareDimension11, it: ComparePayload11['items'][number], best: number | null, emph: boolean) => {137 const v = valueOf(d, it);138 const text = fmtCell(v, d);139 const p = provenanceOf(d, it);140 const isBest = best !== null && num(v) === best;141 const canOpen = v !== null && v !== undefined && v !== '' && d.source !== 'entity';142 return (143 <div className={cn('min-w-0', d.kind === 'number' && 'tnum')}>144 {canOpen ? (145 <Evidence slug={it.entity.slug} property={propertyOf(d)} value={v} display={text} unit={d.unit} fallback={p} entity={{ name: it.entity.name, entity_type: it.entity.entity_type }} className={cn('block text-left whitespace-normal', isBest && 'font-semibold text-ink', emph && 'text-accent')}>146 {d.key === 'openness' && typeof v === 'string' ? <OpennessChip openness={v} /> : d.kind === 'list' ? <span className="flex flex-wrap gap-1">{(Array.isArray(v) ? v : [v]).map((x, i) => <Chip key={i}>{String(x)}</Chip>)}</span> : text}147 </Evidence>148 ) : (149 <span className={cn(text === 'Unavailable' && 'text-ink-3')}>{text}</span>150 )}151 {p && (152 <span className="mt-0.5 hidden items-center gap-1 text-[10.5px] leading-4 text-ink-3 md:flex">153 <TierBadge tier={p.tier} /> <span className="max-w-[9rem] truncate">{p.source_name ?? hostOf(p.url) ?? 'source'}</span> · {fmtAgo(p.observed_at)}154 </span>155 )}156 </div>157 );158 };159 const bestOf = (d: CompareDimension11) => {160 if (d.kind !== 'number' || d.source === 'entity') return null;161 const nums = res.items.map((it) => num(valueOf(d, it))).filter((x): x is number => x !== null);162 if (nums.length < 2) return null;163 const lower = d.higher_is_better === false || (d.higher_is_better === undefined && LOWER_BETTER.test(d.key));164 return lower ? Math.min(...nums) : Math.max(...nums);165 };166 const rowLabel = (d: CompareDimension11) => {167 const comp = res.comparability?.[d.key];168 return (169 <>170 {d.key.startsWith('bench:') && d.benchmark ? (171 <Link href={routes.benchmark(d.benchmark) + (d.metric && d.config_key ? `?metric=${encodeURIComponent(d.metric)}&config_key=${encodeURIComponent(d.config_key)}` : '')} className="text-ink hover:text-accent hover:underline">172 {d.label.split(' · ')[0]}173 </Link>174 ) : (175 <span className="text-ink">{d.label}</span>176 )}177 {(d.unit || d.key.startsWith('bench:')) && <span className="block text-[10.5px] text-ink-3">{d.key.startsWith('bench:') ? d.label.split(' · ').slice(1).join(' · ') : d.unit}</span>}178 {comp && (179 <span className="mt-0.5 flex flex-wrap items-center gap-1">180 <ComparabilityBadge level={comp.level} reasons={comp.reasons} short />181 {comp.reasons?.length ? <Hint text={comp.reasons.join('; ')} /> : null}182 {[...new Set(Object.values(comp.trust ?? {}).map((t) => t.level))].map((lvl) => (183 <TrustBadge key={lvl} level={lvl} />184 ))}185 </span>186 )}187 </>188 );189 };190 const emphasised = (d: CompareDimension11) => !!uc && (uc.match.test(d.key) || uc.match.test(d.label));191 const ROW_TH = { whiteSpace: 'normal', textTransform: 'none', letterSpacing: 0, fontSize: '0.8125rem', fontWeight: 400 } as const;192 const wide = n <= 3 ? 'md:overflow-visible' : 'xl:overflow-visible';193 const stickyTop = n <= 3 ? 'md:sticky md:top-[var(--header-h)] md:z-20' : 'xl:sticky xl:top-[var(--header-h)] xl:z-20';194195 return (196 <div className="space-y-4" data-compare-terminal>197 {/* controls */}198 <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">199 <label className="inline-flex min-h-9 items-center gap-2 text-ink-2">200 <input type="checkbox" checked={hideIdentical} onChange={(e) => setHideIdentical(e.target.checked)} className="size-4 accent-[var(--accent)]" data-hide-identical /> Hide identical rows201 </label>202 <Link href={`?ids=${res.items.map((i) => encodeURIComponent(i.entity.slug)).join(',')}${diffOnly ? '' : '&diff_only=1'}${res.entity_type !== 'model' ? `&mode=${res.entity_type}s` : ''}`} className={cn('inline-flex h-8 items-center border px-2.5 text-xs', diffOnly ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:text-ink')} aria-pressed={diffOnly} data-diff-only>203 Differences only {diffOnly ? '· on' : ''}204 </Link>205 <span className="ml-auto inline-flex items-center gap-1 lg:hidden">206 <button type="button" onClick={() => setLayout('table')} aria-pressed={layout === 'table'} className={cn('inline-flex size-9 items-center justify-center border', layout === 'table' ? 'border-ink text-ink' : 'border-rule text-ink-3')} aria-label="Table layout">207 <Table2 className="size-4" aria-hidden />208 </button>209 <button type="button" onClick={() => setLayout('stack')} aria-pressed={layout === 'stack'} className={cn('inline-flex size-9 items-center justify-center border', layout === 'stack' ? 'border-ink text-ink' : 'border-rule text-ink-3')} aria-label="Stacked cards layout">210 <LayoutList className="size-4" aria-hidden />211 </button>212 </span>213 </div>214 {isModel && (215 <div className="flex flex-wrap items-center gap-1.5" role="group" aria-label="Use case emphasis" data-use-cases>216 <span className="eyebrow mr-1">Emphasise</span>217 {USE_CASES.map((u) => (218 <button key={u.id} type="button" onClick={() => setUseCase(useCase === u.id ? null : u.id)} aria-pressed={useCase === u.id} className={cn('inline-flex h-8 items-center border px-2.5 text-xs whitespace-nowrap', useCase === u.id ? 'border-accent bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>219 {u.label}220 </button>221 ))}222 {uc && <span className="text-[11px] text-ink-3">Relevant rows first and highlighted — no score is combined, no winner is declared.</span>}223 </div>224 )}225226 {/* ------------------------------------------------------------------------------------------ table layout */}227 <div className={cn(layout === 'stack' && 'hidden lg:block')}>228 <div className={cn('table-scroll scrollbar-thin relative', wide)}>229 <table className="data-table compare-matrix compact" data-compare-table>230 <caption className="sr-only">Comparison of {n} entities</caption>231 <thead>232 <tr>233 <th scope="col" className={cn('sticky left-0 z-30 w-[9rem] min-w-[9rem] bg-canvas md:w-[13rem] md:min-w-[13rem]', stickyTop)} style={{ whiteSpace: 'normal' }}>234 Dimension235 </th>236 {res.items.map((it) => (237 <th key={it.entity.id} scope="col" className={cn('min-w-[10rem] bg-canvas align-top', stickyTop)} style={{ whiteSpace: 'normal' }}>238 <span className="flex flex-col items-start gap-1 normal-case tracking-normal">239 <EntityLink e={it.entity} className="text-sm font-semibold" />240 {it.entity.organization && <span className="text-[11px] font-normal text-ink-3">{it.entity.organization.name}</span>}241 <span className="flex items-center gap-1">242 <CompareButton e={it.entity} size="sm" label="Add" />243 {isModel && n === 2 && <Link href={`/models/${encodeURIComponent(res.items[0]!.entity.slug)}/diff/${encodeURIComponent(res.items[1]!.entity.slug)}`} className="inline-flex h-7 items-center border border-rule px-1.5 text-xs font-normal text-ink-2 hover:text-ink">Diff</Link>}244 </span>245 </span>246 </th>247 ))}248 </tr>249 </thead>250 <tbody>251 {body.map((g) => (252 <GroupRows key={g.id} label={g.label} cols={n + 1}>253 {g.dims.map((d) => {254 const best = bestOf(d);255 const emph = emphasised(d);256 return (257 <tr key={d.key} className={cn(emph && 'bg-accent-soft/25')} data-dim={d.key}>258 <th scope="row" className="sticky left-0 z-10 bg-canvas text-left" style={ROW_TH}>259 {rowLabel(d)}260 </th>261 {res.items.map((it) => (262 <td key={it.entity.id} className="align-top" style={{ whiteSpace: 'normal' }}>263 {cellFor(d, it, best, emph)}264 </td>265 ))}266 </tr>267 );268 })}269 </GroupRows>270 ))}271 {isModel && <PricingRows res={res} n={n} />}272 {isModel && (273 <GroupRows label="Hardware fit" cols={n + 1}>274 <tr>275 <th scope="row" className="sticky left-0 z-10 bg-canvas text-left" style={ROW_TH}>276 Estimated memory277 <span className="block text-[10.5px] text-warning">estimated</span>278 </th>279 {res.items.map((it) => (280 <td key={it.entity.id} className="align-top text-xs text-ink-3" style={{ whiteSpace: 'normal' }}>281 {num(it.entity.attributes?.parameter_count) === null ? 'No parameter count — not estimable' : <Link href={`${routes.entity(it.entity)}#hardware-fit`} className="link">Per-device estimates →</Link>}282 </td>283 ))}284 </tr>285 </GroupRows>286 )}287 </tbody>288 </table>289 </div>290 </div>291292 {/* ------------------------------------------------------------------------------------------ stacked layout (mobile) */}293 {layout === 'stack' && (294 <div className="space-y-6 lg:hidden" data-compare-stack>295 {res.items.map((it) => (296 <section key={it.entity.id} className="border-t border-rule pt-3">297 <h3 className="flex flex-wrap items-center gap-2 text-[15px] font-semibold">298 <EntityLink e={it.entity} /> {it.entity.organization && <span className="text-xs font-normal text-ink-3">{it.entity.organization.name}</span>}299 </h3>300 {body.map((g) => (301 <div key={g.id} className="mt-3">302 <p className="eyebrow mb-1">{g.label}</p>303 <dl className="kv [&>div]:grid-cols-[7.5rem_minmax(0,1fr)] [&>div]:py-1">304 {g.dims.map((d) => (305 <div key={d.key} className={cn(emphasised(d) && 'bg-accent-soft/25')}>306 <dt className="text-[12px]">{d.label.split(' · ')[0]}</dt>307 <dd className="text-[13px]">{cellFor(d, it, bestOf(d), emphasised(d))}</dd>308 </div>309 ))}310 </dl>311 </div>312 ))}313 </section>314 ))}315 </div>316 )}317318 <Note>319 {shownRows} rows shown{hiddenRows ? `, ${hiddenRows} identical hidden` : ''}. Bold = best number in a row (highest; lowest for prices) — a reading aid, not a verdict. Benchmarks appear only when every entity has a current result in the same comparability group. {res.note ?? ''} Click any value for its evidence.320 </Note>321 </div>322 );323}324325function GroupRows({ label, cols, children }: { label: string; cols: number; children: React.ReactNode }) {326 return (327 <>328 <tr className="group-row">329 <th colSpan={cols} scope="colgroup" className="sticky left-0 !border-b-0 bg-canvas pt-4 text-left text-[11px] tracking-[0.1em] text-ink-3">330 {label}331 </th>332 </tr>333 {children}334 </>335 );336}337338/** Cheapest current deployment per provider × entity (from the compare payload's prices). */339function PricingRows({ res, n }: { res: ComparePayload11; n: number }) {340 const providers = new Map<string, { slug: string; name: string; entity_type: string }>();341 const per = res.items.map((it) => {342 const m = new Map<string, { input: number | null; output: number | null; observed: string; url: string | null; tier: number }>();343 for (const p of it.prices ?? []) {344 providers.set(p.provider.slug, p.provider);345 const cur = m.get(p.provider.slug) ?? { input: null, output: null, observed: p.observed_at, url: p.source_url, tier: p.tier };346 const i = num(p.input_per_mtok);347 const o = num(p.output_per_mtok);348 if (i !== null && (cur.input === null || i < cur.input)) cur.input = i;349 if (o !== null && (cur.output === null || o < cur.output)) cur.output = o;350 m.set(p.provider.slug, cur);351 }352 return m;353 });354 if (!providers.size) return null;355 const rows = [...providers.values()].sort((a, b) => a.name.localeCompare(b.name));356 return (357 <GroupRows label="Pricing · cheapest deployment per provider (USD / 1M in / out)" cols={n + 1}>358 {rows.map((prov) => (359 <tr key={prov.slug}>360 <th scope="row" className="sticky left-0 z-10 bg-canvas text-left" style={{ whiteSpace: 'normal', textTransform: 'none', letterSpacing: 0, fontSize: '0.8125rem', fontWeight: 400 }}>361 <EntityLink e={prov} />362 </th>363 {per.map((m, i) => {364 const b = m.get(prov.slug);365 return (366 <td key={res.items[i]!.entity.id} className="tnum align-top" style={{ whiteSpace: 'normal' }}>367 {b ? (368 <>369 <span className="text-accent-2">370 {fmtUsdPerM(b.input)} <span className="text-ink-3">/</span> {fmtUsdPerM(b.output)}371 </span>372 <span className="mt-0.5 hidden items-center gap-1 text-[10.5px] text-ink-3 md:flex">373 <TierBadge tier={b.tier} /> {b.url ? <a href={b.url} target="_blank" rel="noopener noreferrer" className="max-w-[9rem] truncate hover:text-accent">{hostOf(b.url)}</a> : null} · {fmtAgo(b.observed)}374 </span>375 </>376 ) : (377 <span className="text-ink-3">{DASH}</span>378 )}379 </td>380 );381 })}382 </tr>383 ))}384 </GroupRows>385 );386}387