@darkrishabh/bench-ai 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +333 -0
  2. package/dist/cli/app.d.ts +11 -0
  3. package/dist/cli/app.d.ts.map +1 -0
  4. package/dist/cli/app.js +48 -0
  5. package/dist/cli/app.js.map +1 -0
  6. package/dist/cli/components/DiffView.d.ts +5 -0
  7. package/dist/cli/components/DiffView.d.ts.map +1 -0
  8. package/dist/cli/components/DiffView.js +14 -0
  9. package/dist/cli/components/DiffView.js.map +1 -0
  10. package/dist/cli/components/EvalView.d.ts +6 -0
  11. package/dist/cli/components/EvalView.d.ts.map +1 -0
  12. package/dist/cli/components/EvalView.js +82 -0
  13. package/dist/cli/components/EvalView.js.map +1 -0
  14. package/dist/cli/components/Spinner.d.ts +4 -0
  15. package/dist/cli/components/Spinner.d.ts.map +1 -0
  16. package/dist/cli/components/Spinner.js +15 -0
  17. package/dist/cli/components/Spinner.js.map +1 -0
  18. package/dist/cli/index.d.ts +3 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +117 -0
  21. package/dist/cli/index.js.map +1 -0
  22. package/dist/cli/run-command.d.ts +11 -0
  23. package/dist/cli/run-command.d.ts.map +1 -0
  24. package/dist/cli/run-command.js +119 -0
  25. package/dist/cli/run-command.js.map +1 -0
  26. package/dist/engine/cost.d.ts +3 -0
  27. package/dist/engine/cost.d.ts.map +1 -0
  28. package/dist/engine/cost.js +52 -0
  29. package/dist/engine/cost.js.map +1 -0
  30. package/dist/engine/diff.d.ts +6 -0
  31. package/dist/engine/diff.d.ts.map +1 -0
  32. package/dist/engine/diff.js +43 -0
  33. package/dist/engine/diff.js.map +1 -0
  34. package/dist/engine/eval.d.ts +14 -0
  35. package/dist/engine/eval.d.ts.map +1 -0
  36. package/dist/engine/eval.js +194 -0
  37. package/dist/engine/eval.js.map +1 -0
  38. package/dist/engine/index.d.ts +15 -0
  39. package/dist/engine/index.d.ts.map +1 -0
  40. package/dist/engine/index.js +10 -0
  41. package/dist/engine/index.js.map +1 -0
  42. package/dist/engine/providers/base.d.ts +7 -0
  43. package/dist/engine/providers/base.d.ts.map +1 -0
  44. package/dist/engine/providers/base.js +2 -0
  45. package/dist/engine/providers/base.js.map +1 -0
  46. package/dist/engine/providers/claude.d.ts +15 -0
  47. package/dist/engine/providers/claude.d.ts.map +1 -0
  48. package/dist/engine/providers/claude.js +53 -0
  49. package/dist/engine/providers/claude.js.map +1 -0
  50. package/dist/engine/providers/minimax.d.ts +16 -0
  51. package/dist/engine/providers/minimax.d.ts.map +1 -0
  52. package/dist/engine/providers/minimax.js +67 -0
  53. package/dist/engine/providers/minimax.js.map +1 -0
  54. package/dist/engine/providers/ollama.d.ts +14 -0
  55. package/dist/engine/providers/ollama.d.ts.map +1 -0
  56. package/dist/engine/providers/ollama.js +60 -0
  57. package/dist/engine/providers/ollama.js.map +1 -0
  58. package/dist/engine/providers/openai-compatible.d.ts +19 -0
  59. package/dist/engine/providers/openai-compatible.d.ts.map +1 -0
  60. package/dist/engine/providers/openai-compatible.js +109 -0
  61. package/dist/engine/providers/openai-compatible.js.map +1 -0
  62. package/dist/engine/providers/subprocess.d.ts +55 -0
  63. package/dist/engine/providers/subprocess.d.ts.map +1 -0
  64. package/dist/engine/providers/subprocess.js +111 -0
  65. package/dist/engine/providers/subprocess.js.map +1 -0
  66. package/dist/engine/suite-loader.d.ts +11 -0
  67. package/dist/engine/suite-loader.d.ts.map +1 -0
  68. package/dist/engine/suite-loader.js +75 -0
  69. package/dist/engine/suite-loader.js.map +1 -0
  70. package/dist/engine/types.d.ts +104 -0
  71. package/dist/engine/types.d.ts.map +1 -0
  72. package/dist/engine/types.js +2 -0
  73. package/dist/engine/types.js.map +1 -0
  74. package/next-env.d.ts +6 -0
  75. package/next.config.ts +26 -0
  76. package/package.json +72 -0
  77. package/public/icon.svg +14 -0
  78. package/src/app/api/diff/route.ts +135 -0
  79. package/src/app/api/models/route.ts +96 -0
  80. package/src/app/api/suite/route.ts +314 -0
  81. package/src/app/globals.css +215 -0
  82. package/src/app/icon.svg +14 -0
  83. package/src/app/layout.tsx +44 -0
  84. package/src/app/opengraph-image.tsx +73 -0
  85. package/src/app/page.tsx +952 -0
  86. package/src/app/suite/layout.tsx +12 -0
  87. package/src/app/suite/page.tsx +206 -0
  88. package/src/app/twitter-image.tsx +1 -0
  89. package/src/components/BenchAiLogo.tsx +38 -0
  90. package/src/components/ComparePanel.tsx +643 -0
  91. package/src/components/ConfigPanel.tsx +809 -0
  92. package/src/components/MarkdownOutput.tsx +16 -0
  93. package/src/components/ModelResponseCard.tsx +313 -0
  94. package/src/components/QuickComparisonBar.tsx +184 -0
  95. package/src/components/ResponsesLineDiff.tsx +149 -0
  96. package/src/components/SettingsPanel.tsx +591 -0
  97. package/src/components/SuitePanel.tsx +875 -0
  98. package/src/lib/brand.ts +4 -0
  99. package/src/lib/config-yaml.ts +70 -0
  100. package/src/lib/consume-suite-sse.ts +70 -0
  101. package/src/lib/describe-judge.ts +23 -0
  102. package/src/lib/model-chip-palette.ts +9 -0
  103. package/src/lib/openai-model-list.ts +33 -0
  104. package/src/lib/provider-ui.ts +30 -0
  105. package/src/lib/resolve-credentials.ts +80 -0
  106. package/src/lib/run-history.ts +66 -0
  107. package/src/lib/simple-line-diff.ts +50 -0
  108. package/src/lib/storage.ts +100 -0
  109. package/src/lib/suite-judge-meta.ts +13 -0
  110. package/src/lib/suite-run-history.ts +81 -0
  111. package/src/types.ts +170 -0
  112. package/vercel.json +5 -0
@@ -0,0 +1,643 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import type { WebProviderResult } from "../types";
5
+ import { formatCost } from "@darkrishabh/bench-ai";
6
+
7
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
8
+
9
+ function wordCount(text: string) {
10
+ return text.split(/\s+/).filter(Boolean).length;
11
+ }
12
+
13
+ function tokensPerSec(tokens: number, latencyMs: number) {
14
+ return latencyMs > 0 ? Math.round((tokens / latencyMs) * 1000) : 0;
15
+ }
16
+
17
+ const STOP_WORDS = new Set([
18
+ "the","and","for","are","but","not","you","all","can","had","her","was",
19
+ "one","our","out","day","get","has","him","his","how","its","may","new",
20
+ "now","old","see","two","way","who","any","use","this","that","with","have",
21
+ "from","they","know","want","been","good","much","some","time","very","when",
22
+ "come","here","just","like","long","make","many","more","only","over","such",
23
+ "take","than","them","well","were","what","your","into","there","their","will",
24
+ "also","each","which","would","could","should","other","after","about","these",
25
+ "those","being","because","through","between","during","before","without",
26
+ ]);
27
+
28
+ function tokenise(text: string): string[] {
29
+ return (text.toLowerCase().match(/\b[a-z]{4,}\b/g) ?? []).filter(
30
+ (w) => !STOP_WORDS.has(w)
31
+ );
32
+ }
33
+
34
+ function jaccardSimilarity(a: string, b: string): number {
35
+ const setA = new Set(tokenise(a));
36
+ const setB = new Set(tokenise(b));
37
+ const intersection = [...setA].filter((w) => setB.has(w)).length;
38
+ const union = new Set([...setA, ...setB]).size;
39
+ return union === 0 ? 0 : intersection / union;
40
+ }
41
+
42
+ function uniqueTerms(text: string, others: string[], n = 8): string[] {
43
+ const freq = new Map<string, number>();
44
+ tokenise(text).forEach((w) => freq.set(w, (freq.get(w) ?? 0) + 1));
45
+ const otherWords = new Set(others.flatMap(tokenise));
46
+ return [...freq.entries()]
47
+ .filter(([w]) => !otherWords.has(w))
48
+ .sort((a, b) => b[1] - a[1])
49
+ .slice(0, n)
50
+ .map(([w]) => w);
51
+ }
52
+
53
+ function countStructure(text: string) {
54
+ return {
55
+ headings: (text.match(/^#{1,6} /gm) ?? []).length,
56
+ bullets: (text.match(/^[-*] /gm) ?? []).length,
57
+ codeBlocks: (text.match(/```/g) ?? []).length / 2,
58
+ tables: (text.match(/^\|.+\|/gm) ?? []).length > 0,
59
+ };
60
+ }
61
+
62
+ // ─── Styles ───────────────────────────────────────────────────────────────────
63
+
64
+ const PROVIDER_COLOR: Record<string, string> = {
65
+ claude: "var(--claude)",
66
+ ollama: "var(--ollama)",
67
+ minimax: "var(--minimax)",
68
+ openai: "var(--openai)",
69
+ groq: "var(--groq)",
70
+ openrouter: "var(--openrouter)",
71
+ "nvidia-nim":"var(--nvidia-nim)",
72
+ together: "var(--together)",
73
+ perplexity: "var(--perplexity)",
74
+ custom: "var(--custom)",
75
+ };
76
+
77
+ const PROVIDER_SUBTLE: Record<string, string> = {
78
+ claude: "var(--claude-subtle)",
79
+ ollama: "var(--ollama-subtle)",
80
+ minimax: "var(--minimax-subtle)",
81
+ openai: "var(--openai-subtle)",
82
+ groq: "var(--groq-subtle)",
83
+ openrouter: "var(--openrouter-subtle)",
84
+ "nvidia-nim":"var(--nvidia-nim-subtle)",
85
+ together: "var(--together-subtle)",
86
+ perplexity: "var(--perplexity-subtle)",
87
+ custom: "var(--custom-subtle)",
88
+ };
89
+
90
+ // ─── Sub-components ───────────────────────────────────────────────────────────
91
+
92
+ function Section({
93
+ title,
94
+ children,
95
+ }: {
96
+ title: string;
97
+ children: React.ReactNode;
98
+ }) {
99
+ return (
100
+ <div
101
+ style={{
102
+ background: "var(--surface)",
103
+ border: "1px solid var(--border)",
104
+ borderRadius: "var(--r-xl)",
105
+ overflow: "hidden",
106
+ boxShadow: "var(--shadow-sm)",
107
+ }}
108
+ >
109
+ <div
110
+ style={{
111
+ padding: "0.75rem 1.2rem",
112
+ borderBottom: "1px solid var(--border)",
113
+ fontWeight: 600,
114
+ fontSize: "0.8125rem",
115
+ letterSpacing: "-0.01em",
116
+ color: "var(--text-1)",
117
+ background: "var(--surface-subtle)",
118
+ }}
119
+ >
120
+ {title}
121
+ </div>
122
+ <div style={{ padding: "1.25rem" }}>{children}</div>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ function WinnerCard({
128
+ icon,
129
+ label,
130
+ winner,
131
+ value,
132
+ color,
133
+ }: {
134
+ icon: string;
135
+ label: string;
136
+ winner: string;
137
+ value: string;
138
+ color: string;
139
+ }) {
140
+ return (
141
+ <div
142
+ style={{
143
+ flex: 1,
144
+ background: "var(--surface)",
145
+ border: "1px solid var(--border)",
146
+ borderTop: `3px solid ${color}`,
147
+ borderRadius: "var(--r-md)",
148
+ padding: "1rem 1.1rem",
149
+ boxShadow: "var(--shadow-xs)",
150
+ minWidth: 0,
151
+ }}
152
+ >
153
+ <div
154
+ style={{
155
+ fontSize: "0.72rem",
156
+ textTransform: "uppercase",
157
+ letterSpacing: "0.06em",
158
+ color: "var(--text-3)",
159
+ marginBottom: "0.35rem",
160
+ }}
161
+ >
162
+ {icon} {label}
163
+ </div>
164
+ <div
165
+ style={{
166
+ fontWeight: 700,
167
+ fontSize: "0.95rem",
168
+ color: "var(--text-1)",
169
+ whiteSpace: "nowrap",
170
+ overflow: "hidden",
171
+ textOverflow: "ellipsis",
172
+ marginBottom: "0.2rem",
173
+ }}
174
+ >
175
+ {winner}
176
+ </div>
177
+ <div style={{ fontSize: "0.8rem", color, fontWeight: 600 }}>{value}</div>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ function MetricRow({
183
+ label,
184
+ results,
185
+ getValue,
186
+ format,
187
+ lowerIsBetter,
188
+ }: {
189
+ label: string;
190
+ results: WebProviderResult[];
191
+ getValue: (r: WebProviderResult) => number;
192
+ format: (v: number) => string;
193
+ lowerIsBetter?: boolean;
194
+ }) {
195
+ const values = results.map(getValue);
196
+ const max = Math.max(...values);
197
+ const min = Math.min(...values);
198
+ const winnerIdx = lowerIsBetter
199
+ ? values.indexOf(min)
200
+ : values.indexOf(max);
201
+
202
+ return (
203
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
204
+ <div
205
+ style={{
206
+ fontSize: "0.75rem",
207
+ fontWeight: 600,
208
+ color: "var(--text-2)",
209
+ textTransform: "uppercase",
210
+ letterSpacing: "0.04em",
211
+ }}
212
+ >
213
+ {label}
214
+ </div>
215
+ {results.map((r, i) => {
216
+ const v = getValue(r);
217
+ const pct = max > 0 ? (v / max) * 100 : 0;
218
+ const barPct = lowerIsBetter && max > 0 ? (v / max) * 100 : pct;
219
+ const barColor =
220
+ i === winnerIdx
221
+ ? PROVIDER_COLOR[r.provider] ?? "var(--accent)"
222
+ : "var(--border-strong)";
223
+ const isWinner = i === winnerIdx;
224
+
225
+ return (
226
+ <div key={r.instanceId} style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
227
+ <div
228
+ style={{
229
+ width: 130,
230
+ fontSize: "0.78rem",
231
+ color: "var(--text-2)",
232
+ flexShrink: 0,
233
+ overflow: "hidden",
234
+ textOverflow: "ellipsis",
235
+ whiteSpace: "nowrap",
236
+ }}
237
+ title={r.label}
238
+ >
239
+ {r.label}
240
+ </div>
241
+ <div
242
+ style={{
243
+ flex: 1,
244
+ height: 8,
245
+ background: "var(--surface-subtle)",
246
+ borderRadius: 4,
247
+ overflow: "hidden",
248
+ border: "1px solid var(--border)",
249
+ }}
250
+ >
251
+ <div
252
+ style={{
253
+ width: `${barPct}%`,
254
+ height: "100%",
255
+ background: barColor,
256
+ borderRadius: 4,
257
+ transition: "width 0.4s ease",
258
+ }}
259
+ />
260
+ </div>
261
+ <div
262
+ style={{
263
+ fontSize: "0.8rem",
264
+ fontWeight: isWinner ? 600 : 400,
265
+ color: isWinner
266
+ ? PROVIDER_COLOR[r.provider] ?? "var(--accent)"
267
+ : "var(--text-2)",
268
+ width: 90,
269
+ textAlign: "right",
270
+ flexShrink: 0,
271
+ display: "flex",
272
+ alignItems: "center",
273
+ justifyContent: "flex-end",
274
+ gap: "0.3rem",
275
+ }}
276
+ >
277
+ {format(v)}
278
+ {isWinner && (
279
+ <span
280
+ style={{
281
+ fontSize: "0.65rem",
282
+ background: PROVIDER_SUBTLE[r.provider] ?? "var(--accent-subtle)",
283
+ color: PROVIDER_COLOR[r.provider] ?? "var(--accent)",
284
+ border: `1px solid ${PROVIDER_COLOR[r.provider] ?? "var(--accent)"}40`,
285
+ borderRadius: 4,
286
+ padding: "0.05rem 0.35rem",
287
+ fontWeight: 700,
288
+ textTransform: "uppercase",
289
+ letterSpacing: "0.03em",
290
+ }}
291
+ >
292
+ {lowerIsBetter ? "best" : "most"}
293
+ </span>
294
+ )}
295
+ </div>
296
+ </div>
297
+ );
298
+ })}
299
+ </div>
300
+ );
301
+ }
302
+
303
+ function SimilarityPill({ pct }: { pct: number }) {
304
+ const color =
305
+ pct > 0.65 ? "var(--green)" : pct > 0.4 ? "var(--amber)" : "var(--text-3)";
306
+ return (
307
+ <div
308
+ style={{
309
+ display: "inline-flex",
310
+ alignItems: "center",
311
+ gap: "0.5rem",
312
+ background: "var(--surface-subtle)",
313
+ border: "1px solid var(--border)",
314
+ borderRadius: 8,
315
+ padding: "0.5rem 0.75rem",
316
+ width: "100%",
317
+ }}
318
+ >
319
+ <div style={{ flex: 1, height: 6, background: "var(--border)", borderRadius: 3, overflow: "hidden" }}>
320
+ <div style={{ width: `${pct * 100}%`, height: "100%", background: color, borderRadius: 3, transition: "width 0.4s ease" }} />
321
+ </div>
322
+ <span style={{ fontSize: "0.82rem", fontWeight: 700, color, width: 36, textAlign: "right", flexShrink: 0 }}>
323
+ {Math.round(pct * 100)}%
324
+ </span>
325
+ </div>
326
+ );
327
+ }
328
+
329
+ function TermChip({ term, color }: { term: string; color: string }) {
330
+ return (
331
+ <span
332
+ style={{
333
+ padding: "0.2rem 0.55rem",
334
+ borderRadius: 5,
335
+ fontSize: "0.75rem",
336
+ background: color + "12",
337
+ border: `1px solid ${color}28`,
338
+ color,
339
+ fontWeight: 500,
340
+ }}
341
+ >
342
+ {term}
343
+ </span>
344
+ );
345
+ }
346
+
347
+ // ─── Main component ───────────────────────────────────────────────────────────
348
+
349
+ export function ComparePanel({ results }: { results: WebProviderResult[] }) {
350
+ const valid = results.filter((r) => !r.error && r.output.length > 0);
351
+
352
+ if (valid.length < 2) {
353
+ return (
354
+ <div
355
+ style={{
356
+ textAlign: "center",
357
+ padding: "3rem",
358
+ color: "var(--text-3)",
359
+ fontSize: "0.875rem",
360
+ }}
361
+ >
362
+ Need at least 2 successful responses to compare.
363
+ </div>
364
+ );
365
+ }
366
+
367
+ // ── At a glance ──
368
+ const fastest = valid.reduce((a, b) => (a.latencyMs < b.latencyMs ? a : b));
369
+ const cheapest = valid.reduce((a, b) => (a.costUsd <= b.costUsd ? a : b));
370
+ const mostDetailed = valid.reduce((a, b) =>
371
+ wordCount(a.output) >= wordCount(b.output) ? a : b
372
+ );
373
+ const tokensPerSecValues = valid.map((r) => tokensPerSec(r.outputTokens, r.latencyMs));
374
+ const fastestGenIdx = tokensPerSecValues.indexOf(Math.max(...tokensPerSecValues));
375
+ const fastestGen = valid[fastestGenIdx];
376
+
377
+ // ── Similarity pairs ──
378
+ const pairs: { a: WebProviderResult; b: WebProviderResult; sim: number }[] = [];
379
+ for (let i = 0; i < valid.length; i++) {
380
+ for (let j = i + 1; j < valid.length; j++) {
381
+ pairs.push({
382
+ a: valid[i],
383
+ b: valid[j],
384
+ sim: jaccardSimilarity(valid[i].output, valid[j].output),
385
+ });
386
+ }
387
+ }
388
+
389
+ // ── Unique terms ──
390
+ const uniqueByModel = valid.map((r) =>
391
+ uniqueTerms(
392
+ r.output,
393
+ valid.filter((o) => o.instanceId !== r.instanceId).map((o) => o.output)
394
+ )
395
+ );
396
+
397
+ // ── Structure ──
398
+ const structures = valid.map((r) => countStructure(r.output));
399
+
400
+ return (
401
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
402
+ {/* At a glance */}
403
+ <div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
404
+ <WinnerCard
405
+ icon="⚡"
406
+ label="Fastest"
407
+ winner={fastest.label}
408
+ value={`${fastest.latencyMs.toLocaleString()}ms`}
409
+ color={PROVIDER_COLOR[fastest.provider] ?? "var(--accent)"}
410
+ />
411
+ <WinnerCard
412
+ icon="💰"
413
+ label="Cheapest"
414
+ winner={cheapest.label}
415
+ value={cheapest.costUsd === 0 ? "Free (local)" : formatCost(cheapest.costUsd)}
416
+ color={PROVIDER_COLOR[cheapest.provider] ?? "var(--accent)"}
417
+ />
418
+ <WinnerCard
419
+ icon="📝"
420
+ label="Most Detailed"
421
+ winner={mostDetailed.label}
422
+ value={`${wordCount(mostDetailed.output).toLocaleString()} words`}
423
+ color={PROVIDER_COLOR[mostDetailed.provider] ?? "var(--accent)"}
424
+ />
425
+ {fastestGen && tokensPerSecValues[fastestGenIdx] > 0 && (
426
+ <WinnerCard
427
+ icon="🔥"
428
+ label="Fastest Generation"
429
+ winner={fastestGen.label}
430
+ value={`${tokensPerSecValues[fastestGenIdx]} tok/s`}
431
+ color={PROVIDER_COLOR[fastestGen.provider] ?? "var(--accent)"}
432
+ />
433
+ )}
434
+ </div>
435
+
436
+ {/* Performance metrics */}
437
+ <Section title="Performance">
438
+ <div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
439
+ <MetricRow
440
+ label="Latency"
441
+ results={valid}
442
+ getValue={(r) => r.latencyMs}
443
+ format={(v) => `${v.toLocaleString()}ms`}
444
+ lowerIsBetter
445
+ />
446
+ <MetricRow
447
+ label="Cost"
448
+ results={valid}
449
+ getValue={(r) => r.costUsd}
450
+ format={(v) => (v === 0 ? "$0.00" : formatCost(v))}
451
+ lowerIsBetter
452
+ />
453
+ {valid.some((r) => r.outputTokens > 0) && (
454
+ <MetricRow
455
+ label="Generation Speed (tok/s)"
456
+ results={valid}
457
+ getValue={(r) => tokensPerSec(r.outputTokens, r.latencyMs)}
458
+ format={(v) => (v > 0 ? `${v} tok/s` : "—")}
459
+ />
460
+ )}
461
+ </div>
462
+ </Section>
463
+
464
+ {/* Output analysis */}
465
+ <Section title="Output Analysis">
466
+ <div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
467
+ <MetricRow
468
+ label="Word Count"
469
+ results={valid}
470
+ getValue={(r) => wordCount(r.output)}
471
+ format={(v) => `${v.toLocaleString()} words`}
472
+ />
473
+ {valid.some((r) => r.outputTokens > 0) && (
474
+ <MetricRow
475
+ label="Output Tokens"
476
+ results={valid}
477
+ getValue={(r) => r.outputTokens}
478
+ format={(v) => (v > 0 ? `${v.toLocaleString()} tokens` : "—")}
479
+ />
480
+ )}
481
+ </div>
482
+
483
+ {/* Structure signals */}
484
+ <div
485
+ style={{
486
+ marginTop: "1.25rem",
487
+ paddingTop: "1.25rem",
488
+ borderTop: "1px solid var(--border)",
489
+ display: "flex",
490
+ flexDirection: "column",
491
+ gap: "0.6rem",
492
+ }}
493
+ >
494
+ <div
495
+ style={{
496
+ fontSize: "0.75rem",
497
+ fontWeight: 600,
498
+ color: "var(--text-2)",
499
+ textTransform: "uppercase",
500
+ letterSpacing: "0.04em",
501
+ marginBottom: "0.25rem",
502
+ }}
503
+ >
504
+ Structure
505
+ </div>
506
+ {valid.map((r, i) => {
507
+ const s = structures[i];
508
+ const tags = [
509
+ s.headings > 0 && `${s.headings} heading${s.headings > 1 ? "s" : ""}`,
510
+ s.bullets > 0 && `${s.bullets} bullet${s.bullets > 1 ? "s" : ""}`,
511
+ s.codeBlocks > 0 && `${Math.round(s.codeBlocks)} code block${s.codeBlocks > 1 ? "s" : ""}`,
512
+ s.tables && "table",
513
+ ].filter(Boolean);
514
+
515
+ return (
516
+ <div
517
+ key={r.instanceId}
518
+ style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}
519
+ >
520
+ <div
521
+ style={{
522
+ width: 130,
523
+ fontSize: "0.78rem",
524
+ color: "var(--text-2)",
525
+ flexShrink: 0,
526
+ overflow: "hidden",
527
+ textOverflow: "ellipsis",
528
+ whiteSpace: "nowrap",
529
+ }}
530
+ >
531
+ {r.label}
532
+ </div>
533
+ <div style={{ display: "flex", gap: "0.35rem", flexWrap: "wrap" }}>
534
+ {tags.length > 0 ? (
535
+ tags.map((tag) => (
536
+ <span
537
+ key={String(tag)}
538
+ style={{
539
+ padding: "0.1rem 0.5rem",
540
+ borderRadius: 5,
541
+ fontSize: "0.75rem",
542
+ background: "var(--surface-subtle)",
543
+ border: "1px solid var(--border)",
544
+ color: "var(--text-2)",
545
+ }}
546
+ >
547
+ {tag}
548
+ </span>
549
+ ))
550
+ ) : (
551
+ <span style={{ fontSize: "0.78rem", color: "var(--text-3)" }}>
552
+ plain prose
553
+ </span>
554
+ )}
555
+ </div>
556
+ </div>
557
+ );
558
+ })}
559
+ </div>
560
+ </Section>
561
+
562
+ {/* Similarity */}
563
+ {pairs.length > 0 && (
564
+ <Section title="Similarity">
565
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
566
+ {pairs.map((p) => (
567
+ <div
568
+ key={`${p.a.instanceId}-${p.b.instanceId}`}
569
+ style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}
570
+ >
571
+ <div
572
+ style={{
573
+ fontSize: "0.78rem",
574
+ color: "var(--text-2)",
575
+ flexShrink: 0,
576
+ width: 260,
577
+ overflow: "hidden",
578
+ textOverflow: "ellipsis",
579
+ whiteSpace: "nowrap",
580
+ }}
581
+ >
582
+ <span style={{ color: PROVIDER_COLOR[p.a.provider] ?? "inherit", fontWeight: 500 }}>
583
+ {p.a.label}
584
+ </span>
585
+ <span style={{ color: "var(--text-3)", margin: "0 0.35rem" }}>↔</span>
586
+ <span style={{ color: PROVIDER_COLOR[p.b.provider] ?? "inherit", fontWeight: 500 }}>
587
+ {p.b.label}
588
+ </span>
589
+ </div>
590
+ <SimilarityPill pct={p.sim} />
591
+ </div>
592
+ ))}
593
+ <p style={{ fontSize: "0.75rem", color: "var(--text-3)", marginTop: "0.25rem" }}>
594
+ Jaccard similarity on meaningful word overlap. &gt;65% = largely similar, &lt;40% = notably different.
595
+ </p>
596
+ </div>
597
+ </Section>
598
+ )}
599
+
600
+ {/* Unique contributions */}
601
+ {uniqueByModel.some((terms) => terms.length > 0) && (
602
+ <Section title="Unique Contributions">
603
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
604
+ {valid.map((r, i) => {
605
+ const terms = uniqueByModel[i];
606
+ const color = PROVIDER_COLOR[r.provider] ?? "var(--accent)";
607
+ return (
608
+ <div key={r.instanceId} style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}>
609
+ <div
610
+ style={{
611
+ fontSize: "0.78rem",
612
+ color: "var(--text-2)",
613
+ width: 130,
614
+ flexShrink: 0,
615
+ paddingTop: "0.15rem",
616
+ overflow: "hidden",
617
+ textOverflow: "ellipsis",
618
+ whiteSpace: "nowrap",
619
+ }}
620
+ >
621
+ {r.label}
622
+ </div>
623
+ <div style={{ display: "flex", gap: "0.35rem", flexWrap: "wrap", flex: 1 }}>
624
+ {terms.length > 0 ? (
625
+ terms.map((t) => <TermChip key={t} term={t} color={color} />)
626
+ ) : (
627
+ <span style={{ fontSize: "0.78rem", color: "var(--text-3)" }}>
628
+ No notably unique terms
629
+ </span>
630
+ )}
631
+ </div>
632
+ </div>
633
+ );
634
+ })}
635
+ <p style={{ fontSize: "0.75rem", color: "var(--text-3)", marginTop: "0.25rem" }}>
636
+ Terms that appear in this model's response but not in others — indicating unique focus areas.
637
+ </p>
638
+ </div>
639
+ </Section>
640
+ )}
641
+ </div>
642
+ );
643
+ }