pi-skill-evolve 0.1.4 → 0.1.6
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/extensions/skill-evolve/lib/aggregate.ts +153 -0
- package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
- package/extensions/skill-evolve/lib/dispatch.ts +32 -52
- package/extensions/skill-evolve/lib/evolution.ts +1 -0
- package/extensions/skill-evolve/lib/report.ts +4 -1
- package/extensions/skill-evolve/lib/runs.ts +35 -2
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
- package/extensions/skill-evolve/lib/telemetry.ts +2 -0
- package/extensions/skill-evolve/lib/web/applied.ts +4 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
- package/extensions/skill-evolve/lib/web/data.ts +13 -226
- package/extensions/skill-evolve/lib/web/done.ts +65 -0
- package/extensions/skill-evolve/lib/web/filter.ts +4 -4
- package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
- package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
- package/extensions/skill-evolve/lib/web/pending.ts +191 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
- package/extensions/skill-evolve/lib/web/render-rows.ts +178 -205
- package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
- package/extensions/skill-evolve/lib/web/render-session.ts +138 -0
- package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
- package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
- package/extensions/skill-evolve/lib/web/render.ts +116 -54
- package/extensions/skill-evolve/lib/web/reports.ts +11 -5
- package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
- package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
- package/extensions/skill-evolve/lib/web/server.ts +21 -1
- package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
- package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
- package/extensions/skill-evolve/lib/web/session-view.ts +276 -0
- package/extensions/skill-evolve/lib/web/shell.ts +42 -11
- package/extensions/skill-evolve/lib/web/style.ts +171 -89
- package/extensions/skill-evolve/lib/web/text.ts +57 -0
- package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
- package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
- package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
- package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
- package/extensions/skill-evolve/test/session-page.test.ts +200 -0
- package/extensions/skill-evolve/test/session-render.test.ts +203 -0
- package/extensions/skill-evolve/test/session-view.test.ts +180 -0
- package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
- package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
- package/extensions/skill-evolve/test/web-render.test.ts +57 -41
- package/extensions/skill-evolve/test/web-server.test.ts +4 -1
- package/extensions/skill-evolve/test/web-session-column.test.ts +139 -0
- package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
- package/package.json +1 -1
|
@@ -1,113 +1,145 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 列表行的渲染:五种视图(待处理 / 待审批 / 已采纳 / 已处理 /
|
|
2
|
+
* 列表行的渲染:五种视图(待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告)先各自映射成同一份 `ListRow`,
|
|
3
|
+
* 再由这里的表格与分组渲染成 HTML。
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* 为什么分两步:五个视图的数据字段不同(提议数在 applied、verdict 在 approvals、findings 在 reports),
|
|
6
|
+
* 但「一行长什么样」必须完全一致 —— 否则同一个人在两页看到的徽章颜色与操作位置都不一样。
|
|
7
|
+
* 映射(本文件的 `xxxRows`)负责取数与措辞,渲染(./render-table.ts)负责表格、分组。
|
|
8
|
+
* 拆两个文件是因为「取数」与「画表」各自都会长;合在一起必然破 300 行。
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 「看日志」是只读 GET,只读面板也照常给(它不写任何东西)。
|
|
10
|
+
* 三条取舍:
|
|
11
|
+
* 1. **不再把 runId 当主标识**:真实数据里同一个会话的 9 条 run 前 8 位完全一样(`354df7db…`),
|
|
12
|
+
* 拿它当抬头等于每行都在报同一串字符。runId 只留在操作列里,显示末 8 位、`title` 放完整值。
|
|
13
|
+
* 2. **操作是小号链接,不是每行两个大按钮**:一行一条 run,按钮比信息显眼就本末倒置了。
|
|
14
|
+
* 3. **同一会话的多条 run 收进一个 `<details>`**:9 条 run 逐行重复「重跑 / 日志」正是原来列表被压没的原因。
|
|
13
15
|
*/
|
|
14
16
|
import type { AppliedItem, ApprovalItem, DoneItem, PendingItem, RunReport, WebView } from "./data.ts";
|
|
15
|
-
import {
|
|
17
|
+
import type { SessionRef } from "./run-session.ts";
|
|
18
|
+
import { escapeHtml } from "./text.ts";
|
|
16
19
|
|
|
17
20
|
/** 行内操作需要的上下文:csrf 与 canWrite 决定按钮渲不渲染,view 决定动作做完回哪一页 */
|
|
18
21
|
export interface RowContext {
|
|
19
22
|
csrf: string;
|
|
20
23
|
writable: boolean;
|
|
21
24
|
view: WebView;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
/** 现时:决定时间列写 `19:35` 还是 `11-07 18:24` */
|
|
26
|
+
now: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 徽章颜色:同一个概念全站一色(灰=排队、蓝=等人、黄=在跑、绿=落地、红=卡死、暗红=被拒、淡灰=归档) */
|
|
30
|
+
export type BadgeTone = "gray" | "blue" | "green" | "red" | "yellow" | "darkred" | "faint";
|
|
31
|
+
|
|
32
|
+
export interface ListRow {
|
|
33
|
+
runId: string;
|
|
34
|
+
/** 状态徽章:`title` 放不能进表格的次要事实(五闸结论 / 层级 / 目标文件 / 失败原因) */
|
|
35
|
+
badge: { text: string; tone: BadgeTone; title: string };
|
|
36
|
+
/** 该条自己的时间;null 表示读不到(时间列写「—」,不拿别的字段冒充) */
|
|
37
|
+
at: number | null;
|
|
38
|
+
skill: string;
|
|
39
|
+
/** 提案改动行数;没有提案(还没出稿)就是 null,列里写「—」 */
|
|
40
|
+
delta: { type: string; added: number; removed: number } | null;
|
|
41
|
+
session: SessionRef;
|
|
42
|
+
/** 操作列(已转义的 HTML 片段) */
|
|
43
|
+
ops: string;
|
|
44
|
+
/** 要不要人动手:组标题的「N 条需要处理」靠它算 */
|
|
45
|
+
needsAction: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 详情链接:文字是 runId 末 8 位,`title` 是完整 runId(真实数据前 8 位全同,只能靠尾巴区分) */
|
|
49
|
+
function detailLink(runId: string): string {
|
|
34
50
|
const href = escapeHtml(`/?view=run&runId=${encodeURIComponent(runId)}`);
|
|
35
|
-
|
|
36
|
-
return
|
|
51
|
+
// runId 只放 title 与详情页:末 8 位混在操作列里(详情 …94137752 · 重跑 · 日志)看着像乱码
|
|
52
|
+
return `<a class="rid" href="${href}" title="${escapeHtml(runId)}">详情</a>`;
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const text = escapeHtml(head);
|
|
42
|
-
const rendered = runId === undefined ? text : linkRunId(text, runId);
|
|
43
|
-
const inner =
|
|
44
|
-
detail.length === 0
|
|
45
|
-
? `<span class="head">${rendered}</span>`
|
|
46
|
-
: `<details><summary class="head">${rendered}</summary><pre>${escapeHtml(detail)}</pre></details>`;
|
|
47
|
-
return acts.length === 0 ? `<li class="row">${inner}</li>` : `<li class="row">${inner}\n${acts}</li>`;
|
|
55
|
+
function logLink(runId: string): string {
|
|
56
|
+
return `<a href="${escapeHtml(`/log?runId=${encodeURIComponent(runId)}`)}">日志</a>`;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
/** 一个 `/action` 表单:csrf / name / runId / view 全走 hidden,按钮就是提交键 */
|
|
51
|
-
function
|
|
52
|
-
const label = name === "rerun" ? "重跑" : "
|
|
53
|
-
const
|
|
54
|
-
const title = name === "rerun" ? "" : ' title="移到 runs/.trash/,不会删数据"';
|
|
60
|
+
function actionForm(name: "rerun" | "cleanup", runId: string, ctx: RowContext): string {
|
|
61
|
+
const label = name === "rerun" ? "重跑" : "清理";
|
|
62
|
+
const title = name === "rerun" ? "重跑这条 run(花 token)" : "移到 runs/.trash/,不会删数据";
|
|
55
63
|
return `<form method="post" action="/action">
|
|
56
64
|
<input type="hidden" name="csrf" value="${escapeHtml(ctx.csrf)}">
|
|
57
65
|
<input type="hidden" name="name" value="${name}">
|
|
58
66
|
<input type="hidden" name="runId" value="${escapeHtml(runId)}">
|
|
59
67
|
<input type="hidden" name="view" value="${ctx.view}">
|
|
60
|
-
<button type="submit"${
|
|
68
|
+
<button type="submit" title="${title}">${label}</button>
|
|
61
69
|
</form>`;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
73
|
+
* 待处理 / 复盘报告的行操作:详情 + 重跑(+ 卡死时的清理)+ 日志。
|
|
74
|
+
* 清理只给 `stale`:非卡死的 run 还在写盘,把目录移走等于抽掉活进程的 cwd。
|
|
75
|
+
* 重跑只在可写面板出现 —— 只读面板给一个点了没反应的按钮等于骗人。
|
|
67
76
|
*/
|
|
68
|
-
function
|
|
69
|
-
const parts
|
|
77
|
+
function activeOps(runId: string, ctx: RowContext, stale: boolean): string {
|
|
78
|
+
const parts = [detailLink(runId)];
|
|
70
79
|
if (ctx.writable) {
|
|
71
|
-
parts.push(
|
|
72
|
-
if (stale) parts.push(
|
|
80
|
+
parts.push(actionForm("rerun", runId, ctx));
|
|
81
|
+
if (stale) parts.push(actionForm("cleanup", runId, ctx));
|
|
73
82
|
}
|
|
74
|
-
parts.push(
|
|
75
|
-
return
|
|
83
|
+
parts.push(logLink(runId));
|
|
84
|
+
return parts.join(" · ");
|
|
76
85
|
}
|
|
77
86
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
/**
|
|
88
|
+
* 档案类(已采纳 / 已处理)的操作:只有详情与日志。
|
|
89
|
+
* 一个已采纳的 run 旁边放「重跑」,只会让人以为它能重放整个采纳流程。
|
|
90
|
+
*/
|
|
91
|
+
function archiveOps(runId: string): string {
|
|
92
|
+
return `${detailLink(runId)} · ${logLink(runId)}`;
|
|
82
93
|
}
|
|
83
94
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
rowActs(item.runId, ctx, item.kind === "stale"),
|
|
93
|
-
);
|
|
94
|
-
})
|
|
95
|
-
.join("\n");
|
|
95
|
+
/** 一个审批动作:csrf 与 runId 都走 hidden,按钮就是提交键 */
|
|
96
|
+
function approvalForm(action: "approve" | "reject", item: ApprovalItem, csrf: string): string {
|
|
97
|
+
const label = action === "approve" ? "批准" : "拒绝";
|
|
98
|
+
return `<form method="post" action="/${action}">
|
|
99
|
+
<input type="hidden" name="csrf" value="${escapeHtml(csrf)}">
|
|
100
|
+
<input type="hidden" name="runId" value="${escapeHtml(item.runId)}">
|
|
101
|
+
<button type="submit"${action === "reject" ? ' class="reject"' : ""}>${label}</button>
|
|
102
|
+
</form>`;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
.
|
|
105
|
+
/** 待处理:四种 kind 各一个徽章;「待入队」是灰的(它在排队,不需要人动手) */
|
|
106
|
+
export function pendingRows(items: readonly PendingItem[], ctx: RowContext): ListRow[] {
|
|
107
|
+
const badge: Record<PendingItem["kind"], { text: string; tone: BadgeTone }> = {
|
|
108
|
+
running: { text: "复盘中", tone: "yellow" },
|
|
109
|
+
approval: { text: "待审批", tone: "blue" },
|
|
110
|
+
stale: { text: "卡死", tone: "red" },
|
|
111
|
+
reviewed: { text: "待入队", tone: "gray" },
|
|
112
|
+
};
|
|
113
|
+
return items.map((item) => ({
|
|
114
|
+
runId: item.runId,
|
|
115
|
+
badge: { ...badge[item.kind], title: item.detail },
|
|
116
|
+
at: item.at,
|
|
117
|
+
skill: item.skill,
|
|
118
|
+
delta: null,
|
|
119
|
+
session: item.session,
|
|
120
|
+
ops: activeOps(item.runId, ctx, item.kind === "stale"),
|
|
121
|
+
needsAction: item.kind === "stale" || item.kind === "approval",
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 已处理:终态各一色;被拒绝是暗红(是结论,不是故障),失败才是红 */
|
|
126
|
+
export function doneRows(items: readonly DoneItem[]): ListRow[] {
|
|
127
|
+
const tone: Record<string, { text: string; tone: BadgeTone }> = {
|
|
128
|
+
applied: { text: "已采纳", tone: "green" },
|
|
129
|
+
rejected: { text: "已拒绝", tone: "darkred" },
|
|
130
|
+
failed: { text: "失败", tone: "red" },
|
|
131
|
+
rolledback: { text: "已回滚", tone: "faint" },
|
|
132
|
+
};
|
|
133
|
+
return items.map((item) => ({
|
|
134
|
+
runId: item.runId,
|
|
135
|
+
badge: { ...(tone[item.status] ?? { text: item.status, tone: "faint" as BadgeTone }), title: item.detail },
|
|
136
|
+
at: item.at,
|
|
137
|
+
skill: item.skill,
|
|
138
|
+
delta: null,
|
|
139
|
+
session: item.session,
|
|
140
|
+
ops: archiveOps(item.runId),
|
|
141
|
+
needsAction: false,
|
|
142
|
+
}));
|
|
111
143
|
}
|
|
112
144
|
|
|
113
145
|
/** 五闸一句话:没记录时写「无记录」而不是「失败」(判不了 ≠ 没过) */
|
|
@@ -117,136 +149,77 @@ function gateText(item: AppliedItem): string {
|
|
|
117
149
|
return failed === 0 ? "五闸:全 ok" : `五闸:${failed} 项失败`;
|
|
118
150
|
}
|
|
119
151
|
|
|
120
|
-
function appliedDetail(item: AppliedItem): string {
|
|
121
|
-
const gates =
|
|
122
|
-
item.gates.length === 0
|
|
123
|
-
? ["五闸:没有 06-verify.json(无法判定)"]
|
|
124
|
-
: item.gates.map((gate) => ` ${gate.gate} ${gate.name} ${gate.ok ? "✓" : "✗"}`);
|
|
125
|
-
return [
|
|
126
|
-
`层级:${item.level}`,
|
|
127
|
-
`采纳:${formatStamp(item.appliedAt)}`,
|
|
128
|
-
`提案:${item.type} +${item.added}/-${item.removed} 行`,
|
|
129
|
-
...gates,
|
|
130
|
-
item.gatesOk === false ? "注意:有闸没过却仍被采纳,先看 06-verify.json" : "",
|
|
131
|
-
"完整 diff:点上方 runId 进详情页",
|
|
132
|
-
]
|
|
133
|
-
.filter((line) => line.length > 0)
|
|
134
|
-
.join("\n");
|
|
135
|
-
}
|
|
136
|
-
|
|
137
152
|
/** 已采纳:技能 · 提案类型 · +N/-M · 采纳时间 · 五闸结论(用户要的就是这五个字段) */
|
|
138
|
-
export function appliedRows(items: readonly AppliedItem[]):
|
|
139
|
-
return items
|
|
140
|
-
.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
153
|
+
export function appliedRows(items: readonly AppliedItem[]): ListRow[] {
|
|
154
|
+
return items.map((item) => ({
|
|
155
|
+
runId: item.runId,
|
|
156
|
+
badge: { text: "已采纳", tone: "green", title: `${item.level} 级 · ${gateText(item)}` },
|
|
157
|
+
at: item.appliedAt,
|
|
158
|
+
skill: item.skill,
|
|
159
|
+
delta: { type: item.type, added: item.added, removed: item.removed },
|
|
160
|
+
session: item.session,
|
|
161
|
+
ops: archiveOps(item.runId),
|
|
162
|
+
needsAction: false,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 待审批:每条都要人动手,所以整页 `needsAction` 全是 true */
|
|
167
|
+
export function approvalRows(items: readonly ApprovalItem[], ctx: RowContext): ListRow[] {
|
|
168
|
+
return items.map((item) => {
|
|
169
|
+
const ops = ctx.writable
|
|
170
|
+
? `${approvalForm("approve", item, ctx.csrf)} · ${approvalForm("reject", item, ctx.csrf)} · ${detailLink(item.runId)}`
|
|
171
|
+
: detailLink(item.runId);
|
|
172
|
+
const facts = [
|
|
173
|
+
`${item.level} 级(必须人批)`,
|
|
174
|
+
`目标:${item.targets.join("、") || "(未写 targets)"}`,
|
|
175
|
+
item.review ? `评审:verdict=${item.review.verdict ?? "(未写)"} · blocking ${item.review.blocking} 条` : "评审:读不到 03-review.json",
|
|
176
|
+
].join(" · ");
|
|
177
|
+
return {
|
|
178
|
+
runId: item.runId,
|
|
179
|
+
badge: { text: "待审批", tone: "blue", title: facts },
|
|
180
|
+
at: item.createdAt,
|
|
181
|
+
skill: item.skill,
|
|
182
|
+
delta: item.proposal
|
|
183
|
+
? { type: item.proposal.type, added: item.proposal.added, removed: item.proposal.removed }
|
|
184
|
+
: null,
|
|
185
|
+
session: item.session,
|
|
186
|
+
ops,
|
|
187
|
+
needsAction: true,
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 复盘报告:状态文案直接取 run 状态(analyzed 写「已分析」,stale 写「卡死」) */
|
|
193
|
+
export function reportRows(reports: readonly RunReport[], ctx: RowContext): ListRow[] {
|
|
194
|
+
const tone: Record<string, { text: string; tone: BadgeTone }> = {
|
|
195
|
+
analyzed: { text: "已分析", tone: "gray" },
|
|
196
|
+
applied: { text: "已采纳", tone: "green" },
|
|
197
|
+
rejected: { text: "已拒绝", tone: "darkred" },
|
|
198
|
+
running: { text: "复盘中", tone: "yellow" },
|
|
199
|
+
stale: { text: "卡死", tone: "red" },
|
|
200
|
+
failed: { text: "失败", tone: "red" },
|
|
201
|
+
};
|
|
202
|
+
return reports.map((report) => {
|
|
203
|
+
const facts = [
|
|
204
|
+
`触发:${report.trigger}`,
|
|
205
|
+
`findings:${report.findings.total} 条`,
|
|
206
|
+
report.review ? `评审:${report.review.status} · blocking ${report.review.blocking} 条` : "评审:无",
|
|
207
|
+
report.failure ?? "",
|
|
208
|
+
]
|
|
209
|
+
.filter((line) => line.length > 0)
|
|
210
|
+
.join(" · ");
|
|
211
|
+
return {
|
|
212
|
+
runId: report.runId,
|
|
213
|
+
badge: { ...(tone[report.status] ?? { text: report.status, tone: "faint" as BadgeTone }), title: facts },
|
|
214
|
+
at: report.startedAt,
|
|
215
|
+
skill: report.proposal?.skill ?? "",
|
|
216
|
+
delta: report.proposal
|
|
217
|
+
? { type: report.proposal.type, added: report.proposal.added, removed: report.proposal.removed }
|
|
218
|
+
: null,
|
|
219
|
+
session: report.session,
|
|
220
|
+
ops: activeOps(report.runId, ctx, report.status === "stale"),
|
|
221
|
+
needsAction: report.status === "stale",
|
|
222
|
+
};
|
|
223
|
+
});
|
|
158
224
|
}
|
|
159
225
|
|
|
160
|
-
function approvalDetail(item: ApprovalItem): string {
|
|
161
|
-
return [
|
|
162
|
-
`层级:${item.level}(必须人批;auto 级不在这里)`,
|
|
163
|
-
`目标:${item.targets.join("、") || "(未写 targets)"}`,
|
|
164
|
-
`入队:${formatStamp(item.createdAt)}`,
|
|
165
|
-
item.proposal
|
|
166
|
-
? `提案:${item.proposal.type} +${item.proposal.added}/-${item.proposal.removed} 行`
|
|
167
|
-
: "提案:读不到 02-proposal.json",
|
|
168
|
-
item.review ? `评审:verdict=${item.review.verdict ?? "(未写)"} · blocking ${item.review.blocking} 条` : "评审:读不到 03-review.json",
|
|
169
|
-
item.detail ? `备注:${item.detail}` : "",
|
|
170
|
-
]
|
|
171
|
-
.filter((line) => line.length > 0)
|
|
172
|
-
.join("\n");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** 一个审批动作表单:csrf 与 runId 都走 hidden,按钮就是提交键 */
|
|
176
|
-
function actionForm(action: "approve" | "reject", item: ApprovalItem, csrf: string): string {
|
|
177
|
-
const label = action === "approve" ? "批准" : "拒绝";
|
|
178
|
-
const cls = action === "approve" ? "" : ' class="reject"';
|
|
179
|
-
return `<form method="post" action="/${action}">
|
|
180
|
-
<input type="hidden" name="csrf" value="${escapeHtml(csrf)}">
|
|
181
|
-
<input type="hidden" name="runId" value="${escapeHtml(item.runId)}">
|
|
182
|
-
<button type="submit"${cls}>${label}</button>
|
|
183
|
-
</form>`;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export function approvalRows(items: readonly ApprovalItem[], ctx: RowContext): string {
|
|
187
|
-
return items
|
|
188
|
-
.map((item) => {
|
|
189
|
-
const detail = `<details><summary class="head">${escapeHtml(approvalHead(item))}</summary><pre>${escapeHtml(
|
|
190
|
-
withId(item.runId, approvalDetail(item)),
|
|
191
|
-
)}</pre></details>`;
|
|
192
|
-
if (!ctx.writable) return `<li class="row">${detail}</li>`;
|
|
193
|
-
const acts = `<div class="acts">\n${actionForm("approve", item, ctx.csrf)}\n${actionForm("reject", item, ctx.csrf)}\n</div>`;
|
|
194
|
-
return `<li class="row">${detail}\n${acts}</li>`;
|
|
195
|
-
})
|
|
196
|
-
.join("\n");
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function gateLine(report: RunReport): string {
|
|
200
|
-
if (!report.verify) return "验证:还没跑过五闸(未采纳)";
|
|
201
|
-
const marks = report.verify.gates.map((gate) => `${gate.gate}${gate.ok ? "✓" : "✗"}`).join(" ");
|
|
202
|
-
return `验证:${report.verify.status} · ${formatStamp(report.verify.at)} · ${marks || "(未记录闸门)"}`;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/** 报告的展开内容:按「结论 → 依据 → 执行」的顺序排,最后才是数字,方便先看结论 */
|
|
206
|
-
function reportDetail(report: RunReport): string {
|
|
207
|
-
const lines = [`状态:${report.status} · 触发:${report.trigger} · 开始:${formatStamp(report.startedAt)}`];
|
|
208
|
-
// 失败留档先给原因与日志末行:先知道“为什么没有 findings”,再看其它字段
|
|
209
|
-
if (report.failure) lines.push(report.failure);
|
|
210
|
-
const rows = report.findings.rows.map((item) => ` · ${item.skill} · ${item.kind} · ${item.reason}`);
|
|
211
|
-
lines.push(`findings:${report.findings.total} 条`);
|
|
212
|
-
lines.push(...rows);
|
|
213
|
-
if (report.findings.total > report.findings.rows.length) {
|
|
214
|
-
lines.push(` …另有 ${report.findings.total - report.findings.rows.length} 条(见 01-findings.json)`);
|
|
215
|
-
}
|
|
216
|
-
lines.push(
|
|
217
|
-
report.proposal
|
|
218
|
-
? `提案:${report.proposal.type} ${report.proposal.skill} +${report.proposal.added}/-${report.proposal.removed}`
|
|
219
|
-
: "提案:无",
|
|
220
|
-
);
|
|
221
|
-
lines.push(
|
|
222
|
-
report.review
|
|
223
|
-
? `评审:${report.review.status}${report.review.verdict ? ` verdict=${report.review.verdict}` : ""} · blocking ${report.review.blocking} 条`
|
|
224
|
-
: "评审:无",
|
|
225
|
-
);
|
|
226
|
-
lines.push(
|
|
227
|
-
report.decision
|
|
228
|
-
? `决策:${report.decision.status} · ${report.decision.approver} · ${formatStamp(report.decision.at)}${report.decision.detail ? ` · ${report.decision.detail}` : ""}`
|
|
229
|
-
: "决策:未采纳(没有 04-decision.json)",
|
|
230
|
-
);
|
|
231
|
-
lines.push(gateLine(report));
|
|
232
|
-
return lines.join("\n");
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export function reportRows(reports: readonly RunReport[], ctx: RowContext): string {
|
|
236
|
-
return reports
|
|
237
|
-
.map((report) => {
|
|
238
|
-
// 行上就要能看出「有没有东西」:全部折叠成一行状态+时间,等于用户得逐个点开才知道
|
|
239
|
-
const findings = report.findings.total > 0 ? ` · ${report.findings.total} 条发现` : " · 无发现";
|
|
240
|
-
const proposal = report.proposal ? ` · ${report.proposal.type} ${report.proposal.skill}` : "";
|
|
241
|
-
const verdict = report.review?.verdict ? ` · ${report.review.verdict}` : "";
|
|
242
|
-
// 卡死的 run 与其它状态同样重要,但中文标签才有用:行首就是用户扫的关键词
|
|
243
|
-
const label = report.status === "stale" ? "卡死" : report.status;
|
|
244
|
-
return row(
|
|
245
|
-
`[${label}] ${shortRunId(report.runId)}${findings}${proposal}${verdict} · ${formatStamp(report.startedAt)}`,
|
|
246
|
-
withId(report.runId, reportDetail(report)),
|
|
247
|
-
rowActs(report.runId, ctx, report.status === "stale"),
|
|
248
|
-
report.runId,
|
|
249
|
-
);
|
|
250
|
-
})
|
|
251
|
-
.join("\n");
|
|
252
|
-
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { WebState } from "./data.ts";
|
|
10
10
|
import { DIFF_PREVIEW_LINES, renderDiffLines, parseDiffLines } from "./diff-lines.ts";
|
|
11
11
|
import type { RunBlocking, RunDetail } from "./run-detail.ts";
|
|
12
|
+
import { sessionHref } from "./run-session.ts";
|
|
12
13
|
import { shellPage } from "./shell.ts";
|
|
13
14
|
import { escapeHtml, formatStamp } from "./text.ts";
|
|
14
15
|
|
|
@@ -21,6 +22,17 @@ function overview(detail: RunDetail): string {
|
|
|
21
22
|
const log = detail.hasChildLog
|
|
22
23
|
? `<a href="/log?runId=${encodeURIComponent(detail.runId)}">child.log</a>`
|
|
23
24
|
: `<span class="off">没有 child.log</span>`;
|
|
25
|
+
// 会话:完整 sessionId 做成「会话详情」链接(列表行上只有前 8 位,这里给全的)
|
|
26
|
+
const sessionFile = detail.sessionFile ?? null;
|
|
27
|
+
const href = sessionHref({ sessionId: detail.sessionId ?? null, sessionFile });
|
|
28
|
+
const session =
|
|
29
|
+
href === null
|
|
30
|
+
? `<span class="off">run.json 没写 sessionId</span>`
|
|
31
|
+
: `<a href="${escapeHtml(href)}"><code>${escapeHtml(detail.sessionId ?? "")}</code></a> <span class="hint">(点开看会话原文)</span>`;
|
|
32
|
+
const sessionFileCell =
|
|
33
|
+
sessionFile === null
|
|
34
|
+
? `<span class="off">run.json 没写 sessionFile</span>`
|
|
35
|
+
: `<code>${escapeHtml(sessionFile)}</code>`;
|
|
24
36
|
return `<section>
|
|
25
37
|
<h2>概要</h2>
|
|
26
38
|
<div class="kv">
|
|
@@ -28,6 +40,8 @@ ${kv("runId", `<code>${escapeHtml(detail.runId)}</code>`)}
|
|
|
28
40
|
${kv("状态", escapeHtml(detail.status))}
|
|
29
41
|
${kv("技能", escapeHtml(detail.skill.length > 0 ? detail.skill : "—"))}
|
|
30
42
|
${kv("触发", escapeHtml(detail.trigger))}
|
|
43
|
+
${kv("会话", session)}
|
|
44
|
+
${kv("会话文件", sessionFileCell)}
|
|
31
45
|
${kv("开始", escapeHtml(formatStamp(detail.startedAt)))}
|
|
32
46
|
${kv("结束", escapeHtml(formatStamp(detail.endedAt)) + (detail.endedAt === null ? "" : ' <span class="hint">(取最后一个产出时刻)</span>'))}
|
|
33
47
|
${kv("pid", detail.pid === null ? "—" : String(detail.pid))}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话详情页(`?view=session&sessionId=...`)的渲染:顶部一行 + 概览 + 目录 + 消息流 + 出口。
|
|
3
|
+
*
|
|
4
|
+
* 这一页的四件东西(用户 2026-11-08 现场反馈):
|
|
5
|
+
* 1. **顶部一行**:`← 返回待处理` / `会话 01a1b90f` / 原文件路径 / `跳到最新 ↓` —— 原来没有返回入口,进来就出不去;
|
|
6
|
+
* 2. **左侧粘性目录**(`>=1200px`):会话动辄几千行,一路往下滚太累;窄屏改用顶部折叠目录兜底;
|
|
7
|
+
* 3. **只看对话**(`?only=user`):只在服务端过滤,工具调用压成一行(不引 JS);
|
|
8
|
+
* 4. **底部出口**:说明渲染了多少条、还剩多少没显示、原文件在哪 —— 面板解析不了的,一键回到原文件。
|
|
9
|
+
*
|
|
10
|
+
* 找不到会话文件不报错:换机器 / 清理过 sessions 是常态,说清「会话文件已不在本机」比 500 有用。
|
|
11
|
+
*/
|
|
12
|
+
import type { WebState } from "./data.ts";
|
|
13
|
+
import { renderToc, renderTranscript } from "./session-render.ts";
|
|
14
|
+
import type { SessionSource } from "./session-file.ts";
|
|
15
|
+
import { MAX_MESSAGES } from "./session-view.ts";
|
|
16
|
+
import { shellPage } from "./shell.ts";
|
|
17
|
+
import { escapeHtml, formatSpan } from "./text.ts";
|
|
18
|
+
|
|
19
|
+
/** 大小:字节 / KB / MB 三档,MB 给两位小数(5MB 级会话差 0.5MB reader 也看得出) */
|
|
20
|
+
function formatSize(bytes: number): string {
|
|
21
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
22
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
|
|
23
|
+
return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 本页自己的地址:`sessionId` + 可选 `file` + 可选 `only=user`(全部走编码后的枚举值) */
|
|
27
|
+
function sessionUrl(sessionId: string, file: string | null, onlyUser: boolean): string {
|
|
28
|
+
const params = new URLSearchParams({ view: "session", sessionId });
|
|
29
|
+
if (file !== null) params.set("file", file);
|
|
30
|
+
if (onlyUser) params.set("only", "user");
|
|
31
|
+
return `/?${params.toString()}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 缺文件时的正文:给三句人话,说明为什么没有、去哪里看 */
|
|
35
|
+
function missingBody(sessionId: string): string {
|
|
36
|
+
return `<p class="empty">会话文件已不在本机:sessionId <code>${escapeHtml(sessionId)}</code> 在
|
|
37
|
+
<code>~/.pi/agent/sessions/*</code> 下没有对应的 <code>.jsonl</code>。
|
|
38
|
+
可能是换了机器、或 sessions 目录被清理过 —— 这一段对话的原文已经取不到了。</p>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 顶部一行:返回入口 + 会话标识 + 原文件 + 跳到最新(长路径自己换行,不挤掉别的元素) */
|
|
42
|
+
function topRow(source: SessionSource, lastId: string | null): string {
|
|
43
|
+
const id = escapeHtml(source.sessionId.slice(0, 8));
|
|
44
|
+
const file =
|
|
45
|
+
source.file === null
|
|
46
|
+
? `<span class="off">会话文件已不在本机</span>`
|
|
47
|
+
: `<code title="${escapeHtml(source.file)}">${escapeHtml(source.file)}</code>`;
|
|
48
|
+
const toEnd = lastId === null ? "" : `<a class="to-end" href="#${lastId}">跳到最新 ↓</a>`;
|
|
49
|
+
return `<div class="ses-top">
|
|
50
|
+
<a href="/?view=pending&range=all">← 返回待处理</a>
|
|
51
|
+
<span class="ses-id">会话 <code>${id}</code></span>
|
|
52
|
+
<span class="ses-file">原文件 ${file}</span>
|
|
53
|
+
${toEnd}
|
|
54
|
+
</div>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 概览卡片:消息数 / 工具调用 / 大小 / 时间跨度 + 「只看对话」切换 */
|
|
58
|
+
function overview(
|
|
59
|
+
source: SessionSource,
|
|
60
|
+
toggle: string,
|
|
61
|
+
onlyUser: boolean,
|
|
62
|
+
): string {
|
|
63
|
+
const transcript = source.transcript;
|
|
64
|
+
if (transcript === null) return "";
|
|
65
|
+
const span = formatSpan(transcript.firstAt, transcript.lastAt);
|
|
66
|
+
const parts = [
|
|
67
|
+
`消息 ${transcript.messages}`,
|
|
68
|
+
`工具调用 ${transcript.toolCalls}`,
|
|
69
|
+
formatSize(source.sizeBytes),
|
|
70
|
+
];
|
|
71
|
+
if (span.length > 0) parts.push(span);
|
|
72
|
+
return `<p class="ov">${parts.join(" · ")} <span class="ov-link">${toggle}${onlyUser ? "(当前只看对话)" : ""}</span></p>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 正文骨架:左侧粘性目录(宽屏)+ 消息流;窄屏把同一份目录换成顶部折叠块。
|
|
77
|
+
* 目录与消息流共用 `renderTranscript` 的产出,锚点 id 与 href 一一对应。
|
|
78
|
+
*/
|
|
79
|
+
function streamOf(source: SessionSource, onlyUser: boolean): { html: string; lastId: string | null } {
|
|
80
|
+
const transcript = source.transcript;
|
|
81
|
+
if (transcript === null) return { html: missingBody(source.sessionId), lastId: null };
|
|
82
|
+
const view = renderTranscript(transcript, {
|
|
83
|
+
dropped: transcript.dropped,
|
|
84
|
+
onlyUser,
|
|
85
|
+
expandHref: sessionUrl(source.sessionId, source.file, false),
|
|
86
|
+
});
|
|
87
|
+
const toc = renderToc(view.toc);
|
|
88
|
+
const lastId = view.shown === 0 ? null : `m-${view.shown}`;
|
|
89
|
+
return {
|
|
90
|
+
html: `<div class="ses-wrap">
|
|
91
|
+
<aside class="toc">${toc}</aside>
|
|
92
|
+
<div class="ses-body">
|
|
93
|
+
<details class="toc-fold"><summary>目录(${view.toc.length} 条用户消息)</summary>
|
|
94
|
+
<div class="toc-list">${toc}</div>
|
|
95
|
+
</details>
|
|
96
|
+
${view.body}
|
|
97
|
+
${lastId === null ? "" : `<a class="to-top" href="#top">↑ 回到顶部</a>`}
|
|
98
|
+
</div>
|
|
99
|
+
</div>`,
|
|
100
|
+
lastId,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 底部出口:渲染了多少、还剩多少、原文件在哪(面板解析不了时全靠这一行) */
|
|
105
|
+
function exitLine(source: SessionSource): string {
|
|
106
|
+
if (source.file === null || source.transcript === null) {
|
|
107
|
+
return `原文件:<span class="off">会话文件已不在本机</span>`;
|
|
108
|
+
}
|
|
109
|
+
const { dropped, messages } = source.transcript;
|
|
110
|
+
const file = `<code>${escapeHtml(source.file)}</code>`;
|
|
111
|
+
if (dropped > 0) {
|
|
112
|
+
return `已渲染最后 ${MAX_MESSAGES} 条 · 前 ${dropped} 条未显示(原文件 ${file},共 ${messages} 条消息)`;
|
|
113
|
+
}
|
|
114
|
+
return `已渲染全部 ${messages} 条消息(原文件 ${file})`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function renderSessionPage(source: SessionSource, options: { state: WebState; onlyUser?: boolean }): string {
|
|
118
|
+
const onlyUser = options.onlyUser === true;
|
|
119
|
+
const transcript = source.transcript;
|
|
120
|
+
const toggle =
|
|
121
|
+
transcript === null
|
|
122
|
+
? ""
|
|
123
|
+
: onlyUser
|
|
124
|
+
? `<a href="${escapeHtml(sessionUrl(source.sessionId, source.file, false))}">看全部</a>`
|
|
125
|
+
: `<a href="${escapeHtml(sessionUrl(source.sessionId, source.file, true))}">只看对话</a>`;
|
|
126
|
+
// 先渲染消息流:「跳到最新」要指向**实际渲染出的最后一块**,而不是 entries 条数(工具块与空正文都不占块号)
|
|
127
|
+
const stream = streamOf(source, onlyUser);
|
|
128
|
+
const body = `${topRow(source, stream.lastId)}
|
|
129
|
+
${overview(source, toggle, onlyUser)}
|
|
130
|
+
${stream.html}`;
|
|
131
|
+
return shellPage({
|
|
132
|
+
state: options.state,
|
|
133
|
+
title: `会话 ${source.sessionId.slice(0, 8)}`,
|
|
134
|
+
active: null,
|
|
135
|
+
body,
|
|
136
|
+
foot: exitLine(source),
|
|
137
|
+
});
|
|
138
|
+
}
|