niceeval 0.1.1 → 0.1.2

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 (133) hide show
  1. package/README.md +8 -8
  2. package/README.zh.md +13 -13
  3. package/docs/README.md +119 -0
  4. package/docs/adapters/README.md +60 -0
  5. package/docs/adapters/authoring.md +179 -0
  6. package/docs/adapters/coding-agent-skills-plugins.md +324 -0
  7. package/docs/adapters/collection.md +128 -0
  8. package/docs/adapters/contract.md +263 -0
  9. package/docs/adapters/reference/agent-eval.md +215 -0
  10. package/docs/adapters/reference/agent-loop-apis.md +69 -0
  11. package/docs/adapters/reference/claude-code-otel-telemetry.md +100 -0
  12. package/docs/adapters/reference/eve-protocol.md +127 -0
  13. package/docs/adapters/reference/otel-genai.md +107 -0
  14. package/docs/adapters/reference/otel-instrumentation.md +65 -0
  15. package/docs/adapters/targets.md +99 -0
  16. package/docs/architecture.md +140 -0
  17. package/docs/assertions.md +388 -0
  18. package/docs/capabilities-by-construction.md +48 -0
  19. package/docs/cli.md +171 -0
  20. package/docs/concepts.md +106 -0
  21. package/docs/e2e-ci.md +295 -0
  22. package/docs/eval-authoring.md +319 -0
  23. package/docs/experiments.md +154 -0
  24. package/docs/getting-started.md +224 -0
  25. package/docs/multi-agent.md +145 -0
  26. package/docs/observability.md +333 -0
  27. package/docs/origin-integration.md +195 -0
  28. package/docs/references.md +35 -0
  29. package/docs/results-format.md +228 -0
  30. package/docs/runner.md +104 -0
  31. package/docs/sandbox.md +276 -0
  32. package/docs/scoring.md +119 -0
  33. package/docs/source-map.md +106 -0
  34. package/docs/tier-sync.md +177 -0
  35. package/docs/view.md +157 -0
  36. package/docs/vision.md +88 -0
  37. package/package.json +35 -5
  38. package/src/agents/ai-sdk-otel.ts +0 -0
  39. package/src/agents/ai-sdk.test.ts +377 -0
  40. package/src/agents/ai-sdk.ts +577 -0
  41. package/src/agents/bub.ts +30 -37
  42. package/src/agents/claude-code.ts +7 -4
  43. package/src/agents/codex.ts +15 -10
  44. package/src/agents/index.ts +43 -1
  45. package/src/agents/sdk-streams.test.ts +145 -0
  46. package/src/agents/sdk-streams.ts +457 -0
  47. package/src/agents/shared.ts +77 -39
  48. package/src/agents/streaming.test.ts +152 -0
  49. package/src/agents/streaming.ts +195 -0
  50. package/src/agents/types.ts +218 -0
  51. package/src/agents/ui-message-stream.test.ts +249 -0
  52. package/src/agents/ui-message-stream.ts +372 -0
  53. package/src/cli.ts +124 -77
  54. package/src/context/context.test.ts +203 -7
  55. package/src/context/context.ts +158 -49
  56. package/src/context/session.test.ts +96 -0
  57. package/src/context/session.ts +119 -9
  58. package/src/context/types.ts +205 -0
  59. package/src/define.ts +4 -14
  60. package/src/expect/index.ts +8 -71
  61. package/src/i18n/core.ts +28 -0
  62. package/src/i18n/en.ts +47 -5
  63. package/src/i18n/index.ts +5 -17
  64. package/src/i18n/zh-CN.ts +47 -5
  65. package/src/index.ts +12 -0
  66. package/src/loaders/index.ts +8 -39
  67. package/src/o11y/otlp/canonical.ts +7 -6
  68. package/src/o11y/otlp/mappers/codex.ts +10 -8
  69. package/src/o11y/otlp/mappers/index.ts +9 -21
  70. package/src/o11y/otlp/receiver.ts +25 -7
  71. package/src/o11y/otlp/sandbox-receiver.ts +57 -21
  72. package/src/o11y/otlp/turn-otel.test.ts +110 -0
  73. package/src/o11y/otlp/turn-otel.ts +125 -0
  74. package/src/o11y/parsers/bub.ts +13 -11
  75. package/src/o11y/parsers/claude-code.ts +27 -51
  76. package/src/o11y/parsers/codex.ts +29 -46
  77. package/src/o11y/parsers/index.ts +5 -14
  78. package/src/o11y/tool-names.test.ts +61 -0
  79. package/src/o11y/tool-names.ts +74 -0
  80. package/src/o11y/types.ts +135 -0
  81. package/src/runner/attempt.ts +456 -0
  82. package/src/runner/fingerprint.ts +59 -0
  83. package/src/runner/remote-sandbox.ts +53 -0
  84. package/src/runner/report.ts +53 -0
  85. package/src/runner/reporters/artifacts.ts +25 -4
  86. package/src/runner/reporters/console.ts +2 -8
  87. package/src/runner/reporters/live.ts +2 -8
  88. package/src/runner/reporters/shared.ts +15 -0
  89. package/src/runner/reporters/table.ts +10 -62
  90. package/src/runner/run.ts +114 -619
  91. package/src/runner/types.ts +237 -0
  92. package/src/sandbox/checkpoint.ts +15 -13
  93. package/src/sandbox/docker-stream.ts +87 -0
  94. package/src/sandbox/docker.ts +42 -120
  95. package/src/sandbox/e2b.ts +24 -75
  96. package/src/sandbox/local-files.ts +33 -0
  97. package/src/sandbox/paths.test.ts +85 -0
  98. package/src/sandbox/paths.ts +45 -0
  99. package/src/sandbox/resolve.ts +10 -34
  100. package/src/sandbox/shell.ts +17 -0
  101. package/src/sandbox/source-files.ts +42 -2
  102. package/src/sandbox/types.ts +158 -0
  103. package/src/sandbox/vercel.ts +55 -71
  104. package/src/scoring/collector.ts +3 -2
  105. package/src/scoring/judge.ts +72 -146
  106. package/src/scoring/match.ts +79 -0
  107. package/src/scoring/types.ts +76 -0
  108. package/src/shared/aggregate.ts +48 -0
  109. package/src/shared/format.ts +20 -0
  110. package/src/shared/outcome.ts +48 -0
  111. package/src/shared/types.ts +37 -0
  112. package/src/types.ts +20 -911
  113. package/src/view/aggregate.ts +103 -0
  114. package/src/view/app/App.tsx +26 -5
  115. package/src/view/app/components/AttemptModal.tsx +12 -3
  116. package/src/view/app/components/CodeView.tsx +9 -5
  117. package/src/view/app/components/CopyControls.tsx +4 -4
  118. package/src/view/app/components/ExperimentTable.tsx +5 -5
  119. package/src/view/app/i18n.ts +31 -7
  120. package/src/view/app/lib/format.ts +17 -20
  121. package/src/view/app/lib/outcome.ts +4 -16
  122. package/src/view/app/main.tsx +3 -5
  123. package/src/view/app/pages/RunsPage.tsx +2 -2
  124. package/src/view/app/pages/TracesPage.tsx +2 -2
  125. package/src/view/app/shared.ts +2 -1
  126. package/src/view/app/types.ts +6 -43
  127. package/src/view/client-dist/app.css +1 -1
  128. package/src/view/client-dist/app.js +18 -18
  129. package/src/view/index.ts +24 -401
  130. package/src/view/loader.ts +234 -0
  131. package/src/view/server.ts +136 -0
  132. package/src/view/shared/types.ts +64 -0
  133. package/src/view/styles.css +60 -9
@@ -0,0 +1,103 @@
1
+ // 聚合层:把 loader 读到的 summary 揉成榜单行与页面 KPI。纯数据变换,不碰 fs / http。
2
+ // 折叠与格式化口径在 shared/ 与前端共用;这里只产原始值(number / ISO),格式化由前端按 locale 做。
3
+
4
+ import type { EvalResult, Usage } from "../types.ts";
5
+ import type { LoadedSummary, ScanResult } from "./loader.ts";
6
+ import { evalLevelStats } from "../shared/outcome.ts";
7
+ import { OUTCOME_ORDER, avg, displayExperimentName, fallbackExperimentLabel, sumMaybe } from "../shared/aggregate.ts";
8
+ import type { ViewData, ViewRow } from "./shared/types.ts";
9
+
10
+ /** 烘焙进 HTML 的页面数据;绝对路径等 server 私有信息在 loader 就没进 summary,这里只挑展示字段。 */
11
+ export function buildViewData(scan: ScanResult): ViewData {
12
+ const latest = scan.loaded[0]?.summary;
13
+ const totals = summarizeAll(scan.loaded);
14
+ return {
15
+ rows: aggregateRows(scan.loaded),
16
+ name: latest?.name,
17
+ lastRunAt: latest?.startedAt,
18
+ passRate: totals.passRate,
19
+ resultCount: totals.results,
20
+ durationMs: totals.durationMs,
21
+ estimatedCostUSD: totals.cost,
22
+ skippedRuns: scan.skipped.map((run) => ({
23
+ dir: run.dir,
24
+ reason: run.reason,
25
+ schemaVersion: run.schemaVersion,
26
+ producerVersion: run.producerVersion,
27
+ command: run.command,
28
+ detail: run.detail,
29
+ })),
30
+ };
31
+ }
32
+
33
+ export function aggregateRows(loaded: LoadedSummary[]): ViewRow[] {
34
+ const groups = new Map<string, EvalResult[]>();
35
+ const lastRunAt = new Map<string, string>();
36
+ for (const item of loaded) {
37
+ for (const result of item.summary.results) {
38
+ const key = result.experimentId ? `exp|||${result.experimentId}` : `legacy|||${result.agent}|||${result.model ?? ""}`;
39
+ groups.set(key, [...(groups.get(key) ?? []), result]);
40
+ const prev = lastRunAt.get(key);
41
+ if (!prev || item.summary.startedAt > prev) lastRunAt.set(key, item.summary.startedAt);
42
+ }
43
+ }
44
+
45
+ return Array.from(groups.entries()).map(([key, results]) => {
46
+ const first = results[0]!;
47
+ const experimentId = first.experimentId;
48
+ const cost = sumMaybe(results.map((r) => r.estimatedCostUSD));
49
+ // 一行 = 一个实验,results 内按 eval id 折叠计票(passed/failed/通过率都是 eval 级)。
50
+ const stats = evalLevelStats(results, (r) => r.id);
51
+ return {
52
+ key,
53
+ experimentId,
54
+ experiment: first.experiment,
55
+ group: experimentGroup(experimentId),
56
+ label: displayExperimentName(experimentId) ?? fallbackExperimentLabel(first),
57
+ agent: first.agent,
58
+ model: first.model,
59
+ lastRunAt: lastRunAt.get(key),
60
+ runs: results.length, // 总 attempt 数(详情里作次要信息)
61
+ evals: stats.evals, // 去重后的 eval 数(成功率分母的口径)
62
+ passed: stats.passed,
63
+ failed: stats.failed,
64
+ errored: stats.errored,
65
+ skipped: stats.skipped,
66
+ passRate: stats.passRate,
67
+ avgDurationMs: avg(results.map((r) => r.durationMs)),
68
+ usage: sumUsage(results.map((r) => r.usage)),
69
+ estimatedCostUSD: cost,
70
+ results: results
71
+ .slice()
72
+ .sort((a, b) => OUTCOME_ORDER[a.outcome] - OUTCOME_ORDER[b.outcome] || a.id.localeCompare(b.id)),
73
+ };
74
+ });
75
+ }
76
+
77
+ function summarizeAll(loaded: LoadedSummary[]) {
78
+ const results = loaded.flatMap((s) => s.summary.results);
79
+ // 顶部总览同样按 eval 计票:每个(实验, eval)只算一份,跨实验/跨 run 不被 runs 灌票。
80
+ const groupKey = (r: EvalResult) => (r.experimentId ? `exp|||${r.experimentId}` : `legacy|||${r.agent}|||${r.model ?? ""}`);
81
+ const stats = evalLevelStats(results, (r) => `${groupKey(r)}|||${r.id}`);
82
+ return {
83
+ results: stats.evals,
84
+ passRate: stats.passRate,
85
+ durationMs: loaded.reduce((sum, s) => sum + (s.summary.durationMs ?? 0), 0),
86
+ cost: sumMaybe(loaded.map((s) => s.summary.estimatedCostUSD)),
87
+ };
88
+ }
89
+
90
+ function sumUsage(items: Array<Usage | undefined>): Usage {
91
+ return {
92
+ inputTokens: items.reduce((n, u) => n + (u?.inputTokens ?? 0), 0),
93
+ outputTokens: items.reduce((n, u) => n + (u?.outputTokens ?? 0), 0),
94
+ cacheReadTokens: items.reduce((n, u) => n + (u?.cacheReadTokens ?? 0), 0),
95
+ cacheWriteTokens: items.reduce((n, u) => n + (u?.cacheWriteTokens ?? 0), 0),
96
+ requests: items.reduce((n, u) => n + (u?.requests ?? 0), 0),
97
+ };
98
+ }
99
+
100
+ function experimentGroup(id: string | undefined): string | undefined {
101
+ if (!id || !id.includes("/")) return undefined;
102
+ return id.split("/").slice(0, -1).join("/");
103
+ }
@@ -3,6 +3,7 @@ import { detectLocale, makeTranslator, persistLocale, setDocumentLocale } from "
3
3
  import type { MessageKey } from "./i18n.ts";
4
4
  import type { Locale, LocalizedText, SortKey, SortState, Tab, ViewData, ViewResult, ViewRow } from "./types.ts";
5
5
  import { buildGroupMap, compareRows, resultFromUrl } from "./lib/rows.ts";
6
+ import { formatCost, formatDateTime, formatDuration, formatPercent } from "./lib/format.ts";
6
7
  import { Metric } from "./components/primitives.tsx";
7
8
  import { GroupSelector } from "./components/GroupSelector.tsx";
8
9
  import { ExperimentTable } from "./components/ExperimentTable.tsx";
@@ -128,18 +129,38 @@ export function App({ data }: { data: ViewData }) {
128
129
  <h1>{localizedText(data.name, locale) || t("hero.title")}</h1>
129
130
  <div className="meta">
130
131
  <span>
131
- <b>{t("hero.lastRun")}</b> {data.lastRun}
132
+ {/* viewData 只带原始值(ISO / number),这里按当前界面 locale 格式化。 */}
133
+ <b>{t("hero.lastRun")}</b> {data.lastRunAt ? formatDateTime(data.lastRunAt, locale) : t("hero.noRuns")}
132
134
  </span>
133
135
  </div>
134
136
  </section>
135
137
 
136
138
  <section className="summary" aria-label="Run summary">
137
- <Metric label={t("metric.passRate")} value={data.passRate} />
138
- <Metric label={t("metric.evalResults")} value={data.resultCount} />
139
- <Metric label={t("metric.duration")} value={data.duration} />
140
- <Metric label={t("metric.cost")} value={data.cost} />
139
+ <Metric label={t("metric.passRate")} value={formatPercent(data.passRate)} />
140
+ <Metric label={t("metric.evalResults")} value={String(data.resultCount)} />
141
+ <Metric label={t("metric.duration")} value={formatDuration(data.durationMs)} />
142
+ <Metric label={t("metric.cost")} value={formatCost(data.estimatedCostUSD)} />
141
143
  </section>
142
144
 
145
+ {(data.skippedRuns?.length ?? 0) > 0 && (
146
+ <section className="incompatible-banner" role="alert">
147
+ <b>{t("banner.skippedTitle")}</b>
148
+ <ul>
149
+ {data.skippedRuns!.map((run) => (
150
+ <li key={run.dir}>
151
+ <span className="ib-dir">{run.dir}</span>
152
+ <span className="ib-meta">
153
+ {run.reason === "incompatible-version"
154
+ ? t("banner.skipped.incompatible", { producer: run.producerVersion ?? "?", schemaVersion: run.schemaVersion })
155
+ : t("banner.skipped.malformed", { detail: run.detail ?? "?" })}
156
+ </span>
157
+ {run.command ? <code>{run.command}</code> : null}
158
+ </li>
159
+ ))}
160
+ </ul>
161
+ </section>
162
+ )}
163
+
143
164
  <TabsContent value="experiments" id="tab-experiments">
144
165
  <div className="section-head">
145
166
  <h2>{t("section.experiments")}</h2>
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
2
2
  import type { ArtifactLoadState, T } from "../shared.ts";
3
3
  import type { ViewResult } from "../types.ts";
4
4
  import { asEvents, asSources } from "../lib/guards.ts";
5
- import { outcomeClass, outcomeLabel, outcomeOf } from "../lib/outcome.ts";
5
+ import { outcomeClass, outcomeLabel } from "../lib/outcome.ts";
6
6
  import { CodeView, NoSourceBody } from "./CodeView.tsx";
7
7
  import { LazyArtifact } from "./LazyArtifact.tsx";
8
8
  import { Dialog, DialogClose, DialogContent, DialogTitle } from "./ui/dialog.tsx";
@@ -29,7 +29,7 @@ export function AttemptModal({ result, onClose, t }: { result: ViewResult; onClo
29
29
  return () => { alive = false; };
30
30
  }, [base, result.hasSources, result.hasEvents]);
31
31
 
32
- const outcome = outcomeOf(result);
32
+ const outcome = result.outcome;
33
33
  const hasCode = Boolean(data.sources?.length);
34
34
 
35
35
  return (
@@ -58,7 +58,16 @@ export function AttemptModal({ result, onClose, t }: { result: ViewResult; onClo
58
58
  ) : data.status !== "loading" ? (
59
59
  <NoSourceBody assertions={allAssertions} events={data.events || []} t={t} />
60
60
  ) : null}
61
- {result.hasTrace && base ? <LazyArtifact type="trace" src={`${base}/trace.json`} t={t} /> : null}
61
+ {result.hasTrace && base ? (
62
+ <LazyArtifact type="trace" src={`${base}/trace.json`} t={t} />
63
+ ) : data.status !== "loading" ? (
64
+ <div className="mt-3 text-xs text-muted">
65
+ {t("trace.enableHint")}
66
+ <a href={t("trace.enableHintUrl")} target="_blank" rel="noreferrer" className="underline">
67
+ {t("trace.enableHintLink")}
68
+ </a>
69
+ </div>
70
+ ) : null}
62
71
  </div>
63
72
  </DialogContent>
64
73
  </Dialog>
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useMemo, useState } from "react";
2
- import { ChevronRight } from "lucide-react";
2
+ import { AlertCircle, CheckCircle2, ChevronRight, MessageCircle, XCircle } from "lucide-react";
3
3
  import type { T } from "../shared.ts";
4
4
  import type { Assertion, CodeSource, SourceTurn, TranscriptEvent } from "../types.ts";
5
5
  import { TOOL_VERB, highlightTs, indexAsserts, indexTurns, locKey, resultBody, toolPrimaryArg } from "../lib/transcript-data.tsx";
@@ -145,11 +145,15 @@ export function CodeLine({
145
145
  <span className="ln">{n}</span>
146
146
  <span className="gmark">
147
147
  {hasAsserts ? (
148
- <span className={`gstat ${status === "pass" ? "good" : status === "warn" ? "warn" : "bad"}`}>
149
- {status === "pass" ? "✓" : status === "warn" ? "!" : "✗"}
150
- </span>
148
+ status === "pass" ? (
149
+ <CheckCircle2 className="gstat good" aria-hidden="true" />
150
+ ) : status === "warn" ? (
151
+ <AlertCircle className="gstat warn" aria-hidden="true" />
152
+ ) : (
153
+ <XCircle className="gstat bad" aria-hidden="true" />
154
+ )
151
155
  ) : hasReply ? (
152
- <ChevronRight className={`gchev${isOpen ? " is-open" : ""}`} aria-hidden="true" />
156
+ <MessageCircle className="gsend" aria-hidden="true" />
153
157
  ) : null}
154
158
  </span>
155
159
  <code className="ctext">{highlightTs(text)}</code>
@@ -2,7 +2,7 @@ import React, { useState } from "react";
2
2
  import { Check, Copy } from "lucide-react";
3
3
  import type { T } from "../shared.ts";
4
4
  import type { ViewResult, ViewRow } from "../types.ts";
5
- import { failingAssertions, outcomeOf, reasonFor } from "../lib/outcome.ts";
5
+ import { failingAssertions, reasonFor } from "../lib/outcome.ts";
6
6
 
7
7
  export function CopyReason({ text, t }: { text: string; t: T }) {
8
8
  const [copied, setCopied] = useState(false);
@@ -29,13 +29,13 @@ export function CopyAllErrors({ rows, t }: { rows: ViewRow[]; t: T }) {
29
29
  const errorEntries = rows.flatMap((row: ViewRow) =>
30
30
  (row.results ?? [])
31
31
  .filter((r: ViewResult) => {
32
- const outcome = outcomeOf(r);
33
- return outcome === "failed" || outcome === "errored";
32
+ return r.outcome === "failed" || r.outcome === "errored";
34
33
  })
35
34
  .map((r: ViewResult) => {
36
35
  const failedAssertions = failingAssertions(r);
37
36
  const reason = reasonFor(r, failedAssertions);
38
- const traceBase = r.artifactAbsBase || r.artifactBase;
37
+ // 静态 HTML 里只有相对 view 根的工件路径;宿主机绝对路径不再进 viewData。
38
+ const traceBase = r.artifactBase;
39
39
  const tracePath = r.hasTrace && traceBase ? `${traceBase}/trace.json` : null;
40
40
  return { experimentName: row.label, evalId: r.id, reason, tracePath };
41
41
  })
@@ -2,7 +2,7 @@ import React, { useState } from "react";
2
2
  import { ChevronRight } from "lucide-react";
3
3
  import type { OpenModal, T } from "../shared.ts";
4
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";
5
+ import { EvalGroup, failingAssertions, groupByEval, outcomeClass, outcomeLabel, outcomeSummary, reasonFor, scoresSummary } from "../lib/outcome.ts";
6
6
  import { configChips } from "../lib/rows.ts";
7
7
  import { formatClock, formatCost, formatDateTime, formatDuration, formatPercent, formatTokens, totalTokens } from "../lib/format.ts";
8
8
  import { Kpi, SortHeader } from "./primitives.tsx";
@@ -91,8 +91,8 @@ export function ExperimentRow({ row, open, onToggle, t }: { row: ViewRow; open:
91
91
  export function ExperimentDetail({ row, openModal, t }: { row: ViewRow; openModal: OpenModal; t: T }) {
92
92
  const totalDuration = (row.results ?? []).reduce((sum: number, r: ViewResult) => sum + (r.durationMs || 0), 0);
93
93
  const sampleResult =
94
- row.results?.find((r: ViewResult) => outcomeOf(r) === "errored") ||
95
- row.results?.find((r: ViewResult) => outcomeOf(r) === "failed") ||
94
+ row.results?.find((r: ViewResult) => r.outcome === "errored") ||
95
+ row.results?.find((r: ViewResult) => r.outcome === "failed") ||
96
96
  row.results?.[0] ||
97
97
  {};
98
98
  const evalGroups = groupByEval(row.results ?? []).sort((a, b) => a.id.localeCompare(b.id));
@@ -158,7 +158,7 @@ export function EvalRow({ group, openModal, t }: { group: EvalGroup; openModal:
158
158
  }
159
159
 
160
160
  // 代表轮:取与 eval 判决相同的第一条,用它的原因/分数做折叠行摘要。
161
- const rep = group.attempts.find((a) => outcomeOf(a) === group.outcome) ?? group.attempts[0]!;
161
+ const rep = group.attempts.find((a) => a.outcome === group.outcome) ?? group.attempts[0]!;
162
162
  const gates = failingAssertions(rep);
163
163
  const reason = reasonFor(rep, gates) || (group.outcome === "passed" ? scoresSummary(rep.assertions || []) : "");
164
164
  const totalDuration = group.attempts.reduce((s, a) => s + (a.durationMs || 0), 0);
@@ -207,7 +207,7 @@ export function EvalRow({ group, openModal, t }: { group: EvalGroup; openModal:
207
207
  }
208
208
 
209
209
  export function Attempt({ result, totalRuns, openModal, t }: { result: ViewResult; totalRuns: number; openModal: OpenModal; t: T }) {
210
- const outcome = outcomeOf(result);
210
+ const outcome = result.outcome;
211
211
  const gates = failingAssertions(result);
212
212
  const reason = reasonFor(result, gates);
213
213
  const allAssertions = result.assertions || [];
@@ -1,4 +1,7 @@
1
- import type { Locale } from "./types.ts";
1
+ // view 前端 i18n:内核(插值/归一)在 src/i18n/core.ts;这里只注入
2
+ // localStorage + navigator 的 locale 来源与 en 默认值。字典与 CLI 侧分开维护。
3
+
4
+ import { interpolate, normalizeLocale, type Locale, type Vars } from "../../i18n/core.ts";
2
5
 
3
6
  export type MessageKey =
4
7
  | "app.title"
@@ -8,6 +11,7 @@ export type MessageKey =
8
11
  | "nav.traces"
9
12
  | "hero.title"
10
13
  | "hero.lastRun"
14
+ | "hero.noRuns"
11
15
  | "metric.passRate"
12
16
  | "metric.evalResults"
13
17
  | "metric.duration"
@@ -73,6 +77,9 @@ export type MessageKey =
73
77
  | "trace.total"
74
78
  | "trace.spans"
75
79
  | "trace.clickDetails"
80
+ | "trace.enableHint"
81
+ | "trace.enableHintLink"
82
+ | "trace.enableHintUrl"
76
83
  | "transcript.noEvents"
77
84
  | "transcript.user"
78
85
  | "transcript.assistant"
@@ -98,7 +105,10 @@ export type MessageKey =
98
105
  | "outcome.passed"
99
106
  | "outcome.failed"
100
107
  | "outcome.errored"
101
- | "outcome.skipped";
108
+ | "outcome.skipped"
109
+ | "banner.skippedTitle"
110
+ | "banner.skipped.incompatible"
111
+ | "banner.skipped.malformed";
102
112
 
103
113
  type Dictionary = Record<MessageKey, string>;
104
114
 
@@ -111,6 +121,7 @@ const dictionaries: Record<Locale, Dictionary> = {
111
121
  "nav.traces": "Traces",
112
122
  "hero.title": "Eval Run Results",
113
123
  "hero.lastRun": "Last run:",
124
+ "hero.noRuns": "No runs yet",
114
125
  "metric.passRate": "Pass Rate",
115
126
  "metric.evalResults": "Eval Results",
116
127
  "metric.duration": "Duration",
@@ -176,6 +187,9 @@ const dictionaries: Record<Locale, Dictionary> = {
176
187
  "trace.total": "total",
177
188
  "trace.spans": "spans",
178
189
  "trace.clickDetails": "click a row for details",
190
+ "trace.enableHint": "No trace for this run. Wire up OTel to get a call waterfall — see the ",
191
+ "trace.enableHintLink": "OTel guide",
192
+ "trace.enableHintUrl": "https://niceeval.com/docs/guides/connect-otel",
179
193
  "transcript.noEvents": "no events",
180
194
  "transcript.user": "user",
181
195
  "transcript.assistant": "assistant",
@@ -197,11 +211,14 @@ const dictionaries: Record<Locale, Dictionary> = {
197
211
  "assert.pass": "pass",
198
212
  "assert.fail": "fail",
199
213
  "assert.soft": "soft",
200
- "assert.evidence": "What the judge saw",
214
+ "assert.evidence": "What was checked",
201
215
  "outcome.passed": "passed",
202
216
  "outcome.failed": "failed",
203
217
  "outcome.errored": "errors",
204
218
  "outcome.skipped": "skipped",
219
+ "banner.skippedTitle": "Some runs could not be loaded and are not shown here:",
220
+ "banner.skipped.incompatible": "written by niceeval {{producer}} (schemaVersion {{schemaVersion}}) — view it with the command on the right",
221
+ "banner.skipped.malformed": "unreadable report ({{detail}}) — it may be corrupted; re-run the eval or delete this run directory",
205
222
  },
206
223
  "zh-CN": {
207
224
  "app.title": "niceeval 实验查看器",
@@ -211,6 +228,7 @@ const dictionaries: Record<Locale, Dictionary> = {
211
228
  "nav.traces": "追踪",
212
229
  "hero.title": "Eval 运行结果",
213
230
  "hero.lastRun": "最近运行:",
231
+ "hero.noRuns": "还没有运行",
214
232
  "metric.passRate": "通过率",
215
233
  "metric.evalResults": "Eval 结果",
216
234
  "metric.duration": "耗时",
@@ -276,6 +294,9 @@ const dictionaries: Record<Locale, Dictionary> = {
276
294
  "trace.total": "总计",
277
295
  "trace.spans": "spans",
278
296
  "trace.clickDetails": "点击行查看详情",
297
+ "trace.enableHint": "这次运行没有 trace。接入 OTel 才有调用瀑布图——看",
298
+ "trace.enableHintLink": "OTel 接入指南",
299
+ "trace.enableHintUrl": "https://niceeval.com/docs/zh/guides/connect-otel",
279
300
  "transcript.noEvents": "没有事件",
280
301
  "transcript.user": "user",
281
302
  "transcript.assistant": "assistant",
@@ -297,11 +318,14 @@ const dictionaries: Record<Locale, Dictionary> = {
297
318
  "assert.pass": "通过",
298
319
  "assert.fail": "失败",
299
320
  "assert.soft": "soft",
300
- "assert.evidence": "裁判看到的材料",
321
+ "assert.evidence": "实际被检查的内容",
301
322
  "outcome.passed": "通过",
302
323
  "outcome.failed": "失败",
303
324
  "outcome.errored": "错误",
304
325
  "outcome.skipped": "跳过",
326
+ "banner.skippedTitle": "以下 run 读取失败,此处不展示:",
327
+ "banner.skipped.incompatible": "由 niceeval {{producer}} 写入(schemaVersion {{schemaVersion}})—— 用右侧命令查看",
328
+ "banner.skipped.malformed": "报告读不了({{detail}})—— 可能已损坏;重跑该 eval 或删除这个 run 目录",
305
329
  },
306
330
  };
307
331
 
@@ -311,7 +335,7 @@ export function detectLocale(): Locale {
311
335
  const stored = readStoredLocale();
312
336
  if (stored) return stored;
313
337
  const candidates = typeof navigator === "undefined" ? [] : [navigator.language, ...(navigator.languages ?? [])];
314
- return candidates.some((value) => value.toLowerCase().startsWith("zh")) ? "zh-CN" : "en";
338
+ return candidates.some((value) => normalizeLocale(value) === "zh-CN") ? "zh-CN" : "en";
315
339
  }
316
340
 
317
341
  export function persistLocale(locale: Locale): void {
@@ -327,8 +351,8 @@ export function setDocumentLocale(locale: Locale): void {
327
351
  document.title = dictionaries[locale]["app.title"];
328
352
  }
329
353
 
330
- export function makeTranslator(locale: Locale): (key: MessageKey) => string {
331
- return (key) => dictionaries[locale][key];
354
+ export function makeTranslator(locale: Locale): (key: MessageKey, vars?: Vars) => string {
355
+ return (key, vars) => interpolate(dictionaries[locale][key], vars);
332
356
  }
333
357
 
334
358
  function readStoredLocale(): Locale | undefined {
@@ -1,5 +1,8 @@
1
1
  import type { ViewUsage } from "../types.ts";
2
2
 
3
+ // 通过率/耗时/成本的展示口径与 server 共用一份实现,见 src/shared/format.ts(CLI 表格同用)。
4
+ export { formatCost, formatDuration, formatPercent } from "../../../shared/format.ts";
5
+
3
6
  export function prettyJson(value: unknown): string {
4
7
  if (typeof value === "string") return value;
5
8
  try {
@@ -10,7 +13,17 @@ export function prettyJson(value: unknown): string {
10
13
  }
11
14
 
12
15
  export function previewText(value: string): string {
13
- return String(value).split("\n").find((line) => line.trim()) || "";
16
+ const str = String(value);
17
+ const trimmed = str.trim();
18
+ // object 结果会被 prettyJson 成多行,只取首行会剩一个 "{";JSON 压回单行再做预览。
19
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
20
+ try {
21
+ return JSON.stringify(JSON.parse(trimmed));
22
+ } catch {
23
+ // 不是合法 JSON,按普通多行文本取首个非空行
24
+ }
25
+ }
26
+ return str.split("\n").find((line) => line.trim()) || "";
14
27
  }
15
28
 
16
29
  export function truncate(value: unknown, n: number): string {
@@ -28,24 +41,12 @@ export function totalTokens(usage?: ViewUsage): number {
28
41
  return (usage?.inputTokens || 0) + (usage?.outputTokens || 0) + (usage?.cacheReadTokens || 0) + (usage?.cacheWriteTokens || 0);
29
42
  }
30
43
 
31
- export function formatPercent(value?: number): string {
32
- if (typeof value !== "number" || !Number.isFinite(value)) return "0%";
33
- return Math.round(value * 100) + "%";
34
- }
35
-
36
44
  /** 断言 / judge 分数本就是 0–1,直接展示原值(去掉末尾零),不转百分比。pass-rate 之类的「比率」仍用 formatPercent。 */
37
45
  export function formatScore(value?: number): string {
38
46
  if (typeof value !== "number" || !Number.isFinite(value)) return "0";
39
47
  return String(Number(value.toFixed(2)));
40
48
  }
41
49
 
42
- export function formatDuration(ms?: number): string {
43
- if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) return "0ms";
44
- if (ms >= 60000) return (ms / 60000).toFixed(1) + "m";
45
- if (ms >= 1000) return (ms / 1000).toFixed(2) + "s";
46
- return Math.round(ms) + "ms";
47
- }
48
-
49
50
  export function formatTokens(value: number): string {
50
51
  if (!Number.isFinite(value) || value <= 0) return "0";
51
52
  if (value >= 1000000) return (value / 1000000).toFixed(2) + "M";
@@ -53,16 +54,12 @@ export function formatTokens(value: number): string {
53
54
  return String(Math.round(value));
54
55
  }
55
56
 
56
- export function formatCost(value?: number): string {
57
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "$0";
58
- return "$" + value.toFixed(value < 1 ? 3 : 2);
59
- }
60
-
61
- export function formatDateTime(iso?: string): string {
57
+ /** 日期按 locale 格式化;不传 locale 时跟随浏览器设置。server 不再预格式化日期,都走这里。 */
58
+ export function formatDateTime(iso?: string, locale?: string): string {
62
59
  if (!iso) return "-";
63
60
  const date = new Date(iso);
64
61
  if (Number.isNaN(date.getTime())) return "-";
65
- return date.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
62
+ return date.toLocaleString(locale ?? [], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
66
63
  }
67
64
 
68
65
  export function formatClock(iso?: string): string {
@@ -2,22 +2,10 @@ import type { Assertion, Outcome, ViewResult, ViewRow } from "../types.ts";
2
2
  import type { T } from "../shared.ts";
3
3
  import { formatScore } from "./format.ts";
4
4
 
5
- export function outcomeOf(result: ViewResult): Outcome {
6
- return result.outcome;
7
- }
5
+ // 折叠口径与 server 聚合共用一份实现,见 src/shared/outcome.ts。
6
+ import { foldEvalOutcome } from "../../../shared/outcome.ts";
8
7
 
9
- /**
10
- * 同一个 eval 的多轮 attempt 折叠成单一判决:任一轮通过 → 通过(对齐 earlyExit「先过一次即停」),
11
- * 否则按 failed > errored > skipped 取最严重的。后端 view/index.ts:foldEvalOutcome 用同样口径,
12
- * 两边必须一致,否则折叠行的状态会和 KPI / 成功率对不上。
13
- */
14
- export function foldEvalOutcome(attempts: ViewResult[]): Outcome {
15
- const outs = attempts.map(outcomeOf);
16
- if (outs.some((o) => o === "passed")) return "passed";
17
- if (outs.some((o) => o === "failed")) return "failed";
18
- if (outs.some((o) => o === "errored")) return "errored";
19
- return "skipped";
20
- }
8
+ export { foldEvalOutcome };
21
9
 
22
10
  export interface EvalGroup {
23
11
  id: string;
@@ -41,7 +29,7 @@ export function groupByEval(results: ViewResult[]): EvalGroup[] {
41
29
  experimentId: sorted[0]!.experimentId,
42
30
  outcome: foldEvalOutcome(sorted),
43
31
  attempts: sorted,
44
- passedAttempts: sorted.filter((a) => outcomeOf(a) === "passed").length,
32
+ passedAttempts: sorted.filter((a) => a.outcome === "passed").length,
45
33
  };
46
34
  });
47
35
  }
@@ -5,11 +5,9 @@ import "../styles.css";
5
5
 
6
6
  const initialData: ViewData = window.__NICEEVAL_VIEW_DATA__ ?? {
7
7
  rows: [],
8
- lastRun: "No runs yet",
9
- passRate: "0%",
10
- resultCount: "0",
11
- duration: "0ms",
12
- cost: "$0",
8
+ passRate: 0,
9
+ resultCount: 0,
10
+ durationMs: 0,
13
11
  };
14
12
 
15
13
  const rootEl = document.getElementById("root");
@@ -1,7 +1,7 @@
1
1
  import { useMemo, useState } from "react";
2
2
  import type { RowRun, T } from "../shared.ts";
3
3
  import type { ViewResult, ViewRow } from "../types.ts";
4
- import { outcomeClass, outcomeLabel, outcomeOf } from "../lib/outcome.ts";
4
+ import { outcomeClass, outcomeLabel } from "../lib/outcome.ts";
5
5
  import { formatCost, formatDateTime, formatDuration, formatTokens, totalTokens } from "../lib/format.ts";
6
6
 
7
7
  export function RunsView({ rows, t }: { rows: ViewRow[]; t: T }) {
@@ -50,7 +50,7 @@ export function RunsView({ rows, t }: { rows: ViewRow[]; t: T }) {
50
50
  <tbody>
51
51
  {filtered.length ? (
52
52
  filtered.map((r: RowRun) => {
53
- const outcome = outcomeOf(r);
53
+ const outcome = r.outcome;
54
54
  return (
55
55
  <tr key={`${r.id}-${r.rowLabel}-${r.attempt}`}>
56
56
  <td>
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import type { RowRun, T } from "../shared.ts";
3
3
  import type { ViewResult, ViewRow } from "../types.ts";
4
- import { outcomeClass, outcomeLabel, outcomeOf } from "../lib/outcome.ts";
4
+ import { outcomeClass, outcomeLabel } from "../lib/outcome.ts";
5
5
  import { formatDuration } from "../lib/format.ts";
6
6
  import { LazyArtifact } from "../components/LazyArtifact.tsx";
7
7
 
@@ -20,7 +20,7 @@ export function TracesView({ rows, t }: { rows: ViewRow[]; t: T }) {
20
20
  <div className="empty">{t("empty.traces")}</div>
21
21
  ) : (
22
22
  traceable.map((r: RowRun) => {
23
- const outcome = outcomeOf(r);
23
+ const outcome = r.outcome;
24
24
  return (
25
25
  <div className="traces-entry" key={`${r.id}-${r.rowLabel}-${r.attempt}`}>
26
26
  <div className="traces-entry-head">
@@ -1,7 +1,8 @@
1
+ import type { Vars } from "../../i18n/core.ts";
1
2
  import type { MessageKey } from "./i18n.ts";
2
3
  import type { CodeSource, TranscriptEvent, ViewJson, ViewResult } from "./types.ts";
3
4
 
4
- export type T = (key: MessageKey) => string;
5
+ export type T = (key: MessageKey, vars?: Vars) => string;
5
6
  export type OpenModal = (result: ViewResult) => void;
6
7
  export type ArtifactLoadState =
7
8
  | { sources: CodeSource[] | null; events: TranscriptEvent[] | null; status: "loading" | "ready" | "none" };
@@ -9,10 +9,12 @@ import type {
9
9
  TraceSpan,
10
10
  Usage,
11
11
  } from "../../types.ts";
12
+ import type { ViewData } from "../shared/types.ts";
12
13
 
13
14
  export type { LocalizedText };
14
-
15
- export type Locale = "en" | "zh-CN";
15
+ // Locale 只在 i18n 内核声明一次;榜单行 / 页面数据形状与 server 共用 shared/types.ts 的声明。
16
+ export type { Locale } from "../../i18n/core.ts";
17
+ export type { SkippedRunNotice, ViewData, ViewRow } from "../shared/types.ts";
16
18
 
17
19
  export type Tab = "experiments" | "runs" | "traces";
18
20
  export type SortKey = "experiment" | "model" | "agent" | "avgDurationMs" | "passRate" | "tokens" | "cost";
@@ -23,50 +25,11 @@ export interface SortState {
23
25
  dir: SortDir;
24
26
  }
25
27
 
26
- export interface ViewRow {
27
- key: string;
28
- experimentId?: string;
29
- experiment?: EvalResult["experiment"];
30
- group?: string;
31
- label: string;
32
- agent: string;
33
- model?: string;
34
- runs: number;
35
- evals: number;
36
- passed: number;
37
- failed: number;
38
- errored: number;
39
- skipped: number;
40
- scored?: number;
41
- passRate: number;
42
- avgDurationMs: number;
43
- usage: Usage;
44
- estimatedCostUSD?: number;
45
- lastRunAt?: string;
46
- results: ViewResult[];
47
- }
48
-
49
- export type ViewResult = EvalResult & {
50
- artifactBase?: string;
51
- artifactAbsBase?: string;
52
- hasEvents?: boolean;
53
- hasTrace?: boolean;
54
- hasSources?: boolean;
55
- };
28
+ /** 前端拿到的单条 attempt 结果就是瘦身后的 EvalResult(artifactBase 由 loader 注入)。 */
29
+ export type ViewResult = EvalResult;
56
30
 
57
31
  export type Assertion = AssertionResult;
58
32
 
59
- export interface ViewData {
60
- rows?: ViewRow[];
61
- /** 项目名(来自 config.name);hero 标题,可按 locale 多语言。 */
62
- name?: LocalizedText;
63
- lastRun: string;
64
- passRate: string;
65
- resultCount: string;
66
- duration: string;
67
- cost: string;
68
- }
69
-
70
33
  export type Outcome = "passed" | "failed" | "errored" | "skipped" | string;
71
34
 
72
35
  export interface SourceTurn {