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,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 只读 Web 面板的数据聚合:把 `.pi/evolution/`
|
|
2
|
+
* 只读 Web 面板的数据聚合:把 `.pi/evolution/` 的散落文件拼成五个视图要的形状。
|
|
3
3
|
*
|
|
4
4
|
* 三条纪律:
|
|
5
5
|
* 1. **只读**:这里不写盘、不开监听、不改任何状态 —— 面板永远不能变成第二个写入方;
|
|
@@ -7,60 +7,34 @@
|
|
|
7
7
|
* null / [],绝不因为一条烂记录让整页 500(用户从飞书点进来只想要个概览);
|
|
8
8
|
* 3. **数字不重算**:summary 直接复用 collectPanelState,和 TUI 状态栏同一口径 ——
|
|
9
9
|
* 两处各算一套,迟早出现「TUI 说待批 2、网页说待批 3」。
|
|
10
|
+
*
|
|
11
|
+
* 这里只留**视图形状 + 聚合入口**:四类待处理条目的判定在 ./pending.ts,已处理在 ./done.ts,
|
|
12
|
+
* 待审批 / 已采纳 / 报告各自一个文件 —— 合在一起必然破 300 行闸门。
|
|
10
13
|
*/
|
|
11
|
-
import * as fs from "node:fs";
|
|
12
|
-
import * as path from "node:path";
|
|
13
14
|
import { collectPanelState } from "../panel.ts";
|
|
14
|
-
import { readQueue
|
|
15
|
-
import { listRuns
|
|
16
|
-
import { CHILD_LOG } from "../spawn-plan.ts";
|
|
17
|
-
import { classifyStale, readLogTail } from "../stale.ts";
|
|
18
|
-
import { readRunError } from "../stale.ts";
|
|
19
|
-
import { asNumber, asText } from "./json-read.ts";
|
|
15
|
+
import { readQueue } from "../queue.ts";
|
|
16
|
+
import { listRuns } from "../runs.ts";
|
|
20
17
|
import { buildApplied, type AppliedItem } from "./applied.ts";
|
|
21
18
|
import { buildApprovals, type ApprovalItem } from "./approvals.ts";
|
|
19
|
+
import { buildDone, type DoneItem } from "./done.ts";
|
|
20
|
+
import { buildPending, type PendingItem } from "./pending.ts";
|
|
22
21
|
import { buildReports, type RunReport } from "./reports.ts";
|
|
23
22
|
|
|
24
23
|
export { MAX_FINDING_ROWS } from "./reports.ts";
|
|
25
24
|
export type { FindingRow, RunReport } from "./reports.ts";
|
|
26
25
|
export type { ApprovalItem } from "./approvals.ts";
|
|
27
26
|
export type { AppliedItem } from "./applied.ts";
|
|
27
|
+
export type { PendingItem, PendingKind } from "./pending.ts";
|
|
28
|
+
export type { DoneItem } from "./done.ts";
|
|
29
|
+
export { DONE_LIMIT } from "./done.ts";
|
|
28
30
|
|
|
29
|
-
/** 列表页(技能管理页 `skills`
|
|
31
|
+
/** 列表页(技能管理页 `skills` 与三个详情页 `run` / `skill` / `session` 不在这个联合里,见 filter.ts 的 PANEL_VIEWS) */
|
|
30
32
|
export const WEB_VIEWS = ["pending", "approvals", "applied", "done", "reports"] as const;
|
|
31
33
|
export type WebView = (typeof WEB_VIEWS)[number];
|
|
32
34
|
|
|
33
|
-
/** 已处理列表上限:网页是「翻最近发生了什么」,不是审计日志(审计看 runs/ 目录本身) */
|
|
34
|
-
export const DONE_LIMIT = 50;
|
|
35
35
|
/** 扫描最近多少个 run:卡死 / 待入队这类判断只需要看近期,runs/ 里可能有几百个历史目录 */
|
|
36
36
|
const SCAN_LIMIT = 100;
|
|
37
37
|
|
|
38
|
-
/** 单 run 默认上限:与 DEFAULT_CONFIG.maxRuntimeMs 同值,面板只用来算「上限 N 分钟」 */
|
|
39
|
-
const DEFAULT_MAX_RUNTIME_MS = 20 * 60_000;
|
|
40
|
-
|
|
41
|
-
/** 待处理四来源:正在跑 / 已判卡死 / 队列待批 / 评审通过但还没入队 */
|
|
42
|
-
export type PendingKind = "running" | "approval" | "stale" | "reviewed";
|
|
43
|
-
|
|
44
|
-
export interface PendingItem {
|
|
45
|
-
runId: string;
|
|
46
|
-
kind: PendingKind;
|
|
47
|
-
skill: string;
|
|
48
|
-
at: number | null;
|
|
49
|
-
/** 展开后的纯文本(渲染层负责转义) */
|
|
50
|
-
detail: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface DoneItem {
|
|
54
|
-
runId: string;
|
|
55
|
-
status: string;
|
|
56
|
-
/** queue = 采纳队列(有层级与详情);run = 只有 run.json 的终态(比如 rejected 从未入队) */
|
|
57
|
-
source: "queue" | "run";
|
|
58
|
-
level: string;
|
|
59
|
-
skill: string;
|
|
60
|
-
at: number | null;
|
|
61
|
-
detail: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
38
|
export interface WebSummary {
|
|
65
39
|
reviewing: number;
|
|
66
40
|
stale: number;
|
|
@@ -89,193 +63,6 @@ export interface WebStateDeps {
|
|
|
89
63
|
maxRuntimeMs?: number;
|
|
90
64
|
}
|
|
91
65
|
|
|
92
|
-
function defaultAlive(pid: number): boolean {
|
|
93
|
-
try {
|
|
94
|
-
process.kill(pid, 0); // 信号 0 只探测存在性,不真发信号
|
|
95
|
-
return true;
|
|
96
|
-
} catch {
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** 把毫秒压成「Xm Ys」:面板上人只关心量级,不关心毫秒 */
|
|
102
|
-
function duration(ms: number): string {
|
|
103
|
-
const total = Math.max(0, Math.floor(ms / 1000));
|
|
104
|
-
return `${Math.floor(total / 60)}m ${total % 60}s`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* 「正在跑」那一条:光看 `复盘 1` 这个数字不知道是谁、跑到哪、要不要杀,
|
|
109
|
-
* 所以把阶段 / 进程 / 最近输出 / 看日志的命令四行摊开(用户 2026-11-08:「复盘中我也想看到状态呢」)。
|
|
110
|
-
*/
|
|
111
|
-
function runningItem(
|
|
112
|
-
run: RunSummary,
|
|
113
|
-
evolutionDir: string,
|
|
114
|
-
now: number,
|
|
115
|
-
alive: (pid: number) => boolean,
|
|
116
|
-
maxRuntimeMs: number,
|
|
117
|
-
): PendingItem | null {
|
|
118
|
-
if (run.status !== "dispatched" || run.pid === null || !alive(run.pid)) return null;
|
|
119
|
-
const reviewing = run.supervisorPid !== null && alive(run.supervisorPid);
|
|
120
|
-
const logPath = path.join(evolutionDir, "runs", run.runId, CHILD_LOG);
|
|
121
|
-
let mtime: number | null = null;
|
|
122
|
-
try {
|
|
123
|
-
mtime = fs.statSync(logPath).mtimeMs; // 拿不到就是还没写过日志
|
|
124
|
-
} catch {
|
|
125
|
-
mtime = null;
|
|
126
|
-
}
|
|
127
|
-
const tail = readLogTail(logPath, 1);
|
|
128
|
-
const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
|
|
129
|
-
const progressLine =
|
|
130
|
-
mtime === null
|
|
131
|
-
? "最近输出:(还没有输出)"
|
|
132
|
-
: `最近输出:${Math.max(0, Math.round((now - mtime) / 1000))}s 前 · 末行:${lastLine.length > 0 ? lastLine : "(还没有输出)"}`;
|
|
133
|
-
return {
|
|
134
|
-
runId: run.runId,
|
|
135
|
-
kind: "running",
|
|
136
|
-
skill: run.proposal?.skill ?? "",
|
|
137
|
-
at: run.dispatchedAt,
|
|
138
|
-
detail: [
|
|
139
|
-
`阶段:${reviewing ? "评审中" : "复盘产出中"}`,
|
|
140
|
-
`进程:pid ${run.pid} · 已跑 ${duration(now - (run.dispatchedAt ?? now))}(上限 ${Math.round(maxRuntimeMs / 60_000)} 分钟)`,
|
|
141
|
-
progressLine,
|
|
142
|
-
`日志:tail -f .pi/evolution/runs/${run.runId}/${CHILD_LOG}`,
|
|
143
|
-
].join("\n"),
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** 四类条目的展示优先级:正在跑最上头,然后卡死、待批、待入队 */
|
|
148
|
-
const PENDING_RANK: Record<PendingKind, number> = { running: 0, stale: 1, approval: 2, reviewed: 3 };
|
|
149
|
-
|
|
150
|
-
function buildPending(
|
|
151
|
-
items: readonly QueueItem[],
|
|
152
|
-
runs: readonly RunSummary[],
|
|
153
|
-
evolutionDir: string,
|
|
154
|
-
deps: WebStateDeps = {},
|
|
155
|
-
): PendingItem[] {
|
|
156
|
-
const now = deps.now ?? Date.now();
|
|
157
|
-
const alive = deps.alive ?? defaultAlive;
|
|
158
|
-
const maxRuntimeMs = deps.maxRuntimeMs ?? DEFAULT_MAX_RUNTIME_MS;
|
|
159
|
-
const pending: PendingItem[] = [];
|
|
160
|
-
// ⓪ 正在跑的:有活 pid 就是「进行中」,先列出来(它不是等批也不是失败)
|
|
161
|
-
const running = new Set<string>();
|
|
162
|
-
for (const run of runs) {
|
|
163
|
-
const item = runningItem(run, evolutionDir, now, alive, maxRuntimeMs);
|
|
164
|
-
if (!item) continue;
|
|
165
|
-
running.add(run.runId);
|
|
166
|
-
pending.push(item);
|
|
167
|
-
}
|
|
168
|
-
// ① 队列里待人批的(approval 级;auto 级不需要人,出现 pending 只是还没轮到执行)
|
|
169
|
-
for (const item of items) {
|
|
170
|
-
if (item.status !== "pending" || item.level !== "approval") continue;
|
|
171
|
-
pending.push({
|
|
172
|
-
runId: item.runId,
|
|
173
|
-
kind: "approval",
|
|
174
|
-
skill: item.skill,
|
|
175
|
-
at: asNumber(item.createdAt),
|
|
176
|
-
detail: [
|
|
177
|
-
"层级:approval(必须人批,auto 不会替你执行)",
|
|
178
|
-
`目标:${item.targets.join("、") || "(未写 targets)"}`,
|
|
179
|
-
item.detail ? `备注:${item.detail}` : "",
|
|
180
|
-
`操作:去「待审批」页(?view=approvals)直接点批准 / 拒绝,也可以在这里回 TUI 敲 /skill-evolve approve ${item.runId}`,
|
|
181
|
-
]
|
|
182
|
-
.filter((line) => line.length > 0)
|
|
183
|
-
.join("\n"),
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
// ② 已判定卡死的 run:它是「失败」而不是「等批」,单独一类,别混进待批数字里。
|
|
187
|
-
// 这里必须包含「判据命中、run.json 还没落盘」的那一批:顶部 summary.stale 用的是
|
|
188
|
-
// collectPanelState 的同一口径,少一类就会出现「头顶说卡死 5、列表只有 3 条」。
|
|
189
|
-
for (const run of runs) {
|
|
190
|
-
if (running.has(run.runId)) continue; // 同一个 run 不能既「进行中」又「卡死」
|
|
191
|
-
const persisted = run.status === "stale";
|
|
192
|
-
if (!persisted && !classifyStale(run, now).stale) continue;
|
|
193
|
-
const error = readRunError(path.join(evolutionDir, "runs", run.runId));
|
|
194
|
-
const tail = error?.childLogTail ?? [];
|
|
195
|
-
const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
|
|
196
|
-
pending.push({
|
|
197
|
-
runId: run.runId,
|
|
198
|
-
kind: "stale",
|
|
199
|
-
skill: run.proposal?.skill ?? "",
|
|
200
|
-
at: error?.at ?? run.dispatchedAt,
|
|
201
|
-
detail: [
|
|
202
|
-
`原因:${error?.reason === "timeout" ? "超时(子进程还活着但超过单 run 上限)" : "子进程已退出"}`,
|
|
203
|
-
lastLine.length > 0 ? `日志末行:${lastLine}` : "日志末行:(子进程没留下任何输出)",
|
|
204
|
-
`排查:cat ${path.join(evolutionDir, "runs", run.runId, "run-error.json")}`,
|
|
205
|
-
"不会自动重跑:需要重新复盘就 /skill-evolve run",
|
|
206
|
-
].join("\n"),
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
// ④ 子进程已产出结论、评审还没跑的:不是失败(子进程本来就该退出),属于「等下一轮接上」
|
|
210
|
-
for (const run of runs) {
|
|
211
|
-
if (run.status !== "analyzed" && run.status !== "proposed") continue;
|
|
212
|
-
pending.push({
|
|
213
|
-
runId: run.runId,
|
|
214
|
-
kind: "reviewed",
|
|
215
|
-
skill: run.proposal?.skill ?? "",
|
|
216
|
-
at: run.dispatchedAt,
|
|
217
|
-
detail: [
|
|
218
|
-
`状态:已出结论(${run.status}),评审还没跑`,
|
|
219
|
-
"会自动接上:下次 settle 或 session_start 继续;想立即接上就 /skill-evolve run",
|
|
220
|
-
`证据:cat ${path.join(evolutionDir, "runs", run.runId, "01-findings.json")}`,
|
|
221
|
-
].join("\n"),
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
// ③ 评审通过但还没进队列(入队发生在 settle / session_start,中间可能还没轮到)
|
|
225
|
-
const queued = new Set(items.map((item) => item.runId));
|
|
226
|
-
for (const run of runs) {
|
|
227
|
-
if (run.status !== "reviewed" || queued.has(run.runId)) continue;
|
|
228
|
-
pending.push({
|
|
229
|
-
runId: run.runId,
|
|
230
|
-
kind: "reviewed",
|
|
231
|
-
skill: run.proposal?.skill ?? "",
|
|
232
|
-
at: run.dispatchedAt,
|
|
233
|
-
detail: [
|
|
234
|
-
`评审:${asText(run.review?.verdict, "(未写 verdict)")} · 建议动作 ${asText(run.review?.action, "无")}`,
|
|
235
|
-
"状态:评审已通过,尚未入队(settle / session_start 会自动补)",
|
|
236
|
-
`兜底:/skill-evolve approve ${run.runId}`,
|
|
237
|
-
].join("\n"),
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
return pending.sort((a, b) => PENDING_RANK[a.kind] - PENDING_RANK[b.kind] || (b.at ?? 0) - (a.at ?? 0));
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* 「已处理」收的终态:回滚 / 失败 / 否决 / 被取代。
|
|
245
|
-
* **不含 applied** —— 采纳有自己的页(见 ./applied.ts),两边都收就等于两个 tab 都算一遍。
|
|
246
|
-
*/
|
|
247
|
-
const DONE_STATUSES = new Set(["rolled-back", "failed", "rejected", "superseded"]);
|
|
248
|
-
|
|
249
|
-
function buildDone(items: readonly QueueItem[], runs: readonly RunSummary[]): DoneItem[] {
|
|
250
|
-
const byRun = new Map<string, DoneItem>();
|
|
251
|
-
for (const run of runs) {
|
|
252
|
-
if (!DONE_STATUSES.has(run.status)) continue;
|
|
253
|
-
byRun.set(run.runId, {
|
|
254
|
-
runId: run.runId,
|
|
255
|
-
status: run.status,
|
|
256
|
-
source: "run",
|
|
257
|
-
level: asText(run.review?.action, "-"),
|
|
258
|
-
skill: run.proposal?.skill ?? "",
|
|
259
|
-
at: run.dispatchedAt,
|
|
260
|
-
detail: run.review?.verdict ? `评审:${run.review.verdict}` : "",
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
// 队列记录覆盖同 runId:它有层级(auto/approval)与失败详情,比 run.json 只有状态有用
|
|
264
|
-
for (const item of items) {
|
|
265
|
-
if (!DONE_STATUSES.has(item.status)) continue;
|
|
266
|
-
byRun.set(item.runId, {
|
|
267
|
-
runId: item.runId,
|
|
268
|
-
status: item.status,
|
|
269
|
-
source: "queue",
|
|
270
|
-
level: item.level,
|
|
271
|
-
skill: item.skill,
|
|
272
|
-
at: asNumber(item.createdAt),
|
|
273
|
-
detail: item.detail ?? "",
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
return [...byRun.values()].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, DONE_LIMIT);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
66
|
export function collectWebState(input: { evolutionDir: string } & WebStateDeps): WebState {
|
|
280
67
|
const { evolutionDir, ...deps } = input;
|
|
281
68
|
const items = readQueue(evolutionDir);
|
|
@@ -285,7 +72,7 @@ export function collectWebState(input: { evolutionDir: string } & WebStateDeps):
|
|
|
285
72
|
pending: buildPending(items, runs, evolutionDir, deps),
|
|
286
73
|
approvals: buildApprovals(evolutionDir, items),
|
|
287
74
|
applied: buildApplied(items, evolutionDir),
|
|
288
|
-
done: buildDone(items, runs),
|
|
75
|
+
done: buildDone(items, runs, evolutionDir),
|
|
289
76
|
reports: buildReports(evolutionDir, runs),
|
|
290
77
|
summary: {
|
|
291
78
|
reviewing: panel.reviewing,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「已处理」视图的数据:回滚 / 失败 / 否决 / 被取代这四种终态。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不和「已采纳」混:采纳有自己的页(见 ./applied.ts),两边都收就等于两个 tab 各算一遍。
|
|
5
|
+
* 队列记录会覆盖同一个 runId 的 run.json 版本:它有层级(auto/approval)与失败详情,比只有状态有用。
|
|
6
|
+
*/
|
|
7
|
+
import type { QueueItem } from "../queue.ts";
|
|
8
|
+
import type { RunSummary } from "../runs.ts";
|
|
9
|
+
import { asNumber, asText } from "./json-read.ts";
|
|
10
|
+
import { sessionForRun, sessionIndex, sessionOfRun, type SessionRef } from "./run-session.ts";
|
|
11
|
+
|
|
12
|
+
/** 已处理列表上限:网页是「翻最近发生了什么」,不是审计日志(审计看 runs/ 目录本身) */
|
|
13
|
+
export const DONE_LIMIT = 50;
|
|
14
|
+
|
|
15
|
+
export interface DoneItem {
|
|
16
|
+
runId: string;
|
|
17
|
+
status: string;
|
|
18
|
+
/** queue = 采纳队列(有层级与详情);run = 只有 run.json 的终态(比如 rejected 从未入队) */
|
|
19
|
+
source: "queue" | "run";
|
|
20
|
+
/** 会话信息:行上「会话」列要用(缺 = NO_SESSION) */
|
|
21
|
+
session: SessionRef;
|
|
22
|
+
level: string;
|
|
23
|
+
skill: string;
|
|
24
|
+
at: number | null;
|
|
25
|
+
detail: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 「已处理」收的终态:回滚 / 失败 / 否决 / 被取代。
|
|
30
|
+
* **不含 applied** —— 采纳有自己的页(见 ./applied.ts),两边都收就等于两个 tab 都算一遍。
|
|
31
|
+
*/
|
|
32
|
+
const DONE_STATUSES = new Set(["rolled-back", "failed", "rejected", "superseded"]);
|
|
33
|
+
|
|
34
|
+
export function buildDone(items: readonly QueueItem[], runs: readonly RunSummary[], evolutionDir: string): DoneItem[] {
|
|
35
|
+
const byRun = new Map<string, DoneItem>();
|
|
36
|
+
const sessions = sessionIndex(runs);
|
|
37
|
+
for (const run of runs) {
|
|
38
|
+
if (!DONE_STATUSES.has(run.status)) continue;
|
|
39
|
+
byRun.set(run.runId, {
|
|
40
|
+
runId: run.runId,
|
|
41
|
+
status: run.status,
|
|
42
|
+
source: "run",
|
|
43
|
+
session: sessionOfRun(run),
|
|
44
|
+
level: asText(run.review?.action, "-"),
|
|
45
|
+
skill: run.proposal?.skill ?? "",
|
|
46
|
+
at: run.dispatchedAt,
|
|
47
|
+
detail: run.review?.verdict ? `评审:${run.review.verdict}` : "",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// 队列记录覆盖同 runId:它有层级(auto/approval)与失败详情,比 run.json 只有状态有用
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
if (!DONE_STATUSES.has(item.status)) continue;
|
|
53
|
+
byRun.set(item.runId, {
|
|
54
|
+
runId: item.runId,
|
|
55
|
+
status: item.status,
|
|
56
|
+
source: "queue",
|
|
57
|
+
session: sessionForRun(item.runId, sessions, evolutionDir),
|
|
58
|
+
level: item.level,
|
|
59
|
+
skill: item.skill,
|
|
60
|
+
at: asNumber(item.createdAt),
|
|
61
|
+
detail: item.detail ?? "",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return [...byRun.values()].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, DONE_LIMIT);
|
|
65
|
+
}
|
|
@@ -174,12 +174,12 @@ export function parseView(raw: string | null): WebView {
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
|
-
* 面板上的全部页面:五个列表页 + 技能管理 +
|
|
177
|
+
* 面板上的全部页面:五个列表页 + 技能管理 + 三个详情页。
|
|
178
178
|
*
|
|
179
|
-
* 详情页(run / skill)与技能管理页**需要额外参数**(runId / name / days
|
|
180
|
-
* 也不能靠 `parseView` 认 —— 这是两件事,两个函数各自负责一件事。
|
|
179
|
+
* 详情页(run / skill / session)与技能管理页**需要额外参数**(runId / name / days / sessionId),
|
|
180
|
+
* 所以它们不能进 `VIEW_ORDER`, 也不能靠 `parseView` 认 —— 这是两件事,两个函数各自负责一件事。
|
|
181
181
|
*/
|
|
182
|
-
export const PANEL_VIEWS = [...WEB_VIEWS, "skills", "skill", "run"] as const;
|
|
182
|
+
export const PANEL_VIEWS = [...WEB_VIEWS, "skills", "skill", "run", "session"] as const;
|
|
183
183
|
export type PanelView = (typeof PANEL_VIEWS)[number];
|
|
184
184
|
|
|
185
185
|
/** `?view=` → 全页面名;只认白名单,其余(含 null、`../`)回落待处理页 */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 列表页的三个**显式开关** query:`live`(自动刷新)、`open`(分组是否展开)、`only`(会话页只看对话)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么与 filter.ts 分开:筛选决定「哪些条目出现」,这三个开关决定「同一批条目怎么呈现/何时重载」。
|
|
5
|
+
* 混进 `ListFilter` 会让 `paginate` / `buildViewPage` 每处都得带着两个不相干的字段。
|
|
6
|
+
*
|
|
7
|
+
* 为什么全走 URL 而不是 JS:面板零 JS。用户点过「收起分组」就该在这次刷新后仍然收着 ——
|
|
8
|
+
* 只有 URL 能记住这件事(服务端读它并显式回写链接),localStorage 之类都不在这个面板的可用手段里。
|
|
9
|
+
*/
|
|
10
|
+
import { pageHref, type ListFilter } from "./filter.ts";
|
|
11
|
+
import type { WebView } from "./data.ts";
|
|
12
|
+
|
|
13
|
+
export interface ListExtras {
|
|
14
|
+
/** 5 秒整页自动刷新:只在待处理页生效(其它页数据不会自己变,重载只会丢展开态) */
|
|
15
|
+
live: boolean;
|
|
16
|
+
/** 分组默认展开;`none` 是用户点了「全部收起」 */
|
|
17
|
+
open: "all" | "none";
|
|
18
|
+
/** 会话页只看对话(user 消息 + 助手纯文本),工具调用压成一行 */
|
|
19
|
+
only: "user" | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_EXTRAS: ListExtras = { live: false, open: "all", only: null };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 解析三个开关。**按页面白名单放行**:`live=1` 在已采纳页没有意义,
|
|
26
|
+
* 照单全收会让「?view=done&live=1」也被 5 秒重载一次,用户会以为面板抽风。
|
|
27
|
+
*/
|
|
28
|
+
export function parseListExtras(params: URLSearchParams, view: WebView | "session"): ListExtras {
|
|
29
|
+
return {
|
|
30
|
+
live: view === "pending" && params.get("live") === "1",
|
|
31
|
+
open: params.get("open") === "none" ? "none" : "all",
|
|
32
|
+
only: view === "session" && params.get("only") === "user" ? "user" : null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 翻页 / 刷新 / 收起分组共用的链接拼装:筛选参数由 pageHref 负责,这里只追加三个开关 */
|
|
37
|
+
export function listHref(view: WebView, filter: ListFilter, page: number, extras: ListExtras): string {
|
|
38
|
+
const base = pageHref(view, filter, page);
|
|
39
|
+
const params = new URLSearchParams(base.slice(base.indexOf("?") + 1));
|
|
40
|
+
if (extras.live) params.set("live", "1");
|
|
41
|
+
if (extras.open === "none") params.set("open", "none");
|
|
42
|
+
return `/?${params.toString()}`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown-lite:会话正文里那一小撮「模型天天写、原样输出却很难看」的语法。
|
|
3
|
+
*
|
|
4
|
+
* 为什么只做这几个(用户 2026-11-08:「Markdown 原样输出满屏 `**加粗**`」):
|
|
5
|
+
* pi 的助手回复几乎每段都带 `**粗体**`、`` `路径` ``、`- ` 列表、``` 代码块、`[文字](url)`,
|
|
6
|
+
* 直接 `<pre>` 输出等于把符号也念给人听。但面板不需要完整 Markdown 实现
|
|
7
|
+
* (表格、脚注、嵌套列表一律不做),行数少、行为可预测比「支持得多」重要。
|
|
8
|
+
*
|
|
9
|
+
* **XSS 纪律**:先 `escapeHtml` 再替换语法,替换只插自己写的标签,用户文本永远进不了标签内部;
|
|
10
|
+
* `[文字](url)` 只保留文字、整段丢弃 URL —— 否则 `[x](javascript:alert(1))` 就是一个可点的洞。
|
|
11
|
+
*/
|
|
12
|
+
import { escapeHtml } from "./text.ts";
|
|
13
|
+
|
|
14
|
+
/** 围栏代码块:开口行可带语言名(不渲染高亮,只要别把它当正文) */
|
|
15
|
+
const FENCE = /```[^\n]*\n([\s\S]*?)```/g;
|
|
16
|
+
|
|
17
|
+
/** 占位符用的控制字符:正文转义后不可能出现,所以不会被内容伪造 */
|
|
18
|
+
const SLOT = "\u0000";
|
|
19
|
+
|
|
20
|
+
/** 行内语法:先抠出代码块占位,再处理粗体 / 行内代码 / 链接(顺序不能反,否则代码块里的 `*` 会被当粗体) */
|
|
21
|
+
function inline(escaped: string, blocks: string[]): string {
|
|
22
|
+
const withSlots = escaped.replace(FENCE, (_all, code: string) => {
|
|
23
|
+
blocks.push(`<pre class="code"><code>${code.replace(/\n$/, "")}</code></pre>`);
|
|
24
|
+
return `${SLOT}${blocks.length - 1}${SLOT}`;
|
|
25
|
+
});
|
|
26
|
+
return withSlots
|
|
27
|
+
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
|
28
|
+
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
|
|
29
|
+
.replace(/\[([^\]\n]+)\]\((?:[^()\s]|\([^()\s]*\))*\)/g, "$1");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 把段落里残留的代码块占位换回真区块(代码块夹在句子中间时不会独占一段) */
|
|
33
|
+
function restore(text: string, blocks: string[]): string {
|
|
34
|
+
return text.replace(new RegExp(`${SLOT}(\\d+)${SLOT}`, "g"), (_all, index: string) => blocks[Number(index)] ?? "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 文字与代码块混在一段:按占位符切开,文字段各自包 `<p>`。
|
|
39
|
+
* 不能直接把 `<pre>` 塞进 `<p>`(浏览器会把 `<p>` 提前闭合,后面的文字被踢出正文流)。
|
|
40
|
+
*/
|
|
41
|
+
function mixed(lines: string[], blocks: string[]): string {
|
|
42
|
+
const out: string[] = [];
|
|
43
|
+
let buffer: string[] = [];
|
|
44
|
+
const flush = (): void => {
|
|
45
|
+
const text = buffer.join("\n").trim();
|
|
46
|
+
if (text.length > 0) out.push(`<p>${text.replace(/\n/g, "<br>")}</p>`);
|
|
47
|
+
buffer = [];
|
|
48
|
+
};
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
line.split(new RegExp(`${SLOT}(\\d+)${SLOT}`)).forEach((part, index) => {
|
|
51
|
+
if (index % 2 === 1) {
|
|
52
|
+
flush();
|
|
53
|
+
out.push(blocks[Number(part)] ?? "");
|
|
54
|
+
} else if (part.length > 0) {
|
|
55
|
+
buffer.push(part);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
flush();
|
|
60
|
+
return out.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 一个段落:代码块占位 / 标题 / 列表 / 普通段落(普通段落里的换行保留成 `<br>`) */
|
|
64
|
+
function block(text: string, blocks: string[]): string {
|
|
65
|
+
const slot = new RegExp(`^${SLOT}(\\d+)${SLOT}$`).exec(text);
|
|
66
|
+
if (slot !== null) return blocks[Number(slot[1])] ?? "";
|
|
67
|
+
const heading = /^(#{1,2}) (.*)$/.exec(text);
|
|
68
|
+
if (heading !== null) {
|
|
69
|
+
const level = heading[1] === "#" ? 3 : 4;
|
|
70
|
+
return `<h${level} class="md-h">${restore(heading[2], blocks)}</h${level}>`;
|
|
71
|
+
}
|
|
72
|
+
const lines = text.split("\n");
|
|
73
|
+
if (lines.every((line) => line.startsWith("- "))) {
|
|
74
|
+
return `<ul class="md-ul">${lines.map((line) => `<li>${restore(line.slice(2), blocks)}</li>`).join("")}</ul>`;
|
|
75
|
+
}
|
|
76
|
+
if (text.includes(SLOT)) return mixed(lines, blocks);
|
|
77
|
+
return `<p>${lines.join("<br>")}</p>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 正文 → HTML。空文本给空串(调用方据此判断「这条没有正文,别画空块」),
|
|
82
|
+
* 其余按空行分段,每段单独判定类型。
|
|
83
|
+
*/
|
|
84
|
+
export function renderMarkdownLite(raw: string): string {
|
|
85
|
+
if (raw.trim().length === 0) return "";
|
|
86
|
+
const blocks: string[] = [];
|
|
87
|
+
const text = inline(escapeHtml(raw), blocks);
|
|
88
|
+
return text
|
|
89
|
+
.split(/\n{2,}/)
|
|
90
|
+
.map((part) => part.replace(/^\n+|\n+$/g, ""))
|
|
91
|
+
.filter((part) => part.length > 0)
|
|
92
|
+
.map((part) => block(part, blocks))
|
|
93
|
+
.filter((part) => part.length > 0)
|
|
94
|
+
.join("\n");
|
|
95
|
+
}
|