niceeval 0.8.1 → 0.9.1

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 (140) hide show
  1. package/INDEX.md +77 -45
  2. package/dist/agents/types.d.ts +28 -6
  3. package/dist/i18n/en.d.ts +2 -0
  4. package/dist/i18n/zh-CN.d.ts +3 -1
  5. package/dist/report/built-in/index.d.ts +3 -2
  6. package/dist/report/built-in/index.js +7 -8
  7. package/dist/report/built-in/standard.d.ts +1 -0
  8. package/dist/report/built-in/standard.js +30 -0
  9. package/dist/report/components.d.ts +69 -2
  10. package/dist/report/components.js +152 -3
  11. package/dist/report/compute.d.ts +28 -1
  12. package/dist/report/compute.js +123 -0
  13. package/dist/report/index.d.ts +4 -4
  14. package/dist/report/index.js +3 -2
  15. package/dist/report/locale.d.ts +39 -1
  16. package/dist/report/locale.js +69 -0
  17. package/dist/report/react/AttemptList.d.ts +3 -1
  18. package/dist/report/react/AttemptList.js +3 -3
  19. package/dist/report/react/CopyFixPrompt.d.ts +12 -0
  20. package/dist/report/react/CopyFixPrompt.js +12 -0
  21. package/dist/report/react/HeroCard.d.ts +13 -0
  22. package/dist/report/react/HeroCard.js +35 -0
  23. package/dist/report/react/PoweredBy.d.ts +5 -0
  24. package/dist/report/react/PoweredBy.js +7 -0
  25. package/dist/report/react/ScopeWarnings.d.ts +12 -0
  26. package/dist/report/react/ScopeWarnings.js +18 -0
  27. package/dist/report/react/TraceWaterfall.d.ts +14 -0
  28. package/dist/report/react/TraceWaterfall.js +22 -0
  29. package/dist/report/react/index.d.ts +6 -1
  30. package/dist/report/react/index.js +6 -0
  31. package/dist/report/report.d.ts +22 -6
  32. package/dist/report/report.js +68 -55
  33. package/dist/report/scope-warnings.d.ts +28 -0
  34. package/dist/report/scope-warnings.js +101 -0
  35. package/dist/report/text/faces.d.ts +19 -1
  36. package/dist/report/text/faces.js +61 -0
  37. package/dist/report/tree.d.ts +1 -1
  38. package/dist/report/tree.js +7 -2
  39. package/dist/report/types.d.ts +45 -0
  40. package/dist/report/web.d.ts +5 -4
  41. package/dist/report/web.js +7 -22
  42. package/dist/results/select.d.ts +17 -4
  43. package/dist/results/select.js +76 -11
  44. package/dist/results/types.d.ts +26 -0
  45. package/dist/runner/fingerprint.d.ts +3 -3
  46. package/dist/runner/sandbox-selection.d.ts +12 -0
  47. package/dist/runner/types.d.ts +18 -6
  48. package/dist/sandbox/types.d.ts +12 -0
  49. package/dist/shared/aggregate.d.ts +1 -1
  50. package/dist/shared/aggregate.js +1 -1
  51. package/docs-site/zh/explanation/evals.mdx +2 -1
  52. package/docs-site/zh/explanation/experiment.mdx +2 -0
  53. package/docs-site/zh/how-to/custom-reports.mdx +23 -7
  54. package/docs-site/zh/how-to/experiments.mdx +2 -2
  55. package/docs-site/zh/how-to/publish-report.mdx +11 -4
  56. package/docs-site/zh/how-to/viewing-results.mdx +17 -8
  57. package/docs-site/zh/how-to/write-experiment.mdx +40 -1
  58. package/docs-site/zh/reference/builtin-agents.mdx +40 -3
  59. package/docs-site/zh/reference/cli.mdx +3 -3
  60. package/docs-site/zh/reference/define-eval.mdx +8 -0
  61. package/docs-site/zh/reference/official-adapters.mdx +10 -5
  62. package/docs-site/zh/troubleshooting/debugging.mdx +3 -3
  63. package/package.json +2 -1
  64. package/src/agents/bub.ts +13 -1
  65. package/src/agents/claude-code.test.ts +43 -1
  66. package/src/agents/claude-code.ts +32 -14
  67. package/src/agents/codex.test.ts +168 -1
  68. package/src/agents/codex.ts +51 -15
  69. package/src/agents/mcp.ts +31 -0
  70. package/src/agents/post-setup.ts +33 -0
  71. package/src/agents/types.ts +28 -7
  72. package/src/cli.ts +15 -14
  73. package/src/define.ts +3 -0
  74. package/src/i18n/en.ts +8 -5
  75. package/src/i18n/zh-CN.ts +8 -4
  76. package/src/index.ts +1 -0
  77. package/src/report/built-in/index.tsx +8 -7
  78. package/src/report/built-in/standard.tsx +59 -0
  79. package/src/report/components.tsx +218 -2
  80. package/src/report/compute.ts +138 -1
  81. package/src/report/dual-render.test.tsx +141 -14
  82. package/src/report/index.ts +20 -0
  83. package/src/report/locale.ts +83 -1
  84. package/src/report/react/AttemptList.tsx +13 -1
  85. package/src/report/react/CopyFixPrompt.tsx +37 -0
  86. package/src/report/react/HeroCard.tsx +59 -0
  87. package/src/report/react/PoweredBy.tsx +20 -0
  88. package/src/report/react/ScopeWarnings.tsx +74 -0
  89. package/src/report/react/TraceWaterfall.tsx +78 -0
  90. package/src/report/react/enhance.js +14 -0
  91. package/src/report/react/index.tsx +11 -0
  92. package/src/report/react/styles.css +193 -7
  93. package/src/report/report.ts +99 -64
  94. package/src/report/scope-warnings.ts +155 -0
  95. package/src/report/site-components.test.tsx +526 -0
  96. package/src/report/text/faces.ts +66 -0
  97. package/src/report/tree.ts +10 -3
  98. package/src/report/types.ts +51 -0
  99. package/src/report/web.ts +7 -40
  100. package/src/results/host-equivalence.test.ts +6 -2
  101. package/src/results/open.ts +5 -4
  102. package/src/results/results.test.ts +78 -1
  103. package/src/results/select.ts +80 -12
  104. package/src/results/types.ts +27 -0
  105. package/src/runner/attempt.ts +10 -9
  106. package/src/runner/discover.test.ts +9 -1
  107. package/src/runner/discover.ts +3 -3
  108. package/src/runner/fingerprint.ts +9 -4
  109. package/src/runner/ledger.test.ts +30 -1
  110. package/src/runner/ledger.ts +26 -4
  111. package/src/runner/run.ts +5 -1
  112. package/src/runner/sandbox-selection.test.ts +131 -0
  113. package/src/runner/sandbox-selection.ts +110 -0
  114. package/src/runner/types.ts +19 -2
  115. package/src/sandbox/types.ts +6 -0
  116. package/src/shared/aggregate.ts +1 -1
  117. package/src/show/index.ts +19 -12
  118. package/src/show/render.ts +12 -12
  119. package/src/show/report-host.test.ts +34 -17
  120. package/src/show/report-host.ts +6 -5
  121. package/src/show/show.test.ts +141 -4
  122. package/src/view/app/App.test.tsx +79 -17
  123. package/src/view/app/App.tsx +25 -74
  124. package/src/view/app/components/CopyControls.tsx +4 -42
  125. package/src/view/app/i18n.ts +5 -227
  126. package/src/view/app/lib/rows.ts +3 -21
  127. package/src/view/app/shared.ts +1 -3
  128. package/src/view/app/types.ts +2 -2
  129. package/src/view/client-dist/app.css +1 -1
  130. package/src/view/client-dist/app.js +20 -20
  131. package/src/view/data.ts +21 -10
  132. package/src/view/index.ts +2 -12
  133. package/src/view/server.ts +4 -4
  134. package/src/view/shared/types.ts +11 -6
  135. package/src/view/styles.css +9 -252
  136. package/src/view/view-report.test.ts +99 -33
  137. package/src/view/app/components/LazyArtifact.tsx +0 -51
  138. package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
  139. package/src/view/app/pages/AttemptsPage.tsx +0 -80
  140. package/src/view/app/pages/TracesPage.tsx +0 -35
@@ -37,11 +37,11 @@ export type HeadTag = {
37
37
  children?: string;
38
38
  };
39
39
  export interface ReportShell {
40
- /** 标题:首页 hero 与浏览器标题。页头左端是恒定的 NiceEval 品牌字标,不由 title 覆盖;回退链 def.title → 唯一快照 name → 内置文案「Eval 运行结果 / Eval Results」。 */
40
+ /** 站点标题:浏览器标题、show 页索引标题行与 `ctx.report.title` 的取值源;`Hero` 组件缺省消费它。回退链 def.title → 唯一快照 name → 内置文案「Eval 运行结果 / Eval Results」。 */
41
41
  title?: LocalizedText;
42
42
  /** 页头右侧的外部链接,如 GitHub、文档、CI。 */
43
43
  links?: ReportLink[];
44
- /** 每页页脚的一段文字;省略时不渲染页脚(品牌行恒在 hero 下方,不占页脚)。 */
44
+ /** 每页页脚的一段文字;省略时不渲染页脚(品牌行归 PoweredBy 组件,不占页脚)。 */
45
45
  footer?: LocalizedText;
46
46
  /**
47
47
  * 注入每页 `<head>` 的结构化标签,在官方与外壳样式之后按声明顺序渲染。
@@ -63,13 +63,27 @@ export interface ReportPage {
63
63
  /** 这一页的报告树;ReportDefinition 不是 ReportNode,页装不进外壳。 */
64
64
  content: ReportNode;
65
65
  }
66
- /** content / pages 互斥由类型表达,不把非法状态留到运行期。 */
66
+ /** content / pages / extends 三选一由类型表达,不把非法状态留到运行期。 */
67
67
  export type ReportDef = ReportShell & ({
68
+ /** 单页缩写,等价于只含 id `report` 的页列表。 */
68
69
  content: ReportNode;
69
70
  pages?: never;
71
+ extends?: never;
70
72
  } | {
73
+ /** 非空页列表;导航按数组顺序显示。 */
71
74
  pages: NonEmptyArray<ReportPage>;
72
75
  content?: never;
76
+ extends?: never;
77
+ } | {
78
+ /**
79
+ * 在另一份报告上叠外壳:页列表取 base 的页列表;本对象声明的外壳字段整字段覆盖
80
+ * base 的同名字段,未声明的沿用 base——没有数组拼接、没有深合并。base 是任何
81
+ * `defineReport` 产物(内建视图或自己别的报告文件的具名导出);合并在
82
+ * `defineReport` 调用时折叠完成,产物仍是普通 ReportDefinition,可以再被 extends。
83
+ */
84
+ extends: ReportDefinition;
85
+ content?: never;
86
+ pages?: never;
73
87
  });
74
88
  /**
75
89
  * defineReport 的唯一产物:只作 --report 文件的默认导出,交给宿主装载。
@@ -136,7 +150,9 @@ export interface RenderReportTextOptions extends TextRenderOptions {
136
150
  }
137
151
  /**
138
152
  * text 宿主的装载语义:选页 → resolve(组合展开 + spec 取数,唯一的 await 边界)→ 树校验 →
139
- * 遍历渲染 text 面;Scope 有挑选警告时在报告顶部前置一块 "! <message>"。不需要 react-dom
153
+ * 遍历渲染 text 面。不需要 react-dom。宿主不在报告树外另设警告通道——挑选警告的呈现件是
154
+ * `ScopeWarnings` 组件,内建报告每页都放它,自定义报告放不放是作者义务
155
+ * (docs/feature/reports/architecture.md「Scope 是计算入口」)。
140
156
  */
141
157
  export declare function renderReportToText(definition: ReportDefinition, ctx: ReportHostContext, options?: RenderReportTextOptions): Promise<string>;
142
158
  /** 页索引标题行(show 多页索引 / view 导航共用的解析结果):按 locale 解析的标题字符串。 */
@@ -161,7 +177,7 @@ export interface RenderTreeTextOptions extends TextRenderOptions {
161
177
  }
162
178
  /**
163
179
  * 渲染一页报告树的 text 面(宿主逐页调用;页选择归宿主):
164
- * resolve(组合展开 + spec 取数)→ validate → render。Scope 有挑选警告时在页顶前置
165
- * "! <message>" 块——宿主是 warning 的唯一呈现者,组件数据不复制 warning
180
+ * resolve(组合展开 + spec 取数)→ validate → render。宿主不在报告树外另设警告通道,
181
+ * 挑选警告由页内的 `ScopeWarnings` 组件呈现(内建报告每页都放它)
166
182
  */
167
183
  export declare function renderReportTreeToText(tree: ReportNode, ctx: ReportTreeHostContext, options?: RenderTreeTextOptions): Promise<string>;
@@ -1,7 +1,9 @@
1
1
  // defineReport:唯一可被宿主装载的产物 —— 一层外壳(标题、外链、页脚、head 标签、脚本、样式)加
2
2
  // 非空页列表;单页与多页不是两种机制,页数只是列表长度(docs/feature/reports/library/shell.md)。
3
3
  // 入参有两级缩写,各有精确展开:树入参 ≡ { content: 树 } ≡ pages: [{ id: "report",
4
- // title: 内置页名, content: 树 }]。`content` `pages` 恰好声明一个,没有隐式默认。
4
+ // title: 内置页名, content: 树 }]。`content` / `pages` / `extends` 恰好声明一个,没有隐式默认;
5
+ // `extends` 在另一份报告上叠外壳——页归 base、外壳逐字段覆盖,合并在调用时折叠完成,
6
+ // 宿主装载看到的永远是已折叠的普通产物。
5
7
  //
6
8
  // renderReportToText 是 text 宿主(show)的装载入口;web 宿主(view)的
7
9
  // renderReportToStaticHtml 在 ./web.ts(那一侧才 import react-dom)。管线以页为单位执行:
@@ -13,7 +15,7 @@ const REPORT_DEFINITION = Symbol.for("niceeval.report.definition");
13
15
  export const DEFAULT_PAGE_ID = "report";
14
16
  const DEFAULT_PAGE_TITLE = { en: "Report", "zh-CN": "报告" };
15
17
  // ───────────────────────── 装载规范化与静态校验 ─────────────────────────
16
- const CONTENT_NEXT_STEP = 'To render the built-in report content, write content: <ExperimentComparison /> (imported from "niceeval/report").';
18
+ const EXTENDS_NEXT_STEP = 'To render the built-in report, write extends: standard (import { standard } from "niceeval/report/built-in").';
17
19
  function isReportNodeInput(value) {
18
20
  if (value === null || value === undefined || typeof value === "boolean")
19
21
  return true;
@@ -31,7 +33,8 @@ function assertNotDefinition(value, where) {
31
33
  value.kind === "report" &&
32
34
  value[REPORT_DEFINITION] === true) {
33
35
  throw new Error(`${where} received a defineReport(...) product, but a report definition is not a report node — the shell cannot nest. ` +
34
- "Pass the page's tree or component here, and export the defineReport product only as the file's default export.");
36
+ "Pass the page's tree or component here. To layer a shell over another report, write defineReport({ extends: base, … }); " +
37
+ "otherwise export the defineReport product as the file's default export.");
35
38
  }
36
39
  }
37
40
  function assertLocalizedText(value, where) {
@@ -155,26 +158,40 @@ export function defineReport(input) {
155
158
  ? { content: input }
156
159
  : input;
157
160
  if (typeof def !== "object" || def === null) {
158
- throw new Error("defineReport expects a report tree or a config object ({ title?, links?, footer?, head?, scripts?, styles?, content | pages }). " +
159
- CONTENT_NEXT_STEP);
161
+ throw new Error("defineReport expects a report tree or a config object ({ title?, links?, footer?, head?, scripts?, styles?, content | pages | extends }). " +
162
+ EXTENDS_NEXT_STEP);
160
163
  }
161
164
  const hasContent = "content" in def && def.content !== undefined;
162
165
  const hasPages = "pages" in def && def.pages !== undefined;
163
- if (hasContent && hasPages) {
164
- throw new Error(`defineReport got both "content" and "pages" declare exactly one. Keep "pages" for a multi-page report, or keep a single tree in "content". ${CONTENT_NEXT_STEP}`);
166
+ const hasExtends = "extends" in def && def.extends !== undefined;
167
+ const declared = [hasContent && '"content"', hasPages && '"pages"', hasExtends && '"extends"'].filter((name) => typeof name === "string");
168
+ if (declared.length > 1) {
169
+ throw new Error(`defineReport got ${declared.join(" and ")} — declare exactly one of "content" (a single tree), "pages" (a multi-page report), or "extends" (another report plus this shell). ${EXTENDS_NEXT_STEP}`);
165
170
  }
166
- if (!hasContent && !hasPages) {
167
- throw new Error(`defineReport got neither "content" nor "pages" — declare exactly one; omission is not a meaningful value, the file must show what renders. ${CONTENT_NEXT_STEP}`);
171
+ if (declared.length === 0) {
172
+ throw new Error(`defineReport got none of "content", "pages" or "extends" — declare exactly one; omission is not a meaningful value, the file must show what renders. ${EXTENDS_NEXT_STEP}`);
168
173
  }
174
+ // extends:报告级复用的唯一位置。页归 base,本对象只贡献外壳;base 已经过 defineReport
175
+ // 校验,页不重验。
176
+ let base;
169
177
  let pages;
170
- if (hasContent) {
178
+ if (hasExtends) {
179
+ const candidate = def.extends;
180
+ if (!isReportDefinition(candidate)) {
181
+ throw new Error('defineReport "extends" must be a defineReport(...) product — the base report whose pages this report inherits. ' +
182
+ EXTENDS_NEXT_STEP);
183
+ }
184
+ base = candidate;
185
+ pages = base.pages;
186
+ }
187
+ else if (hasContent) {
171
188
  assertNotDefinition(def.content, 'defineReport "content"');
172
189
  pages = [{ id: DEFAULT_PAGE_ID, title: DEFAULT_PAGE_TITLE, content: def.content }];
173
190
  }
174
191
  else {
175
192
  const raw = def.pages;
176
193
  if (!Array.isArray(raw) || raw.length === 0) {
177
- throw new Error(`defineReport "pages" must be a non-empty array of { id, title, content }. ${CONTENT_NEXT_STEP}`);
194
+ throw new Error(`defineReport "pages" must be a non-empty array of { id, title, content }. ${EXTENDS_NEXT_STEP}`);
178
195
  }
179
196
  const seen = new Set();
180
197
  for (const page of raw) {
@@ -194,34 +211,43 @@ export function defineReport(input) {
194
211
  assertLocalizedText(def.title, "defineReport title");
195
212
  if (def.footer !== undefined)
196
213
  assertLocalizedText(def.footer, "defineReport footer");
197
- const links = def.links ?? [];
198
- if (!Array.isArray(links))
199
- throw new Error("defineReport links must be an array of { label, href }.");
200
- for (const link of links) {
201
- assertLocalizedText(link?.label, "defineReport link label");
202
- if (typeof link?.href !== "string" || link.href.length === 0) {
203
- throw new Error("defineReport link href must be a non-empty string URL.");
204
- }
205
- // icon 唯一合法形状是 { svg: string }(无类型 JS 传组件 / ReactNode / 裸字符串都在装载期拒绝):
206
- // 外壳声明经序列化边界进前端,ReactNode 过不去,可序列化是外壳契约的一部分。
207
- const icon = link.icon;
208
- if (icon !== undefined) {
209
- const svg = icon?.svg;
210
- if (typeof icon !== "object" || icon === null || typeof svg !== "string" || svg.length === 0) {
211
- throw new Error('defineReport link "icon" must be { svg: string } — an inline SVG string rendered before the label. ' +
212
- "Components and React nodes are not accepted: the shell declaration crosses a serialization boundary. " +
213
- 'Write e.g. icon: { svg: "<svg …>…</svg>" }.');
214
+ // 外壳合并:声明即整字段覆盖,未声明沿用 base(base 的字段已规范化,不重验)。
215
+ let links;
216
+ if (def.links !== undefined) {
217
+ if (!Array.isArray(def.links))
218
+ throw new Error("defineReport links must be an array of { label, href }.");
219
+ for (const link of def.links) {
220
+ assertLocalizedText(link?.label, "defineReport link label");
221
+ if (typeof link?.href !== "string" || link.href.length === 0) {
222
+ throw new Error("defineReport link href must be a non-empty string URL.");
223
+ }
224
+ // icon 唯一合法形状是 { svg: string }(无类型 JS 传组件 / ReactNode / 裸字符串都在装载期拒绝):
225
+ // 外壳声明经序列化边界进前端,ReactNode 过不去,可序列化是外壳契约的一部分。
226
+ const icon = link.icon;
227
+ if (icon !== undefined) {
228
+ const svg = icon?.svg;
229
+ if (typeof icon !== "object" || icon === null || typeof svg !== "string" || svg.length === 0) {
230
+ throw new Error('defineReport link "icon" must be { svg: string } — an inline SVG string rendered before the label. ' +
231
+ "Components and React nodes are not accepted: the shell declaration crosses a serialization boundary. " +
232
+ 'Write e.g. icon: { svg: "<svg …>…</svg>" }.');
233
+ }
214
234
  }
215
235
  }
236
+ links = def.links;
216
237
  }
238
+ else {
239
+ links = base?.links ?? [];
240
+ }
241
+ const title = def.title !== undefined ? def.title : base?.title;
242
+ const footer = def.footer !== undefined ? def.footer : base?.footer;
217
243
  const definition = {
218
244
  kind: "report",
219
- ...(def.title !== undefined ? { title: def.title } : {}),
245
+ ...(title !== undefined ? { title } : {}),
220
246
  links: [...links],
221
- ...(def.footer !== undefined ? { footer: def.footer } : {}),
222
- head: assertHeadTags(def.head),
223
- scripts: assertAssets(def.scripts, "scripts"),
224
- styles: assertAssets(def.styles, "styles"),
247
+ ...(footer !== undefined ? { footer } : {}),
248
+ head: def.head !== undefined ? assertHeadTags(def.head) : [...(base?.head ?? [])],
249
+ scripts: def.scripts !== undefined ? assertAssets(def.scripts, "scripts") : [...(base?.scripts ?? [])],
250
+ styles: def.styles !== undefined ? assertAssets(def.styles, "styles") : [...(base?.styles ?? [])],
225
251
  pages: pages,
226
252
  };
227
253
  Object.defineProperty(definition, REPORT_DEFINITION, { value: true });
@@ -283,17 +309,11 @@ export function pickReportPage(definition, pageId) {
283
309
  }
284
310
  return page;
285
311
  }
286
- /**
287
- * 挑选警告的 text 形态:每条渲染好的 message 前缀 "! ",一行一条。宿主级前置块——
288
- * 宿主是 warning 的唯一呈现者,组件数据不复制 warning;裸跑 / --report 都在报告顶上
289
- * 如实报残缺,不静默(docs/feature/reports/architecture.md「Scope 是计算入口」)。
290
- */
291
- function renderScopeWarningsText(scope, _locale) {
292
- return scope.warnings.map((w) => `! ${w.message}`).join("\n");
293
- }
294
312
  /**
295
313
  * text 宿主的装载语义:选页 → resolve(组合展开 + spec 取数,唯一的 await 边界)→ 树校验 →
296
- * 遍历渲染 text 面;Scope 有挑选警告时在报告顶部前置一块 "! <message>"。不需要 react-dom
314
+ * 遍历渲染 text 面。不需要 react-dom。宿主不在报告树外另设警告通道——挑选警告的呈现件是
315
+ * `ScopeWarnings` 组件,内建报告每页都放它,自定义报告放不放是作者义务
316
+ * (docs/feature/reports/architecture.md「Scope 是计算入口」)。
297
317
  */
298
318
  export async function renderReportToText(definition, ctx, options) {
299
319
  const page = pickReportPage(definition, options?.pageId);
@@ -305,11 +325,7 @@ export async function renderReportToText(definition, ctx, options) {
305
325
  memo: new ResolveMemo(),
306
326
  });
307
327
  validateReportTree(resolved);
308
- const textCtx = createTextContext(options);
309
- const body = renderNodeToText(resolved, textCtx);
310
- return ctx.scope.warnings.length > 0
311
- ? [renderScopeWarningsText(ctx.scope, textCtx.locale), body].join("\n\n")
312
- : body;
328
+ return renderNodeToText(resolved, createTextContext(options));
313
329
  }
314
330
  /** 页索引标题行(show 多页索引 / view 导航共用的解析结果):按 locale 解析的标题字符串。 */
315
331
  export function reportTitleText(definition, scope, locale) {
@@ -318,10 +334,10 @@ export function reportTitleText(definition, scope, locale) {
318
334
  function quoteArg(value) {
319
335
  return /^[A-Za-z0-9._/@-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
320
336
  }
321
- /** 按上下文拼组索引的可复制命令:`niceeval show <patterns> --experiment <id> [--results/--report/--page]`。 */
337
+ /** 按上下文拼组索引的可复制命令:`niceeval show <patterns> --exp <id> [--results/--report/--page]`。 */
322
338
  function experimentCommandFor(ctx) {
323
339
  return (prefix) => {
324
- const parts = ["niceeval show", ...ctx.patterns.map(quoteArg), `--experiment ${quoteArg(prefix)}`];
340
+ const parts = ["niceeval show", ...ctx.patterns.map(quoteArg), `--exp ${quoteArg(prefix)}`];
325
341
  if (ctx.results !== undefined)
326
342
  parts.push(`--results ${quoteArg(ctx.results)}`);
327
343
  if (ctx.report !== undefined)
@@ -333,8 +349,8 @@ function experimentCommandFor(ctx) {
333
349
  }
334
350
  /**
335
351
  * 渲染一页报告树的 text 面(宿主逐页调用;页选择归宿主):
336
- * resolve(组合展开 + spec 取数)→ validate → render。Scope 有挑选警告时在页顶前置
337
- * "! <message>" 块——宿主是 warning 的唯一呈现者,组件数据不复制 warning
352
+ * resolve(组合展开 + spec 取数)→ validate → render。宿主不在报告树外另设警告通道,
353
+ * 挑选警告由页内的 `ScopeWarnings` 组件呈现(内建报告每页都放它)
338
354
  */
339
355
  export async function renderReportTreeToText(tree, ctx, options) {
340
356
  const resolved = await resolveReportTree(tree, {
@@ -350,8 +366,5 @@ export async function renderReportTreeToText(tree, ctx, options) {
350
366
  ? { experimentCommand: experimentCommandFor(options.commandContext) }
351
367
  : {}),
352
368
  });
353
- const body = renderNodeToText(resolved, textCtx);
354
- return ctx.scope.warnings.length > 0
355
- ? [renderScopeWarningsText(ctx.scope, textCtx.locale), body].join("\n\n")
356
- : body;
369
+ return renderNodeToText(resolved, textCtx);
357
370
  }
@@ -0,0 +1,28 @@
1
+ import type { ScopeWarning } from "../results/types.ts";
2
+ import { type ReportLocale } from "./locale.ts";
3
+ /** kind 表登记的类别:integrity(选中集合的分母可能不对)组排在 freshness(可能过期)之前。 */
4
+ export type WarningCategory = "integrity" | "freshness";
5
+ export interface ScopeWarningGroup {
6
+ category: WarningCategory;
7
+ /** 实验组为 experimentId;kind 组为登记的组头文案(含条数);未登记 kind 用 kind 原文。 */
8
+ title: string;
9
+ /** 每条警告一枚、与 warnings 同序;未登记徽标模板的成员不出徽标。 */
10
+ badges: readonly {
11
+ kind: string;
12
+ text: string;
13
+ }[];
14
+ /** 组内命令去重后恰一条时归组头(复制即推进整组);多条或零条为 null,命令随明细逐条走。 */
15
+ headCommand: string | null;
16
+ /** 原始条目(明细层,message 单源)。 */
17
+ warnings: readonly ScopeWarning[];
18
+ }
19
+ export interface GroupedScopeWarnings {
20
+ /** 分类计数汇总行,任何组数下都产出;web 面用作外层折叠块的 <summary>,text 面只在多组时打印。 */
21
+ summary: string;
22
+ groups: readonly ScopeWarningGroup[];
23
+ /** 警告总条数 ≤ 3 时组级明细默认展开(web 面第二层 <details> 的 open;阈值是行为契约,无开关)。 */
24
+ detailsOpen: boolean;
25
+ }
26
+ /** 明细折叠块的标签(「N 条原始警告」)。 */
27
+ export declare function warningDetailsLabel(locale: ReportLocale, n: number): string;
28
+ export declare function groupScopeWarnings(input: readonly ScopeWarning[], locale: ReportLocale): GroupedScopeWarnings;
@@ -0,0 +1,101 @@
1
+ // ScopeWarnings 的聚合层:把 Scope 警告按「下一步动作」组织成组,web / text 两面共用
2
+ // (docs/feature/reports/library/site-components.md「聚合轴是动作,不是发生顺序」)。
3
+ // message 是完整叙述的单源,这里只组织、不改写;徽标 / 组头文案按 kind 表登记的模板
4
+ // (docs/feature/results/library.md「警告 kind 全集」)经 locale 词典渲染,未登记的 kind
5
+ // 回退为单独成组、逐条 message 原样。
6
+ import { gapParts } from "../results/select.js";
7
+ import { localeText } from "./locale.js";
8
+ const CATEGORY = {
9
+ "partial-coverage": "integrity",
10
+ "unfinished-snapshot": "integrity",
11
+ "unreadable-snapshot": "integrity",
12
+ "stale-snapshot": "freshness",
13
+ };
14
+ /** 实验作用域且登记了徽标模板的 kind 才进实验组;其余(含未登记 kind)按 kind 聚合。 */
15
+ const EXPERIMENT_KINDS = new Set(["partial-coverage", "stale-snapshot", "unfinished-snapshot"]);
16
+ function pluralText(locale, base, n) {
17
+ return localeText(locale, `${base}.${n === 1 ? "one" : "other"}`, { n });
18
+ }
19
+ /** 明细折叠块的标签(「N 条原始警告」)。 */
20
+ export function warningDetailsLabel(locale, n) {
21
+ return pluralText(locale, "warnings.details", n);
22
+ }
23
+ function gapText(locale, fromIso, toIso) {
24
+ const { n, unit } = gapParts(fromIso, toIso);
25
+ return localeText(locale, `warnings.gap.${unit}.${n === 1 ? "one" : "other"}`, { n });
26
+ }
27
+ function badgeText(w, locale) {
28
+ switch (w.kind) {
29
+ case "partial-coverage":
30
+ return localeText(locale, "warnings.badge.partialCoverage", {
31
+ covered: String(w.covered),
32
+ total: String(w.total),
33
+ });
34
+ case "stale-snapshot":
35
+ return localeText(locale, "warnings.badge.staleSnapshot", {
36
+ gap: gapText(locale, String(w.startedAt), String(w.latestStartedAt)),
37
+ });
38
+ case "unfinished-snapshot":
39
+ return localeText(locale, "warnings.badge.unfinishedSnapshot");
40
+ default:
41
+ return null;
42
+ }
43
+ }
44
+ /** 组内命令去重:恰一条时它就是「复制即推进整组」的组头命令。 */
45
+ function dedupeCommand(members) {
46
+ const commands = new Set(members.map((w) => w.command).filter((c) => typeof c === "string" && c !== ""));
47
+ return commands.size === 1 ? [...commands][0] : null;
48
+ }
49
+ function groupCategory(members) {
50
+ return members.some((w) => (CATEGORY[w.kind] ?? "integrity") === "integrity") ? "integrity" : "freshness";
51
+ }
52
+ export function groupScopeWarnings(input, locale) {
53
+ const warnings = input;
54
+ const byExperiment = new Map();
55
+ const byKind = new Map();
56
+ for (const w of warnings) {
57
+ if (EXPERIMENT_KINDS.has(w.kind) && typeof w.experimentId === "string") {
58
+ const members = byExperiment.get(w.experimentId) ?? [];
59
+ members.push(w);
60
+ byExperiment.set(w.experimentId, members);
61
+ }
62
+ else {
63
+ const members = byKind.get(w.kind) ?? [];
64
+ members.push(w);
65
+ byKind.set(w.kind, members);
66
+ }
67
+ }
68
+ const groups = [];
69
+ for (const [experimentId, members] of byExperiment) {
70
+ groups.push({
71
+ category: groupCategory(members),
72
+ title: experimentId,
73
+ badges: members
74
+ .map((w) => ({ kind: w.kind, text: badgeText(w, locale) }))
75
+ .filter((b) => b.text !== null),
76
+ headCommand: dedupeCommand(members),
77
+ warnings: members,
78
+ });
79
+ }
80
+ for (const [kind, members] of byKind) {
81
+ groups.push({
82
+ category: CATEGORY[kind] ?? "integrity",
83
+ title: kind === "unreadable-snapshot" ? pluralText(locale, "warnings.group.unreadableSnapshot", members.length) : kind,
84
+ badges: [],
85
+ headCommand: dedupeCommand(members),
86
+ warnings: members,
87
+ });
88
+ }
89
+ // 稳定排序:integrity 在前,同类别保持首次出现顺序。
90
+ const rank = (c) => (c === "integrity" ? 0 : 1);
91
+ groups.sort((a, b) => rank(a.category) - rank(b.category));
92
+ const parts = [];
93
+ if (byExperiment.size > 0)
94
+ parts.push(pluralText(locale, "warnings.summary.experiments", byExperiment.size));
95
+ for (const [kind, members] of byKind) {
96
+ parts.push(kind === "unreadable-snapshot"
97
+ ? pluralText(locale, "warnings.group.unreadableSnapshot", members.length)
98
+ : `${kind} ×${members.length}`);
99
+ }
100
+ return { summary: parts.join(" · "), groups, detailsOpen: warnings.length <= 3 };
101
+ }
@@ -1,4 +1,5 @@
1
- import type { AttemptListItem, DeltaData, EvalListItem, ExperimentComparisonData, ExperimentListItem, LineData, MatrixData, MetricCell, ScatterData, ScopeSummaryData, ScoreboardData, TableData, VerdictTally } from "../types.ts";
1
+ import type { AttemptListItem, DeltaData, EvalListItem, ExperimentComparisonData, ExperimentListItem, HeroData, LineData, MatrixData, MetricCell, ScatterData, ScopeSummaryData, ScopeWarning, ScoreboardData, TableData, TraceWaterfallRow, VerdictTally } from "../types.ts";
2
+ import type { LocalizedText } from "../locale.ts";
2
3
  import type { TextContext } from "../tree.ts";
3
4
  import { type ReportLocale } from "../locale.ts";
4
5
  /** 格子的文本形态:缺数据 —,覆盖不全带 samples/total 角标;display 按 locale 解析。 */
@@ -25,3 +26,20 @@ export declare function deltaText(data: DeltaData, ctx: TextContext): string;
25
26
  export declare function experimentListText(items: readonly ExperimentListItem[], ctx: TextContext, relativeTo?: string): string;
26
27
  export declare function evalListText(items: readonly EvalListItem[], ctx: TextContext): string;
27
28
  export declare function attemptListText(items: readonly AttemptListItem[], total: number | undefined, ctx: TextContext): string;
29
+ /**
30
+ * HeroCard 的 text 面:标题行 + meta 行(最后运行时间;空范围为内置「暂无运行」文案;
31
+ * 多快照时标注合成来源),不含品牌行(品牌行是纯 web 件,text 面零输出)。
32
+ */
33
+ export declare function heroCardText(title: LocalizedText, data: HeroData, ctx: TextContext): string;
34
+ /**
35
+ * ScopeWarnings 的 text 面:按动作聚合(../scope-warnings.ts,与 web 面共用),同构但不折叠——
36
+ * 多组时首行 "! <分类计数汇总>";每组一行组头 "! <标题> — <徽标> → <组头命令>",其下缩进
37
+ * 逐条原样打印 message(已以下一步收尾,不截断掉尾段)。空警告集零输出。
38
+ */
39
+ export declare function scopeWarningsText(warnings: readonly ScopeWarning[], ctx: TextContext): string;
40
+ /**
41
+ * TraceWaterfall 的 text 面:每 attempt 一行——locator、总耗时(缺 trace 如实显示缺失)、
42
+ * 顶层 span 计数与失败标记,行尾是可复制的 `--timing` 下钻命令(经宿主注入的
43
+ * attemptCommand 通道拼出,携带宿主上下文)。attempt 有选择器,索引终结于可执行命令。
44
+ */
45
+ export declare function traceWaterfallText(rows: readonly TraceWaterfallRow[], ctx: TextContext): string;
@@ -3,6 +3,7 @@
3
3
  // 零 react、零 IO、纯同步 —— 这是 text 宿主不需要 react-dom 的那一半。
4
4
  // chrome 文案(注脚、verdict 词、截断提示)经 ctx.locale 查 locale 字典;
5
5
  // 数据 display 是 LocalizedText,按 LocalizedText 回退规则取值。
6
+ import { groupScopeWarnings } from "../scope-warnings.js";
6
7
  import { experimentDisplayName, fitFailureSummary, formatDurationMs, formatMetricValue, formatPlainNumber, formatUSD, verdictMark, } from "../format.js";
7
8
  import { countText, localeText, resolveLocalizedText, resolveMetricLabel, } from "../locale.js";
8
9
  import { indentBlock, padDisplay, stringWidth, textBar, wrapDisplay } from "./layout.js";
@@ -608,3 +609,63 @@ export function attemptListText(items, total, ctx) {
608
609
  blocks.push(localeText(locale, "attemptList.truncatedText", { n: remaining }));
609
610
  return blocks.join("\n\n");
610
611
  }
612
+ // ───────────────────────── 站点组件(HeroCard / ScopeWarnings / TraceWaterfall)─────────────────────────
613
+ /**
614
+ * HeroCard 的 text 面:标题行 + meta 行(最后运行时间;空范围为内置「暂无运行」文案;
615
+ * 多快照时标注合成来源),不含品牌行(品牌行是纯 web 件,text 面零输出)。
616
+ */
617
+ export function heroCardText(title, data, ctx) {
618
+ const locale = ctx.locale;
619
+ const meta = data.latestStartedAt === null
620
+ ? localeText(locale, "hero.noRuns")
621
+ : [
622
+ localeText(locale, "hero.lastRun", { time: formatDateTimeMinute(data.latestStartedAt) }),
623
+ ...(data.snapshots > 1 ? [localeText(locale, "hero.composedSnapshots", { n: data.snapshots })] : []),
624
+ ].join(" · ");
625
+ return `${resolveLocalizedText(title, locale)}\n${meta}`;
626
+ }
627
+ /**
628
+ * ScopeWarnings 的 text 面:按动作聚合(../scope-warnings.ts,与 web 面共用),同构但不折叠——
629
+ * 多组时首行 "! <分类计数汇总>";每组一行组头 "! <标题> — <徽标> → <组头命令>",其下缩进
630
+ * 逐条原样打印 message(已以下一步收尾,不截断掉尾段)。空警告集零输出。
631
+ */
632
+ export function scopeWarningsText(warnings, ctx) {
633
+ if (warnings.length === 0)
634
+ return "";
635
+ const { summary, groups } = groupScopeWarnings(warnings, ctx.locale);
636
+ const lines = [];
637
+ // 汇总行只在多组时打印;单组时组头即汇总,不另起一行(web 面则恒以汇总行作外层 <summary>)。
638
+ if (groups.length > 1)
639
+ lines.push(`! ${summary}`);
640
+ for (const group of groups) {
641
+ const badges = group.badges.length > 0 ? ` — ${group.badges.map((b) => b.text).join(" · ")}` : "";
642
+ const command = group.headCommand !== null ? ` → ${group.headCommand}` : "";
643
+ lines.push(`! ${group.title}${badges}${command}`);
644
+ for (const w of group.warnings)
645
+ lines.push(`! ${w.message}`);
646
+ }
647
+ return lines.join("\n");
648
+ }
649
+ /**
650
+ * TraceWaterfall 的 text 面:每 attempt 一行——locator、总耗时(缺 trace 如实显示缺失)、
651
+ * 顶层 span 计数与失败标记,行尾是可复制的 `--timing` 下钻命令(经宿主注入的
652
+ * attemptCommand 通道拼出,携带宿主上下文)。attempt 有选择器,索引终结于可执行命令。
653
+ */
654
+ export function traceWaterfallText(rows, ctx) {
655
+ const locale = ctx.locale;
656
+ if (rows.length === 0)
657
+ return localeText(locale, "traceWaterfall.empty");
658
+ return rows
659
+ .map((row) => {
660
+ const failedSpans = row.spans.filter((span) => span.failed).length;
661
+ const parts = [
662
+ row.locator,
663
+ row.evalId,
664
+ row.durationMs === null ? localeText(locale, "traceWaterfall.noTrace") : formatDurationMs(row.durationMs),
665
+ countText(locale, "traceWaterfall.spans", row.spans.length),
666
+ ...(failedSpans > 0 ? [`✗ ${countText(locale, "traceWaterfall.failedSpans", failedSpans)}`] : []),
667
+ ];
668
+ return `${parts.join(" · ")} ${ctx.attemptCommand(row.locator)} --timing`;
669
+ })
670
+ .join("\n");
671
+ }
@@ -38,7 +38,7 @@ export interface TextContext {
38
38
  attemptCommand(locator: AttemptLocator): string;
39
39
  /**
40
40
  * 组索引一类「按实验收窄」命令的生成;宿主注入以携带完整上下文(--results / --report /
41
- * --page 与位置参数),默认 `niceeval show --experiment <id>`。非契约字段,官方组件内部用。
41
+ * --page 与位置参数),默认 `niceeval show --exp <id>`。非契约字段,官方组件内部用。
42
42
  */
43
43
  experimentCommand(experimentIdPrefix: string): string;
44
44
  }
@@ -187,7 +187,12 @@ async function resolveNode(node, state, path) {
187
187
  return node;
188
188
  }
189
189
  if (Array.isArray(node)) {
190
- return Promise.all(node.map((child) => resolveNode(child, state, path)));
190
+ const resolved = await Promise.all(node.map((child) => resolveNode(child, state, path)));
191
+ // resolve 重建的 children 数组对 React 是动态列表(JSX 静态 children 的免 key 待遇随重建
192
+ // 丢失);声明序即身份,给缺 key 的元素补声明位 key,免得 web 面渲染刷 key 警告。
193
+ return resolved.map((child, i) => isReportElement(child) && (child.key === undefined || child.key === null)
194
+ ? { ...child, key: `.nre-${i}` }
195
+ : child);
191
196
  }
192
197
  if (!isReportElement(node))
193
198
  return node;
@@ -346,7 +351,7 @@ export function createTextContext(options) {
346
351
  // 默认下钻命令:AttemptLocator 是 `@` 前缀的不透明短串,`niceeval show @<locator>` 是
347
352
  // show 已实现的真实 CLI 语法,不需要反查 eval id 再拼一条近似命令。
348
353
  const attemptCommand = options?.attemptCommand ?? ((locator) => `niceeval show ${locator}`);
349
- const experimentCommand = options?.experimentCommand ?? ((prefix) => `niceeval show --experiment ${shellQuote(prefix)}`);
354
+ const experimentCommand = options?.experimentCommand ?? ((prefix) => `niceeval show --exp ${shellQuote(prefix)}`);
350
355
  const make = (w) => ({
351
356
  width: w,
352
357
  locale,
@@ -275,6 +275,51 @@ export interface ScopeSummaryData {
275
275
  /** costUSD 按 attempt 求和;缺失成本不伪造为 0。 */
276
276
  totalCostUSD: MetricCell;
277
277
  }
278
+ /**
279
+ * `HeroCard` 的数据(docs/feature/reports/library/site-components.md):站点标题区的
280
+ * 运行 meta——最后运行时间与快照合成来源。标题不在 data 里,它是站点声明与 Scope 的合成物,
281
+ * 经 `HeroCardProps.title` 传入。
282
+ */
283
+ export interface HeroData {
284
+ /** Scope 中最新快照的开始时间;空 Scope 为 null,不编造当前时间。 */
285
+ latestStartedAt: string | null;
286
+ /** 贡献当前水位的快照数;大于 1 时 web 面标注「由 N 次运行合成」。 */
287
+ snapshots: number;
288
+ }
289
+ /**
290
+ * `CopyFixPrompt` 的数据:resolve 期算好的修复 prompt 全文与参与的失败数
291
+ * (docs/feature/reports/library/site-components.md)。
292
+ */
293
+ export interface CopyFixPromptData {
294
+ /** 修复 prompt 全文;失败逐条含 eval id、主失败摘要与 attempt 下钻命令。 */
295
+ prompt: string;
296
+ /** 参与 prompt 的失败 attempt 数(verdict 为 failed / errored)。 */
297
+ failures: number;
298
+ }
299
+ /** `TraceWaterfall` 一行里的一个顶层 span 摘要(canonical OTel 字段归一后的形态)。 */
300
+ export interface TraceSpanSummary {
301
+ name: string;
302
+ /** 归一后的语义角色;turn 归入 agent,未识别落 other。 */
303
+ kind: "agent" | "model" | "tool" | "other";
304
+ /** 相对该 attempt trace 起点的偏移(毫秒)。 */
305
+ startOffsetMs: number;
306
+ durationMs: number;
307
+ /** span status 为 error 时 true(web 面失败标记的来源)。 */
308
+ failed: boolean;
309
+ }
310
+ /**
311
+ * `TraceWaterfall` 一行 = 一次 attempt 的执行时间瀑布摘要。只画被测 agent 的原始 span
312
+ * (trace.json);runner 生命周期节点(`result.phases`)不进瀑布,组合视图归 attempt 详情。
313
+ */
314
+ export interface TraceWaterfallRow {
315
+ experimentId: string;
316
+ evalId: string;
317
+ locator: AttemptLocator;
318
+ /** trace.json 缺失或为空时 null;行照常出现,证据位置如实显示缺失,不猜值。 */
319
+ durationMs: number | null;
320
+ /** 顶层 span 摘要,按 startOffsetMs 升序。 */
321
+ spans: readonly TraceSpanSummary[];
322
+ }
278
323
  /** 一个可比组的数据;三个子块都只消费本组快照,不能含其它父目录的引用。 */
279
324
  export interface ExperimentComparisonGroupData {
280
325
  /** experiment id 的完整父路径;根目录 experiment 使用完整 id。 */
@@ -12,14 +12,15 @@ export interface StaticHtmlOptions {
12
12
  }
13
13
  /**
14
14
  * web 宿主的装载语义:选页 → resolve(组合展开 + spec 取数,唯一的 await 边界)→
15
- * 树校验(与 text 宿主同一遍)→ 静态渲染 web 面;Scope 有挑选警告时在报告顶部前置
16
- * 一块警告 HTML(宿主是 warning 的唯一呈现者,组件数据不复制 warning)。
15
+ * 树校验(与 text 宿主同一遍)→ 静态渲染 web 面。宿主不在报告树外另设警告通道——
16
+ * 挑选警告的呈现件是 `ScopeWarnings` 组件,内建报告每页都放它,自定义报告放不放是
17
+ * 作者义务(docs/feature/reports/architecture.md「Scope 是计算入口」)。
17
18
  */
18
19
  export declare function renderReportToStaticHtml(definition: ReportDefinition, ctx: ReportHostContext, options?: StaticHtmlOptions): Promise<string>;
19
20
  /**
20
21
  * 渲染一页报告树的 web 面(宿主逐页调用;页选择归宿主):resolve → validate → 静态渲染。
21
- * Scope 有挑选警告时在页顶前置警告块(带 command 的警告渲染为可复制命令)——宿主是
22
- * warning 的唯一呈现者,组件数据不复制 warning。ctx.report 是宿主规范化后的声明。
22
+ * 挑选警告由页内的 `ScopeWarnings` 组件呈现,宿主不前置任何树外块。
23
+ * ctx.report 是宿主规范化后的声明。
23
24
  */
24
25
  export declare function renderReportTreeToStaticHtml(tree: import("./tree.ts").ReportNode, ctx: {
25
26
  scope: Scope;