pi-skill-evolve 0.1.5 → 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/web/list-query.ts +43 -0
- package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
- package/extensions/skill-evolve/lib/web/render-rows.ts +177 -235
- package/extensions/skill-evolve/lib/web/render-session.ts +105 -34
- 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/server.ts +9 -1
- package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
- package/extensions/skill-evolve/lib/web/session-view.ts +100 -84
- package/extensions/skill-evolve/lib/web/shell.ts +42 -11
- package/extensions/skill-evolve/lib/web/style.ts +171 -106
- package/extensions/skill-evolve/lib/web/text.ts +57 -0
- package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
- package/extensions/skill-evolve/test/session-page.test.ts +11 -5
- package/extensions/skill-evolve/test/session-render.test.ts +203 -0
- package/extensions/skill-evolve/test/session-view.test.ts +75 -87
- 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 +30 -32
- package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
- package/package.json +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 列表的表格与分组:五列(状态 / 时间 / 技能 / 提案 / 操作)+ 同会话多条 run 的 `<details>` 分组。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:行数据怎么取(render-rows.ts)与表怎么画是两件事,
|
|
5
|
+
* 表这部分自己就有三块(单行、分组、组标题摘要),挤在映射文件里会破 300 行。
|
|
6
|
+
* 这里只认 `ListRow`,不认识任何具体视图的数据结构。
|
|
7
|
+
*/
|
|
8
|
+
import { sessionHref, type SessionRef } from "./run-session.ts";
|
|
9
|
+
import type { ListExtras } from "./list-query.ts";
|
|
10
|
+
import { escapeHtml, formatClock, formatIso } from "./text.ts";
|
|
11
|
+
import type { ListRow } from "./render-rows.ts";
|
|
12
|
+
|
|
13
|
+
/** 表格骨架:五列固定,列宽由 CSS 管;操作列右对齐,扫行时视线不用来回跳 */
|
|
14
|
+
const TABLE_HEAD = `<thead><tr><th class="status">状态</th><th class="time">时间</th><th class="skill">技能</th><th class="delta">提案</th><th class="ops">操作</th></tr></thead>`;
|
|
15
|
+
|
|
16
|
+
/** 会话小字:单条会话的 run 不进组,所以会话只在这种行的状态格里出现一次 */
|
|
17
|
+
function sessionLine(session: SessionRef): string {
|
|
18
|
+
// 老 run.json 可能没有 sessionId:写「会话 —」而不是凭空造一个空链接(点进去只会 404)
|
|
19
|
+
if (session.sessionId === null) return `<span class="sk-sess">会话 —</span>`;
|
|
20
|
+
const href = sessionHref(session);
|
|
21
|
+
const text = escapeHtml(`会话 ${session.sessionId.slice(0, 8)}`);
|
|
22
|
+
const link = href === null ? text : `<a class="sess" href="${escapeHtml(href)}" title="${escapeHtml(session.sessionId)}">${text}</a>`;
|
|
23
|
+
return `<span class="sk-sess">${link}</span>`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rowHtml(row: ListRow, standalone: boolean, now: number): string {
|
|
27
|
+
const time = row.at === null ? "—" : formatClock(row.at, now);
|
|
28
|
+
const skill = row.skill.length === 0 ? `<span class="off">—</span>` : `<code>${escapeHtml(row.skill)}</code>`;
|
|
29
|
+
const delta =
|
|
30
|
+
row.delta === null ? `<span class="off">—</span>` : `+${row.delta.added} / -${row.delta.removed}`;
|
|
31
|
+
const at = row.at === null ? "" : ` title="${escapeHtml(formatIso(row.at))}"`;
|
|
32
|
+
const badge = `<span class="badge ${row.badge.tone}" title="${escapeHtml(row.badge.title)}">${escapeHtml(row.badge.text)}</span>`;
|
|
33
|
+
return `<tr>
|
|
34
|
+
<td class="status">${badge}${standalone ? sessionLine(row.session) : ""}</td>
|
|
35
|
+
<td class="time"${at}>${time}</td>
|
|
36
|
+
<td class="skill">${skill}</td>
|
|
37
|
+
<td class="delta">${delta}</td>
|
|
38
|
+
<td class="ops">${row.ops}</td>
|
|
39
|
+
</tr>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 一张表:表头固定五列,列宽与省略号由 CSS 管 */
|
|
43
|
+
function table(rows: readonly ListRow[], now: number, standalone: boolean): string {
|
|
44
|
+
return `<table class="rows">
|
|
45
|
+
${TABLE_HEAD}
|
|
46
|
+
<tbody>
|
|
47
|
+
${rows.map((row) => rowHtml(row, standalone, now)).join("\n")}
|
|
48
|
+
</tbody>
|
|
49
|
+
</table>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 一个渲染块:连续的单条 run 合成一张表;同一会话的多条 run 各成一组 */
|
|
53
|
+
export type RowBlock =
|
|
54
|
+
| { kind: "rows"; rows: ListRow[] }
|
|
55
|
+
| { kind: "group"; session: SessionRef; rows: ListRow[] };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 按会话分组:**只有同一 sessionId 出现两次以上才进组**。
|
|
59
|
+
* 单条会话的 run 包进 `<details>` 只会多一次点击(组里就一行),所以直接平铺。
|
|
60
|
+
* 组出现在它第一条成员的位置,成员内部按时间倒序。
|
|
61
|
+
*/
|
|
62
|
+
export function groupRows(rows: readonly ListRow[]): RowBlock[] {
|
|
63
|
+
const counts = new Map<string, number>();
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
const id = row.session.sessionId;
|
|
66
|
+
if (id !== null) counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
67
|
+
}
|
|
68
|
+
const emitted = new Set<string>();
|
|
69
|
+
const blocks: RowBlock[] = [];
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
const id = row.session.sessionId;
|
|
72
|
+
if (id !== null && (counts.get(id) ?? 0) > 1) {
|
|
73
|
+
if (emitted.has(id)) continue;
|
|
74
|
+
emitted.add(id);
|
|
75
|
+
const members = rows
|
|
76
|
+
.filter((item) => item.session.sessionId === id)
|
|
77
|
+
.sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
|
|
78
|
+
blocks.push({ kind: "group", session: row.session, rows: members });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const last = blocks.at(-1);
|
|
82
|
+
if (last !== undefined && last.kind === "rows") last.rows.push(row);
|
|
83
|
+
else blocks.push({ kind: "rows", rows: [row] });
|
|
84
|
+
}
|
|
85
|
+
return blocks;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 组标题一行:`会话 01a1b90f · 9 条 · 最近 19:35 · 1 条需要处理`。
|
|
90
|
+
* 「最近」用组内最新那条的时间;「需要处理」为 0 时写「无待办」—— 组默认展开,标题就是这一组的摘要。
|
|
91
|
+
*/
|
|
92
|
+
function groupSummary(block: { session: SessionRef; rows: ListRow[] }, now: number): string {
|
|
93
|
+
const latest = block.rows.reduce<number | null>((acc, row) => {
|
|
94
|
+
if (row.at === null) return acc;
|
|
95
|
+
return acc === null ? row.at : Math.max(acc, row.at);
|
|
96
|
+
}, null);
|
|
97
|
+
const parts = [`${block.rows.length} 条`];
|
|
98
|
+
if (latest !== null) parts.push(`最近 ${formatClock(latest, now)}`);
|
|
99
|
+
const todo = block.rows.filter((row) => row.needsAction).length;
|
|
100
|
+
parts.push(todo > 0 ? `${todo} 条需要处理` : "无待办");
|
|
101
|
+
const id = block.session.sessionId ?? "—";
|
|
102
|
+
const href = sessionHref(block.session);
|
|
103
|
+
const label =
|
|
104
|
+
href === null
|
|
105
|
+
? escapeHtml(`会话 ${id.slice(0, 8)}`)
|
|
106
|
+
: `<a class="sess" href="${escapeHtml(href)}" title="${escapeHtml(id)}">${escapeHtml(`会话 ${id.slice(0, 8)}`)}</a>`;
|
|
107
|
+
return `${label} · ${parts.join(" · ")}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 列表正文:块与块之间留空,组默认展开(`open`),由 URL 里的 `open=none` 决定要不要收起来。
|
|
112
|
+
* 收起状态只能靠 URL 记住 —— 面板零 JS,`<details>` 自己的状态刷新就没了。
|
|
113
|
+
*/
|
|
114
|
+
export function renderRows(rows: readonly ListRow[], extras: ListExtras, now: number): string {
|
|
115
|
+
const blocks = groupRows(rows);
|
|
116
|
+
const open = extras.open === "none" ? "" : " open";
|
|
117
|
+
return blocks
|
|
118
|
+
.map((block) => {
|
|
119
|
+
if (block.kind === "rows") return table(block.rows, now, true);
|
|
120
|
+
return `<details class="grp"${open}>
|
|
121
|
+
<summary>${groupSummary(block, now)}</summary>
|
|
122
|
+
${table(block.rows, now, false)}
|
|
123
|
+
</details>`;
|
|
124
|
+
})
|
|
125
|
+
.join("\n");
|
|
126
|
+
}
|
|
@@ -1,31 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Web 面板的服务端渲染:纯字符串拼 HTML,不引模板库、不发任何外部请求、不依赖 JS。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 这一版只负责**列表页**:粘性头部(摘要 + tabs + 控制条)→ 横幅 → 筛选 → 表格 → 分页 → 最近操作 → 页脚。
|
|
5
5
|
* 页面骨架(`<head>` / 状态栏 / 六个 tab / 页脚)在 ./shell.ts;
|
|
6
|
-
*
|
|
7
|
-
* 技能管理页在 ./render-skills.ts,run 详情页在 ./render-run.ts ——
|
|
8
|
-
* 拆开是因为合在一起必然破 300 行,而拆开之后每段都能单独看。
|
|
6
|
+
* 列表行与分组在 ./render-rows.ts,控制条/筛选/分页/刷新提示/最近操作在 ./render-controls.ts,
|
|
7
|
+
* 技能管理页在 ./render-skills.ts,run 详情页在 ./render-run.ts —— 拆开是因为合在一起必然破 300 行。
|
|
9
8
|
*
|
|
10
9
|
* 渲染层不读盘:`controls`(开关与最后活动)与 `audit`(最近操作)都由服务端读好传进来。
|
|
11
10
|
* 拿不到开关状态时**不渲染控制条**,而不是猜一个「已开」——顶部写错状态比不写更糟。
|
|
12
11
|
*
|
|
13
|
-
*
|
|
12
|
+
* 刷新策略(用户 2026-11-08 报「待处理一直帮我收起,点开过一会又被收起」):
|
|
13
|
+
* 默认**不**自动刷新,只给「更新于 …· 刷新」;`&live=1` 才是用户显式要的 5 秒重载,
|
|
14
|
+
* 且那时页头会写清「展开态会重置」。整页重载必然丢 `<details>` 展开态,这代价只该由要求它的人承担。
|
|
14
15
|
*/
|
|
15
16
|
import type { AuditEntry } from "./audit-log.ts";
|
|
16
17
|
import type { WebControls } from "./activity.ts";
|
|
17
18
|
import type { WebState, WebView } from "./data.ts";
|
|
19
|
+
import { NO_FILTER, buildViewPage, parseView, type ListFilter, type ViewPage } from "./filter.ts";
|
|
20
|
+
import { DEFAULT_EXTRAS, listHref, type ListExtras } from "./list-query.ts";
|
|
18
21
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
type
|
|
25
|
-
type
|
|
26
|
-
} from "./
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
22
|
+
appliedRows,
|
|
23
|
+
approvalRows,
|
|
24
|
+
doneRows,
|
|
25
|
+
pendingRows,
|
|
26
|
+
reportRows,
|
|
27
|
+
type ListRow,
|
|
28
|
+
type RowContext,
|
|
29
|
+
} from "./render-rows.ts";
|
|
30
|
+
import { groupRows, renderRows } from "./render-table.ts";
|
|
31
|
+
import {
|
|
32
|
+
activityText,
|
|
33
|
+
renderAudit,
|
|
34
|
+
renderControlBar,
|
|
35
|
+
renderFilterForm,
|
|
36
|
+
renderLiveNote,
|
|
37
|
+
renderPagination,
|
|
38
|
+
renderUpdatedAt,
|
|
39
|
+
} from "./render-controls.ts";
|
|
29
40
|
import { VIEW_LABEL, shellPage } from "./shell.ts";
|
|
30
41
|
import { escapeHtml } from "./text.ts";
|
|
31
42
|
|
|
@@ -33,13 +44,31 @@ import { escapeHtml } from "./text.ts";
|
|
|
33
44
|
export { escapeHtml, formatStamp, shortRunId } from "./text.ts";
|
|
34
45
|
export { parseView } from "./filter.ts";
|
|
35
46
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
/**
|
|
48
|
+
* 每个列表页的空态:一句话说「为什么空」+ 一句下一步。
|
|
49
|
+
* 只写「暂无数据」等于把「接下来该敲什么」留给人自己猜,而这里每个空态都对应一个具体动作。
|
|
50
|
+
*/
|
|
51
|
+
export const EMPTY: Record<WebView, { text: string; next: string }> = {
|
|
52
|
+
pending: {
|
|
53
|
+
text: "暂无待处理:所有复盘都已完成,也没有待人批的改动。",
|
|
54
|
+
next: "下一批提案来自真实会话:确认 <code>/skill-evolve telemetry on</code> 与 <code>/skill-evolve dispatch on</code>。",
|
|
55
|
+
},
|
|
56
|
+
approvals: {
|
|
57
|
+
text: "没有待批:auto 级不需要人批,approval 级的会出现在这里。",
|
|
58
|
+
next: "想让提案先来这儿:<code>/skill-evolve level approval</code>。",
|
|
59
|
+
},
|
|
60
|
+
applied: {
|
|
61
|
+
text: "还没有已采纳的提案:采纳过的会连 diff 一起留在这里。",
|
|
62
|
+
next: "先 <code>/skill-evolve dispatch on</code>,让真实会话自动出提案。",
|
|
63
|
+
},
|
|
64
|
+
done: {
|
|
65
|
+
text: "暂无已处理记录:还没有回滚、失败或被否决的提案。",
|
|
66
|
+
next: "只有走完终态的 run 才进这里;在跑的 run 看「待处理」。",
|
|
67
|
+
},
|
|
68
|
+
reports: {
|
|
69
|
+
text: "暂无复盘报告:还没有 run 写过 01-findings.json。",
|
|
70
|
+
next: "先 <code>/skill-evolve run</code> 手动跑一次复盘。",
|
|
71
|
+
},
|
|
43
72
|
};
|
|
44
73
|
|
|
45
74
|
/** 页脚按视图分:只有待审批页能点,其它页要把「去哪批」写清楚 */
|
|
@@ -51,31 +80,58 @@ function footer(view: WebView, canWrite: boolean): string {
|
|
|
51
80
|
}
|
|
52
81
|
return `点「批准」= 在 TUI 敲 <code>/skill-evolve approve <runId></code>,走同一套五道闸;「拒绝」只改队列状态,不动任何技能文件。${api}`;
|
|
53
82
|
}
|
|
54
|
-
return
|
|
55
|
-
|
|
83
|
+
return `本机面板 · 只绑 127.0.0.1 · 无鉴权 · 机器可读副本见 <a href="/api/state">/api/state</a>。`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 五个视图各自的「条目 → 行」映射;字段差异都在各自函数里消化,渲染层只认 ListRow */
|
|
87
|
+
function rowsOf(page: ViewPage, ctx: RowContext): ListRow[] {
|
|
88
|
+
switch (page.view) {
|
|
89
|
+
case "pending":
|
|
90
|
+
return pendingRows(page.rows, ctx);
|
|
91
|
+
case "approvals":
|
|
92
|
+
return approvalRows(page.rows, ctx);
|
|
93
|
+
case "applied":
|
|
94
|
+
return appliedRows(page.rows);
|
|
95
|
+
case "done":
|
|
96
|
+
return doneRows(page.rows);
|
|
97
|
+
default:
|
|
98
|
+
return reportRows(page.rows, ctx);
|
|
99
|
+
}
|
|
56
100
|
}
|
|
57
101
|
|
|
58
102
|
/**
|
|
59
|
-
*
|
|
103
|
+
* 分组开关:只在真的出现了组(同会话 ≥2 条)时给链接 —— 没有组的页面给「收起分组」等于给一个点了没反应的开关。
|
|
104
|
+
* 两个显式链接(`open=all` / `open=none`)优先于默认值,靠 URL 记住,不用 JS。
|
|
105
|
+
*/
|
|
106
|
+
function groupToggle(view: WebView, filter: ListFilter, extras: ListExtras, hasGroup: boolean): string {
|
|
107
|
+
if (!hasGroup) return "";
|
|
108
|
+
const collapsed = extras.open === "none";
|
|
109
|
+
const target = { ...extras, open: collapsed ? ("all" as const) : ("none" as const) };
|
|
110
|
+
const text = collapsed ? "展开全部分组" : "收起全部分组";
|
|
111
|
+
return `<p class="hint"><a href="${escapeHtml(listHref(view, filter, 1, target))}">${text}</a></p>\n`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 列表区:筛选表单 + 表格(或空态)+ 分页。待审批页不给筛选也不分页 ——
|
|
60
116
|
* 它必须在一屏里看全(每个都等着人点),筛掉一部分只会制造「怎么还有没批的」。
|
|
61
117
|
*/
|
|
62
|
-
function listSection(
|
|
118
|
+
function listSection(
|
|
119
|
+
view: WebView,
|
|
120
|
+
page: ViewPage,
|
|
121
|
+
filter: ListFilter,
|
|
122
|
+
extras: ListExtras,
|
|
123
|
+
ctx: RowContext,
|
|
124
|
+
): string {
|
|
125
|
+
const rows = rowsOf(page, ctx);
|
|
63
126
|
const body =
|
|
64
|
-
|
|
65
|
-
?
|
|
66
|
-
:
|
|
67
|
-
|
|
68
|
-
: page.view === "applied"
|
|
69
|
-
? appliedRows(page.rows)
|
|
70
|
-
: page.view === "done"
|
|
71
|
-
? doneRows(page.rows)
|
|
72
|
-
: reportRows(page.rows, ctx);
|
|
73
|
-
const list = body.length === 0 ? `<p class="empty">${EMPTY[view]}</p>` : `<ul>\n${body}\n</ul>`;
|
|
74
|
-
if (view === "approvals") return `<main>\n${list}\n</main>`;
|
|
127
|
+
rows.length === 0
|
|
128
|
+
? `<p class="empty">${EMPTY[view].text}</p>\n<p class="next">${EMPTY[view].next}</p>`
|
|
129
|
+
: `${groupToggle(view, filter, extras, groupRows(rows).some((block) => block.kind === "group"))}${renderRows(rows, extras, ctx.now)}`;
|
|
130
|
+
if (view === "approvals") return `<main>\n${body}\n</main>`;
|
|
75
131
|
return `<main>
|
|
76
|
-
${renderFilterForm(view, filter)}
|
|
77
|
-
${
|
|
78
|
-
${renderPagination(view, page, filter)}
|
|
132
|
+
${renderFilterForm(view, filter, extras)}
|
|
133
|
+
${body}
|
|
134
|
+
${renderPagination(view, page, filter, extras)}
|
|
79
135
|
</main>`;
|
|
80
136
|
}
|
|
81
137
|
|
|
@@ -92,6 +148,8 @@ export interface RenderOptions {
|
|
|
92
148
|
audit?: readonly AuditEntry[] | null;
|
|
93
149
|
/** 列表筛选;不传按默认(全部技能 / 全部状态 / 最近 7 天 / 第 1 页) */
|
|
94
150
|
filter?: ListFilter;
|
|
151
|
+
/** 三个显式开关(自动刷新 / 分组展开);不传就是默认(不刷新、展开) */
|
|
152
|
+
extras?: ListExtras;
|
|
95
153
|
/** 现时(单测注入固定时钟) */
|
|
96
154
|
now?: number;
|
|
97
155
|
}
|
|
@@ -102,23 +160,24 @@ export function renderPage(state: WebState, view: WebView, options: RenderOption
|
|
|
102
160
|
const now = options.now ?? Date.now();
|
|
103
161
|
// 渲染层不自带筛选:不传就是「全部、第 1 页」;产品默认的 7 天由 HTTP 层(parseListQuery)给
|
|
104
162
|
const filter = options.filter ?? NO_FILTER;
|
|
163
|
+
const extras = options.extras ?? DEFAULT_EXTRAS;
|
|
164
|
+
// `live` 只在待处理页有意义:别的页数据不会自己变,给了也忽略(与 parseListExtras 同一口径)
|
|
165
|
+
const live = extras.live && view === "pending";
|
|
105
166
|
const page = buildViewPage(state, view, filter, now);
|
|
106
|
-
const ctx: RowContext = { csrf, writable, view };
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
view === "pending"
|
|
114
|
-
? `<meta http-equiv="refresh" content="5; url=${plainRefresh ? "?view=pending" : escapeHtml(pageHref(view, filter, page.page))}">\n`
|
|
115
|
-
: "";
|
|
167
|
+
const ctx: RowContext = { csrf, writable, view, now };
|
|
168
|
+
const here = listHref(view, filter, page.page, extras);
|
|
169
|
+
// 只有 `&live=1` 的待处理页才 5 秒重载:别的页数据不会自己变,重载只会丢展开态与滚动位置
|
|
170
|
+
const refresh = live ? `<meta http-equiv="refresh" content="5; url=${escapeHtml(here)}">\n` : "";
|
|
171
|
+
const liveNote = live
|
|
172
|
+
? renderLiveNote(listHref(view, filter, page.page, { ...extras, live: false }))
|
|
173
|
+
: "";
|
|
116
174
|
const notice = options.notice
|
|
117
|
-
? `<p class="notice${options.notice.kind === "err" ? " err" : ""}">${escapeHtml(options.notice.text)}</p
|
|
175
|
+
? `<p class="notice${options.notice.kind === "err" ? " err" : ""}">${escapeHtml(options.notice.text)}</p>`
|
|
118
176
|
: "";
|
|
119
177
|
const top = [
|
|
120
|
-
options.controls ? renderControlBar(options.controls, { view,
|
|
121
|
-
|
|
178
|
+
options.controls ? renderControlBar(options.controls, { view, csrf, canWrite: writable }) : "",
|
|
179
|
+
liveNote,
|
|
180
|
+
notice,
|
|
122
181
|
]
|
|
123
182
|
.filter((part) => part.length > 0)
|
|
124
183
|
.map((part) => `${part}\n`)
|
|
@@ -129,8 +188,11 @@ export function renderPage(state: WebState, view: WebView, options: RenderOption
|
|
|
129
188
|
title: VIEW_LABEL[view],
|
|
130
189
|
active: view,
|
|
131
190
|
head: refresh,
|
|
191
|
+
meta: options.controls
|
|
192
|
+
? `${renderUpdatedAt(now, here)} · ${escapeHtml(activityText(options.controls.lastActivityAt, now))}`
|
|
193
|
+
: renderUpdatedAt(now, here),
|
|
132
194
|
top,
|
|
133
|
-
body: listSection(view, page, filter, ctx),
|
|
195
|
+
body: listSection(view, page, filter, extras, ctx),
|
|
134
196
|
foot: `${audit}${footer(view, writable)}`,
|
|
135
197
|
});
|
|
136
198
|
}
|
|
@@ -22,6 +22,7 @@ import { collectControls } from "./activity.ts";
|
|
|
22
22
|
import { readAudit } from "./audit-log.ts";
|
|
23
23
|
import { collectWebState } from "./data.ts";
|
|
24
24
|
import { parseListQuery, parsePanelView } from "./filter.ts";
|
|
25
|
+
import { parseListExtras } from "./list-query.ts";
|
|
25
26
|
import { HTML_TYPE, MAX_BODY_BYTES, TEXT_TYPE, sendText } from "./http.ts";
|
|
26
27
|
import { clearWebPid, isAlive, readWebPid, writeWebPid } from "./panel-pid.ts";
|
|
27
28
|
import { renderPage } from "./render.ts";
|
|
@@ -98,7 +99,12 @@ function renderGet(ctx: RouteContext, url: URL): GetResult {
|
|
|
98
99
|
sessionId,
|
|
99
100
|
...(url.searchParams.get("file") !== null ? { file: url.searchParams.get("file") } : {}),
|
|
100
101
|
});
|
|
101
|
-
|
|
102
|
+
// `only=user` 在服务端过滤(面板零 JS):列表只渲染用户消息与助手纯文本,工具调用压成一行
|
|
103
|
+
return {
|
|
104
|
+
status: 200,
|
|
105
|
+
body: renderSessionPage(source, { state, onlyUser: url.searchParams.get("only") === "user" }),
|
|
106
|
+
type: HTML_TYPE,
|
|
107
|
+
};
|
|
102
108
|
}
|
|
103
109
|
if (view === "run") {
|
|
104
110
|
const runId = url.searchParams.get("runId") ?? "";
|
|
@@ -138,6 +144,8 @@ function renderGet(ctx: RouteContext, url: URL): GetResult {
|
|
|
138
144
|
controls,
|
|
139
145
|
audit: readAudit(ctx.evolutionDir, RECENT_ACTIONS),
|
|
140
146
|
filter: parseListQuery(url.searchParams),
|
|
147
|
+
// 三个显式开关(自动刷新 / 分组展开 / 只看对话的前两个):`live` 按页面白名单放行,仅待处理页生效
|
|
148
|
+
extras: parseListExtras(url.searchParams, view),
|
|
141
149
|
}),
|
|
142
150
|
type: HTML_TYPE,
|
|
143
151
|
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话正文与目录的**渲染**:把 session-view.ts 解析出来的块拼成 HTML 与目录条目。
|
|
3
|
+
*
|
|
4
|
+
* 与解析拆开是因为两件事的行数各自都不小:解析要处理 pi 的 JSONL 细节,
|
|
5
|
+
* 渲染要处理 Markdown、折叠、目录锚点与总输出上限 —— 合在一个文件里必然破 300 行。
|
|
6
|
+
*
|
|
7
|
+
* 三条口径:
|
|
8
|
+
* 1. **正文走 Markdown-lite**:pi 的对话里满是 `**加粗**`、`` `代码` ``、列表,
|
|
9
|
+
* 原样输出等于让人读一堆星号(用户 2026-11-08:「Markdown 原样输出」);
|
|
10
|
+
* 2. **工具块默认收起**,抬头给「次数 + 首个参数摘要」——`bash ×9` 看不出调了什么;
|
|
11
|
+
* 3. **正文与目录一次遍历产出**:锚点 id 与目录 href 因此不会对不上。
|
|
12
|
+
*/
|
|
13
|
+
import { renderMarkdownLite } from "./markdown-lite.ts";
|
|
14
|
+
import {
|
|
15
|
+
MAX_MESSAGES,
|
|
16
|
+
MAX_OUTPUT,
|
|
17
|
+
MAX_TEXT,
|
|
18
|
+
MAX_USER_TEXT,
|
|
19
|
+
type SessionEntry,
|
|
20
|
+
type SessionToolCall,
|
|
21
|
+
type SessionTranscript,
|
|
22
|
+
} from "./session-view.ts";
|
|
23
|
+
import { escapeHtml, formatTime, formatTimeSeconds } from "./text.ts";
|
|
24
|
+
|
|
25
|
+
/** 折叠抬头里的首个参数摘要上限 */
|
|
26
|
+
export const TOOL_PREVIEW = 60;
|
|
27
|
+
/** 目录条目里的用户消息摘要上限 */
|
|
28
|
+
export const TOC_TEXT = 40;
|
|
29
|
+
|
|
30
|
+
/** 截断并加省略号:中文一行话也可能超长,所以在字符数上截,不在行上截 */
|
|
31
|
+
function clip(text: string, max: number): string {
|
|
32
|
+
const trimmed = text.trim();
|
|
33
|
+
return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 工具调用的抬头:`read ×3、bash ×2`(同名的合成一个计数,别写三遍 read) */
|
|
37
|
+
function toolSummary(calls: readonly SessionToolCall[]): string {
|
|
38
|
+
const counts = new Map<string, number>();
|
|
39
|
+
for (const call of calls) counts.set(call.name, (counts.get(call.name) ?? 0) + 1);
|
|
40
|
+
return [...counts.entries()].map(([name, count]) => `${name} ×${count}`).join("、");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 一个工具调用块:名字 + 参数摘要 + 结果前 200 字(全转义,original 里可能带尖括号) */
|
|
44
|
+
function toolBlock(call: SessionToolCall): string {
|
|
45
|
+
return `<div class="tool">
|
|
46
|
+
<p class="tool-name">${escapeHtml(call.name)}</p>
|
|
47
|
+
<pre class="tool-args">${escapeHtml(call.args.length > 0 ? call.args : "(无参数)")}</pre>
|
|
48
|
+
<pre class="tool-result">${escapeHtml(call.result)}</pre>
|
|
49
|
+
</div>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 一组工具调用:`<details>` **默认收起**(不加 `open`)——
|
|
54
|
+
* 一段会话动辄几百次调用,默认展开等于把对话正文顶到十屏之外。
|
|
55
|
+
* 抬头里给「首个参数摘要」:`bash ×9` 看不出调了什么,`rg -n "x" lib` 一眼就知道。
|
|
56
|
+
*/
|
|
57
|
+
function toolGroup(calls: readonly SessionToolCall[]): string {
|
|
58
|
+
const first = calls[0];
|
|
59
|
+
// 摘要按工具取(bash 取 command 第一行、read 取文件名…),取不到就只留 `▸ bash ×9`
|
|
60
|
+
const preview = first === undefined ? "" : clip(first.summary, TOOL_PREVIEW);
|
|
61
|
+
const tail = preview.length === 0 ? "" : ` · <span class="muted">${escapeHtml(preview)}</span>`;
|
|
62
|
+
return `<details class="tools"><summary>▸ ${escapeHtml(toolSummary(calls))}${tail}</summary>
|
|
63
|
+
${calls.map(toolBlock).join("\n")}
|
|
64
|
+
</details>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `only=user` 时工具调用的替身:一行说清省略了多少次,并给「看全部」的链接 */
|
|
68
|
+
function omittedLine(calls: readonly SessionToolCall[], expandHref: string | null): string {
|
|
69
|
+
const more = expandHref === null ? "" : `(<a href="${escapeHtml(expandHref)}">看全部</a>)`;
|
|
70
|
+
return `<p class="hint">… 本轮省略 ${calls.length} 次工具调用${more}</p>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 目录条目:`19:35 · 前 40 字`(换行压成空格,否则目录会被一段代码撑成几行) */
|
|
74
|
+
export interface TocEntry {
|
|
75
|
+
id: string;
|
|
76
|
+
at: number | null;
|
|
77
|
+
text: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface TranscriptView {
|
|
81
|
+
/** 消息流 HTML */
|
|
82
|
+
body: string;
|
|
83
|
+
/** 目录条目(只收 user 消息,顺序与正文一致) */
|
|
84
|
+
toc: TocEntry[];
|
|
85
|
+
/** 实际渲染的块数 */
|
|
86
|
+
shown: number;
|
|
87
|
+
/** 是否撞到总输出上限 */
|
|
88
|
+
truncated: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RenderSessionOptions {
|
|
92
|
+
/** 因「只渲染最后 200 条」丢掉的条数(>0 时在顶部说明一句) */
|
|
93
|
+
dropped?: number;
|
|
94
|
+
/** 只看对话:工具调用压成一行 */
|
|
95
|
+
onlyUser?: boolean;
|
|
96
|
+
/** 「看全部」链接(去掉 only=user 的同一会话地址) */
|
|
97
|
+
expandHref?: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 一条对话消息:徽章 + 时刻 + Markdown-lite 正文(用户底色更亮,扫读时能一眼分段) */
|
|
101
|
+
function messageBlock(
|
|
102
|
+
entry: Extract<SessionEntry, { kind: "user" | "assistant" }>,
|
|
103
|
+
id: string,
|
|
104
|
+
label: { text: string; tone: string },
|
|
105
|
+
): { html: string; text: string } {
|
|
106
|
+
const max = entry.kind === "user" ? MAX_USER_TEXT : MAX_TEXT;
|
|
107
|
+
const body = clip(entry.text, max);
|
|
108
|
+
if (body.length === 0) return { html: "", text: "" };
|
|
109
|
+
const at = entry.at === null ? "" : `<span class="at">${formatTimeSeconds(entry.at)}</span>`;
|
|
110
|
+
const html = `<div class="msg ${entry.kind} m" id="${id}">
|
|
111
|
+
<div class="mhead"><span class="badge ${label.tone}">${label.text}</span>${at}</div>
|
|
112
|
+
<div class="msg-body">${renderMarkdownLite(body)}</div>
|
|
113
|
+
</div>`;
|
|
114
|
+
return { html, text: body.replace(/\s+/g, " ").trim() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 会话正文 + 目录:**一次遍历同时产出两者**,锚点 id 与目录 href 因此不会对不上
|
|
119
|
+
* (分两处各自编号迟早出现「点了目录跳错块」)。
|
|
120
|
+
*/
|
|
121
|
+
export function renderTranscript(
|
|
122
|
+
transcript: SessionTranscript,
|
|
123
|
+
options: RenderSessionOptions = {},
|
|
124
|
+
): TranscriptView {
|
|
125
|
+
const parts: string[] = [];
|
|
126
|
+
const toc: TocEntry[] = [];
|
|
127
|
+
let size = 0;
|
|
128
|
+
let shown = 0;
|
|
129
|
+
let truncated = false;
|
|
130
|
+
if (options.dropped !== undefined && options.dropped > 0) {
|
|
131
|
+
parts.push(
|
|
132
|
+
`<p class="hint">只渲染最后 ${MAX_MESSAGES} 条消息,前面还有 ${options.dropped} 条未显示(看全文请用 rg/read 打开原文件)。</p>`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
for (const entry of transcript.entries) {
|
|
136
|
+
const id = `m-${shown + 1}`;
|
|
137
|
+
const rendered =
|
|
138
|
+
entry.kind === "tools"
|
|
139
|
+
? {
|
|
140
|
+
html:
|
|
141
|
+
options.onlyUser === true ? omittedLine(entry.calls, options.expandHref ?? null) : toolGroup(entry.calls),
|
|
142
|
+
text: "",
|
|
143
|
+
}
|
|
144
|
+
: messageBlock(
|
|
145
|
+
entry,
|
|
146
|
+
id,
|
|
147
|
+
entry.kind === "user" ? { text: "用户", tone: "blue" } : { text: "助手", tone: "green" },
|
|
148
|
+
);
|
|
149
|
+
if (rendered.html.length === 0) continue;
|
|
150
|
+
if (size + rendered.html.length > MAX_OUTPUT) {
|
|
151
|
+
truncated = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
shown += 1;
|
|
155
|
+
parts.push(rendered.html);
|
|
156
|
+
size += rendered.html.length;
|
|
157
|
+
if (entry.kind === "user") {
|
|
158
|
+
toc.push({ id, at: entry.at, text: clip(rendered.text, TOC_TEXT) });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (shown === 0) {
|
|
162
|
+
return {
|
|
163
|
+
body: '<p class="empty">这段会话里没有可渲染的对话(可能全是工具调用与系统提示)。</p>',
|
|
164
|
+
toc,
|
|
165
|
+
shown,
|
|
166
|
+
truncated,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (truncated) {
|
|
170
|
+
parts.push(
|
|
171
|
+
`<p class="hint">已达页面输出上限(${Math.round(MAX_OUTPUT / 1024)}KB),后面还有内容未渲染:继续看请用 rg/read 打开原文件。</p>`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return { body: parts.join("\n"), toc, shown, truncated };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 目录内容(左侧粘性栏与窄屏折叠目录共用同一份):
|
|
179
|
+
* 只列 user 消息 —— 一段会话里用户消息就是天然的「章节」。
|
|
180
|
+
*/
|
|
181
|
+
export function renderToc(toc: readonly TocEntry[]): string {
|
|
182
|
+
if (toc.length === 0) return `<p class="hint">这段会话里没有用户消息。</p>`;
|
|
183
|
+
return toc
|
|
184
|
+
.map((item) => {
|
|
185
|
+
const when = item.at === null ? "—" : formatTime(item.at);
|
|
186
|
+
return `<a href="#${item.id}">${escapeHtml(when)} · ${escapeHtml(item.text)}</a>`;
|
|
187
|
+
})
|
|
188
|
+
.join("\n");
|
|
189
|
+
}
|