niceeval 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -0
- package/README.zh.md +160 -0
- package/bin/niceeval.js +11 -0
- package/package.json +96 -0
- package/src/agents/bub.ts +223 -0
- package/src/agents/builtin.ts +6 -0
- package/src/agents/claude-code.ts +96 -0
- package/src/agents/codex.ts +122 -0
- package/src/agents/index.ts +25 -0
- package/src/agents/shared.ts +145 -0
- package/src/cli.ts +421 -0
- package/src/context/context.test.ts +96 -0
- package/src/context/context.ts +427 -0
- package/src/context/control-flow.ts +27 -0
- package/src/context/session.ts +124 -0
- package/src/define.ts +112 -0
- package/src/expect/index.ts +243 -0
- package/src/i18n/en.ts +131 -0
- package/src/i18n/index.ts +39 -0
- package/src/i18n/zh-CN.ts +132 -0
- package/src/index.ts +39 -0
- package/src/loaders/index.ts +56 -0
- package/src/o11y/cost.ts +57 -0
- package/src/o11y/derive.ts +304 -0
- package/src/o11y/otlp/canonical.ts +112 -0
- package/src/o11y/otlp/mappers/bub.ts +30 -0
- package/src/o11y/otlp/mappers/codex.ts +38 -0
- package/src/o11y/otlp/mappers/index.ts +25 -0
- package/src/o11y/otlp/parse.ts +330 -0
- package/src/o11y/otlp/receiver.ts +100 -0
- package/src/o11y/otlp/sandbox-receiver.ts +113 -0
- package/src/o11y/otlp/select.ts +82 -0
- package/src/o11y/parsers/bub.ts +240 -0
- package/src/o11y/parsers/claude-code.ts +270 -0
- package/src/o11y/parsers/codex.ts +480 -0
- package/src/o11y/parsers/index.ts +37 -0
- package/src/o11y/prices.json +9873 -0
- package/src/runner/discover.ts +77 -0
- package/src/runner/reporters/artifacts.ts +81 -0
- package/src/runner/reporters/console.ts +76 -0
- package/src/runner/reporters/index.ts +5 -0
- package/src/runner/reporters/json.ts +52 -0
- package/src/runner/reporters/live.ts +178 -0
- package/src/runner/reporters/table.ts +328 -0
- package/src/runner/run.ts +860 -0
- package/src/runner/sandbox-prep.ts +91 -0
- package/src/sandbox/checkpoint.ts +39 -0
- package/src/sandbox/docker.ts +539 -0
- package/src/sandbox/e2b.ts +203 -0
- package/src/sandbox/index.ts +25 -0
- package/src/sandbox/registry.ts +60 -0
- package/src/sandbox/resolve.ts +131 -0
- package/src/sandbox/source-files.ts +30 -0
- package/src/sandbox/vercel.ts +236 -0
- package/src/scoring/collector.ts +113 -0
- package/src/scoring/judge.ts +236 -0
- package/src/scoring/scoped.ts +289 -0
- package/src/scoring/verdict.ts +20 -0
- package/src/source-loc.ts +53 -0
- package/src/types.ts +913 -0
- package/src/util.test.ts +31 -0
- package/src/util.ts +50 -0
- package/src/view/app/App.tsx +189 -0
- package/src/view/app/components/AttemptModal.tsx +66 -0
- package/src/view/app/components/CodeView.tsx +272 -0
- package/src/view/app/components/CopyControls.tsx +89 -0
- package/src/view/app/components/ExperimentTable.tsx +266 -0
- package/src/view/app/components/GroupSelector.tsx +61 -0
- package/src/view/app/components/LazyArtifact.tsx +50 -0
- package/src/view/app/components/Trace.tsx +100 -0
- package/src/view/app/components/Transcript.tsx +130 -0
- package/src/view/app/components/primitives.tsx +43 -0
- package/src/view/app/components/ui/badge.tsx +21 -0
- package/src/view/app/components/ui/dialog.tsx +34 -0
- package/src/view/app/components/ui/tabs.tsx +30 -0
- package/src/view/app/i18n.ts +341 -0
- package/src/view/app/index.html +13 -0
- package/src/view/app/lib/cn.ts +7 -0
- package/src/view/app/lib/format.ts +73 -0
- package/src/view/app/lib/guards.ts +61 -0
- package/src/view/app/lib/outcome.ts +96 -0
- package/src/view/app/lib/rows.ts +63 -0
- package/src/view/app/lib/transcript-data.tsx +121 -0
- package/src/view/app/main.tsx +17 -0
- package/src/view/app/pages/RunsPage.tsx +83 -0
- package/src/view/app/pages/TracesPage.tsx +40 -0
- package/src/view/app/shared.ts +10 -0
- package/src/view/app/types.ts +114 -0
- package/src/view/app/vite.config.ts +26 -0
- package/src/view/client-dist/app.css +2 -0
- package/src/view/client-dist/app.js +56 -0
- package/src/view/index.ts +406 -0
- package/src/view/styles.css +1074 -0
- package/src/view/template.html +15 -0
package/src/util.test.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatThrown } from "./util.ts";
|
|
3
|
+
|
|
4
|
+
describe("formatThrown", () => {
|
|
5
|
+
it("uses the stack trace when available, so the report can locate the throw site", () => {
|
|
6
|
+
function throwsFromHere(): never {
|
|
7
|
+
throw new TypeError("Cannot read properties of undefined (reading 'text')");
|
|
8
|
+
}
|
|
9
|
+
let caught: unknown;
|
|
10
|
+
try {
|
|
11
|
+
throwsFromHere();
|
|
12
|
+
} catch (e) {
|
|
13
|
+
caught = e;
|
|
14
|
+
}
|
|
15
|
+
const formatted = formatThrown(caught);
|
|
16
|
+
expect(formatted).toContain("TypeError: Cannot read properties of undefined (reading 'text')");
|
|
17
|
+
expect(formatted).toContain("throwsFromHere");
|
|
18
|
+
expect(formatted).toContain("util.test.ts");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("falls back to name: message when the error has no stack", () => {
|
|
22
|
+
const e = new Error("boom");
|
|
23
|
+
delete (e as { stack?: string }).stack;
|
|
24
|
+
expect(formatThrown(e)).toBe("Error: boom");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("stringifies non-Error thrown values", () => {
|
|
28
|
+
expect(formatThrown("just a string")).toBe("just a string");
|
|
29
|
+
expect(formatThrown(42)).toBe("42");
|
|
30
|
+
});
|
|
31
|
+
});
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// 小工具。
|
|
2
|
+
|
|
3
|
+
import { t } from "./i18n/index.ts";
|
|
4
|
+
|
|
5
|
+
/** 读必需的环境变量,缺了就清晰报错(agent 鉴权用)。 */
|
|
6
|
+
export function requireEnv(name: string): string {
|
|
7
|
+
const v = process.env[name];
|
|
8
|
+
if (v === undefined || v === "") {
|
|
9
|
+
throw new Error(t("util.requiredEnv", { name }));
|
|
10
|
+
}
|
|
11
|
+
return v;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 取环境变量,缺了返回 undefined。 */
|
|
15
|
+
export function getEnv(name: string): string | undefined {
|
|
16
|
+
const v = process.env[name];
|
|
17
|
+
return v === undefined || v === "" ? undefined : v;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 去掉 JS/TS 注释(块注释 + 行注释),好让断言只对真实代码生效,不被注释里的迁移说明误伤。 */
|
|
21
|
+
export function stripComments(code: string): string {
|
|
22
|
+
return code.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 把 catch 到的 e 转成报告用字符串。优先带 stack(定位到 eval 脚本抛错的具体 file:line),
|
|
27
|
+
* 只在没有 stack 时才退化到 `name: message`。EvalResult.error 走这个,别再手写
|
|
28
|
+
* `e instanceof Error ? e.message : String(e)`——那样用户永远看不出错误发生在哪一行。
|
|
29
|
+
*/
|
|
30
|
+
export function formatThrown(e: unknown): string {
|
|
31
|
+
if (e instanceof Error) return e.stack ?? `${e.name}: ${e.message}`;
|
|
32
|
+
return String(e);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 零填充到 4 位(数据集扇出的 id:sql/0000)。 */
|
|
36
|
+
export function pad4(n: number): string {
|
|
37
|
+
return String(n).padStart(4, "0");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 把任意值安全地转成简短字符串(报告 / 日志用)。 */
|
|
41
|
+
export function brief(value: unknown, max = 200): string {
|
|
42
|
+
let s: string;
|
|
43
|
+
try {
|
|
44
|
+
s = typeof value === "string" ? value : JSON.stringify(value);
|
|
45
|
+
} catch {
|
|
46
|
+
s = String(value);
|
|
47
|
+
}
|
|
48
|
+
if (s.length > max) return s.slice(0, max) + "…";
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { detectLocale, makeTranslator, persistLocale, setDocumentLocale } from "./i18n.ts";
|
|
3
|
+
import type { MessageKey } from "./i18n.ts";
|
|
4
|
+
import type { Locale, LocalizedText, SortKey, SortState, Tab, ViewData, ViewResult, ViewRow } from "./types.ts";
|
|
5
|
+
import { buildGroupMap, compareRows, resultFromUrl } from "./lib/rows.ts";
|
|
6
|
+
import { Metric } from "./components/primitives.tsx";
|
|
7
|
+
import { GroupSelector } from "./components/GroupSelector.tsx";
|
|
8
|
+
import { ExperimentTable } from "./components/ExperimentTable.tsx";
|
|
9
|
+
import { CopyAllErrors } from "./components/CopyControls.tsx";
|
|
10
|
+
import { AttemptModal } from "./components/AttemptModal.tsx";
|
|
11
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs.tsx";
|
|
12
|
+
import { RunsView } from "./pages/RunsPage.tsx";
|
|
13
|
+
import { TracesView } from "./pages/TracesPage.tsx";
|
|
14
|
+
|
|
15
|
+
export const navItems: { id: Tab; label: MessageKey }[] = [
|
|
16
|
+
{ id: "experiments", label: "nav.experiments" },
|
|
17
|
+
{ id: "runs", label: "nav.runs" },
|
|
18
|
+
{ id: "traces", label: "nav.traces" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** 把 config.name 解析成当前界面语言的一条文案:字符串原样返回,多语言映射按 locale 挑,挑不到回退 en / 第一条。 */
|
|
22
|
+
export function localizedText(text: LocalizedText | undefined, locale: Locale): string | undefined {
|
|
23
|
+
if (!text) return undefined;
|
|
24
|
+
if (typeof text === "string") return text;
|
|
25
|
+
return text[locale] ?? text.en ?? Object.values(text)[0];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function App({ data }: { data: ViewData }) {
|
|
29
|
+
const rows = data.rows ?? [];
|
|
30
|
+
const [locale, setLocale] = useState<Locale>(() => detectLocale());
|
|
31
|
+
const t = useMemo(() => makeTranslator(locale), [locale]);
|
|
32
|
+
const [tab, setTab] = useState<Tab>("experiments");
|
|
33
|
+
const [sort, setSort] = useState<SortState>({ key: "passRate", dir: -1 });
|
|
34
|
+
const [query, setQuery] = useState("");
|
|
35
|
+
const [openRows, setOpenRows] = useState<Set<string>>(() => new Set());
|
|
36
|
+
const [selectedGroup, setSelectedGroup] = useState(() => {
|
|
37
|
+
const groups = [...new Set(rows.map((r) => r.group).filter(Boolean))].sort();
|
|
38
|
+
return groups[0] ?? null;
|
|
39
|
+
});
|
|
40
|
+
const [modalResult, setModalResult] = useState<ViewResult | null>(() => resultFromUrl(rows));
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
setDocumentLocale(locale);
|
|
44
|
+
persistLocale(locale);
|
|
45
|
+
}, [locale]);
|
|
46
|
+
|
|
47
|
+
const openModal = useCallback((result: ViewResult) => {
|
|
48
|
+
setModalResult(result);
|
|
49
|
+
const p = new URLSearchParams();
|
|
50
|
+
p.set("modal", result.id);
|
|
51
|
+
if (result.experimentId) p.set("exp", result.experimentId);
|
|
52
|
+
p.set("a", String(result.attempt));
|
|
53
|
+
history.replaceState(null, "", "?" + p.toString());
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const closeModal = useCallback(() => {
|
|
57
|
+
setModalResult(null);
|
|
58
|
+
history.replaceState(null, "", location.pathname);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const groupMap = useMemo<Map<string, ViewRow[]>>(() => buildGroupMap(rows), [rows]);
|
|
62
|
+
const pool = selectedGroup ? groupMap.get(selectedGroup) ?? [] : rows;
|
|
63
|
+
const filtered = useMemo(() => {
|
|
64
|
+
const q = query.trim().toLowerCase();
|
|
65
|
+
return pool
|
|
66
|
+
.filter((row: ViewRow) => {
|
|
67
|
+
if (!q) return true;
|
|
68
|
+
return [
|
|
69
|
+
row.label,
|
|
70
|
+
row.group || "",
|
|
71
|
+
row.experimentId || "",
|
|
72
|
+
row.agent,
|
|
73
|
+
row.model || "",
|
|
74
|
+
...(row.results ?? []).map((r: ViewResult) => r.id),
|
|
75
|
+
]
|
|
76
|
+
.join(" ")
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.includes(q);
|
|
79
|
+
})
|
|
80
|
+
.sort((a: ViewRow, b: ViewRow) => compareRows(a, b, sort.key) * sort.dir);
|
|
81
|
+
}, [pool, query, sort]);
|
|
82
|
+
|
|
83
|
+
const setSortKey = (key: SortKey) => {
|
|
84
|
+
setSort((prev) =>
|
|
85
|
+
prev.key === key ? { key, dir: prev.dir === 1 ? -1 : 1 } : { key, dir: key === "experiment" || key === "agent" ? 1 : -1 },
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const toggleRow = (key: string) => {
|
|
90
|
+
setOpenRows((prev) => {
|
|
91
|
+
const next = new Set(prev);
|
|
92
|
+
if (next.has(key)) next.delete(key);
|
|
93
|
+
else next.add(key);
|
|
94
|
+
return next;
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
|
|
100
|
+
<header className="topbar">
|
|
101
|
+
<a className="brand" href="https://github.com/CorrectRoadH/niceeval" target="_blank" rel="noreferrer">
|
|
102
|
+
<span className="mark" />
|
|
103
|
+
<span>NiceEval</span>
|
|
104
|
+
</a>
|
|
105
|
+
<TabsList aria-label={t("nav.label")}>
|
|
106
|
+
{navItems.map((item) => (
|
|
107
|
+
<TabsTrigger key={item.id} value={item.id}>
|
|
108
|
+
{t(item.label)}
|
|
109
|
+
</TabsTrigger>
|
|
110
|
+
))}
|
|
111
|
+
</TabsList>
|
|
112
|
+
<div className="lang-switch" aria-label="Language">
|
|
113
|
+
{(["en", "zh-CN"] satisfies Locale[]).map((item) => (
|
|
114
|
+
<button
|
|
115
|
+
key={item}
|
|
116
|
+
className={locale === item ? "is-active" : ""}
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={() => setLocale(item)}
|
|
119
|
+
aria-pressed={locale === item}
|
|
120
|
+
>
|
|
121
|
+
{item === "zh-CN" ? "中文" : "EN"}
|
|
122
|
+
</button>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
</header>
|
|
126
|
+
<main>
|
|
127
|
+
<section className="hero">
|
|
128
|
+
<h1>{localizedText(data.name, locale) || t("hero.title")}</h1>
|
|
129
|
+
<div className="meta">
|
|
130
|
+
<span>
|
|
131
|
+
<b>{t("hero.lastRun")}</b> {data.lastRun}
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
</section>
|
|
135
|
+
|
|
136
|
+
<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} />
|
|
141
|
+
</section>
|
|
142
|
+
|
|
143
|
+
<TabsContent value="experiments" id="tab-experiments">
|
|
144
|
+
<div className="section-head">
|
|
145
|
+
<h2>{t("section.experiments")}</h2>
|
|
146
|
+
</div>
|
|
147
|
+
<GroupSelector groupMap={groupMap} selectedGroup={selectedGroup} onSelect={setSelectedGroup} t={t} />
|
|
148
|
+
<div className="section-sub-head">
|
|
149
|
+
<span className="group-detail-label">{selectedGroup ?? ""}</span>
|
|
150
|
+
<div className="controls">
|
|
151
|
+
<input
|
|
152
|
+
className="search"
|
|
153
|
+
type="search"
|
|
154
|
+
placeholder={t("search.experiments")}
|
|
155
|
+
autoComplete="off"
|
|
156
|
+
value={query}
|
|
157
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
158
|
+
/>
|
|
159
|
+
<CopyAllErrors rows={filtered} t={t} />
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
{rows.length ? (
|
|
163
|
+
<ExperimentTable
|
|
164
|
+
rows={filtered}
|
|
165
|
+
sort={sort}
|
|
166
|
+
setSortKey={setSortKey}
|
|
167
|
+
openRows={openRows}
|
|
168
|
+
toggleRow={toggleRow}
|
|
169
|
+
openModal={openModal}
|
|
170
|
+
t={t}
|
|
171
|
+
/>
|
|
172
|
+
) : (
|
|
173
|
+
<div className="empty">
|
|
174
|
+
{t("empty.summary")}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</TabsContent>
|
|
178
|
+
|
|
179
|
+
<TabsContent value="runs">
|
|
180
|
+
<RunsView rows={rows} t={t} />
|
|
181
|
+
</TabsContent>
|
|
182
|
+
<TabsContent value="traces">
|
|
183
|
+
<TracesView rows={rows} t={t} />
|
|
184
|
+
</TabsContent>
|
|
185
|
+
</main>
|
|
186
|
+
{modalResult && <AttemptModal result={modalResult} onClose={closeModal} t={t} />}
|
|
187
|
+
</Tabs>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { ArtifactLoadState, T } from "../shared.ts";
|
|
3
|
+
import type { ViewResult } from "../types.ts";
|
|
4
|
+
import { asEvents, asSources } from "../lib/guards.ts";
|
|
5
|
+
import { outcomeClass, outcomeLabel, outcomeOf } from "../lib/outcome.ts";
|
|
6
|
+
import { CodeView, NoSourceBody } from "./CodeView.tsx";
|
|
7
|
+
import { LazyArtifact } from "./LazyArtifact.tsx";
|
|
8
|
+
import { Dialog, DialogClose, DialogContent, DialogTitle } from "./ui/dialog.tsx";
|
|
9
|
+
import { Badge } from "./ui/badge.tsx";
|
|
10
|
+
|
|
11
|
+
export function AttemptModal({ result, onClose, t }: { result: ViewResult; onClose: () => void; t: T }) {
|
|
12
|
+
const allAssertions = result.assertions || [];
|
|
13
|
+
const base = result.artifactBase;
|
|
14
|
+
const [data, setData] = useState<ArtifactLoadState>({ sources: null, events: null, status: "loading" });
|
|
15
|
+
|
|
16
|
+
// Esc / 焦点陷阱 / 背景滚动锁 / 点遮罩关闭 都交给 Radix Dialog;这里只保留工件拉取。
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!base) { setData({ sources: null, events: null, status: "none" }); return; }
|
|
19
|
+
let alive = true;
|
|
20
|
+
const grab = (name: string, has?: boolean): Promise<unknown> =>
|
|
21
|
+
has
|
|
22
|
+
? fetch("/artifact?p=" + encodeURIComponent(`${base}/${name}`))
|
|
23
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
24
|
+
.catch(() => null)
|
|
25
|
+
: Promise.resolve(null);
|
|
26
|
+
Promise.all([grab("sources.json", result.hasSources), grab("events.json", result.hasEvents)]).then(([sources, events]) => {
|
|
27
|
+
if (alive) setData({ sources: asSources(sources), events: asEvents(events), status: "ready" });
|
|
28
|
+
});
|
|
29
|
+
return () => { alive = false; };
|
|
30
|
+
}, [base, result.hasSources, result.hasEvents]);
|
|
31
|
+
|
|
32
|
+
const outcome = outcomeOf(result);
|
|
33
|
+
const hasCode = Boolean(data.sources?.length);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
|
37
|
+
<DialogContent aria-describedby={undefined}>
|
|
38
|
+
<div className="flex min-w-0 shrink-0 items-center justify-between gap-3 border-b border-line px-[18px] pb-[11px] pt-[13px]">
|
|
39
|
+
<div className="flex min-w-0 flex-col gap-[3px]">
|
|
40
|
+
<Badge tone={outcomeClass(outcome)}>{outcomeLabel(outcome, t)}</Badge>
|
|
41
|
+
<DialogTitle asChild>
|
|
42
|
+
<span className="truncate text-sm font-[640] text-text">{result.id}</span>
|
|
43
|
+
</DialogTitle>
|
|
44
|
+
{result.description ? <span className="truncate text-xs text-muted">{result.description}</span> : null}
|
|
45
|
+
</div>
|
|
46
|
+
<DialogClose
|
|
47
|
+
aria-label={t("action.close")}
|
|
48
|
+
className="grid h-7 w-7 shrink-0 place-items-center rounded-md border border-transparent text-sm text-muted transition-colors hover:border-line hover:bg-panel-2 hover:text-text"
|
|
49
|
+
>
|
|
50
|
+
x
|
|
51
|
+
</DialogClose>
|
|
52
|
+
</div>
|
|
53
|
+
<div className="flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
|
54
|
+
{result.error ? <div className="modal-error">{result.error}</div> : null}
|
|
55
|
+
{data.status === "loading" ? <div className="conv-loading">{t("trace.loading")}</div> : null}
|
|
56
|
+
{hasCode ? (
|
|
57
|
+
<CodeView sources={data.sources ?? []} events={data.events || []} assertions={allAssertions} t={t} />
|
|
58
|
+
) : data.status !== "loading" ? (
|
|
59
|
+
<NoSourceBody assertions={allAssertions} events={data.events || []} t={t} />
|
|
60
|
+
) : null}
|
|
61
|
+
{result.hasTrace && base ? <LazyArtifact type="trace" src={`${base}/trace.json`} t={t} /> : null}
|
|
62
|
+
</div>
|
|
63
|
+
</DialogContent>
|
|
64
|
+
</Dialog>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { ChevronRight } from "lucide-react";
|
|
3
|
+
import type { T } from "../shared.ts";
|
|
4
|
+
import type { Assertion, CodeSource, SourceTurn, TranscriptEvent } from "../types.ts";
|
|
5
|
+
import { TOOL_VERB, highlightTs, indexAsserts, indexTurns, locKey, resultBody, toolPrimaryArg } from "../lib/transcript-data.tsx";
|
|
6
|
+
import { formatScore, previewText, truncate } from "../lib/format.ts";
|
|
7
|
+
import { Transcript } from "./Transcript.tsx";
|
|
8
|
+
|
|
9
|
+
/** soft 断言没过阈值不影响 outcome,颜色上跟 gate 失败(红)区分开,用 warn(黄)。 */
|
|
10
|
+
function assertTone(a: Assertion): "good" | "warn" | "bad" {
|
|
11
|
+
if (a.passed) return "good";
|
|
12
|
+
return a.severity === "soft" ? "warn" : "bad";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CodeView({ sources, events, assertions, t }: { sources: CodeSource[]; events: TranscriptEvent[]; assertions: Assertion[]; t: T }) {
|
|
16
|
+
const turns = useMemo(() => indexTurns(events), [events]);
|
|
17
|
+
const asserts = useMemo(() => indexAsserts(assertions), [assertions]);
|
|
18
|
+
const [open, setOpen] = useState<Set<string>>(() => new Set());
|
|
19
|
+
const toggle = useCallback((k: string) => {
|
|
20
|
+
setOpen((prev) => {
|
|
21
|
+
const next = new Set(prev);
|
|
22
|
+
if (next.has(k)) next.delete(k); else next.add(k);
|
|
23
|
+
return next;
|
|
24
|
+
});
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
// 哪些 loc 被源码行覆盖到了;没覆盖到的(读不到源码的文件)放底部兜底。
|
|
28
|
+
const sourceKeys = new Set<string>();
|
|
29
|
+
for (const f of sources) {
|
|
30
|
+
const n = f.content.split("\n").length;
|
|
31
|
+
for (let i = 1; i <= n; i++) sourceKeys.add(locKey(f.path, i));
|
|
32
|
+
}
|
|
33
|
+
const orphanAsserts = [...asserts.byKey.entries()]
|
|
34
|
+
.filter(([k]) => !sourceKeys.has(k))
|
|
35
|
+
.flatMap(([, v]) => v)
|
|
36
|
+
.concat(asserts.noloc);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="codeview">
|
|
40
|
+
{sources.map((file) => (
|
|
41
|
+
<CodeFile
|
|
42
|
+
key={file.path}
|
|
43
|
+
file={file}
|
|
44
|
+
turns={turns.byKey}
|
|
45
|
+
asserts={asserts.byKey}
|
|
46
|
+
open={open}
|
|
47
|
+
toggle={toggle}
|
|
48
|
+
t={t}
|
|
49
|
+
/>
|
|
50
|
+
))}
|
|
51
|
+
{orphanAsserts.length ? (
|
|
52
|
+
<div className="code-orphans">
|
|
53
|
+
<div className="code-orphans-head">{t("code.otherAssertions")}</div>
|
|
54
|
+
<AssertDetail asserts={orphanAsserts} t={t} />
|
|
55
|
+
</div>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function CodeFile({
|
|
62
|
+
file,
|
|
63
|
+
turns,
|
|
64
|
+
asserts,
|
|
65
|
+
open,
|
|
66
|
+
toggle,
|
|
67
|
+
t,
|
|
68
|
+
}: {
|
|
69
|
+
file: CodeSource;
|
|
70
|
+
turns: Map<string, SourceTurn>;
|
|
71
|
+
asserts: Map<string, Assertion[]>;
|
|
72
|
+
open: Set<string>;
|
|
73
|
+
toggle: (key: string) => void;
|
|
74
|
+
t: T;
|
|
75
|
+
}) {
|
|
76
|
+
const lines = file.content.replace(/\n$/, "").split("\n");
|
|
77
|
+
return (
|
|
78
|
+
<div className="code-file">
|
|
79
|
+
<div className="code-file-head">{file.path}</div>
|
|
80
|
+
<div className="code-lines">
|
|
81
|
+
{lines.map((text: string, i: number) => {
|
|
82
|
+
const n = i + 1;
|
|
83
|
+
const k = locKey(file.path, n);
|
|
84
|
+
return (
|
|
85
|
+
<CodeLine
|
|
86
|
+
key={n}
|
|
87
|
+
n={n}
|
|
88
|
+
text={text}
|
|
89
|
+
turn={turns.get(k)}
|
|
90
|
+
asserts={asserts.get(k)}
|
|
91
|
+
isOpen={open.has(k)}
|
|
92
|
+
onToggle={() => toggle(k)}
|
|
93
|
+
t={t}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function CodeLine({
|
|
103
|
+
n,
|
|
104
|
+
text,
|
|
105
|
+
turn,
|
|
106
|
+
asserts,
|
|
107
|
+
isOpen,
|
|
108
|
+
onToggle,
|
|
109
|
+
t,
|
|
110
|
+
}: {
|
|
111
|
+
n: number;
|
|
112
|
+
text: string;
|
|
113
|
+
turn?: SourceTurn;
|
|
114
|
+
asserts?: Assertion[];
|
|
115
|
+
isOpen: boolean;
|
|
116
|
+
onToggle: () => void;
|
|
117
|
+
t: T;
|
|
118
|
+
}) {
|
|
119
|
+
const hasReply = !!turn;
|
|
120
|
+
const hasAsserts = !!(asserts && asserts.length);
|
|
121
|
+
// 只有 gate 断言没过才算这一行真的"fail";只剩 soft 断言没过阈值时是"warn"(不影响 outcome)。
|
|
122
|
+
const status = hasAsserts
|
|
123
|
+
? asserts?.every((a: Assertion) => a.passed)
|
|
124
|
+
? "pass"
|
|
125
|
+
: asserts?.some((a: Assertion) => !a.passed && a.severity === "gate")
|
|
126
|
+
? "fail"
|
|
127
|
+
: "warn"
|
|
128
|
+
: null;
|
|
129
|
+
const clickable = hasReply || hasAsserts;
|
|
130
|
+
const rowCls =
|
|
131
|
+
"code-line" +
|
|
132
|
+
(status ? ` line-${status}` : "") +
|
|
133
|
+
(hasReply && !status ? " line-send" : "") +
|
|
134
|
+
(clickable ? " line-clickable" : "") +
|
|
135
|
+
(isOpen ? " is-open" : "");
|
|
136
|
+
return (
|
|
137
|
+
<>
|
|
138
|
+
<div
|
|
139
|
+
className={rowCls}
|
|
140
|
+
onClick={clickable ? onToggle : undefined}
|
|
141
|
+
role={clickable ? "button" : undefined}
|
|
142
|
+
tabIndex={clickable ? 0 : undefined}
|
|
143
|
+
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggle(); } } : undefined}
|
|
144
|
+
>
|
|
145
|
+
<span className="ln">{n}</span>
|
|
146
|
+
<span className="gmark">
|
|
147
|
+
{hasAsserts ? (
|
|
148
|
+
<span className={`gstat ${status === "pass" ? "good" : status === "warn" ? "warn" : "bad"}`}>
|
|
149
|
+
{status === "pass" ? "✓" : status === "warn" ? "!" : "✗"}
|
|
150
|
+
</span>
|
|
151
|
+
) : hasReply ? (
|
|
152
|
+
<ChevronRight className={`gchev${isOpen ? " is-open" : ""}`} aria-hidden="true" />
|
|
153
|
+
) : null}
|
|
154
|
+
</span>
|
|
155
|
+
<code className="ctext">{highlightTs(text)}</code>
|
|
156
|
+
<span className="lbadges">
|
|
157
|
+
{hasAsserts ? asserts?.map((a: Assertion, i: number) => <AssertBadge key={i} a={a} />) : null}
|
|
158
|
+
{hasReply ? (
|
|
159
|
+
<span className="reply-hint">{isOpen ? t("code.hide") : t("code.reply")}</span>
|
|
160
|
+
) : clickable ? (
|
|
161
|
+
<ChevronRight className={`line-chev${isOpen ? " is-open" : ""}`} aria-hidden="true" />
|
|
162
|
+
) : null}
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
{isOpen && hasReply && turn ? <ReplyPanel turn={turn} t={t} /> : null}
|
|
166
|
+
{isOpen && hasAsserts && asserts ? <AssertDetail asserts={asserts} t={t} /> : null}
|
|
167
|
+
</>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 行尾分数徽章:judge / 带阈值的断言显示分数(过绿不过红);纯 gate 断言靠行色 + gutter 勾叉。 */
|
|
172
|
+
export function AssertBadge({ a }: { a: Assertion }) {
|
|
173
|
+
const showPct = a.threshold !== undefined || (a.score > 0 && a.score < 1);
|
|
174
|
+
if (!showPct) return null;
|
|
175
|
+
return (
|
|
176
|
+
<span className={`abadge ${assertTone(a)}`}>
|
|
177
|
+
{formatScore(a.score)}
|
|
178
|
+
{a.threshold !== undefined ? <span className="abadge-th">/{formatScore(a.threshold)}</span> : null}
|
|
179
|
+
</span>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function ReplyPanel({ turn, t }: { turn: SourceTurn; t: T }) {
|
|
184
|
+
if (!turn.replies.length) return <div className="line-detail reply-empty">{t("code.noReply")}</div>;
|
|
185
|
+
return (
|
|
186
|
+
<div className="line-detail reply-panel">
|
|
187
|
+
{turn.replies.map((r, j) => {
|
|
188
|
+
if (r.kind === "text")
|
|
189
|
+
return (
|
|
190
|
+
<div key={j} className="reply-assistant">
|
|
191
|
+
<span className="reply-role">{t("transcript.assistant")}</span>
|
|
192
|
+
<div className="reply-text">{r.text}</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
if (r.kind === "thinking")
|
|
196
|
+
return (
|
|
197
|
+
<details key={j} className="reply-think">
|
|
198
|
+
<summary>{t("transcript.thinking")}</summary>
|
|
199
|
+
<div className="reply-think-text">{r.text}</div>
|
|
200
|
+
</details>
|
|
201
|
+
);
|
|
202
|
+
if (r.kind === "error")
|
|
203
|
+
return <div key={j} className="reply-err">! {r.text}</div>;
|
|
204
|
+
if (r.kind === "tool") {
|
|
205
|
+
const verb = (r.ev.tool ? TOOL_VERB[r.ev.tool] : undefined) || r.ev.name || r.ev.tool || "tool";
|
|
206
|
+
const arg = toolPrimaryArg(r.ev);
|
|
207
|
+
const out = r.result ? resultBody(r.result.output) : "";
|
|
208
|
+
return (
|
|
209
|
+
<div key={j} className="reply-tool">
|
|
210
|
+
<span className="reply-tool-name">{arg ? `${verb}(${truncate(arg, 80)})` : verb}</span>
|
|
211
|
+
{out ? <span className="reply-tool-out">→ {truncate(previewText(out), 100)}</span> : null}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
})}
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function AssertDetail({ asserts, t }: { asserts: Assertion[]; t: T }) {
|
|
222
|
+
return (
|
|
223
|
+
<div className="line-detail assert-detail">
|
|
224
|
+
{asserts.map((a: Assertion, i: number) => (
|
|
225
|
+
<div key={i} className="assert-row">
|
|
226
|
+
<span className={`abadge ${assertTone(a)}`}>{a.passed ? t("assert.pass") : t("assert.fail")}</span>
|
|
227
|
+
<span className="assert-name">{a.name}</span>
|
|
228
|
+
{a.severity === "soft" ? <span className="assert-sev">{t("assert.soft")}</span> : null}
|
|
229
|
+
{a.threshold !== undefined ? (
|
|
230
|
+
<span className="assert-score">
|
|
231
|
+
{formatScore(a.score)} / {formatScore(a.threshold)}
|
|
232
|
+
</span>
|
|
233
|
+
) : null}
|
|
234
|
+
{a.detail ? <div className="assert-reason">{a.detail}</div> : null}
|
|
235
|
+
{a.evidence ? (
|
|
236
|
+
<details className="assert-evidence">
|
|
237
|
+
<summary>{t("assert.evidence")}</summary>
|
|
238
|
+
<pre className="assert-evidence-pre">{a.evidence}</pre>
|
|
239
|
+
</details>
|
|
240
|
+
) : null}
|
|
241
|
+
</div>
|
|
242
|
+
))}
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 没源码可叠时(此 run 早于 source-loc,或源码不可读:远程沙箱等)。不退回老的分组视图——
|
|
249
|
+
* 用代码视图同一套视觉语言:一句说明 + checks(绿过/红不过)+ 原始会话流。重跑即可看到代码视图。
|
|
250
|
+
*/
|
|
251
|
+
export function NoSourceBody({ assertions, events, t }: { assertions: Assertion[]; events: TranscriptEvent[]; t: T }) {
|
|
252
|
+
const checks = assertions || [];
|
|
253
|
+
return (
|
|
254
|
+
<div className="nosource">
|
|
255
|
+
<div className="nosource-note">
|
|
256
|
+
{t("code.noSource")}
|
|
257
|
+
</div>
|
|
258
|
+
{checks.length ? (
|
|
259
|
+
<div className="nosource-block">
|
|
260
|
+
<div className="nosource-head">{t("code.checks")}</div>
|
|
261
|
+
<AssertDetail asserts={checks} t={t} />
|
|
262
|
+
</div>
|
|
263
|
+
) : null}
|
|
264
|
+
{events?.length ? (
|
|
265
|
+
<div className="nosource-block">
|
|
266
|
+
<div className="nosource-head">{t("code.conversation")}</div>
|
|
267
|
+
<Transcript events={events} t={t} />
|
|
268
|
+
</div>
|
|
269
|
+
) : null}
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|