niceeval 0.1.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 (94) hide show
  1. package/README.md +164 -0
  2. package/README.zh.md +160 -0
  3. package/bin/niceeval.js +11 -0
  4. package/package.json +96 -0
  5. package/src/agents/bub.ts +223 -0
  6. package/src/agents/builtin.ts +6 -0
  7. package/src/agents/claude-code.ts +96 -0
  8. package/src/agents/codex.ts +122 -0
  9. package/src/agents/index.ts +25 -0
  10. package/src/agents/shared.ts +145 -0
  11. package/src/cli.ts +421 -0
  12. package/src/context/context.test.ts +96 -0
  13. package/src/context/context.ts +427 -0
  14. package/src/context/control-flow.ts +27 -0
  15. package/src/context/session.ts +124 -0
  16. package/src/define.ts +112 -0
  17. package/src/expect/index.ts +243 -0
  18. package/src/i18n/en.ts +131 -0
  19. package/src/i18n/index.ts +39 -0
  20. package/src/i18n/zh-CN.ts +132 -0
  21. package/src/index.ts +39 -0
  22. package/src/loaders/index.ts +56 -0
  23. package/src/o11y/cost.ts +57 -0
  24. package/src/o11y/derive.ts +304 -0
  25. package/src/o11y/otlp/canonical.ts +112 -0
  26. package/src/o11y/otlp/mappers/bub.ts +30 -0
  27. package/src/o11y/otlp/mappers/codex.ts +38 -0
  28. package/src/o11y/otlp/mappers/index.ts +25 -0
  29. package/src/o11y/otlp/parse.ts +330 -0
  30. package/src/o11y/otlp/receiver.ts +100 -0
  31. package/src/o11y/otlp/sandbox-receiver.ts +113 -0
  32. package/src/o11y/otlp/select.ts +82 -0
  33. package/src/o11y/parsers/bub.ts +240 -0
  34. package/src/o11y/parsers/claude-code.ts +270 -0
  35. package/src/o11y/parsers/codex.ts +480 -0
  36. package/src/o11y/parsers/index.ts +37 -0
  37. package/src/o11y/prices.json +9873 -0
  38. package/src/runner/discover.ts +77 -0
  39. package/src/runner/reporters/artifacts.ts +81 -0
  40. package/src/runner/reporters/console.ts +76 -0
  41. package/src/runner/reporters/index.ts +5 -0
  42. package/src/runner/reporters/json.ts +52 -0
  43. package/src/runner/reporters/live.ts +178 -0
  44. package/src/runner/reporters/table.ts +328 -0
  45. package/src/runner/run.ts +860 -0
  46. package/src/runner/sandbox-prep.ts +91 -0
  47. package/src/sandbox/checkpoint.ts +39 -0
  48. package/src/sandbox/docker.ts +539 -0
  49. package/src/sandbox/e2b.ts +203 -0
  50. package/src/sandbox/index.ts +25 -0
  51. package/src/sandbox/registry.ts +60 -0
  52. package/src/sandbox/resolve.ts +131 -0
  53. package/src/sandbox/source-files.ts +30 -0
  54. package/src/sandbox/vercel.ts +236 -0
  55. package/src/scoring/collector.ts +113 -0
  56. package/src/scoring/judge.ts +236 -0
  57. package/src/scoring/scoped.ts +289 -0
  58. package/src/scoring/verdict.ts +20 -0
  59. package/src/source-loc.ts +53 -0
  60. package/src/types.ts +913 -0
  61. package/src/util.test.ts +31 -0
  62. package/src/util.ts +50 -0
  63. package/src/view/app/App.tsx +189 -0
  64. package/src/view/app/components/AttemptModal.tsx +66 -0
  65. package/src/view/app/components/CodeView.tsx +272 -0
  66. package/src/view/app/components/CopyControls.tsx +89 -0
  67. package/src/view/app/components/ExperimentTable.tsx +266 -0
  68. package/src/view/app/components/GroupSelector.tsx +61 -0
  69. package/src/view/app/components/LazyArtifact.tsx +50 -0
  70. package/src/view/app/components/Trace.tsx +100 -0
  71. package/src/view/app/components/Transcript.tsx +130 -0
  72. package/src/view/app/components/primitives.tsx +43 -0
  73. package/src/view/app/components/ui/badge.tsx +21 -0
  74. package/src/view/app/components/ui/dialog.tsx +34 -0
  75. package/src/view/app/components/ui/tabs.tsx +30 -0
  76. package/src/view/app/i18n.ts +341 -0
  77. package/src/view/app/index.html +13 -0
  78. package/src/view/app/lib/cn.ts +7 -0
  79. package/src/view/app/lib/format.ts +73 -0
  80. package/src/view/app/lib/guards.ts +61 -0
  81. package/src/view/app/lib/outcome.ts +96 -0
  82. package/src/view/app/lib/rows.ts +63 -0
  83. package/src/view/app/lib/transcript-data.tsx +121 -0
  84. package/src/view/app/main.tsx +17 -0
  85. package/src/view/app/pages/RunsPage.tsx +83 -0
  86. package/src/view/app/pages/TracesPage.tsx +40 -0
  87. package/src/view/app/shared.ts +10 -0
  88. package/src/view/app/types.ts +114 -0
  89. package/src/view/app/vite.config.ts +26 -0
  90. package/src/view/client-dist/app.css +2 -0
  91. package/src/view/client-dist/app.js +56 -0
  92. package/src/view/index.ts +406 -0
  93. package/src/view/styles.css +1074 -0
  94. package/src/view/template.html +15 -0
@@ -0,0 +1,89 @@
1
+ import React, { useState } from "react";
2
+ import { Check, Copy } from "lucide-react";
3
+ import type { T } from "../shared.ts";
4
+ import type { ViewResult, ViewRow } from "../types.ts";
5
+ import { failingAssertions, outcomeOf, reasonFor } from "../lib/outcome.ts";
6
+
7
+ export function CopyReason({ text, t }: { text: string; t: T }) {
8
+ const [copied, setCopied] = useState(false);
9
+ const copy = async (event: React.MouseEvent<HTMLButtonElement>) => {
10
+ event.stopPropagation();
11
+ try {
12
+ await copyText(text);
13
+ setCopied(true);
14
+ setTimeout(() => setCopied(false), 1200);
15
+ } catch {
16
+ setCopied(false);
17
+ }
18
+ };
19
+ return (
20
+ <button className={`copy-reason${copied ? " is-copied" : ""}`} onClick={copy} aria-label={t("action.copyReason")} title={t("action.copyReason")}>
21
+ {copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
22
+ </button>
23
+ );
24
+ }
25
+
26
+ export function CopyAllErrors({ rows, t }: { rows: ViewRow[]; t: T }) {
27
+ const [copied, setCopied] = useState(false);
28
+
29
+ const errorEntries = rows.flatMap((row: ViewRow) =>
30
+ (row.results ?? [])
31
+ .filter((r: ViewResult) => {
32
+ const outcome = outcomeOf(r);
33
+ return outcome === "failed" || outcome === "errored";
34
+ })
35
+ .map((r: ViewResult) => {
36
+ const failedAssertions = failingAssertions(r);
37
+ const reason = reasonFor(r, failedAssertions);
38
+ const traceBase = r.artifactAbsBase || r.artifactBase;
39
+ const tracePath = r.hasTrace && traceBase ? `${traceBase}/trace.json` : null;
40
+ return { experimentName: row.label, evalId: r.id, reason, tracePath };
41
+ })
42
+ );
43
+
44
+ if (!errorEntries.length) return null;
45
+
46
+ const copy = async (event: React.MouseEvent<HTMLButtonElement>) => {
47
+ event.stopPropagation();
48
+ const text = errorEntries
49
+ .map(({ experimentName, evalId, reason, tracePath }: { experimentName: string; evalId: string; reason: string; tracePath: string | null }) =>
50
+ [
51
+ `实验: ${experimentName} Eval: ${evalId}`,
52
+ reason ? `错误: ${reason}` : null,
53
+ tracePath ? `Trace: ${tracePath}` : null,
54
+ ]
55
+ .filter(Boolean)
56
+ .join("\n")
57
+ )
58
+ .join("\n\n");
59
+ try {
60
+ await copyText(text);
61
+ setCopied(true);
62
+ setTimeout(() => setCopied(false), 1500);
63
+ } catch {
64
+ setCopied(false);
65
+ }
66
+ };
67
+
68
+ return (
69
+ <button className={`copy-all-errors${copied ? " is-copied" : ""}`} onClick={copy} title={t("action.copyErrors")}>
70
+ {copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
71
+ <span>{copied ? t("action.copied") : `${t("action.copyErrors")} (${errorEntries.length})`}</span>
72
+ </button>
73
+ );
74
+ }
75
+
76
+ export async function copyText(text: string): Promise<void> {
77
+ if (navigator.clipboard?.writeText) {
78
+ await navigator.clipboard.writeText(text);
79
+ return;
80
+ }
81
+ const ta = document.createElement("textarea");
82
+ ta.value = text;
83
+ ta.style.position = "fixed";
84
+ ta.style.opacity = "0";
85
+ document.body.appendChild(ta);
86
+ ta.select();
87
+ document.execCommand("copy");
88
+ ta.remove();
89
+ }
@@ -0,0 +1,266 @@
1
+ import React, { useState } from "react";
2
+ import { ChevronRight } from "lucide-react";
3
+ import type { OpenModal, T } from "../shared.ts";
4
+ import type { Assertion, SortKey, SortState, ViewResult, ViewRow } from "../types.ts";
5
+ import { EvalGroup, failingAssertions, groupByEval, outcomeClass, outcomeLabel, outcomeOf, outcomeSummary, reasonFor, scoresSummary } from "../lib/outcome.ts";
6
+ import { configChips } from "../lib/rows.ts";
7
+ import { formatClock, formatCost, formatDateTime, formatDuration, formatPercent, formatTokens, totalTokens } from "../lib/format.ts";
8
+ import { Kpi, SortHeader } from "./primitives.tsx";
9
+
10
+ export function ExperimentTable({
11
+ rows,
12
+ sort,
13
+ setSortKey,
14
+ openRows,
15
+ toggleRow,
16
+ openModal,
17
+ t,
18
+ }: {
19
+ rows: ViewRow[];
20
+ sort: SortState;
21
+ setSortKey: (key: SortKey) => void;
22
+ openRows: Set<string>;
23
+ toggleRow: (key: string) => void;
24
+ openModal: OpenModal;
25
+ t: T;
26
+ }) {
27
+ return (
28
+ <div className="table-wrap">
29
+ <table>
30
+ <thead>
31
+ <tr>
32
+ <SortHeader name={t("table.experiment")} sortKey="experiment" sort={sort} onSort={setSortKey} />
33
+ <SortHeader name={t("table.model")} sortKey="model" sort={sort} onSort={setSortKey} />
34
+ <SortHeader name={t("table.agent")} sortKey="agent" sort={sort} onSort={setSortKey} />
35
+ <SortHeader name={t("table.avgDuration")} sortKey="avgDurationMs" sort={sort} onSort={setSortKey} />
36
+ <SortHeader name={t("table.successRate")} sortKey="passRate" sort={sort} onSort={setSortKey} />
37
+ <SortHeader name={t("table.tokens")} sortKey="tokens" sort={sort} onSort={setSortKey} />
38
+ <SortHeader name={t("table.estCost")} sortKey="cost" sort={sort} onSort={setSortKey} />
39
+ <th>{t("table.outcomes")}</th>
40
+ </tr>
41
+ </thead>
42
+ <tbody>
43
+ {rows.map((row: ViewRow) => (
44
+ <React.Fragment key={row.key}>
45
+ <ExperimentRow row={row} open={openRows.has(row.key)} onToggle={() => toggleRow(row.key)} t={t} />
46
+ {openRows.has(row.key) ? <ExperimentDetail row={row} openModal={openModal} t={t} /> : null}
47
+ </React.Fragment>
48
+ ))}
49
+ </tbody>
50
+ </table>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ export function ExperimentRow({ row, open, onToggle, t }: { row: ViewRow; open: boolean; onToggle: () => void; t: T }) {
56
+ const tone = row.passRate >= 0.8 ? "good" : row.passRate >= 0.5 ? "warn" : "bad";
57
+ return (
58
+ <tr
59
+ className={`main-row${open ? " is-open" : ""}`}
60
+ tabIndex={0}
61
+ onClick={onToggle}
62
+ onKeyDown={(e) => {
63
+ if (e.key === "Enter" || e.key === " ") {
64
+ e.preventDefault();
65
+ onToggle();
66
+ }
67
+ }}
68
+ >
69
+ <td>
70
+ <ChevronRight className="chev-icon" aria-hidden="true" />
71
+ <span className="name">{row.label}</span>
72
+ <div className="sub">
73
+ {row.evals} {row.evals === 1 ? t("detail.evalResult") : t("detail.evalResults")}
74
+ {row.runs > row.evals ? ` · ${row.runs} ${t("detail.runsUnit")}` : ""}
75
+ {row.lastRunAt ? ` · ${formatDateTime(row.lastRunAt)}` : ""}
76
+ </div>
77
+ </td>
78
+ <td>{row.model || t("config.default")}</td>
79
+ <td>{row.agent}</td>
80
+ <td className="num">{formatDuration(row.avgDurationMs)}</td>
81
+ <td className={`num ${tone}`}>{formatPercent(row.passRate)}</td>
82
+ <td className="num">{formatTokens(totalTokens(row.usage))}</td>
83
+ <td className="num">{formatCost(row.estimatedCostUSD)}</td>
84
+ <td>
85
+ <span className="pill">{outcomeSummary(row, t)}</span>
86
+ </td>
87
+ </tr>
88
+ );
89
+ }
90
+
91
+ export function ExperimentDetail({ row, openModal, t }: { row: ViewRow; openModal: OpenModal; t: T }) {
92
+ const totalDuration = (row.results ?? []).reduce((sum: number, r: ViewResult) => sum + (r.durationMs || 0), 0);
93
+ const sampleResult =
94
+ row.results?.find((r: ViewResult) => outcomeOf(r) === "errored") ||
95
+ row.results?.find((r: ViewResult) => outcomeOf(r) === "failed") ||
96
+ row.results?.[0] ||
97
+ {};
98
+ const evalGroups = groupByEval(row.results ?? []).sort((a, b) => a.id.localeCompare(b.id));
99
+ return (
100
+ <tr className="detail-row">
101
+ <td className="detail-cell" colSpan={8}>
102
+ <div className="detail">
103
+ <div className="config-strip">
104
+ {configChips(row, t).map(([label, value]) => (
105
+ <span className="config-chip" key={label}>
106
+ <span>{label}</span>
107
+ <b>{value}</b>
108
+ </span>
109
+ ))}
110
+ </div>
111
+ <div className="detail-kpis">
112
+ <Kpi label={t("detail.evals")} value={row.evals} />
113
+ <Kpi label={t("detail.passed")} value={row.passed} className="good" />
114
+ <Kpi label={t("detail.failed")} value={row.failed} className={row.failed ? "bad" : ""} />
115
+ <Kpi label={t("detail.errored")} value={row.errored} className={row.errored ? "infra-err" : ""} />
116
+ {row.runs > row.evals ? <Kpi label={t("detail.runs")} value={row.runs} /> : null}
117
+ <Kpi label={t("detail.totalTime")} value={formatDuration(totalDuration)} />
118
+ <Kpi label={t("detail.totalCost")} value={formatCost(row.estimatedCostUSD)} />
119
+ <Kpi label={t("detail.ran")} value={formatDateTime(row.lastRunAt)} title={row.lastRunAt || ""} />
120
+ </div>
121
+ <h3>{t("detail.evaluationAttempts")}</h3>
122
+ <div className="eval-list">
123
+ <div className="eval-grid-head">
124
+ <span>{t("detail.status")}</span>
125
+ <span>{t("detail.eval")}</span>
126
+ <span>{t("detail.reason")}</span>
127
+ <span>{t("detail.time")}</span>
128
+ <span>{t("table.tokens")}</span>
129
+ <span>{t("table.estCost")}</span>
130
+ <span>{t("detail.run")}</span>
131
+ </div>
132
+ {evalGroups.map((group) => (
133
+ <EvalRow key={`${group.experimentId ?? ""}-${group.id}`} group={group} openModal={openModal} t={t} />
134
+ ))}
135
+ </div>
136
+ <details className="raw-details">
137
+ <summary>
138
+ {t("detail.rawSample")} <span className="raw-note">{t("detail.rawNote")}</span>
139
+ </summary>
140
+ <pre>{JSON.stringify(sampleResult, null, 2)}</pre>
141
+ </details>
142
+ </div>
143
+ </td>
144
+ </tr>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * 一个 eval 一行:多轮(runs>1)折叠成单行摘要,点开展开各轮 attempt;单轮直接就是那条 attempt。
150
+ * Run 列显示 通过轮数 / 总轮数(如 0/3、2/3),让 earlyExit 重试和 flaky 一眼可见。
151
+ */
152
+ export function EvalRow({ group, openModal, t }: { group: EvalGroup; openModal: OpenModal; t: T }) {
153
+ const [open, setOpen] = useState(false);
154
+ const n = group.attempts.length;
155
+
156
+ if (n === 1) {
157
+ return <Attempt result={group.attempts[0]!} totalRuns={1} openModal={openModal} t={t} />;
158
+ }
159
+
160
+ // 代表轮:取与 eval 判决相同的第一条,用它的原因/分数做折叠行摘要。
161
+ const rep = group.attempts.find((a) => outcomeOf(a) === group.outcome) ?? group.attempts[0]!;
162
+ const gates = failingAssertions(rep);
163
+ const reason = reasonFor(rep, gates) || (group.outcome === "passed" ? scoresSummary(rep.assertions || []) : "");
164
+ const totalDuration = group.attempts.reduce((s, a) => s + (a.durationMs || 0), 0);
165
+ const totalTok = group.attempts.reduce((s, a) => s + totalTokens(a.usage), 0);
166
+ const totalCost = group.attempts.reduce((s, a) => s + (a.estimatedCostUSD || 0), 0);
167
+ const toggle = () => setOpen((v) => !v);
168
+
169
+ return (
170
+ <>
171
+ <div
172
+ className={`eval-item eval-item-clickable eval-group${open ? " is-open" : ""}`}
173
+ role="button"
174
+ tabIndex={0}
175
+ aria-expanded={open}
176
+ onClick={toggle}
177
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }}
178
+ >
179
+ <span className="attempt-status">
180
+ <span className={outcomeClass(group.outcome)}>{outcomeLabel(group.outcome, t)}</span>
181
+ </span>
182
+ <span className="eval-id">
183
+ <ChevronRight className="chev-icon" aria-hidden="true" />
184
+ {group.id}
185
+ </span>
186
+ <div className="assertions-cell">
187
+ <span className="assertions" title={reason || undefined}>
188
+ {reason || <span className="reason-empty">—</span>}
189
+ </span>
190
+ </div>
191
+ <span className="num">{formatDuration(totalDuration)}</span>
192
+ <span className="num">{formatTokens(totalTok)}</span>
193
+ <span className="num">{formatCost(totalCost)}</span>
194
+ <span className="num run-ratio" title={`${group.passedAttempts}/${n} ${t("detail.passed")}`}>
195
+ {group.passedAttempts}/{n}
196
+ </span>
197
+ </div>
198
+ {open ? (
199
+ <div className="eval-attempts">
200
+ {group.attempts.map((a) => (
201
+ <Attempt key={`${a.id}-${a.attempt}`} result={a} totalRuns={n} openModal={openModal} t={t} />
202
+ ))}
203
+ </div>
204
+ ) : null}
205
+ </>
206
+ );
207
+ }
208
+
209
+ export function Attempt({ result, totalRuns, openModal, t }: { result: ViewResult; totalRuns: number; openModal: OpenModal; t: T }) {
210
+ const outcome = outcomeOf(result);
211
+ const gates = failingAssertions(result);
212
+ const reason = reasonFor(result, gates);
213
+ const allAssertions = result.assertions || [];
214
+ const hasScores = allAssertions.some((a: Assertion) => a.score !== undefined && a.score !== null);
215
+ const hasBody = result.hasEvents || result.hasTrace || hasScores;
216
+
217
+ const inlineScores = !reason && outcome === "passed" ? scoresSummary(allAssertions) : "";
218
+ const displayReason = reason || inlineScores;
219
+
220
+ const handleOpen = () => openModal(result);
221
+
222
+ const cells = (
223
+ <>
224
+ <span className="attempt-status">
225
+ <span className={outcomeClass(outcome)}>{outcomeLabel(outcome, t)}</span>
226
+ </span>
227
+ <span className="eval-id">{result.id}</span>
228
+ <div className="assertions-cell">
229
+ <span
230
+ className={`assertions${hasBody ? " assertions-link" : ""}`}
231
+ title={displayReason || undefined}
232
+ onClick={hasBody ? (e) => { e.stopPropagation(); handleOpen(); } : undefined}
233
+ >
234
+ {displayReason || <span className="reason-empty">—</span>}
235
+ </span>
236
+ </div>
237
+ <span className="num">
238
+ {formatDuration(result.durationMs)}
239
+ {result.startedAt ? <small className="ran-at">{formatClock(result.startedAt)}</small> : null}
240
+ </span>
241
+ <span className="num">{formatTokens(totalTokens(result.usage))}</span>
242
+ <span className="num">{formatCost(result.estimatedCostUSD)}</span>
243
+ <span className="num" title={`attempt ${result.attempt + 1} of ${totalRuns}`}>
244
+ #{result.attempt + 1}
245
+ </span>
246
+ </>
247
+ );
248
+
249
+ if (!hasBody) {
250
+ return <div className="eval-item">{cells}</div>;
251
+ }
252
+
253
+ return (
254
+ <div
255
+ className="eval-item eval-item-clickable"
256
+ role="button"
257
+ tabIndex={0}
258
+ onClick={handleOpen}
259
+ onKeyDown={(e) => {
260
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleOpen(); }
261
+ }}
262
+ >
263
+ {cells}
264
+ </div>
265
+ );
266
+ }
@@ -0,0 +1,61 @@
1
+ import type { T } from "../shared.ts";
2
+ import type { ViewResult, ViewRow } from "../types.ts";
3
+ import { evalPassRate, groupByEval } from "../lib/outcome.ts";
4
+ import { formatCost, formatDateTime, formatPercent } from "../lib/format.ts";
5
+
6
+ export function GroupSelector({
7
+ groupMap,
8
+ selectedGroup,
9
+ onSelect,
10
+ t,
11
+ }: {
12
+ groupMap: Map<string, ViewRow[]>;
13
+ selectedGroup: string | null;
14
+ onSelect: (group: string) => void;
15
+ t: T;
16
+ }) {
17
+ if (!groupMap.size) return <div id="group-selector" className="group-selector" />;
18
+ return (
19
+ <div id="group-selector" className="group-selector">
20
+ {[...groupMap.keys()].sort().map((group) => {
21
+ const groupRows = groupMap.get(group) ?? [];
22
+ const allResults = groupRows.flatMap((r: ViewRow) => r.results ?? []);
23
+ const evalGroups = groupByEval(allResults); // 卡片计票按 eval,不按 attempt
24
+ const failed = evalGroups.filter((g) => g.outcome === "failed").length;
25
+ const errored = evalGroups.filter((g) => g.outcome === "errored").length;
26
+ const passRate = evalPassRate(allResults);
27
+ const tone = passRate >= 0.8 ? "good" : passRate >= 0.5 ? "warn" : "bad";
28
+ const totalCost = groupRows.reduce((s: number, r: ViewRow) => s + (r.estimatedCostUSD || 0), 0);
29
+ const lastRun = groupRows
30
+ .map((r: ViewRow) => r.lastRunAt)
31
+ .filter((value): value is string => Boolean(value))
32
+ .sort()
33
+ .at(-1);
34
+ const selected = selectedGroup === group;
35
+ return (
36
+ <div
37
+ key={group}
38
+ className={`group-card${selected ? " is-selected" : ""}`}
39
+ tabIndex={0}
40
+ role="button"
41
+ onClick={() => onSelect(group)}
42
+ onKeyDown={(e) => {
43
+ if (e.key === "Enter" || e.key === " ") {
44
+ e.preventDefault();
45
+ onSelect(group);
46
+ }
47
+ }}
48
+ >
49
+ <div className="group-card-name">{group}</div>
50
+ <div className={`group-card-rate ${tone}`}>{formatPercent(passRate)}</div>
51
+ <div className="group-card-meta">
52
+ {groupRows.length} {groupRows.length === 1 ? t("detail.evalResult") : t("detail.evalResults")} · {failed} {t("outcome.failed")}
53
+ {errored ? ` · ${errored} ${t("outcome.errored")}` : ""} · {formatCost(totalCost)}
54
+ </div>
55
+ {lastRun ? <div className="group-card-time">{formatDateTime(lastRun)}</div> : null}
56
+ </div>
57
+ );
58
+ })}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,50 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { LazyArtifactType, T } from "../shared.ts";
3
+ import { asEvents, asSpans } from "../lib/guards.ts";
4
+ import { Trace } from "./Trace.tsx";
5
+ import { Transcript } from "./Transcript.tsx";
6
+
7
+ export function LazyArtifact({ type, src, autoLoad = false, t }: { type: LazyArtifactType; src: string; autoLoad?: boolean; t: T }) {
8
+ const [open, setOpen] = useState(autoLoad);
9
+ const [loaded, setLoaded] = useState(false);
10
+ const [content, setContent] = useState<unknown>(null);
11
+ const [error, setError] = useState("");
12
+
13
+ const load = async () => {
14
+ if (loaded) return;
15
+ setLoaded(true);
16
+ try {
17
+ const resp = await fetch("/artifact?p=" + encodeURIComponent(src));
18
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
19
+ const body = await resp.json();
20
+ setContent(body);
21
+ setError("");
22
+ } catch (e) {
23
+ setLoaded(false);
24
+ setError(`${t("trace.loadFailed")} ${String(e)}`);
25
+ }
26
+ };
27
+
28
+ useEffect(() => {
29
+ if (autoLoad) void load();
30
+ }, []);
31
+
32
+ return (
33
+ <details
34
+ className="trace-details"
35
+ open={open}
36
+ onToggle={(e) => {
37
+ const isOpen = e.currentTarget.open;
38
+ setOpen(isOpen);
39
+ if (isOpen) void load();
40
+ }}
41
+ >
42
+ <summary>{type === "transcript" ? t("trace.transcript") : t("trace.timing")}</summary>
43
+ <div className="trace-slot">
44
+ {error ? <div className="trace-span-meta">{error}</div> : !content ? <div className="trace-span-meta">{t("trace.loading")}</div> : null}
45
+ {content && type === "transcript" ? <Transcript events={asEvents(content) ?? []} t={t} /> : null}
46
+ {content && type === "trace" ? <Trace spans={asSpans(content) ?? []} t={t} /> : null}
47
+ </div>
48
+ </details>
49
+ );
50
+ }
@@ -0,0 +1,100 @@
1
+ import type { ReactNode } from "react";
2
+ import type { T } from "../shared.ts";
3
+ import type { Span, ViewJson } from "../types.ts";
4
+ import { formatDuration } from "../lib/format.ts";
5
+
6
+ export function Trace({ spans, t }: { spans: Span[]; t: T }) {
7
+ if (!spans?.length) return <div className="trace-span-meta">{t("trace.noSpans")}</div>;
8
+ const t0 = Math.min(...spans.map((s) => s.startMs));
9
+ const t1 = Math.max(...spans.map((s) => s.endMs));
10
+ const total = Math.max(1, t1 - t0);
11
+ const byId = new Map(spans.map((s) => [s.spanId, s]));
12
+ const depthOf = (span: Span): number => {
13
+ let depth = 0;
14
+ let cur = span;
15
+ const seen = new Set();
16
+ while (cur && cur.parentSpanId && byId.has(cur.parentSpanId) && !seen.has(cur.spanId)) {
17
+ seen.add(cur.spanId);
18
+ const next = byId.get(cur.parentSpanId);
19
+ if (!next) break;
20
+ cur = next;
21
+ depth++;
22
+ if (depth > 40) break;
23
+ }
24
+ return depth;
25
+ };
26
+ const ordered = [...spans].sort((a, b) => a.startMs - b.startMs || depthOf(a) - depthOf(b));
27
+ return (
28
+ <div className="trace">
29
+ <div className="trace-span-meta">
30
+ {t("trace.total")} {formatDuration(total)} · {spans.length} {t("trace.spans")} · {t("trace.clickDetails")}
31
+ </div>
32
+ {ordered.map((span) => {
33
+ const left = ((span.startMs - t0) / total) * 100;
34
+ const width = Math.max(0.6, ((span.endMs - span.startMs) / total) * 100);
35
+ const kind = span.kind || "other";
36
+ const tone = span.status === "error" ? "bad" : "k-" + kind;
37
+ const detail = spanAttrs(span.attributes);
38
+ const row = (
39
+ <summary className="trace-row">
40
+ <div className="trace-label" style={{ paddingLeft: depthOf(span) * 12 }} title={span.name}>
41
+ {kind !== "other" ? <span className={`kind-chip k-${kind}`}>{kind}</span> : null}
42
+ {span.name}
43
+ </div>
44
+ <div className="trace-track">
45
+ <div className={`trace-bar ${tone}`} style={{ left: `${left}%`, width: `${width}%` }} />
46
+ </div>
47
+ <div className="trace-dur num">{formatDuration(span.endMs - span.startMs)}</div>
48
+ </summary>
49
+ );
50
+ return detail ? (
51
+ <details className="span-d" key={span.spanId}>
52
+ {row}
53
+ {detail}
54
+ </details>
55
+ ) : (
56
+ <div className="span-d" key={span.spanId}>
57
+ {row}
58
+ </div>
59
+ );
60
+ })}
61
+ </div>
62
+ );
63
+ }
64
+
65
+ export function spanAttrs(attrs?: Record<string, ViewJson>): ReactNode {
66
+ if (!attrs) return null;
67
+ const hide = /^(code\.|thread\.|target$|busy_ns$|idle_ns$|rpc\.|app_server\.)/;
68
+ const keys = Object.keys(attrs).filter((k) => !hide.test(k));
69
+ if (!keys.length) return null;
70
+ const io = keys.filter((k) => k.startsWith("io."));
71
+ const rest = keys.filter((k) => !k.startsWith("io.")).sort();
72
+ return (
73
+ <div className="span-attrs">
74
+ {io.map((key) => {
75
+ const label = key.replace(/^io\./, "");
76
+ const value = String(attrs[key]);
77
+ return label === "input" || label === "output" ? (
78
+ <div className="attr-io" key={key}>
79
+ <span className="attr-k">{label}</span>
80
+ <pre className="attr-pre">{value}</pre>
81
+ </div>
82
+ ) : (
83
+ <AttrRow key={key} label={label} value={value} />
84
+ );
85
+ })}
86
+ {rest.map((key) => (
87
+ <AttrRow key={key} label={key} value={typeof attrs[key] === "object" ? JSON.stringify(attrs[key]) : String(attrs[key])} />
88
+ ))}
89
+ </div>
90
+ );
91
+ }
92
+
93
+ export function AttrRow({ label, value }: { label: string; value: string }) {
94
+ return (
95
+ <div className="attr-row">
96
+ <span className="attr-k">{label}</span>
97
+ <span className="attr-v">{value}</span>
98
+ </div>
99
+ );
100
+ }