@lingjingai/scriptctl 0.75.0 → 0.77.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 +1 -0
- package/changes/0.76.0.md +18 -0
- package/changes/0.77.0.md +13 -0
- package/dist/bin.js +1 -1
- package/dist/{chunk-7AXMHZL5.js → chunk-NKSTP54U.js} +197 -190
- package/dist/cli.js +1 -1
- package/dist/review-agent.js +348 -344
- package/package.json +3 -1
- package/scripts/review-view.html +836 -0
- package/scripts/review-view.mjs +662 -0
- package/skills/scriptctl/SKILL.md +2 -1
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Review 链路</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #f6f6f3; --panel: #ffffff; --line: #e2e1da; --line-strong: #c9c7bd;
|
|
10
|
+
--text: #24231f; --muted: #6d6b63; --faint: #96948a; --hover: #f0efe9; --selected: #e8eefb;
|
|
11
|
+
--accent: #2f5fc4; --red: #c0392b; --red-bg: #fbeae8; --amber: #a86a08; --amber-bg: #fbf1dc; --green: #2f7d4f; --green-bg: #e6f3ea;
|
|
12
|
+
--c-read: #5b8def; --c-media: #9a6bdc; --c-write: #e39a2d; --c-record: #41a56b; --c-check: #9b9a92; --c-final: #5d6b7a;
|
|
13
|
+
--seg-call: #2aa198; --seg-agent: #e0714f; --seg-other: #9b9a92; --seg-deep: #9a6bdc; --gap: #d9d8d0;
|
|
14
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
15
|
+
}
|
|
16
|
+
@media (prefers-color-scheme: dark) {
|
|
17
|
+
:root {
|
|
18
|
+
--bg: #17181a; --panel: #1f2023; --line: #303236; --line-strong: #474a50;
|
|
19
|
+
--text: #e8e7e2; --muted: #a3a29b; --faint: #7a7972; --hover: #292b2f; --selected: #24304a;
|
|
20
|
+
--accent: #86a9f0; --red: #ee7d70; --red-bg: #3c2422; --amber: #e0ae57; --amber-bg: #3a2e18; --green: #6cc591; --green-bg: #1e3327;
|
|
21
|
+
--gap: #34363a;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
html, body { height: 100%; margin: 0; }
|
|
26
|
+
body { display: flex; flex-direction: column; background: var(--bg); color: var(--text); font: 13px/1.55 -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif; }
|
|
27
|
+
button, select, input { font: inherit; color: inherit; background: var(--panel); border: 1px solid var(--line-strong); border-radius: 6px; padding: 3px 8px; }
|
|
28
|
+
button { cursor: pointer; } button:hover { background: var(--hover); }
|
|
29
|
+
a { color: var(--accent); }
|
|
30
|
+
header { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 8px 14px; background: var(--panel); border-bottom: 1px solid var(--line); }
|
|
31
|
+
header strong { font-size: 14px; }
|
|
32
|
+
#ws { max-width: 380px; }
|
|
33
|
+
#open { display: flex; gap: 6px; flex: 1; min-width: 240px; }
|
|
34
|
+
#open input { flex: 1; min-width: 120px; }
|
|
35
|
+
#status { color: var(--muted); }
|
|
36
|
+
#summary { padding: 10px 14px 0; }
|
|
37
|
+
.totals { display: flex; flex-wrap: wrap; gap: 4px 18px; color: var(--muted); margin-bottom: 8px; }
|
|
38
|
+
.totals b { color: var(--text); font-weight: 600; }
|
|
39
|
+
.flow { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 10px; }
|
|
40
|
+
.tile { flex: 1 0 118px; min-width: 118px; text-align: left; padding: 7px 10px; border-radius: 8px; background: var(--panel); border: 1px solid var(--line); line-height: 1.4; }
|
|
41
|
+
.tile .name { font-weight: 600; }
|
|
42
|
+
.tile .big { font-size: 15px; font-weight: 600; margin-top: 2px; }
|
|
43
|
+
.tile .sub { color: var(--muted); font-size: 12px; }
|
|
44
|
+
.share { height: 3px; margin-top: 5px; background: var(--hover); border-radius: 2px; overflow: hidden; }
|
|
45
|
+
.share i { display: block; height: 100%; background: var(--seg-agent); }
|
|
46
|
+
main { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(460px, 46%) minmax(0, 1fr); border-top: 1px solid var(--line); }
|
|
47
|
+
#left, #right { min-height: 0; overflow: auto; }
|
|
48
|
+
#left { border-right: 1px solid var(--line); background: var(--panel); }
|
|
49
|
+
.sticky { position: sticky; top: 0; z-index: 3; background: var(--panel); }
|
|
50
|
+
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 6px 10px; border-bottom: 1px solid var(--line); color: var(--muted); }
|
|
51
|
+
.toolbar label { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
|
52
|
+
.axis, .row { display: grid; grid-template-columns: 148px minmax(120px, 1fr) 118px; align-items: center; gap: 8px; padding: 0 10px; }
|
|
53
|
+
.axis { height: 22px; border-bottom: 1px solid var(--line); color: var(--faint); font-size: 11px; }
|
|
54
|
+
.axis .ticks { position: relative; height: 100%; }
|
|
55
|
+
.axis .ticks span { position: absolute; top: 3px; white-space: nowrap; }
|
|
56
|
+
.phase { display: flex; gap: 8px; align-items: baseline; width: 100%; padding: 5px 10px; text-align: left; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); background: var(--hover); font-weight: 600; }
|
|
57
|
+
.phase small { font-weight: 400; color: var(--muted); }
|
|
58
|
+
.row { width: 100%; min-height: 26px; padding-top: 2px; padding-bottom: 2px; text-align: left; border: 0; border-radius: 0; border-bottom: 1px solid var(--line); background: transparent; }
|
|
59
|
+
.row:hover { background: var(--hover); }
|
|
60
|
+
.row.selected { background: var(--selected); }
|
|
61
|
+
.row .name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
62
|
+
.row .name small { color: var(--faint); margin-left: 3px; }
|
|
63
|
+
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--red); margin-right: 4px; vertical-align: 1px; }
|
|
64
|
+
.track { position: relative; height: 14px; border-radius: 2px; }
|
|
65
|
+
.seg { position: absolute; top: 2px; height: 10px; min-width: 2px; border-radius: 2px; }
|
|
66
|
+
.seg.call { background: var(--seg-call); } .seg.agent { background: var(--seg-agent); } .seg.deep { background: var(--seg-deep); } .seg.other { background: var(--seg-other); }
|
|
67
|
+
.seg.failed { outline: 1.5px solid var(--red); }
|
|
68
|
+
.seg.approximate { opacity: .45; }
|
|
69
|
+
.metrics { display: flex; gap: 7px; justify-content: flex-end; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
70
|
+
.outcomes { display: inline-flex; gap: 3px; }
|
|
71
|
+
.f { color: var(--green); } .u { color: var(--amber); } .n { color: var(--faint); }
|
|
72
|
+
.legend { display: flex; flex-wrap: wrap; gap: 4px 12px; color: var(--muted); font-size: 12px; }
|
|
73
|
+
.legend i { display: inline-block; width: 10px; height: 10px; margin-right: 4px; border-radius: 2px; vertical-align: -1px; }
|
|
74
|
+
#logs { padding: 10px; color: var(--muted); }
|
|
75
|
+
#logs li { margin: 2px 0; }
|
|
76
|
+
#right { padding: 14px 18px 60px; }
|
|
77
|
+
.empty { color: var(--muted); padding: 40px 0; text-align: center; }
|
|
78
|
+
h2 { margin: 0 0 6px; font-size: 16px; }
|
|
79
|
+
h3 { margin: 22px 0 8px; font-size: 13px; }
|
|
80
|
+
h3 small { color: var(--muted); font-weight: 400; margin-left: 6px; }
|
|
81
|
+
.badge { display: inline-block; padding: 0 7px; border-radius: 10px; font-size: 12px; background: var(--hover); color: var(--muted); }
|
|
82
|
+
.badge.bad { background: var(--red-bg); color: var(--red); } .badge.warn { background: var(--amber-bg); color: var(--amber); } .badge.good { background: var(--green-bg); color: var(--green); }
|
|
83
|
+
.facts { display: flex; flex-wrap: wrap; gap: 3px 16px; color: var(--muted); margin: 6px 0; }
|
|
84
|
+
.facts b { color: var(--text); font-weight: 500; }
|
|
85
|
+
.note { padding: 7px 10px; margin: 8px 0; border-radius: 6px; background: var(--hover); color: var(--muted); }
|
|
86
|
+
.note.warn { background: var(--amber-bg); color: var(--amber); } .note.bad { background: var(--red-bg); color: var(--red); }
|
|
87
|
+
.strip { display: flex; align-items: flex-end; gap: 1px; height: 46px; margin: 6px 0 2px; padding-bottom: 1px; border-bottom: 1px solid var(--line-strong); }
|
|
88
|
+
.strip button { flex: 0 0 auto; min-width: 3px; padding: 0; border: 0; border-radius: 1px 1px 0 0; }
|
|
89
|
+
.strip button:hover { outline: 2px solid var(--text); }
|
|
90
|
+
.c-read { background: var(--c-read); } .c-media { background: var(--c-media); } .c-write { background: var(--c-write); } .c-record { background: var(--c-record); } .c-check { background: var(--c-check); } .c-final { background: var(--c-final); }
|
|
91
|
+
.step, .card { margin: 3px 0; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
|
|
92
|
+
.step > summary, .card > summary { display: flex; align-items: center; flex-wrap: wrap; gap: 3px 8px; padding: 5px 9px; cursor: pointer; list-style: none; }
|
|
93
|
+
.step > summary::-webkit-details-marker, .card > summary::-webkit-details-marker { display: none; }
|
|
94
|
+
.step[open], .card[open] { border-color: var(--line-strong); }
|
|
95
|
+
.num { color: var(--faint); min-width: 26px; font-variant-numeric: tabular-nums; }
|
|
96
|
+
.when { color: var(--faint); font-variant-numeric: tabular-nums; }
|
|
97
|
+
.tool { display: inline-flex; max-width: 100%; gap: 5px; align-items: baseline; padding: 0 6px; border-radius: 4px; background: var(--hover); }
|
|
98
|
+
.tool i { align-self: center; flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; }
|
|
99
|
+
.tool b { font-weight: 600; font-family: var(--mono); font-size: 12px; }
|
|
100
|
+
.tool span { overflow: hidden; max-width: 340px; white-space: nowrap; text-overflow: ellipsis; color: var(--muted); }
|
|
101
|
+
.flag { padding: 0 5px; border-radius: 4px; font-size: 11px; background: var(--amber-bg); color: var(--amber); }
|
|
102
|
+
.flag.bad { background: var(--red-bg); color: var(--red); }
|
|
103
|
+
.right { margin-left: auto; display: flex; gap: 10px; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
104
|
+
.body { padding: 2px 10px 10px; border-top: 1px solid var(--line); }
|
|
105
|
+
.call { margin-top: 9px; }
|
|
106
|
+
.call-head { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 4px; }
|
|
107
|
+
.label { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
|
108
|
+
pre { margin: 2px 0; padding: 6px 8px; background: var(--bg); border-radius: 5px; font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
109
|
+
pre.clamp { max-height: 190px; overflow: hidden; }
|
|
110
|
+
.more { margin-top: 2px; padding: 0 7px; font-size: 12px; }
|
|
111
|
+
.text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
112
|
+
.kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 10px; margin-top: 6px; }
|
|
113
|
+
.kv dt { color: var(--muted); } .kv dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
114
|
+
.frames { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 6px; margin: 4px 0; }
|
|
115
|
+
.frames figure { margin: 0; } .frames img { display: block; width: 100%; min-height: 60px; background: var(--hover); border-radius: 4px; }
|
|
116
|
+
.frames figcaption { color: var(--muted); font-size: 12px; }
|
|
117
|
+
video { display: block; width: 100%; max-width: 420px; max-height: 300px; margin: 4px 0; background: #000; border-radius: 4px; }
|
|
118
|
+
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
119
|
+
th, td { padding: 3px 7px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
|
120
|
+
th { color: var(--muted); font-weight: 500; }
|
|
121
|
+
td.r, th.r { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
122
|
+
.message { margin: 6px 0; padding: 6px 8px; border-left: 3px solid var(--line-strong); }
|
|
123
|
+
.md { line-height: 1.65; overflow-wrap: anywhere; }
|
|
124
|
+
.md > :first-child { margin-top: 8px; } .md > :last-child { margin-bottom: 0; }
|
|
125
|
+
.md h3, .md h4, .md h5, .md h6 { margin: 16px 0 6px; font-weight: 600; }
|
|
126
|
+
.md h3 { font-size: 16px; } .md h4 { font-size: 15px; } .md h5 { font-size: 14px; } .md h6 { font-size: 13px; }
|
|
127
|
+
.md p { margin: 6px 0; }
|
|
128
|
+
.md ul, .md ol { margin: 4px 0; padding-left: 22px; }
|
|
129
|
+
.md li { margin: 3px 0; } .md li > p { margin: 0; }
|
|
130
|
+
.md strong { font-weight: 600; }
|
|
131
|
+
.md hr { margin: 12px 0; border: 0; border-top: 1px solid var(--line); }
|
|
132
|
+
.md blockquote { margin: 6px 0; padding: 0 10px; border-left: 3px solid var(--line-strong); color: var(--muted); }
|
|
133
|
+
.md table { margin: 6px 0; }
|
|
134
|
+
.md code, .note code { padding: 0 4px; border-radius: 3px; background: var(--hover); font: 12px var(--mono); }
|
|
135
|
+
.note code { background: var(--panel); }
|
|
136
|
+
@media (max-width: 900px) { main { display: block; overflow: auto; } #left, #right { overflow: visible; } #left { border-right: 0; } .sticky { position: static; } }
|
|
137
|
+
</style>
|
|
138
|
+
</head>
|
|
139
|
+
<body>
|
|
140
|
+
<header>
|
|
141
|
+
<strong>Review 链路</strong>
|
|
142
|
+
<select id="ws" aria-label="选择 workspace"></select>
|
|
143
|
+
<form id="open"><input id="path" placeholder="或粘贴一个拉片 workspace 路径" aria-label="workspace 路径"><button>打开</button></form>
|
|
144
|
+
<button id="reload">刷新</button>
|
|
145
|
+
<span id="status"></span>
|
|
146
|
+
</header>
|
|
147
|
+
<section id="summary"></section>
|
|
148
|
+
<main>
|
|
149
|
+
<section id="left"></section>
|
|
150
|
+
<section id="right"><p class="empty">点左边的任一行,查看它每一步做了什么</p></section>
|
|
151
|
+
</main>
|
|
152
|
+
<script>
|
|
153
|
+
const PHASES = [
|
|
154
|
+
["episode", "逐集审核"], ["identity", "身份核对"], ["asset", "资产核对"],
|
|
155
|
+
["deep", "人物深审"], ["escalation", "疑点复核"], ["episode2", "逐集再审"],
|
|
156
|
+
];
|
|
157
|
+
const TOOL_KINDS = {
|
|
158
|
+
scan: "read", read: "read", read_many: "read", refs: "read", find: "read", assets: "read", bindings: "read", span: "read", issues: "check",
|
|
159
|
+
read_appearance_source: "read", read_escalation_finding: "read",
|
|
160
|
+
watch: "media", frames: "media", asr_window: "media",
|
|
161
|
+
edit: "write", replace: "write", ref: "write", insert: "write", move: "write", del: "write", merge: "write", state: "write", asset: "write", apply_changes: "write", speaker: "write",
|
|
162
|
+
finding: "record", record_findings: "record", validate: "check",
|
|
163
|
+
};
|
|
164
|
+
const KIND_NAMES = { read: "读取", media: "看片", write: "修改", record: "记录结论", check: "校验", final: "收尾" };
|
|
165
|
+
const KIND_ORDER = ["media", "write", "record", "read", "check", "final"];
|
|
166
|
+
const OUTCOME = { fixed: ["已修复", "good"], unresolved: ["未决", "warn"], unchanged: ["无需修改", ""] };
|
|
167
|
+
const VERDICT = { ok: "good", no_change: "good", apply: "good", fix: "good", uncertain: "warn", error: "bad" };
|
|
168
|
+
|
|
169
|
+
const $ = (selector) => document.querySelector(selector);
|
|
170
|
+
const el = (tag, props = {}, ...kids) => {
|
|
171
|
+
const node = document.createElement(tag);
|
|
172
|
+
for (const [key, value] of Object.entries(props)) {
|
|
173
|
+
if (value == null || value === false) continue;
|
|
174
|
+
if (key === "class") node.className = value;
|
|
175
|
+
else if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
|
|
176
|
+
else node.setAttribute(key, value === true ? "" : value);
|
|
177
|
+
}
|
|
178
|
+
for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false) node.append(kid.nodeType ? kid : String(kid));
|
|
179
|
+
return node;
|
|
180
|
+
};
|
|
181
|
+
const dur = (ms) => ms == null ? "—" : ms < 1000 ? `${Math.round(ms)}毫秒` : ms < 60_000 ? `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}秒`
|
|
182
|
+
: ms < 3_600_000 ? `${(ms / 60_000).toFixed(1)}分` : `${(ms / 3_600_000).toFixed(1)}时`;
|
|
183
|
+
const num = (n) => !n ? "0" : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}K` : String(n);
|
|
184
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
185
|
+
const clock = (t) => { const d = new Date(t); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; };
|
|
186
|
+
const stamp = (t) => { const d = new Date(t); return `${d.getMonth() + 1}/${d.getDate()} ${clock(t)}:${pad(d.getSeconds())}`; };
|
|
187
|
+
const api = async (url) => {
|
|
188
|
+
const response = await fetch(url);
|
|
189
|
+
const body = await response.json();
|
|
190
|
+
if (!response.ok) throw new Error(body.error ?? response.statusText);
|
|
191
|
+
return body;
|
|
192
|
+
};
|
|
193
|
+
const secondsOf = (at) => {
|
|
194
|
+
const parts = String(at).trim().split(":").map(Number);
|
|
195
|
+
return parts.some(Number.isNaN) ? null : parts.reduce((sum, part) => sum * 60 + part, 0);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
let current = null, selected = null, sortBy = "time", onlyProblems = false;
|
|
199
|
+
const collapsed = new Set();
|
|
200
|
+
|
|
201
|
+
function union(spans) {
|
|
202
|
+
const sorted = spans.filter(([a]) => a != null).sort((a, b) => a[0] - b[0]);
|
|
203
|
+
let total = 0, end = -Infinity;
|
|
204
|
+
for (const [a, b] of sorted) {
|
|
205
|
+
if (b <= end) continue;
|
|
206
|
+
total += b - Math.max(a, end);
|
|
207
|
+
end = b;
|
|
208
|
+
}
|
|
209
|
+
return total;
|
|
210
|
+
}
|
|
211
|
+
const groupOf = (unit) => unit.phase === "episode" && ["same-process", "other-process"].includes(unit.rerun) ? "episode2" : unit.phase;
|
|
212
|
+
const isProblem = (unit) => unit.state === "failed" || unit.failures > 0 || unit.outcomes.unresolved > 0 || unit.verdict === "uncertain";
|
|
213
|
+
|
|
214
|
+
// ---- overview ----------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
function renderSummary(run) {
|
|
217
|
+
const units = run.units;
|
|
218
|
+
const spans = units.filter((unit) => unit.start != null && !unit.approximate).map((unit) => [unit.start, unit.end]);
|
|
219
|
+
const sum = (field) => units.reduce((total, unit) => total + (unit[field] ?? 0), 0);
|
|
220
|
+
const outcome = (name) => units.reduce((total, unit) => total + unit.outcomes[name], 0);
|
|
221
|
+
const input = sum("input"), calls = sum("model_calls");
|
|
222
|
+
const start = Math.min(...spans.map(([a]) => a)), end = Math.max(...spans.map(([, b]) => b));
|
|
223
|
+
const tiles = PHASES.flatMap(([id, name]) => {
|
|
224
|
+
const list = units.filter((unit) => groupOf(unit) === id);
|
|
225
|
+
if (!list.length) return [];
|
|
226
|
+
const count = list.reduce((total, unit) => total + unit.model_calls, 0);
|
|
227
|
+
const time = union(list.filter((unit) => !unit.approximate).map((unit) => [unit.start, unit.end]));
|
|
228
|
+
return [el("button", { class: "tile", onclick: () => document.getElementById(`phase-${id}`)?.scrollIntoView({ block: "start" }) },
|
|
229
|
+
el("div", { class: "name" }, name),
|
|
230
|
+
el("div", { class: "big" }, `${count} 次调用`),
|
|
231
|
+
el("div", { class: "sub" }, `${dur(time)} · ${list.length} 个单元`),
|
|
232
|
+
el("div", { class: "share" }, el("i", { style: `width:${calls ? Math.round(count / calls * 100) : 0}%` })))];
|
|
233
|
+
});
|
|
234
|
+
$("#summary").replaceChildren(
|
|
235
|
+
el("div", { class: "totals" },
|
|
236
|
+
el("span", {}, run.title ? `《${run.title}》 ` : "", `${run.episodes} 集`),
|
|
237
|
+
el("span", {}, "有模型在跑的时间 ", el("b", {}, dur(union(spans)))),
|
|
238
|
+
el("span", {}, "首尾跨度 ", el("b", {}, Number.isFinite(start) ? dur(end - start) : "—")),
|
|
239
|
+
el("span", {}, "模型调用 ", el("b", {}, calls)),
|
|
240
|
+
el("span", {}, "输入 ", el("b", {}, num(input)), ` token(缓存 ${input ? Math.round(sum("cached") / input * 100) : 0}%)`),
|
|
241
|
+
el("span", {}, "输出 ", el("b", {}, num(sum("output")))),
|
|
242
|
+
el("span", {}, "结论 ", el("b", { class: "f" }, `修复 ${outcome("fixed")}`), " · ", el("b", { class: "u" }, `未决 ${outcome("unresolved")}`), " · ", el("b", { class: "n" }, `无需修改 ${outcome("unchanged")}`)),
|
|
243
|
+
run.failures ? el("span", {}, "失败快照 ", el("b", {}, run.failures)) : null),
|
|
244
|
+
el("div", { class: "flow" }, tiles));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function makeScale(units) {
|
|
248
|
+
const spans = units.filter((unit) => unit.start != null).map((unit) => [unit.start, Math.max(unit.end, unit.start + 1000)]).sort((a, b) => a[0] - b[0]);
|
|
249
|
+
if (!spans.length) return { pct: () => 0, pieces: [], gaps: [] };
|
|
250
|
+
const active = union(spans);
|
|
251
|
+
const limit = Math.max(180_000, active * 0.04);
|
|
252
|
+
const pieces = [];
|
|
253
|
+
for (const [a, b] of spans) {
|
|
254
|
+
const last = pieces.at(-1);
|
|
255
|
+
if (last && a - last.end <= limit) last.end = Math.max(last.end, b);
|
|
256
|
+
else pieces.push({ start: a, end: b });
|
|
257
|
+
}
|
|
258
|
+
const GAP = 2.5;
|
|
259
|
+
const room = 100 - GAP * (pieces.length - 1);
|
|
260
|
+
const total = pieces.reduce((sum, piece) => sum + piece.end - piece.start, 0);
|
|
261
|
+
let offset = 0;
|
|
262
|
+
const gaps = [];
|
|
263
|
+
pieces.forEach((piece, index) => {
|
|
264
|
+
piece.left = offset;
|
|
265
|
+
piece.width = (piece.end - piece.start) / total * room;
|
|
266
|
+
offset += piece.width;
|
|
267
|
+
if (index < pieces.length - 1) { gaps.push({ left: offset, width: GAP, ms: pieces[index + 1].start - piece.end }); offset += GAP; }
|
|
268
|
+
});
|
|
269
|
+
const pct = (t) => {
|
|
270
|
+
const piece = pieces.find((item) => t <= item.end) ?? pieces.at(-1);
|
|
271
|
+
return piece.left + Math.max(0, Math.min(1, (t - piece.start) / (piece.end - piece.start || 1))) * piece.width;
|
|
272
|
+
};
|
|
273
|
+
return { pct, pieces, gaps };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function segClass(unit, kind) {
|
|
277
|
+
const base = unit.phase === "deep" ? "deep" : kind ?? "other";
|
|
278
|
+
return `seg ${base}${unit.state === "failed" ? " failed" : ""}${unit.approximate ? " approximate" : ""}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function bar(unit, scale, style) {
|
|
282
|
+
const parts = unit.segments.length ? unit.segments : unit.start != null ? [{ start: unit.start, end: unit.end }] : [];
|
|
283
|
+
return el("div", { class: "track", style }, parts.map((part) => {
|
|
284
|
+
const left = scale.pct(part.start);
|
|
285
|
+
const width = Math.max(0.3, scale.pct(part.end) - left);
|
|
286
|
+
return el("div", { class: segClass(unit, part.kind), style: `left:${left}%;width:${width}%` });
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function unitRow(unit, scale, trackStyle) {
|
|
291
|
+
const verdict = unit.verdict === "uncertain" ? el("small", {}, "存疑") : null;
|
|
292
|
+
return el("button", { class: `row${selected === unit.id ? " selected" : ""}`, "data-id": unit.id, title: unit.label, onclick: () => openUnit(unit.id) },
|
|
293
|
+
el("div", { class: "name" }, unit.state === "failed" || unit.failures ? el("span", { class: "dot", title: "失败或有失败快照" }) : null, unit.label, verdict),
|
|
294
|
+
bar(unit, scale, trackStyle),
|
|
295
|
+
el("div", { class: "metrics" },
|
|
296
|
+
unit.reused ? el("span", {}, "复用") : el("span", {}, unit.approximate ? `≈${dur(unit.wall_ms)}` : dur(unit.wall_ms)),
|
|
297
|
+
el("span", { title: "模型调用次数" }, unit.model_calls ? `${unit.model_calls}次` : ""),
|
|
298
|
+
el("span", { class: "outcomes", title: "修复 / 未决 / 无需修改" },
|
|
299
|
+
unit.outcomes.fixed ? el("span", { class: "f" }, unit.outcomes.fixed) : null,
|
|
300
|
+
unit.outcomes.unresolved ? el("span", { class: "u" }, unit.outcomes.unresolved) : null,
|
|
301
|
+
unit.outcomes.unchanged ? el("span", { class: "n" }, unit.outcomes.unchanged) : null)));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function renderLeft(run) {
|
|
305
|
+
const scale = makeScale(run.units);
|
|
306
|
+
const trackStyle = scale.gaps.length ? `background:${scale.gaps.map((gap) => `linear-gradient(var(--gap),var(--gap)) ${gap.left}% 0/${gap.width}% 100% no-repeat`).join(",")}` : "";
|
|
307
|
+
const sorters = { time: (a, b) => (a.start ?? Infinity) - (b.start ?? Infinity), wall: (a, b) => (b.wall_ms ?? 0) - (a.wall_ms ?? 0),
|
|
308
|
+
calls: (a, b) => b.model_calls - a.model_calls, input: (a, b) => b.input - a.input };
|
|
309
|
+
const sections = PHASES.flatMap(([id, name]) => {
|
|
310
|
+
const all = run.units.filter((unit) => groupOf(unit) === id);
|
|
311
|
+
const list = all.filter((unit) => !onlyProblems || isProblem(unit)).sort(sorters[sortBy]);
|
|
312
|
+
if (!all.length) return [];
|
|
313
|
+
const time = union(all.filter((unit) => !unit.approximate).map((unit) => [unit.start, unit.end]));
|
|
314
|
+
const closed = collapsed.has(id);
|
|
315
|
+
return [el("div", { id: `phase-${id}` },
|
|
316
|
+
el("button", { class: "phase", onclick: () => { closed ? collapsed.delete(id) : collapsed.add(id); renderLeft(run); } },
|
|
317
|
+
`${closed ? "▸" : "▾"} ${name}`,
|
|
318
|
+
el("small", {}, `${all.length} 个单元 · ${dur(time)} · ${all.reduce((sum, unit) => sum + unit.model_calls, 0)} 次调用`)),
|
|
319
|
+
closed ? null : list.map((unit) => unitRow(unit, scale, trackStyle)))];
|
|
320
|
+
});
|
|
321
|
+
const scroll = $("#left").scrollTop;
|
|
322
|
+
const ticks = scale.pieces.filter((piece, index, all) => index === 0 || piece.left - all[index - 1].left >= 14);
|
|
323
|
+
$("#left").replaceChildren(
|
|
324
|
+
el("div", { class: "sticky" }, el("div", { class: "toolbar" },
|
|
325
|
+
el("label", {}, "排序", el("select", { onchange: (event) => { sortBy = event.target.value; renderLeft(run); } },
|
|
326
|
+
[["time", "时间"], ["wall", "耗时"], ["calls", "调用数"], ["input", "输入 token"]].map(([value, text]) => el("option", { value, selected: sortBy === value }, text)))),
|
|
327
|
+
el("label", {}, el("input", { type: "checkbox", checked: onlyProblems, onchange: (event) => { onlyProblems = event.target.checked; renderLeft(run); } }), "只看有问题的"),
|
|
328
|
+
el("div", { class: "legend", style: "margin-left:auto" },
|
|
329
|
+
el("span", {}, el("i", { style: "background:var(--seg-call)" }), "单次调用"), el("span", {}, el("i", { style: "background:var(--seg-agent)" }), "agent"),
|
|
330
|
+
scale.gaps.length ? el("span", {}, el("i", { style: "background:var(--gap)" }), "跳过的空档") : null)),
|
|
331
|
+
el("div", { class: "axis" }, el("span", {}, "单元"),
|
|
332
|
+
el("div", { class: "ticks" }, ticks.map((piece) => el("span", { style: `left:${piece.left}%` }, clock(piece.start)))),
|
|
333
|
+
el("span", { style: "text-align:right" }, "耗时 · 调用 · 结论"))),
|
|
334
|
+
...sections,
|
|
335
|
+
el("details", { id: "logs" }, el("summary", {}, `这次拼出来的进程日志(${run.logs.length})`),
|
|
336
|
+
run.logs.length ? el("ul", {}, run.logs.map((log) => el("li", {}, `${log.file} · ${log.units} 个单元 · `, log.finished ? `跑完 ${dur(log.wall_ms)}` : "没有跑完(被中断或续跑)")))
|
|
337
|
+
: el("p", {}, "没找到 NDJSON 日志,结论来自 global.json,只保留每个单元最后一次的结果。")));
|
|
338
|
+
$("#left").scrollTop = scroll;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---- shared pieces --------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/** "第 3 集 01:20" in model text opens that frame. */
|
|
344
|
+
function rich(text) {
|
|
345
|
+
const out = [];
|
|
346
|
+
const pattern = /第\s*(\d+)\s*集[\s·,,]*(\d{1,2}:\d{2}(?:\.\d+)?)/g;
|
|
347
|
+
let last = 0, match;
|
|
348
|
+
while ((match = pattern.exec(text))) {
|
|
349
|
+
out.push(text.slice(last, match.index));
|
|
350
|
+
out.push(current?.videos.includes(Number(match[1]))
|
|
351
|
+
? el("a", { href: frameUrl(match[1], match[2]), target: "_blank", title: "看这一帧" }, match[0]) : match[0]);
|
|
352
|
+
last = match.index + match[0].length;
|
|
353
|
+
}
|
|
354
|
+
out.push(text.slice(last));
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
const frameUrl = (episode, at) => `/api/frame?w=${current.id}&ep=${episode}&at=${encodeURIComponent(at)}`;
|
|
358
|
+
|
|
359
|
+
// Agents write their reports in Markdown. Everything is built as DOM nodes, so model text never becomes markup.
|
|
360
|
+
const MD_INLINE = /(`+)(.+?)\1|\*\*(.+?)\*\*|\*([^\s*](?:[^*]*[^\s*])?)\*|~~(.+?)~~|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
361
|
+
const MD_LIST = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
|
|
362
|
+
const MD_RULE = /^\s{0,3}([-*_])\s*(?:\1\s*){2,}$/;
|
|
363
|
+
const MD_TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$/;
|
|
364
|
+
const MD_QUOTE = /^\s{0,3}>\s?/;
|
|
365
|
+
const indentOf = (line) => /^\s*/.exec(line)[0].length;
|
|
366
|
+
const startsBlock = (line) => /^\s{0,3}(#{1,6}\s|>|`{3}|~{3})/.test(line) || MD_RULE.test(line) || MD_LIST.test(line);
|
|
367
|
+
const tableCells = (line) => line.trim().replace(/^\||\|$/g, "").split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\\|/g, "|"));
|
|
368
|
+
|
|
369
|
+
function mdInline(text) {
|
|
370
|
+
const out = [];
|
|
371
|
+
let last = 0;
|
|
372
|
+
for (const match of text.matchAll(MD_INLINE)) {
|
|
373
|
+
const [whole, , code, bold, italic, strike, label, href] = match;
|
|
374
|
+
out.push(rich(text.slice(last, match.index)));
|
|
375
|
+
out.push(code != null ? el("code", {}, code)
|
|
376
|
+
: bold != null ? el("strong", {}, mdInline(bold))
|
|
377
|
+
: italic != null ? el("em", {}, mdInline(italic))
|
|
378
|
+
: strike != null ? el("s", {}, mdInline(strike))
|
|
379
|
+
: el("a", { href, target: "_blank", rel: "noreferrer" }, mdInline(label)));
|
|
380
|
+
last = match.index + whole.length;
|
|
381
|
+
}
|
|
382
|
+
out.push(rich(text.slice(last)));
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function mdList(lines, from) {
|
|
387
|
+
const first = MD_LIST.exec(lines[from]);
|
|
388
|
+
const indent = first[1].length, ordered = /\d/.test(first[2]);
|
|
389
|
+
const items = [];
|
|
390
|
+
let i = from;
|
|
391
|
+
while (true) {
|
|
392
|
+
let next = i;
|
|
393
|
+
while (next < lines.length && !lines[next].trim()) next++;
|
|
394
|
+
const head = MD_LIST.exec(lines[next] ?? "");
|
|
395
|
+
if (!head || head[1].length < indent || head[1].length > indent + 1 || /\d/.test(head[2]) !== ordered) break;
|
|
396
|
+
const width = head[1].length + head[2].length + 1;
|
|
397
|
+
const body = [head[3]];
|
|
398
|
+
i = next + 1;
|
|
399
|
+
while (i < lines.length) {
|
|
400
|
+
const line = lines[i];
|
|
401
|
+
if (!line.trim()) {
|
|
402
|
+
// A blank line stays inside the item only when what follows is indented under it.
|
|
403
|
+
const after = lines.slice(i + 1).find((item) => item.trim());
|
|
404
|
+
if (!after || indentOf(after) <= indent + 1) break;
|
|
405
|
+
body.push("");
|
|
406
|
+
} else if (indentOf(line) > indent + 1) body.push(line.slice(Math.min(width, indentOf(line))));
|
|
407
|
+
else if (startsBlock(line)) break;
|
|
408
|
+
else body.push(line.trimStart());
|
|
409
|
+
i++;
|
|
410
|
+
}
|
|
411
|
+
items.push(el("li", {}, mdBlocks(body)));
|
|
412
|
+
}
|
|
413
|
+
const start = Number.parseInt(first[2], 10);
|
|
414
|
+
return { node: el(ordered ? "ol" : "ul", { start: ordered && start !== 1 ? start : null }, items), next: i };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function mdBlocks(lines) {
|
|
418
|
+
const out = [];
|
|
419
|
+
let paragraph = [];
|
|
420
|
+
const flush = () => {
|
|
421
|
+
if (paragraph.length) out.push(el("p", {}, paragraph.map((line, index) => [index ? el("br") : null, mdInline(line)])));
|
|
422
|
+
paragraph = [];
|
|
423
|
+
};
|
|
424
|
+
for (let i = 0; i < lines.length;) {
|
|
425
|
+
const line = lines[i];
|
|
426
|
+
const heading = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
427
|
+
const fence = /^\s*(`{3,}|~{3,})/.exec(line);
|
|
428
|
+
if (!line.trim()) { flush(); i++; continue; }
|
|
429
|
+
if (!fence && !heading && !MD_RULE.test(line) && !MD_QUOTE.test(line) && !MD_LIST.test(line)
|
|
430
|
+
&& !(line.includes("|") && lines[i + 1]?.includes("-") && MD_TABLE_RULE.test(lines[i + 1]))) {
|
|
431
|
+
paragraph.push(line.trim());
|
|
432
|
+
i++;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
flush();
|
|
436
|
+
if (fence) {
|
|
437
|
+
const end = lines.findIndex((item, index) => index > i && item.trim().startsWith(fence[1]));
|
|
438
|
+
const stop = end < 0 ? lines.length : end;
|
|
439
|
+
out.push(el("pre", {}, lines.slice(i + 1, stop).join("\n")));
|
|
440
|
+
i = stop + 1;
|
|
441
|
+
} else if (heading) {
|
|
442
|
+
out.push(el(`h${Math.min(heading[1].length + 2, 6)}`, {}, mdInline(heading[2])));
|
|
443
|
+
i++;
|
|
444
|
+
} else if (MD_RULE.test(line)) {
|
|
445
|
+
out.push(el("hr"));
|
|
446
|
+
i++;
|
|
447
|
+
} else if (MD_QUOTE.test(line)) {
|
|
448
|
+
const quoted = [];
|
|
449
|
+
while (i < lines.length && MD_QUOTE.test(lines[i])) quoted.push(lines[i++].replace(MD_QUOTE, ""));
|
|
450
|
+
out.push(el("blockquote", {}, mdBlocks(quoted)));
|
|
451
|
+
} else if (MD_LIST.test(line)) {
|
|
452
|
+
const list = mdList(lines, i);
|
|
453
|
+
out.push(list.node);
|
|
454
|
+
i = list.next;
|
|
455
|
+
} else {
|
|
456
|
+
const rows = [];
|
|
457
|
+
for (i += 2; i < lines.length && lines[i].trim() && lines[i].includes("|"); i++) rows.push(tableCells(lines[i]));
|
|
458
|
+
out.push(el("table", {},
|
|
459
|
+
el("thead", {}, el("tr", {}, tableCells(line).map((cell) => el("th", {}, mdInline(cell))))),
|
|
460
|
+
el("tbody", {}, rows.map((row) => el("tr", {}, row.map((cell) => el("td", {}, mdInline(cell))))))));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
flush();
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
const markdown = (text) => el("div", { class: "md" }, mdBlocks(text.replace(/\r\n?/g, "\n").split("\n")));
|
|
467
|
+
|
|
468
|
+
function block(value, clampAt = 700) {
|
|
469
|
+
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "";
|
|
470
|
+
const long = text.length > clampAt || text.split("\n").length > 10;
|
|
471
|
+
const pre = el("pre", { class: long ? "clamp" : null }, rich(text));
|
|
472
|
+
if (!long) return pre;
|
|
473
|
+
const more = el("button", { class: "more", onclick: () => { const open = pre.classList.toggle("clamp"); more.textContent = open ? `展开全部(${text.length} 字符)` : "收起"; } }, `展开全部(${text.length} 字符)`);
|
|
474
|
+
return el("div", {}, pre, more);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function kv(object) {
|
|
478
|
+
return el("dl", { class: "kv" }, Object.entries(object).flatMap(([key, value]) => {
|
|
479
|
+
if (value == null || value === "") return [];
|
|
480
|
+
return [el("dt", {}, key), el("dd", {}, typeof value === "object" ? JSON.stringify(value) : rich(String(value)))];
|
|
481
|
+
}));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Build a details body only when it is opened: a long run has thousands of rows. */
|
|
485
|
+
function lazy(summary, build, className, open = false) {
|
|
486
|
+
const node = el("details", { class: className }, el("summary", {}, summary));
|
|
487
|
+
let built = false;
|
|
488
|
+
node.addEventListener("toggle", () => {
|
|
489
|
+
if (!node.open || built) return;
|
|
490
|
+
built = true;
|
|
491
|
+
node.append(el("div", { class: "body" }, build()));
|
|
492
|
+
});
|
|
493
|
+
if (open) node.open = true;
|
|
494
|
+
return node;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---- agent steps ------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
const asArgs = (args) => args && typeof args === "object" ? args : {};
|
|
500
|
+
function toolSummary(call) {
|
|
501
|
+
const a = asArgs(call.args);
|
|
502
|
+
switch (call.tool) {
|
|
503
|
+
case "read": case "refs": return a.addr ?? a.at ?? "";
|
|
504
|
+
case "read_many": return (a.addresses ?? []).join(", ");
|
|
505
|
+
case "scan": return a.episode ? `第 ${a.episode} 集` : "";
|
|
506
|
+
case "find": return a.pattern ?? a.query ?? "";
|
|
507
|
+
case "bindings": return a.asset ?? a.addr ?? "";
|
|
508
|
+
case "span": return a.range ?? "";
|
|
509
|
+
case "frames": return `${a.episode ? `第 ${a.episode} 集 ` : ""}${(a.timestamps ?? []).join(", ")}`;
|
|
510
|
+
case "watch": case "asr_window": return `${a.episode ? `第 ${a.episode} 集 ` : ""}${a.from}–${a.to}${a.question ? ` ${a.question}` : ""}`;
|
|
511
|
+
case "edit": return `${a.at ?? ""} ${Object.keys(a).filter((key) => key !== "at").join(",")}`;
|
|
512
|
+
case "replace": return `${a.pattern ?? ""} → ${a.to ?? ""}`;
|
|
513
|
+
case "finding": return `${OUTCOME[a.outcome]?.[0] ?? a.outcome ?? ""} ${a.title ?? ""}`;
|
|
514
|
+
case "record_findings": return `${a.findings?.length ?? 0} 条`;
|
|
515
|
+
case "apply_changes": return `${a.changes?.length ?? 0} 处修改`;
|
|
516
|
+
default: return a.at ?? a.asset ?? a.src ?? "";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const stepKind = (step) => {
|
|
520
|
+
if (!step.calls.length) return "final";
|
|
521
|
+
const kinds = step.calls.map((call) => TOOL_KINDS[call.tool] ?? "read");
|
|
522
|
+
return KIND_ORDER.find((kind) => kinds.includes(kind)) ?? "read";
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
function callBlock(call, unit) {
|
|
526
|
+
const args = asArgs(call.args);
|
|
527
|
+
const episode = args.episode ?? unit.episode;
|
|
528
|
+
const parts = [];
|
|
529
|
+
const hasVideo = current.videos.includes(Number(episode));
|
|
530
|
+
if (call.tool === "frames" && args.timestamps) {
|
|
531
|
+
if (args.note) parts.push(el("div", { class: "label" }, `目的:${args.note}`));
|
|
532
|
+
parts.push(hasVideo ? el("div", { class: "frames" }, args.timestamps.map((at) => el("figure", {},
|
|
533
|
+
el("a", { href: frameUrl(episode, at), target: "_blank" }, el("img", { src: frameUrl(episode, at), loading: "lazy", alt: `第 ${episode} 集 ${at}` })),
|
|
534
|
+
el("figcaption", {}, `第 ${episode} 集 ${at}`))))
|
|
535
|
+
: el("div", { class: "note" }, "workspace 的 manifest 里没有这一集的原片路径,看不了画面。"));
|
|
536
|
+
parts.push(el("div", { class: "label" }, "这些画面是按时间戳重新从原片抽的,不是模型当时收到的文件。"));
|
|
537
|
+
const failed = call.result?.failed;
|
|
538
|
+
if (failed?.length) parts.push(el("div", { class: "note bad" }, `抽帧失败:${failed.join("; ")}`));
|
|
539
|
+
if (typeof call.result === "string") parts.push(block(call.result));
|
|
540
|
+
} else if ((call.tool === "watch" || call.tool === "asr_window") && args.from) {
|
|
541
|
+
if (args.question) parts.push(el("div", { class: "label" }, "提问"), el("div", { class: "text" }, args.question));
|
|
542
|
+
const from = secondsOf(args.from), to = secondsOf(args.to);
|
|
543
|
+
if (hasVideo && from != null) parts.push(el("video", { controls: true, preload: "none", src: `/api/video?w=${current.id}&ep=${episode}#t=${from},${to}` }));
|
|
544
|
+
parts.push(el("div", { class: "label" }, "返回"), block(call.result ?? "(没有返回)"));
|
|
545
|
+
} else {
|
|
546
|
+
if (call.args != null && Object.keys(args).length) parts.push(el("div", { class: "label" }, "参数"), typeof call.args === "string" ? block(call.args) : kv(args));
|
|
547
|
+
if (call.result !== undefined) parts.push(el("div", { class: "label" }, "返回"), block(call.result));
|
|
548
|
+
}
|
|
549
|
+
return el("div", { class: "call" },
|
|
550
|
+
el("div", { class: "call-head" }, toolChip(call, false), call.dup ? el("span", { class: "flag" }, "重复调用") : null,
|
|
551
|
+
call.failed ? el("span", { class: "flag bad" }, "报错") : null, call.budget ? el("span", { class: "flag bad" }, "预算用完") : null),
|
|
552
|
+
parts);
|
|
553
|
+
}
|
|
554
|
+
function toolChip(call, withSummary = true) {
|
|
555
|
+
const kind = TOOL_KINDS[call.tool] ?? "read";
|
|
556
|
+
return el("span", { class: "tool" }, el("i", { class: `c-${kind}` }), el("b", {}, call.tool), withSummary ? el("span", {}, toolSummary(call)) : null);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function stepRow(step, unit, runStart) {
|
|
560
|
+
const flags = [
|
|
561
|
+
step.calls.some((call) => call.failed) ? el("span", { class: "flag bad" }, "报错") : null,
|
|
562
|
+
step.calls.some((call) => call.budget) ? el("span", { class: "flag bad" }, "预算用完") : null,
|
|
563
|
+
step.calls.some((call) => call.dup) ? el("span", { class: "flag" }, "重复") : null,
|
|
564
|
+
step.slow ? el("span", { class: "flag" }, "慢") : null,
|
|
565
|
+
];
|
|
566
|
+
const summary = [
|
|
567
|
+
el("span", { class: "num" }, `#${step.step}`),
|
|
568
|
+
step.calls.length ? step.calls.map((call) => toolChip(call)) : el("span", { class: "tool" }, el("i", { class: "c-final" }), el("b", {}, "收尾文字")),
|
|
569
|
+
flags,
|
|
570
|
+
el("span", { class: "right" },
|
|
571
|
+
el("span", { class: "when" }, `+${dur(step.start - runStart)}`),
|
|
572
|
+
el("b", { title: step.model_ms != null ? `模型 ${dur(step.model_ms)} · 工具 ${dur(step.step_ms - step.model_ms)}` : "这一步总耗时" }, dur(step.step_ms)),
|
|
573
|
+
el("span", { title: "输入 token(缓存比例)" }, `入 ${num(step.input)}${step.input ? ` (${Math.round(step.cached / step.input * 100)}%)` : ""}`),
|
|
574
|
+
el("span", { title: `输出 token,其中思考 ${num(step.reasoning_tokens)}` }, `出 ${num(step.output)}`)),
|
|
575
|
+
];
|
|
576
|
+
const node = lazy(summary, () => [
|
|
577
|
+
el("div", { class: "facts" },
|
|
578
|
+
step.model_ms != null ? el("span", {}, "模型 ", el("b", {}, dur(step.model_ms)), " · 工具 ", el("b", {}, dur(step.step_ms - step.model_ms))) : null,
|
|
579
|
+
step.route ? el("span", {}, step.route) : null,
|
|
580
|
+
el("span", {}, `思考 ${num(step.reasoning_tokens)} token`), el("span", {}, `结束原因 ${step.finish ?? "—"}`)),
|
|
581
|
+
step.reasoning ? [el("div", { class: "label" }, "思考摘要"), markdown(step.reasoning)] : null,
|
|
582
|
+
step.calls.map((call) => callBlock(call, unit)),
|
|
583
|
+
step.text ? [el("div", { class: "label" }, "模型文字"), markdown(step.text)] : null,
|
|
584
|
+
], "step");
|
|
585
|
+
node.id = `step-${step.start}-${step.step}`;
|
|
586
|
+
return node;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function strip(steps) {
|
|
590
|
+
const maxInput = Math.max(1, ...steps.map((step) => step.input));
|
|
591
|
+
const total = Math.max(1, steps.reduce((sum, step) => sum + (step.step_ms ?? 0), 0));
|
|
592
|
+
return el("div", { class: "strip" }, steps.map((step) => {
|
|
593
|
+
const kind = stepKind(step);
|
|
594
|
+
return el("button", { class: `c-${kind}`, "aria-label": `第 ${step.step} 步`, title: `#${step.step} ${KIND_NAMES[kind]} · ${dur(step.step_ms)} · 输入 ${num(step.input)}`,
|
|
595
|
+
style: `flex-grow:${Math.max(step.step_ms ?? 0, 1) / total * 100};flex-basis:0;height:${Math.max(12, step.input / maxInput * 100)}%`,
|
|
596
|
+
onclick: () => {
|
|
597
|
+
const node = document.getElementById(`step-${step.start}-${step.step}`);
|
|
598
|
+
node.open = true;
|
|
599
|
+
node.scrollIntoView({ block: "center" });
|
|
600
|
+
} });
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function kindTable(steps) {
|
|
605
|
+
const rows = new Map();
|
|
606
|
+
for (const step of steps) {
|
|
607
|
+
const kind = stepKind(step);
|
|
608
|
+
const row = rows.get(kind) ?? { steps: 0, ms: 0 };
|
|
609
|
+
row.steps += 1; row.ms += step.step_ms ?? 0;
|
|
610
|
+
rows.set(kind, row);
|
|
611
|
+
}
|
|
612
|
+
return el("div", { class: "legend" }, KIND_ORDER.filter((kind) => rows.has(kind)).map((kind) =>
|
|
613
|
+
el("span", {}, el("i", { class: `c-${kind}` }), `${KIND_NAMES[kind]} ${rows.get(kind).steps} 步 · ${dur(rows.get(kind).ms)}`)));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ---- single calls, findings, failures -----------------------------------------------------
|
|
617
|
+
|
|
618
|
+
const CALL_NAMES = { asset: "资产核对", handoff: "处理上报", episode: "逐集查登记", duplicates: "查重复资产" };
|
|
619
|
+
function nonEmpty(changes) {
|
|
620
|
+
return Object.fromEntries(Object.entries(changes ?? {}).filter(([, list]) => list?.length));
|
|
621
|
+
}
|
|
622
|
+
function callCard(call) {
|
|
623
|
+
const summary = [
|
|
624
|
+
el("b", {}, CALL_NAMES[call.type] ?? call.type),
|
|
625
|
+
el("span", { style: "color:var(--muted)" }, call.target),
|
|
626
|
+
call.verdict ? el("span", { class: `badge ${VERDICT[call.verdict] ?? ""}` }, call.verdict) : null,
|
|
627
|
+
el("span", { class: "right" }, call.images ? el("span", {}, `${call.images} 张图`) : null, el("span", {}, `提示 ${num(call.prompt_chars)} 字`), el("b", {}, dur(call.ms))),
|
|
628
|
+
];
|
|
629
|
+
return lazy(summary, () => {
|
|
630
|
+
const value = call.value ?? {};
|
|
631
|
+
const issues = Array.isArray(value.issues) ? value.issues : null;
|
|
632
|
+
const edits = Array.isArray(value.edits) ? value.edits : null;
|
|
633
|
+
return [
|
|
634
|
+
el("div", { class: "facts" }, el("span", {}, stamp(call.start)), call.route ? el("span", {}, call.route) : null, call.attempts > 1 ? el("span", {}, `尝试 ${call.attempts} 次`) : null),
|
|
635
|
+
call.error ? el("div", { class: "note bad" }, call.error) : null,
|
|
636
|
+
value.reason ? [el("div", { class: "label" }, "模型的理由"), el("div", { class: "text" }, rich(value.reason))] : null,
|
|
637
|
+
edits?.length ? [el("div", { class: "label" }, "给出的修改"), edits.map((edit) => kv(edit))] : null,
|
|
638
|
+
value.changes && Object.keys(nonEmpty(value.changes)).length ? [el("div", { class: "label" }, "给出的修改"), block(nonEmpty(value.changes))] : null,
|
|
639
|
+
issues?.length ? [el("div", { class: "label" }, `发现 ${issues.length} 个问题`), issues.map((issue) => kv({ 标题: issue.title, 分类: issue.category, 置信度: issue.confidence, 理由: issue.reason, 修改: nonEmpty(issue.changes) }))] : null,
|
|
640
|
+
value.pairs?.length ? [el("div", { class: "label" }, "重复候选"), block(value.pairs)] : null,
|
|
641
|
+
value.not_objects?.length ? [el("div", { class: "label" }, "误登记候选"), block(value.not_objects)] : null,
|
|
642
|
+
el("details", {}, el("summary", { class: "label" }, "原始返回"), block(value)),
|
|
643
|
+
el("div", { class: "label" }, "这次调用的 prompt 和附图没有保存,只记录了字符数和图片数。"),
|
|
644
|
+
];
|
|
645
|
+
}, "card");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function findingCard(finding) {
|
|
649
|
+
const [label, tone] = OUTCOME[finding.outcome] ?? [finding.outcome, ""];
|
|
650
|
+
const anchor = finding.anchor ?? {};
|
|
651
|
+
const place = [anchor.episode_number ? `第 ${anchor.episode_number} 集` : "全剧", anchor.scene_number ? `第 ${anchor.scene_number} 场` : "", anchor.timestamp ?? ""].filter(Boolean).join(" ");
|
|
652
|
+
return lazy([
|
|
653
|
+
el("span", { class: `badge ${tone}` }, label), el("span", { class: "badge" }, finding.category),
|
|
654
|
+
el("span", {}, finding.title), el("span", { class: "right" }, place),
|
|
655
|
+
], () => [
|
|
656
|
+
kv({ 问题: finding.issue, 影响: finding.impact, 处理: finding.resolution, 未决原因: finding.unresolved_reason, 置信度: finding.confidence,
|
|
657
|
+
对象: finding.subject ? `${finding.subject.name ?? ""} ${finding.subject.id ?? ""}` : null, 修改前: finding.before, 修改后: finding.after, id: finding.id }),
|
|
658
|
+
finding.evidence?.length ? [el("div", { class: "label" }, `证据(${finding.evidence.length})`),
|
|
659
|
+
finding.evidence.map((item) => block(typeof item === "string" ? item : `${item.kind ?? ""} | ${item.label ?? ""} | ${item.detail ?? ""}`, 500))] : null,
|
|
660
|
+
], "card");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function failureCard(failure) {
|
|
664
|
+
return lazy([
|
|
665
|
+
el("span", { class: "badge bad" }, failure.provider ?? "失败"), el("span", {}, (failure.error ?? "").slice(0, 110)),
|
|
666
|
+
el("span", { class: "right" }, failure.at ? stamp(failure.at) : ""),
|
|
667
|
+
], () => {
|
|
668
|
+
const holder = el("div", {});
|
|
669
|
+
const show = el("button", { onclick: async () => {
|
|
670
|
+
show.disabled = true;
|
|
671
|
+
show.textContent = "加载中…";
|
|
672
|
+
try {
|
|
673
|
+
const prompt = await api(`/api/failure?w=${current.id}&f=${encodeURIComponent(failure.file)}`);
|
|
674
|
+
holder.replaceChildren(el("div", {},
|
|
675
|
+
prompt.response ? el("div", { class: "label" }, "接口返回") : null, prompt.response ? block(prompt.response) : null,
|
|
676
|
+
el("div", { class: "label" }, `这次请求发给模型的 ${prompt.messages.length} 条消息`),
|
|
677
|
+
prompt.messages.map((message) => el("div", { class: "message" }, el("b", {}, message.role), message.parts.map((part) => block(part.text, 1500))))));
|
|
678
|
+
show.remove();
|
|
679
|
+
} catch (error) { show.disabled = false; show.textContent = `加载失败:${error.message}`; }
|
|
680
|
+
} }, `查看这次请求的完整 prompt(${failure.messages} 条消息)`);
|
|
681
|
+
return [
|
|
682
|
+
kv({ 模型: `${failure.provider ?? ""} / ${failure.model ?? ""}`, 错误: failure.error, 错误链: failure.chain?.map((item) => `${item.name}: ${item.message}${item.status_code ? ` (${item.status_code})` : ""}`).join("\n") }),
|
|
683
|
+
failure.messages ? el("div", { style: "margin-top:8px" }, show) : null, holder,
|
|
684
|
+
];
|
|
685
|
+
}, "card");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function usageTable(rows) {
|
|
689
|
+
return el("table", {}, el("thead", {}, el("tr", {}, el("th", {}, "环节"), ["调用", "输入", "缓存", "输出", "思考", "模型耗时"].map((name) => el("th", { class: "r" }, name)))),
|
|
690
|
+
el("tbody", {}, rows.map((row) => el("tr", {}, el("td", {}, row.key), [row.calls, num(row.input), num(row.cached), num(row.output), num(row.reasoning), row.ms ? dur(row.ms) : "—"].map((cell) => el("td", { class: "r" }, cell))))));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ---- unit detail -----------------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
function renderUnit(unit) {
|
|
696
|
+
const steps = unit.runs.flatMap((run) => run.steps);
|
|
697
|
+
const stateBadge = unit.state === "completed" ? ["完成", "good"] : unit.state === "failed" ? ["失败", "bad"]
|
|
698
|
+
: unit.state === "unreported" ? ["日志里没有结果", "warn"] : [unit.state, ""];
|
|
699
|
+
const phaseName = PHASES.find(([id]) => id === groupOf(unit))?.[1];
|
|
700
|
+
const sections = [
|
|
701
|
+
el("h2", {}, unit.label, " ", el("span", { class: `badge ${stateBadge[1]}` }, stateBadge[0]), " ", el("span", { class: "badge" }, phaseName), unit.kind ? [" ", el("span", { class: "badge" }, unit.kind)] : null),
|
|
702
|
+
el("div", { class: "facts" },
|
|
703
|
+
unit.start != null ? el("span", {}, unit.approximate ? "约 " : "", stamp(unit.start)) : el("span", {}, "没有时间信息"),
|
|
704
|
+
el("span", {}, "耗时 ", el("b", {}, dur(unit.wall_ms))),
|
|
705
|
+
el("span", {}, "模型调用 ", el("b", {}, unit.model_calls)),
|
|
706
|
+
unit.steps ? el("span", {}, "步数 ", el("b", {}, unit.steps)) : null,
|
|
707
|
+
unit.media_calls ? el("span", {}, "看片预算用了 ", el("b", {}, unit.media_calls)) : null,
|
|
708
|
+
unit.input ? el("span", {}, "输入 ", el("b", {}, num(unit.input)), ` · 缓存 ${Math.round(unit.cached / unit.input * 100)}%`) : null,
|
|
709
|
+
unit.output ? el("span", {}, "输出 ", el("b", {}, num(unit.output)), ` · 思考 ${num(unit.reasoning)}`) : null,
|
|
710
|
+
unit.log ? el("span", {}, `来自 ${unit.log}`) : null),
|
|
711
|
+
unit.reused ? el("div", { class: "note" }, "这次没有调用模型,日志里只是复述了之前保存的结论。") : null,
|
|
712
|
+
unit.approximate && !unit.reused ? el("div", { class: "note" }, "这条结果没有带时间,开始时间是按日志结束时间倒推的。") : null,
|
|
713
|
+
unit.failure_kind ? el("div", { class: "note bad" }, `失败类型:${unit.failure_kind}${unit.retry ? `(${unit.retry})` : ""}`) : null,
|
|
714
|
+
unit.error ? el("div", { class: "note bad" }, block(unit.error)) : null,
|
|
715
|
+
unit.message?.includes("\n")
|
|
716
|
+
? el("details", { class: "card", open: true }, el("summary", {}, el("b", {}, "Agent 报告"), el("span", { class: "right" }, `${unit.message.length} 字`)), el("div", { class: "body" }, markdown(unit.message)))
|
|
717
|
+
: unit.message ? el("div", { class: "note" }, mdInline(unit.message)) : null,
|
|
718
|
+
];
|
|
719
|
+
if (unit.phase === "episode" && unit.rerun === "retry") {
|
|
720
|
+
sections.push(el("div", { class: "note" }, `上一次审这一集没有完成,这是第 ${unit.pass} 次尝试。`));
|
|
721
|
+
} else if (unit.phase === "episode" && unit.rerun) {
|
|
722
|
+
const where = unit.rerun === "same-process" ? "同一个进程在全剧收敛后又审了一次" : "这一集完成后,另一个进程又审了一次";
|
|
723
|
+
const why = unit.handoffs ? `结论里引用了 ${unit.handoffs} 条全剧交回的问题。`
|
|
724
|
+
: unit.rerun === "same-process" ? "结论里没有引用全剧交回的问题,多半是依据变化触发的(推断,日志没有记录触发原因)。" : "日志没有记录重跑原因。";
|
|
725
|
+
sections.push(el("div", { class: "note warn" }, `${where},这是第 ${unit.pass} 次。${why}`));
|
|
726
|
+
}
|
|
727
|
+
if (unit.phase === "asset" && unit.calls.length && unit.runs.length) {
|
|
728
|
+
sections.push(el("div", { class: "note warn" }, `单次核对的结论是 ${unit.calls.at(-1).verdict ?? "存疑"},所以启动了 agent 继续查。`));
|
|
729
|
+
}
|
|
730
|
+
if (unit.calls.length) {
|
|
731
|
+
sections.push(el("h3", {}, "单次调用", el("small", {}, `${unit.calls.length} 次 · 问一次答一次`)), unit.calls.map((call) => {
|
|
732
|
+
const card = callCard(call);
|
|
733
|
+
if (unit.calls.length === 1) card.open = true;
|
|
734
|
+
return card;
|
|
735
|
+
}));
|
|
736
|
+
}
|
|
737
|
+
if (unit.runs.length) {
|
|
738
|
+
const failureHint = unit.failureRows.some((failure) => failure.messages) ? " 这个单元有失败快照,可以在下面看到真实发给模型的 prompt。" : "";
|
|
739
|
+
sections.push(el("h3", {}, "Agent 步骤", el("small", {}, `${steps.length} 步`)),
|
|
740
|
+
el("div", { class: "label" }, `色块宽度是耗时,高度是这一步的输入 token。trace 只记录工具调用和结果(单条最多 6000 字符),prompt 和思考内容基本没有记录。${failureHint}`));
|
|
741
|
+
for (const [index, run] of unit.runs.entries()) {
|
|
742
|
+
if (unit.runs.length > 1) sections.push(el("h3", {}, `第 ${index + 1} 批`, el("small", {}, run.file)));
|
|
743
|
+
sections.push(strip(run.steps), kindTable(run.steps), run.steps.map((step) => stepRow(step, unit, run.start)));
|
|
744
|
+
}
|
|
745
|
+
} else if (unit.steps && unit.phase !== "deep") {
|
|
746
|
+
sections.push(el("div", { class: "note warn" }, `日志显示这次执行了 ${unit.steps} 步,但 workspace 里没有对应的 trace 文件,只能看到下面的汇总。`));
|
|
747
|
+
}
|
|
748
|
+
if (unit.phase === "deep") {
|
|
749
|
+
sections.push(el("div", { class: "note" }, "人物深审按固定环节调用模型,不走 agent 循环,所以没有逐步记录。下面是各环节的调用量;每集的观察和修订方案在原始文件里。"));
|
|
750
|
+
if (unit.files.length) sections.push(el("h3", {}, "原始文件"), el("div", { class: "facts" }, unit.files.map((file) => el("a", { href: `/api/file?w=${current.id}&p=${encodeURIComponent(file)}`, target: "_blank" }, file.split("/").pop()))));
|
|
751
|
+
}
|
|
752
|
+
if (unit.failureRows.length) sections.push(el("h3", {}, "失败快照", el("small", {}, `${unit.failureRows.length} 个 · 请求失败时保存的现场`)), unit.failureRows.map(failureCard));
|
|
753
|
+
if (unit.findings.length) {
|
|
754
|
+
const order = { unresolved: 0, fixed: 1, unchanged: 2 };
|
|
755
|
+
sections.push(el("h3", {}, "结论", el("small", {}, `${unit.findings.length} 条 · 未决排在前面`)),
|
|
756
|
+
[...unit.findings].sort((a, b) => (order[a.outcome] ?? 3) - (order[b.outcome] ?? 3)).map(findingCard));
|
|
757
|
+
}
|
|
758
|
+
if (unit.usage.length) sections.push(el("details", { open: unit.phase === "deep" }, el("summary", { class: "label" }, "模型调用明细"), usageTable(unit.usage)));
|
|
759
|
+
if (unit.tool_calls && Object.keys(unit.tool_calls).length) {
|
|
760
|
+
sections.push(el("div", { class: "label" }, `工具调用次数:${Object.entries(unit.tool_calls).sort((a, b) => b[1] - a[1]).map(([name, count]) => `${name} ${count}`).join(",")}`));
|
|
761
|
+
}
|
|
762
|
+
$("#right").replaceChildren(...sections.flat(Infinity).filter(Boolean));
|
|
763
|
+
$("#right").scrollTop = 0;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function openUnit(id, updateHash = true) {
|
|
767
|
+
selected = id;
|
|
768
|
+
document.querySelectorAll(".row.selected").forEach((row) => row.classList.remove("selected"));
|
|
769
|
+
const row = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`);
|
|
770
|
+
row?.classList.add("selected");
|
|
771
|
+
if (updateHash) location.hash = `w=${current.id}&u=${encodeURIComponent(id)}`;
|
|
772
|
+
$("#right").replaceChildren(el("p", { class: "empty" }, "加载中…"));
|
|
773
|
+
try { renderUnit(await api(`/api/unit?w=${current.id}&id=${encodeURIComponent(id)}`)); }
|
|
774
|
+
catch (error) { $("#right").replaceChildren(el("div", { class: "note bad" }, error.message)); }
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ---- loading ------------------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
async function loadWorkspaces(preferred) {
|
|
780
|
+
const rows = await api("/api/workspaces");
|
|
781
|
+
$("#ws").replaceChildren(...rows.map((row) => el("option", { value: row.id, selected: row.id === preferred, title: row.path }, row.name)));
|
|
782
|
+
return rows;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function openRun(id, unit) {
|
|
786
|
+
$("#status").textContent = "读取中…";
|
|
787
|
+
try {
|
|
788
|
+
current = await api(`/api/run?w=${id}`);
|
|
789
|
+
$("#ws").value = id;
|
|
790
|
+
selected = unit ?? null;
|
|
791
|
+
renderSummary(current);
|
|
792
|
+
renderLeft(current);
|
|
793
|
+
$("#status").textContent = current.path;
|
|
794
|
+
if (unit) { await openUnit(unit, false); document.querySelector(".row.selected")?.scrollIntoView({ block: "center" }); }
|
|
795
|
+
else {
|
|
796
|
+
location.hash = `w=${id}`;
|
|
797
|
+
$("#right").replaceChildren(el("p", { class: "empty" }, "点左边的任一行,查看它每一步做了什么"));
|
|
798
|
+
}
|
|
799
|
+
} catch (error) { $("#status").textContent = error.message; }
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function parseHash() {
|
|
803
|
+
const params = new URLSearchParams(location.hash.slice(1));
|
|
804
|
+
return { w: params.get("w"), u: params.get("u") };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Pasted links and the back button change only the hash.
|
|
808
|
+
addEventListener("hashchange", () => {
|
|
809
|
+
const { w, u } = parseHash();
|
|
810
|
+
if (w && w !== current?.id) openRun(w, u);
|
|
811
|
+
else if (u && u !== selected) openUnit(u, false);
|
|
812
|
+
});
|
|
813
|
+
$("#ws").addEventListener("change", (event) => openRun(event.target.value));
|
|
814
|
+
$("#reload").addEventListener("click", () => current && openRun(current.id, selected));
|
|
815
|
+
$("#open").addEventListener("submit", async (event) => {
|
|
816
|
+
event.preventDefault();
|
|
817
|
+
const value = $("#path").value.trim();
|
|
818
|
+
if (!value) return;
|
|
819
|
+
try {
|
|
820
|
+
const { id } = await api(`/api/open?path=${encodeURIComponent(value)}`);
|
|
821
|
+
await loadWorkspaces(id);
|
|
822
|
+
$("#path").value = "";
|
|
823
|
+
await openRun(id);
|
|
824
|
+
} catch (error) { $("#status").textContent = error.message; }
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
(async () => {
|
|
828
|
+
const hash = parseHash();
|
|
829
|
+
const rows = await loadWorkspaces(hash.w);
|
|
830
|
+
const id = rows.some((row) => row.id === hash.w) ? hash.w : rows[0]?.id;
|
|
831
|
+
if (id) await openRun(id, hash.w === id ? hash.u : null);
|
|
832
|
+
else $("#status").textContent = "没找到带 .review 的拉片 workspace,可以在上面粘贴路径打开。";
|
|
833
|
+
})();
|
|
834
|
+
</script>
|
|
835
|
+
</body>
|
|
836
|
+
</html>
|