niceeval 0.8.0 → 0.9.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.
- package/INDEX.md +77 -45
- package/dist/agents/types.d.ts +28 -6
- package/dist/i18n/en.d.ts +2 -0
- package/dist/i18n/zh-CN.d.ts +2 -0
- package/dist/report/built-in/index.d.ts +3 -2
- package/dist/report/built-in/index.js +7 -8
- package/dist/report/built-in/standard.d.ts +1 -0
- package/dist/report/built-in/standard.js +30 -0
- package/dist/report/components.d.ts +72 -6
- package/dist/report/components.js +159 -10
- package/dist/report/compute.d.ts +31 -4
- package/dist/report/compute.js +131 -12
- package/dist/report/index.d.ts +4 -4
- package/dist/report/index.js +3 -2
- package/dist/report/locale.d.ts +39 -1
- package/dist/report/locale.js +69 -0
- package/dist/report/react/AttemptList.d.ts +3 -1
- package/dist/report/react/AttemptList.js +3 -3
- package/dist/report/react/CopyFixPrompt.d.ts +12 -0
- package/dist/report/react/CopyFixPrompt.js +12 -0
- package/dist/report/react/HeroCard.d.ts +13 -0
- package/dist/report/react/HeroCard.js +35 -0
- package/dist/report/react/PoweredBy.d.ts +5 -0
- package/dist/report/react/PoweredBy.js +7 -0
- package/dist/report/react/ScopeWarnings.d.ts +12 -0
- package/dist/report/react/ScopeWarnings.js +18 -0
- package/dist/report/react/TraceWaterfall.d.ts +14 -0
- package/dist/report/react/TraceWaterfall.js +22 -0
- package/dist/report/react/index.d.ts +6 -1
- package/dist/report/react/index.js +6 -0
- package/dist/report/report.d.ts +22 -6
- package/dist/report/report.js +66 -53
- package/dist/report/scope-warnings.d.ts +28 -0
- package/dist/report/scope-warnings.js +101 -0
- package/dist/report/text/faces.d.ts +19 -1
- package/dist/report/text/faces.js +61 -0
- package/dist/report/tree.js +6 -1
- package/dist/report/types.d.ts +45 -10
- package/dist/report/web.d.ts +5 -4
- package/dist/report/web.js +7 -22
- package/dist/results/select.d.ts +15 -2
- package/dist/results/select.js +75 -10
- package/dist/results/types.d.ts +26 -11
- package/dist/runner/fingerprint.d.ts +3 -3
- package/dist/runner/sandbox-selection.d.ts +12 -0
- package/dist/runner/types.d.ts +18 -6
- package/dist/sandbox/types.d.ts +12 -0
- package/docs-site/zh/explanation/evals.mdx +2 -1
- package/docs-site/zh/explanation/experiment.mdx +2 -0
- package/docs-site/zh/how-to/custom-reports.mdx +22 -6
- package/docs-site/zh/how-to/publish-report.mdx +6 -8
- package/docs-site/zh/how-to/viewing-results.mdx +3 -3
- package/docs-site/zh/how-to/write-experiment.mdx +40 -1
- package/docs-site/zh/reference/builtin-agents.mdx +40 -3
- package/docs-site/zh/reference/cli.mdx +1 -2
- package/docs-site/zh/reference/define-eval.mdx +8 -0
- package/docs-site/zh/reference/official-adapters.mdx +10 -5
- package/docs-site/zh/reference/report-components.mdx +2 -2
- package/docs-site/zh/reference/results-data.mdx +2 -4
- package/package.json +2 -1
- package/src/agents/bub.ts +13 -1
- package/src/agents/claude-code.test.ts +43 -1
- package/src/agents/claude-code.ts +32 -14
- package/src/agents/codex.test.ts +168 -1
- package/src/agents/codex.ts +51 -15
- package/src/agents/mcp.ts +31 -0
- package/src/agents/post-setup.ts +33 -0
- package/src/agents/types.ts +28 -7
- package/src/cli.ts +9 -11
- package/src/context/context.ts +11 -5
- package/src/define.ts +3 -0
- package/src/i18n/en.ts +5 -2
- package/src/i18n/zh-CN.ts +5 -1
- package/src/index.ts +1 -0
- package/src/report/built-in/index.tsx +8 -7
- package/src/report/built-in/standard.tsx +59 -0
- package/src/report/components.tsx +231 -17
- package/src/report/compute.ts +146 -21
- package/src/report/dual-render.test.tsx +139 -12
- package/src/report/index.ts +20 -1
- package/src/report/locale.ts +83 -1
- package/src/report/react/AttemptList.tsx +13 -1
- package/src/report/react/CopyFixPrompt.tsx +37 -0
- package/src/report/react/HeroCard.tsx +59 -0
- package/src/report/react/PoweredBy.tsx +20 -0
- package/src/report/react/ScopeWarnings.tsx +74 -0
- package/src/report/react/TraceWaterfall.tsx +78 -0
- package/src/report/react/enhance.js +14 -0
- package/src/report/react/index.tsx +11 -0
- package/src/report/react/styles.css +187 -7
- package/src/report/report.test.ts +2 -20
- package/src/report/report.ts +97 -62
- package/src/report/scope-warnings.ts +155 -0
- package/src/report/site-components.test.tsx +526 -0
- package/src/report/text/faces.ts +66 -0
- package/src/report/tree.ts +8 -1
- package/src/report/types.ts +51 -11
- package/src/report/web.ts +7 -40
- package/src/results/copy.ts +15 -78
- package/src/results/host-equivalence.test.ts +5 -1
- package/src/results/open.ts +5 -4
- package/src/results/publish.ts +4 -146
- package/src/results/results.test.ts +86 -9
- package/src/results/select.ts +78 -10
- package/src/results/types.ts +27 -7
- package/src/runner/attempt.ts +10 -9
- package/src/runner/discover.test.ts +9 -1
- package/src/runner/discover.ts +3 -3
- package/src/runner/fingerprint.ts +9 -4
- package/src/runner/ledger.test.ts +30 -1
- package/src/runner/ledger.ts +26 -4
- package/src/runner/run.ts +5 -1
- package/src/runner/sandbox-selection.test.ts +131 -0
- package/src/runner/sandbox-selection.ts +110 -0
- package/src/runner/types.ts +19 -2
- package/src/sandbox/types.ts +6 -0
- package/src/show/index.ts +17 -10
- package/src/show/render.ts +11 -11
- package/src/show/report-host.test.ts +32 -15
- package/src/show/report-host.ts +5 -4
- package/src/show/show.test.ts +140 -3
- package/src/view/app/App.test.tsx +78 -17
- package/src/view/app/App.tsx +17 -78
- package/src/view/app/components/CopyControls.tsx +4 -42
- package/src/view/app/i18n.ts +5 -227
- package/src/view/app/lib/rows.ts +3 -21
- package/src/view/app/shared.ts +1 -3
- package/src/view/app/types.ts +2 -2
- package/src/view/artifact-serving.test.ts +1 -1
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +20 -20
- package/src/view/data.ts +2 -13
- package/src/view/index.ts +1 -12
- package/src/view/server.ts +0 -2
- package/src/view/shared/types.ts +11 -6
- package/src/view/site-parity.test.ts +1 -1
- package/src/view/site.ts +1 -1
- package/src/view/styles.css +6 -266
- package/src/view/view-report.test.ts +64 -27
- package/src/view/app/components/LazyArtifact.tsx +0 -51
- package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
- package/src/view/app/pages/AttemptsPage.tsx +0 -80
- package/src/view/app/pages/TracesPage.tsx +0 -35
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// TraceWaterfall:执行时间瀑布的 web 面——每个 attempt 一行,静态渲染顶层 span 分解条
|
|
2
|
+
// (定位按 startOffsetMs / durationMs 百分比;失败 span 带失败标记),行链接 attempt 详情。
|
|
3
|
+
// trace 缺失的行照常出现并如实显示缺失;排序、缩放是渐进增强,静态 HTML 无 JS 完整可读
|
|
4
|
+
// (docs/feature/reports/library/site-components.md「TraceWaterfall」)。
|
|
5
|
+
|
|
6
|
+
import type { ReactElement } from "react";
|
|
7
|
+
import type { TraceWaterfallRow } from "../types.ts";
|
|
8
|
+
import type { AttemptLocator } from "../../results/locator.ts";
|
|
9
|
+
import { DEFAULT_REPORT_LOCALE, countText, localeText, type ReportLocale } from "../locale.ts";
|
|
10
|
+
import { cx, formatDurationMs } from "./format.ts";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ATTEMPT_HREF = (locator: AttemptLocator): string => `#/attempt/${locator}`;
|
|
13
|
+
|
|
14
|
+
function pct(part: number, total: number): string {
|
|
15
|
+
if (total <= 0) return "0%";
|
|
16
|
+
return `${Math.min(100, Math.max(0, (part / total) * 100)).toFixed(2)}%`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 执行时间瀑布(纯 web 渲染面):嵌入自有 React 页面时配合 `traceWaterfallData()` 使用。
|
|
21
|
+
* 只画被测 agent 的原始 span;runner 生命周期节点不在 data 里,组合视图归 attempt 详情。
|
|
22
|
+
*/
|
|
23
|
+
export function TraceWaterfall({
|
|
24
|
+
data,
|
|
25
|
+
attemptHref = DEFAULT_ATTEMPT_HREF,
|
|
26
|
+
className,
|
|
27
|
+
locale = DEFAULT_REPORT_LOCALE,
|
|
28
|
+
}: {
|
|
29
|
+
data: readonly TraceWaterfallRow[];
|
|
30
|
+
attemptHref?: (locator: AttemptLocator) => string;
|
|
31
|
+
className?: string;
|
|
32
|
+
locale?: ReportLocale;
|
|
33
|
+
}): ReactElement {
|
|
34
|
+
return (
|
|
35
|
+
<section className={cx("nre", "nre-trace-waterfall", className)}>
|
|
36
|
+
{data.length === 0 && <p className="nre-waterfall-empty">{localeText(locale, "traceWaterfall.empty")}</p>}
|
|
37
|
+
<ul className="nre-waterfall">
|
|
38
|
+
{data.map((row) => {
|
|
39
|
+
const failedSpans = row.spans.filter((span) => span.failed).length;
|
|
40
|
+
return (
|
|
41
|
+
<li key={row.locator} className="nre-waterfall-row">
|
|
42
|
+
<div className="nre-waterfall-head">
|
|
43
|
+
<a className="nre-locator" href={attemptHref(row.locator)}>
|
|
44
|
+
{row.locator}
|
|
45
|
+
</a>
|
|
46
|
+
<span className="nre-waterfall-eval">{row.evalId}</span>
|
|
47
|
+
<span className="nre-waterfall-experiment">{row.experimentId}</span>
|
|
48
|
+
{/* trace 缺失如实显示缺失,不猜值、不藏行 */}
|
|
49
|
+
<span className="nre-waterfall-duration">
|
|
50
|
+
{row.durationMs === null ? localeText(locale, "traceWaterfall.noTrace") : formatDurationMs(row.durationMs)}
|
|
51
|
+
</span>
|
|
52
|
+
<span className="nre-waterfall-count">{countText(locale, "traceWaterfall.spans", row.spans.length)}</span>
|
|
53
|
+
{failedSpans > 0 && (
|
|
54
|
+
<span className="nre-waterfall-failed">✗ {countText(locale, "traceWaterfall.failedSpans", failedSpans)}</span>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
{row.durationMs !== null && row.spans.length > 0 && (
|
|
58
|
+
<div className="nre-waterfall-track">
|
|
59
|
+
{row.spans.map((span, i) => (
|
|
60
|
+
<span
|
|
61
|
+
key={i}
|
|
62
|
+
className={cx("nre-waterfall-span", `nre-span-${span.kind}`, span.failed && "nre-span-failed")}
|
|
63
|
+
style={{
|
|
64
|
+
left: pct(span.startOffsetMs, row.durationMs!),
|
|
65
|
+
width: `max(${pct(span.durationMs, row.durationMs!)}, 0.5%)`,
|
|
66
|
+
}}
|
|
67
|
+
title={`${span.name} · ${formatDurationMs(span.durationMs)}${span.failed ? " · ✗" : ""}`}
|
|
68
|
+
/>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</li>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</ul>
|
|
76
|
+
</section>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -196,6 +196,20 @@
|
|
|
196
196
|
}
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
// AttemptList 的过滤(<AttemptList filter />):按条目全文 + data-nre-verdict(verdict 词
|
|
200
|
+
// 不在可见文本里,判定符是 ✓/✗)收窄 .nre-attempt 行。只改浏览状态,不改数据与初始 HTML。
|
|
201
|
+
document.addEventListener("input", function (e) {
|
|
202
|
+
var input = closest(e.target, "input[data-nre-attempt-filter]");
|
|
203
|
+
if (!input) return;
|
|
204
|
+
var scope = input.parentElement;
|
|
205
|
+
var rows = scope ? scope.querySelectorAll(".nre-attempt") : [];
|
|
206
|
+
var query = input.value.trim().toLowerCase();
|
|
207
|
+
for (var i = 0; i < rows.length; i++) {
|
|
208
|
+
var haystack = rows[i].textContent + " " + (rows[i].getAttribute("data-nre-verdict") || "");
|
|
209
|
+
rows[i].classList.toggle("nre-row-hidden", query !== "" && haystack.toLowerCase().indexOf(query) === -1);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
199
213
|
// ───────────────────────── tooltip:.nre-scatter-point / .nre-line-point ─────────────────────────
|
|
200
214
|
// 首次 hover 时把点内 <title> 的内容搬进 data-nre-title(避免与原生 tooltip 重影),
|
|
201
215
|
// 渲染样式化 tooltip div(定位在点上方,挂在所属 figure 里)。无 JS 时 <title> 原样生效。
|
|
@@ -21,16 +21,25 @@ export { MetricLine } from "./MetricLine.tsx";
|
|
|
21
21
|
export { DeltaTable } from "./DeltaTable.tsx";
|
|
22
22
|
export { Scoreboard } from "./Scoreboard.tsx";
|
|
23
23
|
|
|
24
|
+
// 站点组件的纯 web 面(data 形态;Hero 是组合组件,只住 niceeval/report)
|
|
25
|
+
export { HeroCard } from "./HeroCard.tsx";
|
|
26
|
+
export { PoweredBy } from "./PoweredBy.tsx";
|
|
27
|
+
export { ScopeWarnings } from "./ScopeWarnings.tsx";
|
|
28
|
+
export { CopyFixPrompt } from "./CopyFixPrompt.tsx";
|
|
29
|
+
export { TraceWaterfall } from "./TraceWaterfall.tsx";
|
|
30
|
+
|
|
24
31
|
// 数据契约类型(家在 ../types.ts,「算」与「画」两侧共用同一份)
|
|
25
32
|
export type {
|
|
26
33
|
AttemptListItem,
|
|
27
34
|
AttemptLocator,
|
|
35
|
+
CopyFixPromptData,
|
|
28
36
|
DeltaData,
|
|
29
37
|
EvalListItem,
|
|
30
38
|
ExperimentComparisonData,
|
|
31
39
|
ExperimentComparisonGroupData,
|
|
32
40
|
ExperimentListEvalRow,
|
|
33
41
|
ExperimentListItem,
|
|
42
|
+
HeroData,
|
|
34
43
|
LineData,
|
|
35
44
|
MatrixData,
|
|
36
45
|
MetricCell,
|
|
@@ -40,6 +49,8 @@ export type {
|
|
|
40
49
|
ScopeWarning,
|
|
41
50
|
ScoreboardData,
|
|
42
51
|
TableData,
|
|
52
|
+
TraceSpanSummary,
|
|
53
|
+
TraceWaterfallRow,
|
|
43
54
|
VerdictTally,
|
|
44
55
|
} from "../types.ts";
|
|
45
56
|
|
|
@@ -436,18 +436,63 @@ table.nre,
|
|
|
436
436
|
margin: 0.25rem 0 0;
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
-
/* ---- 宿主警告块:.nre-
|
|
439
|
+
/* ---- 宿主警告块:.nre-warnings 是默认折叠的 <details>,<summary> 汇总行恒可见。
|
|
440
|
+
不占框:无边框、无底色、不缩进,警示色只落在汇总行文字与徽标上。 ---- */
|
|
440
441
|
.nre .nre-warnings {
|
|
441
|
-
list-style: none;
|
|
442
442
|
margin: 0;
|
|
443
|
-
padding: 10px 14px;
|
|
444
|
-
border: 1px solid color-mix(in oklch, var(--warn), var(--line) 45%);
|
|
445
|
-
border-left: 3px solid var(--warn);
|
|
446
|
-
border-radius: 8px;
|
|
447
|
-
background: color-mix(in oklch, var(--warn), var(--panel) 92%);
|
|
448
443
|
color: var(--text);
|
|
449
444
|
font-size: 13px;
|
|
450
445
|
}
|
|
446
|
+
/* 外层 <details> 的 <summary>:分类计数汇总行,原生无 JS 可展开。 */
|
|
447
|
+
.nre .nre-warnings > .nre-warnings-summary {
|
|
448
|
+
font-weight: 650;
|
|
449
|
+
color: var(--warn);
|
|
450
|
+
cursor: pointer;
|
|
451
|
+
}
|
|
452
|
+
.nre .nre-warning-groups {
|
|
453
|
+
list-style: none;
|
|
454
|
+
margin: 8px 0 0;
|
|
455
|
+
padding: 0;
|
|
456
|
+
}
|
|
457
|
+
.nre .nre-warning-group + .nre-warning-group {
|
|
458
|
+
margin-top: 10px;
|
|
459
|
+
padding-top: 10px;
|
|
460
|
+
border-top: 1px solid var(--line);
|
|
461
|
+
}
|
|
462
|
+
.nre .nre-warning-head {
|
|
463
|
+
display: flex;
|
|
464
|
+
flex-wrap: wrap;
|
|
465
|
+
align-items: baseline;
|
|
466
|
+
gap: 4px 10px;
|
|
467
|
+
}
|
|
468
|
+
.nre .nre-warning-title { font-weight: 650; }
|
|
469
|
+
.nre .nre-warning-badge {
|
|
470
|
+
padding: 1px 8px;
|
|
471
|
+
border: 1px solid color-mix(in oklch, var(--warn), var(--line) 35%);
|
|
472
|
+
border-radius: 999px;
|
|
473
|
+
background: color-mix(in oklch, var(--warn), var(--panel) 84%);
|
|
474
|
+
font-size: 0.9em;
|
|
475
|
+
white-space: nowrap;
|
|
476
|
+
}
|
|
477
|
+
/* freshness 组(数字对但可能过期)视觉降一档,integrity 保持警示底色。 */
|
|
478
|
+
.nre .nre-warning-group[data-category="freshness"] .nre-warning-badge {
|
|
479
|
+
border-color: var(--line);
|
|
480
|
+
background: var(--panel);
|
|
481
|
+
color: var(--soft);
|
|
482
|
+
}
|
|
483
|
+
.nre .nre-warning-details { margin: 4px 0 0; }
|
|
484
|
+
/* <details> 原生无 JS 可展开,cursor 不经 .nre-js 门控。 */
|
|
485
|
+
.nre .nre-warning-details > summary {
|
|
486
|
+
color: var(--soft);
|
|
487
|
+
font-size: 0.9em;
|
|
488
|
+
cursor: pointer;
|
|
489
|
+
}
|
|
490
|
+
.nre .nre-warning-details > ul {
|
|
491
|
+
list-style: none;
|
|
492
|
+
margin: 4px 0 0;
|
|
493
|
+
padding: 0 0 0 12px;
|
|
494
|
+
border-left: 2px solid var(--line);
|
|
495
|
+
}
|
|
451
496
|
.nre .nre-warning + .nre-warning { margin-top: 8px; }
|
|
452
497
|
/* 警告携带的下一步命令:等宽块,无 JS 时点击即全选;enhance.js 在场时点击复制 */
|
|
453
498
|
.nre .nre-warning-command {
|
|
@@ -470,6 +515,141 @@ table.nre,
|
|
|
470
515
|
color: var(--good);
|
|
471
516
|
}
|
|
472
517
|
|
|
518
|
+
/* ---- Hero / PoweredBy(站点标题区与品牌行) ---- */
|
|
519
|
+
.nre-hero { margin: 0 0 18px; }
|
|
520
|
+
.nre-hero .nre-hero-title {
|
|
521
|
+
margin: 0;
|
|
522
|
+
font-size: 24px;
|
|
523
|
+
font-weight: 700;
|
|
524
|
+
letter-spacing: -0.01em;
|
|
525
|
+
}
|
|
526
|
+
.nre-hero .nre-hero-meta {
|
|
527
|
+
margin: 4px 0 0;
|
|
528
|
+
color: var(--muted);
|
|
529
|
+
font-size: 13px;
|
|
530
|
+
}
|
|
531
|
+
.nre-powered-by {
|
|
532
|
+
margin: 6px 0 0;
|
|
533
|
+
font-size: 12px;
|
|
534
|
+
}
|
|
535
|
+
.nre-powered-by a {
|
|
536
|
+
color: var(--soft);
|
|
537
|
+
text-decoration: none;
|
|
538
|
+
}
|
|
539
|
+
.nre-powered-by a:hover { color: var(--text); text-decoration: underline; }
|
|
540
|
+
|
|
541
|
+
/* ---- CopyFixPrompt(批量修复 prompt:折叠块 + 增强层复制) ---- */
|
|
542
|
+
.nre-copy-fix-prompt {
|
|
543
|
+
margin: 0 0 16px;
|
|
544
|
+
border: 1px solid var(--line);
|
|
545
|
+
border-radius: 8px;
|
|
546
|
+
background: var(--panel);
|
|
547
|
+
padding: 8px 12px;
|
|
548
|
+
}
|
|
549
|
+
.nre-copy-fix-prompt > summary {
|
|
550
|
+
cursor: pointer;
|
|
551
|
+
font-weight: 620;
|
|
552
|
+
font-size: 13px;
|
|
553
|
+
}
|
|
554
|
+
.nre-copy-fix-prompt .nre-copy-fix-prompt-copy {
|
|
555
|
+
margin: 8px 0 0;
|
|
556
|
+
padding: 4px 10px;
|
|
557
|
+
border: 1px solid var(--line);
|
|
558
|
+
border-radius: 7px;
|
|
559
|
+
background: var(--panel);
|
|
560
|
+
color: var(--text);
|
|
561
|
+
font: inherit;
|
|
562
|
+
font-size: 12px;
|
|
563
|
+
}
|
|
564
|
+
:root.nre-js .nre-copy-fix-prompt .nre-copy-fix-prompt-copy { cursor: copy; }
|
|
565
|
+
.nre-copy-fix-prompt .nre-copy-fix-prompt-copy[data-nre-copied]::after {
|
|
566
|
+
content: "✓";
|
|
567
|
+
margin-left: 0.6em;
|
|
568
|
+
color: var(--good);
|
|
569
|
+
}
|
|
570
|
+
.nre-copy-fix-prompt .nre-copy-fix-prompt-text {
|
|
571
|
+
margin: 8px 0 0;
|
|
572
|
+
padding: 8px 10px;
|
|
573
|
+
border: 1px solid var(--line);
|
|
574
|
+
border-radius: 7px;
|
|
575
|
+
background: var(--bg, transparent);
|
|
576
|
+
font-size: 12px;
|
|
577
|
+
line-height: 1.5;
|
|
578
|
+
overflow-x: auto;
|
|
579
|
+
white-space: pre-wrap;
|
|
580
|
+
overflow-wrap: anywhere;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* ---- TraceWaterfall(每 attempt 一行的静态时间瀑布) ---- */
|
|
584
|
+
.nre-trace-waterfall .nre-waterfall {
|
|
585
|
+
list-style: none;
|
|
586
|
+
margin: 0;
|
|
587
|
+
padding: 0;
|
|
588
|
+
}
|
|
589
|
+
.nre-trace-waterfall .nre-waterfall-row {
|
|
590
|
+
border: 1px solid var(--line);
|
|
591
|
+
border-radius: 8px;
|
|
592
|
+
padding: 10px 14px;
|
|
593
|
+
margin-bottom: 8px;
|
|
594
|
+
background: var(--panel);
|
|
595
|
+
}
|
|
596
|
+
.nre-trace-waterfall .nre-waterfall-head {
|
|
597
|
+
display: flex;
|
|
598
|
+
flex-wrap: wrap;
|
|
599
|
+
gap: 0.75em;
|
|
600
|
+
align-items: baseline;
|
|
601
|
+
}
|
|
602
|
+
.nre-trace-waterfall .nre-waterfall-eval { font-weight: 620; }
|
|
603
|
+
.nre-trace-waterfall .nre-waterfall-experiment,
|
|
604
|
+
.nre-trace-waterfall .nre-waterfall-duration,
|
|
605
|
+
.nre-trace-waterfall .nre-waterfall-count {
|
|
606
|
+
color: var(--muted);
|
|
607
|
+
font-size: 0.9em;
|
|
608
|
+
font-variant-numeric: tabular-nums;
|
|
609
|
+
}
|
|
610
|
+
.nre-trace-waterfall .nre-waterfall-failed { color: var(--bad); font-size: 0.9em; }
|
|
611
|
+
/* 分解条:span 按 startOffsetMs / durationMs 百分比静态定位,失败 span 换警示色 */
|
|
612
|
+
.nre-trace-waterfall .nre-waterfall-track {
|
|
613
|
+
position: relative;
|
|
614
|
+
height: 14px;
|
|
615
|
+
margin-top: 8px;
|
|
616
|
+
border-radius: 4px;
|
|
617
|
+
background: color-mix(in oklch, var(--line), var(--panel) 60%);
|
|
618
|
+
overflow: hidden;
|
|
619
|
+
}
|
|
620
|
+
.nre-trace-waterfall .nre-waterfall-span {
|
|
621
|
+
position: absolute;
|
|
622
|
+
top: 2px;
|
|
623
|
+
bottom: 2px;
|
|
624
|
+
border-radius: 2px;
|
|
625
|
+
background: var(--line-strong);
|
|
626
|
+
}
|
|
627
|
+
.nre-trace-waterfall .nre-span-agent { background: color-mix(in oklch, var(--good), var(--line-strong) 40%); }
|
|
628
|
+
.nre-trace-waterfall .nre-span-model { background: color-mix(in oklch, var(--warn), var(--line-strong) 40%); }
|
|
629
|
+
.nre-trace-waterfall .nre-span-tool { background: color-mix(in oklch, var(--line-strong), var(--text) 25%); }
|
|
630
|
+
.nre-trace-waterfall .nre-span-failed { background: var(--bad); }
|
|
631
|
+
.nre-trace-waterfall .nre-waterfall-empty {
|
|
632
|
+
border: 1px dashed var(--line-strong);
|
|
633
|
+
border-radius: 8px;
|
|
634
|
+
padding: 18px;
|
|
635
|
+
color: var(--muted);
|
|
636
|
+
text-align: center;
|
|
637
|
+
background: var(--panel);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/* ---- AttemptList 的过滤框(<AttemptList filter />,渐进增强) ---- */
|
|
641
|
+
.nre-attempt-list > .nre-filter {
|
|
642
|
+
height: 34px;
|
|
643
|
+
min-width: 260px;
|
|
644
|
+
margin-bottom: 12px;
|
|
645
|
+
border: 1px solid var(--line);
|
|
646
|
+
border-radius: 7px;
|
|
647
|
+
padding: 0 11px;
|
|
648
|
+
background: var(--panel);
|
|
649
|
+
color: var(--text);
|
|
650
|
+
font: inherit;
|
|
651
|
+
}
|
|
652
|
+
|
|
473
653
|
/* ---- MetricMatrix ---- */
|
|
474
654
|
.nre-metric-matrix .nre-matrix-caption {
|
|
475
655
|
caption-side: top;
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// niceeval/results 的读取契约手工构造)。覆盖登记行:两级聚合 vs 平铺、errored=0 口径、
|
|
4
4
|
// skipped=null、null≠0、Scoreboard 固定分母(notRun/unscorable 分开)、权重最长前缀、
|
|
5
5
|
// 身份键去重、现刻水位、自定义指标 where/aggregate、evalGroup 完整父路径、verdict 权威、
|
|
6
|
-
// MetricCell 诚实、缺 artifact 指标、repeatedFailedCommands、实体列表 failureSummary
|
|
7
|
-
//
|
|
6
|
+
// MetricCell 诚实、缺 artifact 指标、repeatedFailedCommands、实体列表 failureSummary、
|
|
7
|
+
// scopeSummaryData 两级计票、experimentComparisonData 分区、pairsByFlag、
|
|
8
8
|
// MetricLine 点身份、空数组反馈、metricTableData sort。
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it } from "vitest";
|
|
@@ -595,24 +595,6 @@ describe("实体列表 data", () => {
|
|
|
595
595
|
expect(byEval.get("list/errored")!.costUSD).toBeNull();
|
|
596
596
|
});
|
|
597
597
|
|
|
598
|
-
it("redact 只改写 failureSummary(含嵌套 attempt 条目);身份字段、locator 与数值指标原样", async () => {
|
|
599
|
-
const redact = (text: string) => text.replaceAll("41", "[redacted]");
|
|
600
|
-
const attempts = await attemptListData([listSnap()], { redact });
|
|
601
|
-
const failedItem = attempts.find((item) => item.evalId === "list/failed")!;
|
|
602
|
-
expect(failedItem.failureSummary).toContain("[redacted]");
|
|
603
|
-
expect(failedItem.evalId).toBe("list/failed");
|
|
604
|
-
expect(failedItem.experimentId).toBe("exp/list");
|
|
605
|
-
expect(failedItem.costUSD).toBe(0.1);
|
|
606
|
-
|
|
607
|
-
const evals = await evalListData([listSnap()], { redact });
|
|
608
|
-
const nested = evals.find((item) => item.evalId === "list/failed")!.attempts[0]!;
|
|
609
|
-
expect(nested.failureSummary).toContain("[redacted]");
|
|
610
|
-
|
|
611
|
-
const experiments = await experimentListData([listSnap()], { redact });
|
|
612
|
-
const nestedInExp = experiments[0]!.evalRows.find((row) => row.evalId === "list/failed")!.attempts[0]!;
|
|
613
|
-
expect(nestedInExp.failureSummary).toContain("[redacted]");
|
|
614
|
-
});
|
|
615
|
-
|
|
616
598
|
it("experimentListData:evalVerdicts / endToEndPassRate / costUSD / durationMs / tokens 齐全,默认按端到端成功率降序", async () => {
|
|
617
599
|
const winner = snap({ experimentId: "exp/win", results: [res("a", "passed"), res("b", "passed")] });
|
|
618
600
|
const loser = snap({ experimentId: "exp/lose", results: [res("a", "failed"), res("b", "passed")] });
|
package/src/report/report.ts
CHANGED
|
@@ -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`
|
|
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)。管线以页为单位执行:
|
|
@@ -51,11 +53,11 @@ export type HeadTag =
|
|
|
51
53
|
| { tag: "script" | "style"; attrs?: Record<string, string | true>; children?: string };
|
|
52
54
|
|
|
53
55
|
export interface ReportShell {
|
|
54
|
-
/**
|
|
56
|
+
/** 站点标题:浏览器标题、show 页索引标题行与 `ctx.report.title` 的取值源;`Hero` 组件缺省消费它。回退链 def.title → 唯一快照 name → 内置文案「Eval 运行结果 / Eval Results」。 */
|
|
55
57
|
title?: LocalizedText;
|
|
56
58
|
/** 页头右侧的外部链接,如 GitHub、文档、CI。 */
|
|
57
59
|
links?: ReportLink[];
|
|
58
|
-
/** 每页页脚的一段文字;省略时不渲染页脚(
|
|
60
|
+
/** 每页页脚的一段文字;省略时不渲染页脚(品牌行归 PoweredBy 组件,不占页脚)。 */
|
|
59
61
|
footer?: LocalizedText;
|
|
60
62
|
/**
|
|
61
63
|
* 注入每页 `<head>` 的结构化标签,在官方与外壳样式之后按声明顺序渲染。
|
|
@@ -80,11 +82,32 @@ export interface ReportPage {
|
|
|
80
82
|
content: ReportNode;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
/** content / pages
|
|
85
|
+
/** content / pages / extends 三选一由类型表达,不把非法状态留到运行期。 */
|
|
84
86
|
export type ReportDef = ReportShell &
|
|
85
87
|
(
|
|
86
|
-
| {
|
|
87
|
-
|
|
88
|
+
| {
|
|
89
|
+
/** 单页缩写,等价于只含 id `report` 的页列表。 */
|
|
90
|
+
content: ReportNode;
|
|
91
|
+
pages?: never;
|
|
92
|
+
extends?: never;
|
|
93
|
+
}
|
|
94
|
+
| {
|
|
95
|
+
/** 非空页列表;导航按数组顺序显示。 */
|
|
96
|
+
pages: NonEmptyArray<ReportPage>;
|
|
97
|
+
content?: never;
|
|
98
|
+
extends?: never;
|
|
99
|
+
}
|
|
100
|
+
| {
|
|
101
|
+
/**
|
|
102
|
+
* 在另一份报告上叠外壳:页列表取 base 的页列表;本对象声明的外壳字段整字段覆盖
|
|
103
|
+
* base 的同名字段,未声明的沿用 base——没有数组拼接、没有深合并。base 是任何
|
|
104
|
+
* `defineReport` 产物(内建视图或自己别的报告文件的具名导出);合并在
|
|
105
|
+
* `defineReport` 调用时折叠完成,产物仍是普通 ReportDefinition,可以再被 extends。
|
|
106
|
+
*/
|
|
107
|
+
extends: ReportDefinition;
|
|
108
|
+
content?: never;
|
|
109
|
+
pages?: never;
|
|
110
|
+
}
|
|
88
111
|
);
|
|
89
112
|
|
|
90
113
|
const REPORT_DEFINITION: unique symbol = Symbol.for("niceeval.report.definition");
|
|
@@ -124,8 +147,8 @@ const DEFAULT_PAGE_TITLE: LocalizedText = { en: "Report", "zh-CN": "报告" };
|
|
|
124
147
|
|
|
125
148
|
// ───────────────────────── 装载规范化与静态校验 ─────────────────────────
|
|
126
149
|
|
|
127
|
-
const
|
|
128
|
-
'To render the built-in report
|
|
150
|
+
const EXTENDS_NEXT_STEP =
|
|
151
|
+
'To render the built-in report, write extends: standard (import { standard } from "niceeval/report/built-in").';
|
|
129
152
|
|
|
130
153
|
function isReportNodeInput(value: unknown): boolean {
|
|
131
154
|
if (value === null || value === undefined || typeof value === "boolean") return true;
|
|
@@ -148,7 +171,8 @@ function assertNotDefinition(value: unknown, where: string): void {
|
|
|
148
171
|
) {
|
|
149
172
|
throw new Error(
|
|
150
173
|
`${where} received a defineReport(...) product, but a report definition is not a report node — the shell cannot nest. ` +
|
|
151
|
-
"Pass the page's tree or component here
|
|
174
|
+
"Pass the page's tree or component here. To layer a shell over another report, write defineReport({ extends: base, … }); " +
|
|
175
|
+
"otherwise export the defineReport product as the file's default export.",
|
|
152
176
|
);
|
|
153
177
|
}
|
|
154
178
|
}
|
|
@@ -317,33 +341,50 @@ export function defineReport(input: ReportNode | ReportDef): ReportDefinition {
|
|
|
317
341
|
: (input as ReportDef);
|
|
318
342
|
if (typeof def !== "object" || def === null) {
|
|
319
343
|
throw new Error(
|
|
320
|
-
"defineReport expects a report tree or a config object ({ title?, links?, footer?, head?, scripts?, styles?, content | pages }). " +
|
|
321
|
-
|
|
344
|
+
"defineReport expects a report tree or a config object ({ title?, links?, footer?, head?, scripts?, styles?, content | pages | extends }). " +
|
|
345
|
+
EXTENDS_NEXT_STEP,
|
|
322
346
|
);
|
|
323
347
|
}
|
|
324
348
|
|
|
325
349
|
const hasContent = "content" in def && def.content !== undefined;
|
|
326
350
|
const hasPages = "pages" in def && def.pages !== undefined;
|
|
327
|
-
|
|
351
|
+
const hasExtends = "extends" in def && (def as { extends?: unknown }).extends !== undefined;
|
|
352
|
+
const declared = [hasContent && '"content"', hasPages && '"pages"', hasExtends && '"extends"'].filter(
|
|
353
|
+
(name): name is string => typeof name === "string",
|
|
354
|
+
);
|
|
355
|
+
if (declared.length > 1) {
|
|
328
356
|
throw new Error(
|
|
329
|
-
`defineReport got
|
|
357
|
+
`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}`,
|
|
330
358
|
);
|
|
331
359
|
}
|
|
332
|
-
if (
|
|
360
|
+
if (declared.length === 0) {
|
|
333
361
|
throw new Error(
|
|
334
|
-
`defineReport got
|
|
362
|
+
`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}`,
|
|
335
363
|
);
|
|
336
364
|
}
|
|
337
365
|
|
|
338
|
-
|
|
339
|
-
|
|
366
|
+
// extends:报告级复用的唯一位置。页归 base,本对象只贡献外壳;base 已经过 defineReport
|
|
367
|
+
// 校验,页不重验。
|
|
368
|
+
let base: ReportDefinition | undefined;
|
|
369
|
+
let pages: readonly ReportPage[];
|
|
370
|
+
if (hasExtends) {
|
|
371
|
+
const candidate = (def as { extends: unknown }).extends;
|
|
372
|
+
if (!isReportDefinition(candidate)) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
'defineReport "extends" must be a defineReport(...) product — the base report whose pages this report inherits. ' +
|
|
375
|
+
EXTENDS_NEXT_STEP,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
base = candidate;
|
|
379
|
+
pages = base.pages;
|
|
380
|
+
} else if (hasContent) {
|
|
340
381
|
assertNotDefinition(def.content, 'defineReport "content"');
|
|
341
382
|
pages = [{ id: DEFAULT_PAGE_ID, title: DEFAULT_PAGE_TITLE, content: def.content as ReportNode }];
|
|
342
383
|
} else {
|
|
343
384
|
const raw = def.pages as unknown;
|
|
344
385
|
if (!Array.isArray(raw) || raw.length === 0) {
|
|
345
386
|
throw new Error(
|
|
346
|
-
`defineReport "pages" must be a non-empty array of { id, title, content }. ${
|
|
387
|
+
`defineReport "pages" must be a non-empty array of { id, title, content }. ${EXTENDS_NEXT_STEP}`,
|
|
347
388
|
);
|
|
348
389
|
}
|
|
349
390
|
const seen = new Set<string>();
|
|
@@ -367,36 +408,44 @@ export function defineReport(input: ReportNode | ReportDef): ReportDefinition {
|
|
|
367
408
|
|
|
368
409
|
if (def.title !== undefined) assertLocalizedText(def.title, "defineReport title");
|
|
369
410
|
if (def.footer !== undefined) assertLocalizedText(def.footer, "defineReport footer");
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
'
|
|
387
|
-
|
|
411
|
+
// 外壳合并:声明即整字段覆盖,未声明沿用 base(base 的字段已规范化,不重验)。
|
|
412
|
+
let links: readonly ReportLink[];
|
|
413
|
+
if (def.links !== undefined) {
|
|
414
|
+
if (!Array.isArray(def.links)) throw new Error("defineReport links must be an array of { label, href }.");
|
|
415
|
+
for (const link of def.links) {
|
|
416
|
+
assertLocalizedText((link as ReportLink)?.label, "defineReport link label");
|
|
417
|
+
if (typeof (link as ReportLink)?.href !== "string" || (link as ReportLink).href.length === 0) {
|
|
418
|
+
throw new Error("defineReport link href must be a non-empty string URL.");
|
|
419
|
+
}
|
|
420
|
+
// icon 唯一合法形状是 { svg: string }(无类型 JS 传组件 / ReactNode / 裸字符串都在装载期拒绝):
|
|
421
|
+
// 外壳声明经序列化边界进前端,ReactNode 过不去,可序列化是外壳契约的一部分。
|
|
422
|
+
const icon = (link as { icon?: unknown }).icon;
|
|
423
|
+
if (icon !== undefined) {
|
|
424
|
+
const svg = (icon as { svg?: unknown })?.svg;
|
|
425
|
+
if (typeof icon !== "object" || icon === null || typeof svg !== "string" || svg.length === 0) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
'defineReport link "icon" must be { svg: string } — an inline SVG string rendered before the label. ' +
|
|
428
|
+
"Components and React nodes are not accepted: the shell declaration crosses a serialization boundary. " +
|
|
429
|
+
'Write e.g. icon: { svg: "<svg …>…</svg>" }.',
|
|
430
|
+
);
|
|
431
|
+
}
|
|
388
432
|
}
|
|
389
433
|
}
|
|
434
|
+
links = def.links;
|
|
435
|
+
} else {
|
|
436
|
+
links = base?.links ?? [];
|
|
390
437
|
}
|
|
438
|
+
const title = def.title !== undefined ? def.title : base?.title;
|
|
439
|
+
const footer = def.footer !== undefined ? def.footer : base?.footer;
|
|
391
440
|
|
|
392
441
|
const definition = {
|
|
393
442
|
kind: "report" as const,
|
|
394
|
-
...(
|
|
443
|
+
...(title !== undefined ? { title } : {}),
|
|
395
444
|
links: [...links],
|
|
396
|
-
...(
|
|
397
|
-
head: assertHeadTags(def.head),
|
|
398
|
-
scripts: assertAssets(def.scripts, "scripts"),
|
|
399
|
-
styles: assertAssets(def.styles, "styles"),
|
|
445
|
+
...(footer !== undefined ? { footer } : {}),
|
|
446
|
+
head: def.head !== undefined ? assertHeadTags(def.head) : [...(base?.head ?? [])],
|
|
447
|
+
scripts: def.scripts !== undefined ? assertAssets(def.scripts, "scripts") : [...(base?.scripts ?? [])],
|
|
448
|
+
styles: def.styles !== undefined ? assertAssets(def.styles, "styles") : [...(base?.styles ?? [])],
|
|
400
449
|
pages: pages as unknown as NonEmptyArray<ReportPage>,
|
|
401
450
|
};
|
|
402
451
|
Object.defineProperty(definition, REPORT_DEFINITION, { value: true });
|
|
@@ -484,18 +533,11 @@ export interface RenderReportTextOptions extends TextRenderOptions {
|
|
|
484
533
|
pageId?: string;
|
|
485
534
|
}
|
|
486
535
|
|
|
487
|
-
/**
|
|
488
|
-
* 挑选警告的 text 形态:每条渲染好的 message 前缀 "! ",一行一条。宿主级前置块——
|
|
489
|
-
* 宿主是 warning 的唯一呈现者,组件数据不复制 warning;裸跑 / --report 都在报告顶上
|
|
490
|
-
* 如实报残缺,不静默(docs/feature/reports/architecture.md「Scope 是计算入口」)。
|
|
491
|
-
*/
|
|
492
|
-
function renderScopeWarningsText(scope: Scope, _locale: ReportLocale): string {
|
|
493
|
-
return scope.warnings.map((w) => `! ${w.message}`).join("\n");
|
|
494
|
-
}
|
|
495
|
-
|
|
496
536
|
/**
|
|
497
537
|
* text 宿主的装载语义:选页 → resolve(组合展开 + spec 取数,唯一的 await 边界)→ 树校验 →
|
|
498
|
-
* 遍历渲染 text
|
|
538
|
+
* 遍历渲染 text 面。不需要 react-dom。宿主不在报告树外另设警告通道——挑选警告的呈现件是
|
|
539
|
+
* `ScopeWarnings` 组件,内建报告每页都放它,自定义报告放不放是作者义务
|
|
540
|
+
* (docs/feature/reports/architecture.md「Scope 是计算入口」)。
|
|
499
541
|
*/
|
|
500
542
|
export async function renderReportToText(
|
|
501
543
|
definition: ReportDefinition,
|
|
@@ -511,11 +553,7 @@ export async function renderReportToText(
|
|
|
511
553
|
memo: new ResolveMemo(),
|
|
512
554
|
});
|
|
513
555
|
validateReportTree(resolved);
|
|
514
|
-
|
|
515
|
-
const body = renderNodeToText(resolved, textCtx);
|
|
516
|
-
return ctx.scope.warnings.length > 0
|
|
517
|
-
? [renderScopeWarningsText(ctx.scope, textCtx.locale), body].join("\n\n")
|
|
518
|
-
: body;
|
|
556
|
+
return renderNodeToText(resolved, createTextContext(options));
|
|
519
557
|
}
|
|
520
558
|
|
|
521
559
|
/** 页索引标题行(show 多页索引 / view 导航共用的解析结果):按 locale 解析的标题字符串。 */
|
|
@@ -563,8 +601,8 @@ export interface RenderTreeTextOptions extends TextRenderOptions {
|
|
|
563
601
|
|
|
564
602
|
/**
|
|
565
603
|
* 渲染一页报告树的 text 面(宿主逐页调用;页选择归宿主):
|
|
566
|
-
* resolve(组合展开 + spec 取数)→ validate → render
|
|
567
|
-
*
|
|
604
|
+
* resolve(组合展开 + spec 取数)→ validate → render。宿主不在报告树外另设警告通道,
|
|
605
|
+
* 挑选警告由页内的 `ScopeWarnings` 组件呈现(内建报告每页都放它)。
|
|
568
606
|
*/
|
|
569
607
|
export async function renderReportTreeToText(
|
|
570
608
|
tree: ReportNode,
|
|
@@ -584,8 +622,5 @@ export async function renderReportTreeToText(
|
|
|
584
622
|
? { experimentCommand: experimentCommandFor(options.commandContext) }
|
|
585
623
|
: {}),
|
|
586
624
|
});
|
|
587
|
-
|
|
588
|
-
return ctx.scope.warnings.length > 0
|
|
589
|
-
? [renderScopeWarningsText(ctx.scope, textCtx.locale), body].join("\n\n")
|
|
590
|
-
: body;
|
|
625
|
+
return renderNodeToText(resolved, textCtx);
|
|
591
626
|
}
|