@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,16 @@
1
+ "use client";
2
+
3
+ import ReactMarkdown from "react-markdown";
4
+ import remarkGfm from "remark-gfm";
5
+
6
+ interface MarkdownOutputProps {
7
+ content: string;
8
+ }
9
+
10
+ export function MarkdownOutput({ content }: MarkdownOutputProps) {
11
+ return (
12
+ <div className="md">
13
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
14
+ </div>
15
+ );
16
+ }
@@ -0,0 +1,313 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { formatCost } from "@darkrishabh/bench-ai";
5
+ import type { WebProviderResult } from "../types";
6
+ import { MarkdownOutput } from "./MarkdownOutput";
7
+ import { formatProviderDisplayName, providerUi } from "../lib/provider-ui";
8
+
9
+ type BadgeVariant = "good" | "warn" | "muted";
10
+
11
+ function StarButton({
12
+ n,
13
+ active,
14
+ onClick,
15
+ }: {
16
+ n: number;
17
+ active: boolean;
18
+ onClick: () => void;
19
+ }) {
20
+ return (
21
+ <button
22
+ type="button"
23
+ onClick={onClick}
24
+ aria-label={`${n} star${n > 1 ? "s" : ""}`}
25
+ style={{
26
+ border: "none",
27
+ background: "transparent",
28
+ cursor: "pointer",
29
+ padding: "0.12rem",
30
+ lineHeight: 0,
31
+ color: active ? "var(--amber)" : "var(--text-3)",
32
+ fontFamily: "inherit",
33
+ }}
34
+ >
35
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
36
+ <path
37
+ d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
38
+ fill={active ? "currentColor" : "none"}
39
+ stroke="currentColor"
40
+ strokeWidth="1.35"
41
+ strokeLinejoin="round"
42
+ />
43
+ </svg>
44
+ </button>
45
+ );
46
+ }
47
+
48
+ function badgeColors(variant: BadgeVariant) {
49
+ switch (variant) {
50
+ case "good":
51
+ return {
52
+ bg: "var(--green-subtle)",
53
+ color: "var(--green)",
54
+ border: "rgba(4, 120, 87, 0.22)",
55
+ };
56
+ case "warn":
57
+ return {
58
+ bg: "var(--red-subtle)",
59
+ color: "var(--red)",
60
+ border: "rgba(185, 28, 28, 0.2)",
61
+ };
62
+ default:
63
+ return {
64
+ bg: "var(--surface-muted)",
65
+ color: "var(--text-2)",
66
+ border: "var(--border)",
67
+ };
68
+ }
69
+ }
70
+
71
+ export function ModelResponseCard({
72
+ r,
73
+ badge,
74
+ latencyTone,
75
+ rating,
76
+ onRate,
77
+ }: {
78
+ r: WebProviderResult;
79
+ badge: { label: string; variant: BadgeVariant } | null;
80
+ latencyTone: "fast" | "slow" | "neutral";
81
+ rating: number;
82
+ onRate: (stars: number) => void;
83
+ }) {
84
+ const color = providerUi(r.provider).color;
85
+ const hasError = Boolean(r.error);
86
+ const [copied, setCopied] = useState(false);
87
+ const providerUpper = formatProviderDisplayName(r.provider);
88
+
89
+ const latencyColor =
90
+ hasError || latencyTone === "neutral"
91
+ ? "var(--text-1)"
92
+ : latencyTone === "fast"
93
+ ? "var(--green)"
94
+ : "var(--red)";
95
+
96
+ const costColor =
97
+ hasError ? "var(--text-1)" : r.costUsd === 0 ? "var(--green)" : "var(--amber)";
98
+
99
+ const copyOutput = async () => {
100
+ if (hasError || !r.output) return;
101
+ try {
102
+ await navigator.clipboard.writeText(r.output);
103
+ setCopied(true);
104
+ setTimeout(() => setCopied(false), 2000);
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ };
109
+
110
+ const badgeStyle = badge && !hasError ? badgeColors(badge.variant) : null;
111
+
112
+ return (
113
+ <div
114
+ style={{
115
+ background: "var(--surface)",
116
+ border: "1px solid var(--border)",
117
+ borderRadius: "var(--r-xl)",
118
+ overflow: "hidden",
119
+ boxShadow: "var(--shadow-sm)",
120
+ display: "flex",
121
+ flexDirection: "column",
122
+ minWidth: 0,
123
+ }}
124
+ >
125
+ <div
126
+ style={{
127
+ padding: "0.85rem 1.05rem",
128
+ borderBottom: "1px solid var(--border)",
129
+ background: "var(--surface)",
130
+ display: "flex",
131
+ alignItems: "flex-start",
132
+ gap: "0.65rem",
133
+ borderLeft: `3px solid ${hasError ? "var(--red)" : color}`,
134
+ }}
135
+ >
136
+ <div
137
+ style={{
138
+ width: 8,
139
+ height: 8,
140
+ borderRadius: "50%",
141
+ background: hasError ? "var(--red)" : color,
142
+ flexShrink: 0,
143
+ marginTop: "0.35rem",
144
+ opacity: hasError ? 1 : 0.9,
145
+ }}
146
+ />
147
+ <div style={{ flex: 1, minWidth: 0 }}>
148
+ <div
149
+ style={{
150
+ fontSize: "0.68rem",
151
+ fontWeight: 700,
152
+ color: "var(--text-3)",
153
+ letterSpacing: "0.06em",
154
+ }}
155
+ >
156
+ {providerUpper}
157
+ </div>
158
+ <div
159
+ style={{
160
+ fontSize: "0.9rem",
161
+ color: "var(--text-1)",
162
+ fontWeight: 700,
163
+ marginTop: 3,
164
+ overflow: "hidden",
165
+ textOverflow: "ellipsis",
166
+ whiteSpace: "nowrap",
167
+ fontFamily: "var(--font-mono)",
168
+ }}
169
+ >
170
+ {r.model}
171
+ </div>
172
+ </div>
173
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: "0.35rem", flexShrink: 0 }}>
174
+ {badgeStyle && badge && (
175
+ <span
176
+ style={{
177
+ fontSize: "0.62rem",
178
+ fontWeight: 700,
179
+ textTransform: "uppercase",
180
+ letterSpacing: "0.04em",
181
+ padding: "0.22rem 0.5rem",
182
+ borderRadius: 999,
183
+ background: badgeStyle.bg,
184
+ color: badgeStyle.color,
185
+ border: `1px solid ${badgeStyle.border}`,
186
+ whiteSpace: "nowrap",
187
+ }}
188
+ >
189
+ {badge.label}
190
+ </span>
191
+ )}
192
+ {hasError && (
193
+ <span
194
+ style={{
195
+ fontSize: "0.62rem",
196
+ color: "var(--red)",
197
+ background: "var(--red-subtle)",
198
+ border: "1px solid rgba(185, 28, 28, 0.2)",
199
+ borderRadius: 999,
200
+ padding: "0.22rem 0.5rem",
201
+ fontWeight: 700,
202
+ }}
203
+ >
204
+ Failed
205
+ </span>
206
+ )}
207
+ </div>
208
+ </div>
209
+
210
+ {!hasError && (
211
+ <div
212
+ style={{
213
+ display: "grid",
214
+ gridTemplateColumns: "repeat(3, 1fr)",
215
+ gap: 0,
216
+ borderBottom: "1px solid var(--border)",
217
+ background: "var(--surface-subtle)",
218
+ }}
219
+ >
220
+ <div style={{ padding: "0.55rem 0.65rem", borderRight: "1px solid var(--border)" }}>
221
+ <div style={{ fontSize: "0.62rem", fontWeight: 600, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.04em" }}>
222
+ Latency
223
+ </div>
224
+ <div style={{ fontSize: "0.82rem", fontWeight: 700, color: latencyColor, marginTop: 2, fontVariantNumeric: "tabular-nums" }}>
225
+ {r.latencyMs.toLocaleString()}ms
226
+ </div>
227
+ </div>
228
+ <div style={{ padding: "0.55rem 0.65rem", borderRight: "1px solid var(--border)" }}>
229
+ <div style={{ fontSize: "0.62rem", fontWeight: 600, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.04em" }}>
230
+ Tokens in → out
231
+ </div>
232
+ <div style={{ fontSize: "0.82rem", fontWeight: 600, color: "var(--text-1)", marginTop: 2, fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }}>
233
+ {r.inputTokens} → {r.outputTokens}
234
+ </div>
235
+ </div>
236
+ <div style={{ padding: "0.55rem 0.65rem" }}>
237
+ <div style={{ fontSize: "0.62rem", fontWeight: 600, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.04em" }}>
238
+ Cost
239
+ </div>
240
+ <div style={{ fontSize: "0.82rem", fontWeight: 700, color: costColor, marginTop: 2, fontVariantNumeric: "tabular-nums" }}>
241
+ {r.costUsd === 0 ? "$0.00" : formatCost(r.costUsd)}
242
+ </div>
243
+ </div>
244
+ </div>
245
+ )}
246
+
247
+ <div
248
+ style={{
249
+ padding: "1rem 1.1rem",
250
+ overflowY: "auto",
251
+ maxHeight: 480,
252
+ flex: 1,
253
+ background: "var(--surface)",
254
+ }}
255
+ >
256
+ {hasError ? (
257
+ <div
258
+ style={{
259
+ color: "var(--red)",
260
+ background: "var(--red-subtle)",
261
+ border: "1px solid rgba(185, 28, 28, 0.18)",
262
+ borderRadius: "var(--r-md)",
263
+ padding: "0.85rem 1rem",
264
+ fontSize: "0.875rem",
265
+ lineHeight: 1.6,
266
+ }}
267
+ >
268
+ {r.error}
269
+ </div>
270
+ ) : (
271
+ <MarkdownOutput content={r.output} />
272
+ )}
273
+ </div>
274
+
275
+ {!hasError && (
276
+ <div
277
+ style={{
278
+ borderTop: "1px solid var(--border)",
279
+ padding: "0.55rem 1rem",
280
+ display: "flex",
281
+ alignItems: "center",
282
+ justifyContent: "space-between",
283
+ gap: "0.75rem",
284
+ background: "var(--surface-subtle)",
285
+ }}
286
+ >
287
+ <div style={{ display: "flex", alignItems: "center", gap: "0.05rem" }} role="group" aria-label="Rate this response">
288
+ {[1, 2, 3, 4, 5].map((n) => (
289
+ <StarButton key={n} n={n} active={n <= rating} onClick={() => onRate(n === rating ? 0 : n)} />
290
+ ))}
291
+ </div>
292
+ <button
293
+ type="button"
294
+ onClick={copyOutput}
295
+ style={{
296
+ border: "1px solid var(--border)",
297
+ background: "var(--surface)",
298
+ borderRadius: "var(--r-md)",
299
+ padding: "0.35rem 0.75rem",
300
+ fontSize: "0.78rem",
301
+ fontWeight: 600,
302
+ color: "var(--text-2)",
303
+ cursor: "pointer",
304
+ fontFamily: "inherit",
305
+ }}
306
+ >
307
+ {copied ? "Copied" : "Copy"}
308
+ </button>
309
+ </div>
310
+ )}
311
+ </div>
312
+ );
313
+ }
@@ -0,0 +1,184 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import type { WebProviderResult } from "../types";
5
+ import { formatCost } from "@darkrishabh/bench-ai";
6
+
7
+ function MiniChart({
8
+ label,
9
+ labelIcon,
10
+ results,
11
+ getValue,
12
+ format,
13
+ lowerIsBetter,
14
+ barColor,
15
+ }: {
16
+ label: string;
17
+ labelIcon?: ReactNode;
18
+ results: WebProviderResult[];
19
+ getValue: (r: WebProviderResult) => number;
20
+ format: (v: number) => string;
21
+ lowerIsBetter?: boolean;
22
+ barColor: string;
23
+ }) {
24
+ const values = results.map(getValue);
25
+ const max = Math.max(...values, 1e-9);
26
+ const min = Math.min(...values);
27
+ const span = Math.max(max - min, 1e-9);
28
+
29
+ return (
30
+ <div style={{ flex: "1 1 140px", minWidth: 120 }}>
31
+ <div
32
+ style={{
33
+ display: "flex",
34
+ alignItems: "center",
35
+ gap: "0.35rem",
36
+ marginBottom: "0.4rem",
37
+ }}
38
+ >
39
+ {labelIcon}
40
+ <div
41
+ style={{
42
+ fontSize: "0.65rem",
43
+ fontWeight: 600,
44
+ color: "var(--text-3)",
45
+ textTransform: "uppercase",
46
+ letterSpacing: "0.05em",
47
+ }}
48
+ >
49
+ {label}
50
+ </div>
51
+ </div>
52
+ <div style={{ display: "flex", alignItems: "flex-end", gap: "0.35rem", height: 52 }}>
53
+ {results.map((r) => {
54
+ const v = getValue(r);
55
+ const t = lowerIsBetter ? (max - v) / span : (v - min) / span;
56
+ const h = 8 + Math.round(t * 44);
57
+ return (
58
+ <div
59
+ key={r.instanceId}
60
+ title={`${r.label}: ${format(v)}`}
61
+ style={{
62
+ flex: 1,
63
+ minWidth: 6,
64
+ maxWidth: 28,
65
+ height: h,
66
+ borderRadius: 4,
67
+ background: r.error ? "var(--border-strong)" : barColor,
68
+ opacity: r.error ? 0.35 : 0.92,
69
+ transition: "height 0.25s ease",
70
+ }}
71
+ />
72
+ );
73
+ })}
74
+ </div>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ export function QuickComparisonBar({
80
+ results,
81
+ onFullCompare,
82
+ }: {
83
+ results: WebProviderResult[];
84
+ onFullCompare: () => void;
85
+ }) {
86
+ if (results.length === 0) return null;
87
+
88
+ return (
89
+ <div
90
+ style={{
91
+ position: "fixed",
92
+ bottom: 0,
93
+ left: 0,
94
+ right: 0,
95
+ zIndex: 40,
96
+ background: "var(--surface)",
97
+ borderTop: "1px solid var(--border)",
98
+ boxShadow: "0 -6px 24px rgba(15, 23, 42, 0.05)",
99
+ }}
100
+ >
101
+ <div
102
+ style={{
103
+ maxWidth: 1120,
104
+ margin: "0 auto",
105
+ padding: "0.65rem 1.5rem 0.75rem",
106
+ display: "flex",
107
+ alignItems: "stretch",
108
+ gap: "1rem",
109
+ flexWrap: "wrap",
110
+ }}
111
+ >
112
+ <div
113
+ style={{
114
+ fontSize: "0.72rem",
115
+ fontWeight: 700,
116
+ color: "var(--text-2)",
117
+ textTransform: "uppercase",
118
+ letterSpacing: "0.06em",
119
+ paddingTop: "0.35rem",
120
+ flexShrink: 0,
121
+ }}
122
+ >
123
+ Quick comparison
124
+ </div>
125
+ <div style={{ display: "flex", flex: 1, gap: "1.25rem", flexWrap: "wrap", minWidth: 0 }}>
126
+ <MiniChart
127
+ label="Latency (ms)"
128
+ labelIcon={
129
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--chart-latency)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
130
+ <path d="M12 5v14M19 12l-7 7-7-7" />
131
+ </svg>
132
+ }
133
+ results={results}
134
+ getValue={(r) => (r.error ? 0 : r.latencyMs)}
135
+ format={(v) => `${Math.round(v)}ms`}
136
+ lowerIsBetter
137
+ barColor="var(--chart-latency)"
138
+ />
139
+ <MiniChart
140
+ label="Output tokens"
141
+ results={results}
142
+ getValue={(r) => (r.error ? 0 : r.outputTokens)}
143
+ format={(v) => `${Math.round(v)}`}
144
+ barColor="var(--chart-tokens)"
145
+ />
146
+ <MiniChart
147
+ label="Cost"
148
+ results={results}
149
+ getValue={(r) => (r.error ? 0 : r.costUsd)}
150
+ format={(v) => (v === 0 ? "$0" : formatCost(v))}
151
+ lowerIsBetter
152
+ barColor="var(--chart-cost)"
153
+ />
154
+ </div>
155
+ <button
156
+ type="button"
157
+ onClick={onFullCompare}
158
+ style={{
159
+ alignSelf: "center",
160
+ display: "inline-flex",
161
+ alignItems: "center",
162
+ gap: "0.35rem",
163
+ padding: "0.5rem 1rem",
164
+ borderRadius: "var(--r-md)",
165
+ border: "1px solid var(--border)",
166
+ background: "var(--surface)",
167
+ color: "var(--text-1)",
168
+ fontSize: "0.8125rem",
169
+ fontWeight: 600,
170
+ cursor: "pointer",
171
+ fontFamily: "inherit",
172
+ boxShadow: "var(--shadow-xs)",
173
+ flexShrink: 0,
174
+ }}
175
+ >
176
+ Full compare
177
+ <span style={{ fontSize: "0.95rem", lineHeight: 1, color: "var(--text-2)" }} aria-hidden>
178
+
179
+ </span>
180
+ </button>
181
+ </div>
182
+ </div>
183
+ );
184
+ }
@@ -0,0 +1,149 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import type { WebProviderResult } from "../types";
5
+ import { diffLines } from "../lib/simple-line-diff";
6
+ import { providerUi } from "../lib/provider-ui";
7
+
8
+ export function ResponsesLineDiff({
9
+ results,
10
+ leftId,
11
+ rightId,
12
+ onLeftId,
13
+ onRightId,
14
+ }: {
15
+ results: WebProviderResult[];
16
+ leftId: string;
17
+ rightId: string;
18
+ onLeftId: (id: string) => void;
19
+ onRightId: (id: string) => void;
20
+ }) {
21
+ const ok = results.filter((r) => !r.error && r.output.trim().length > 0);
22
+ const left = results.find((r) => r.instanceId === leftId) ?? ok[0];
23
+ const right = results.find((r) => r.instanceId === rightId) ?? ok[1] ?? ok[0];
24
+
25
+ const lines = useMemo(() => {
26
+ if (!left || !right || left.instanceId === right.instanceId) return [];
27
+ return diffLines(left.output, right.output);
28
+ }, [left, right]);
29
+
30
+ const selectStyle = {
31
+ flex: 1,
32
+ minWidth: 0,
33
+ padding: "0.45rem 0.6rem",
34
+ borderRadius: "var(--r-md)",
35
+ border: "1px solid var(--border)",
36
+ background: "var(--surface)",
37
+ color: "var(--text-1)",
38
+ fontSize: "0.8rem",
39
+ fontFamily: "var(--font-mono)",
40
+ } as const;
41
+
42
+ if (ok.length < 2) {
43
+ return (
44
+ <div style={{ padding: "2rem", textAlign: "center", color: "var(--text-3)", fontSize: "0.875rem" }}>
45
+ Need at least two successful responses to diff.
46
+ </div>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
52
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.65rem", alignItems: "center" }}>
53
+ <label style={{ display: "flex", flexDirection: "column", gap: "0.25rem", flex: "1 1 200px" }}>
54
+ <span style={{ fontSize: "0.65rem", fontWeight: 600, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
55
+ Baseline
56
+ </span>
57
+ <select value={left?.instanceId ?? ""} onChange={(e) => onLeftId(e.target.value)} style={selectStyle}>
58
+ {results.map((r) => (
59
+ <option
60
+ key={r.instanceId}
61
+ value={r.instanceId}
62
+ disabled={Boolean(r.error) || !r.output.trim()}
63
+ >
64
+ {r.label}
65
+ {r.error ? " (error)" : ""}
66
+ </option>
67
+ ))}
68
+ </select>
69
+ </label>
70
+ <label style={{ display: "flex", flexDirection: "column", gap: "0.25rem", flex: "1 1 200px" }}>
71
+ <span style={{ fontSize: "0.65rem", fontWeight: 600, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
72
+ Compare
73
+ </span>
74
+ <select value={right?.instanceId ?? ""} onChange={(e) => onRightId(e.target.value)} style={selectStyle}>
75
+ {results.map((r) => (
76
+ <option
77
+ key={r.instanceId}
78
+ value={r.instanceId}
79
+ disabled={Boolean(r.error) || !r.output.trim()}
80
+ >
81
+ {r.label}
82
+ {r.error ? " (error)" : ""}
83
+ </option>
84
+ ))}
85
+ </select>
86
+ </label>
87
+ </div>
88
+
89
+ <div
90
+ style={{
91
+ fontSize: "0.72rem",
92
+ color: "var(--text-3)",
93
+ display: "flex",
94
+ gap: "1rem",
95
+ flexWrap: "wrap",
96
+ }}
97
+ >
98
+ <span>
99
+ <span style={{ color: providerUi(left!.provider).color, fontWeight: 600 }}>{left!.label}</span>
100
+ <span style={{ margin: "0 0.35rem" }}>vs</span>
101
+ <span style={{ color: providerUi(right!.provider).color, fontWeight: 600 }}>{right!.label}</span>
102
+ </span>
103
+ </div>
104
+
105
+ <div
106
+ style={{
107
+ border: "1px solid var(--border)",
108
+ borderRadius: "var(--r-lg)",
109
+ background: "var(--surface-muted)",
110
+ maxHeight: 560,
111
+ overflow: "auto",
112
+ fontFamily: "var(--font-mono)",
113
+ fontSize: "0.78rem",
114
+ lineHeight: 1.55,
115
+ }}
116
+ >
117
+ {lines.map((line, i) => {
118
+ const bg =
119
+ line.type === "add"
120
+ ? "rgba(4, 120, 87, 0.12)"
121
+ : line.type === "remove"
122
+ ? "rgba(185, 28, 28, 0.1)"
123
+ : "transparent";
124
+ const prefix = line.type === "add" ? "+ " : line.type === "remove" ? "− " : " ";
125
+ const color =
126
+ line.type === "add" ? "var(--green)" : line.type === "remove" ? "var(--red)" : "var(--text-2)";
127
+ return (
128
+ <div
129
+ key={i}
130
+ style={{
131
+ padding: "0.08rem 0.65rem",
132
+ background: bg,
133
+ color,
134
+ whiteSpace: "pre-wrap",
135
+ wordBreak: "break-word",
136
+ }}
137
+ >
138
+ {prefix}
139
+ {line.text}
140
+ </div>
141
+ );
142
+ })}
143
+ </div>
144
+ <p style={{ fontSize: "0.75rem", color: "var(--text-3)", margin: 0 }}>
145
+ Line-level diff (LCS). Green lines appear only in the compare model; red only in the baseline.
146
+ </p>
147
+ </div>
148
+ );
149
+ }