@keyou007/agent-bot 0.1.24-alpha.2 → 0.1.24-alpha.4
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/CHANGELOG.md +103 -0
- package/README.md +33 -5
- package/README.zh.md +32 -5
- package/dist/acp/AcpJsonRpcConnection.js +3 -1
- package/dist/acp/AcpJsonRpcConnection.js.map +1 -1
- package/dist/acp/AcpSessionManager.js +0 -14
- package/dist/acp/AcpSessionManager.js.map +1 -1
- package/dist/acp/acpTypes.js.map +1 -1
- package/dist/cli/SelfUpdateRunner.js.map +1 -1
- package/dist/cli/SelfUpdater.js +2 -0
- package/dist/cli/SelfUpdater.js.map +1 -1
- package/dist/codex/AppServerConnection.js +3 -1
- package/dist/codex/AppServerConnection.js.map +1 -1
- package/dist/codex/CodexEventMapper.js +28 -1
- package/dist/codex/CodexEventMapper.js.map +1 -1
- package/dist/codex/CodexRuntime.js +92 -0
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/commands/CommandRouter.js +5 -3
- package/dist/commands/CommandRouter.js.map +1 -1
- package/dist/feishu/CardRenderer.js +45 -23
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +2 -0
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuMessageClient.js +44 -0
- package/dist/feishu/FeishuMessageClient.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +114 -37
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/feishu/RecentMessageContext.js +70 -0
- package/dist/feishu/RecentMessageContext.js.map +1 -0
- package/dist/index.js +62 -24
- package/dist/index.js.map +1 -1
- package/dist/local-files/DiagramPreview.js +171 -0
- package/dist/local-files/DiagramPreview.js.map +1 -0
- package/dist/local-files/LocalFileViewerServer.js +141 -3
- package/dist/local-files/LocalFileViewerServer.js.map +1 -1
- package/dist/local-files/PreviewSyntaxHighlight.js +57 -0
- package/dist/local-files/PreviewSyntaxHighlight.js.map +1 -0
- package/dist/local-files/TurnPreviewPage.js +497 -62
- package/dist/local-files/TurnPreviewPage.js.map +1 -1
- package/dist/presentation/OutboundRouter.js +18 -9
- package/dist/presentation/OutboundRouter.js.map +1 -1
- package/dist/presentation/TurnStateReducer.js +155 -6
- package/dist/presentation/TurnStateReducer.js.map +1 -1
- package/dist/presentation/turnReasoning.js +23 -0
- package/dist/presentation/turnReasoning.js.map +1 -0
- package/dist/proxy/ProxySessionController.js +466 -38
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/state/StateStore.js +43 -2
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/TurnPreviewJournal.js +469 -0
- package/dist/state/TurnPreviewJournal.js.map +1 -0
- package/dist/state/migrations.js +10 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/updates/AutomaticUpdatePreparer.js +16 -7
- package/dist/updates/AutomaticUpdatePreparer.js.map +1 -1
- package/dist/updates/ManualUpdateController.js +102 -0
- package/dist/updates/ManualUpdateController.js.map +1 -0
- package/dist/updates/PublishedRelease.js +42 -4
- package/dist/updates/PublishedRelease.js.map +1 -1
- package/dist/utils/ProtocolLineWarning.js +15 -0
- package/dist/utils/ProtocolLineWarning.js.map +1 -0
- package/docs/technical-reference.md +21 -1
- package/docs/technical-reference.zh.md +21 -1
- package/npm-shrinkwrap.json +1401 -10
- package/package.json +3 -1
- package/skills/agent-bot/SKILL.md +12 -1
- package/src/acp/AcpJsonRpcConnection.ts +3 -1
- package/src/acp/AcpSessionManager.ts +0 -17
- package/src/acp/acpTypes.ts +0 -1
- package/src/cli/SelfUpdateRunner.ts +1 -0
- package/src/cli/SelfUpdater.ts +2 -0
- package/src/codex/AppServerConnection.ts +3 -1
- package/src/codex/CodexEventMapper.ts +29 -1
- package/src/codex/CodexRuntime.ts +87 -0
- package/src/commands/CommandRouter.ts +4 -3
- package/src/commands/commandTypes.ts +1 -0
- package/src/feishu/CardRenderer.ts +43 -21
- package/src/feishu/FeishuConnector.ts +2 -0
- package/src/feishu/FeishuMessageClient.ts +44 -0
- package/src/feishu/FeishuTurnPresenter.ts +107 -31
- package/src/feishu/RecentMessageContext.ts +90 -0
- package/src/feishu/types.ts +5 -0
- package/src/index.ts +62 -22
- package/src/local-files/DiagramPreview.ts +173 -0
- package/src/local-files/LocalFileViewerServer.ts +129 -2
- package/src/local-files/PreviewSyntaxHighlight.ts +58 -0
- package/src/local-files/TurnPreviewPage.ts +537 -59
- package/src/presentation/OutboundRouter.ts +25 -7
- package/src/presentation/TurnStateReducer.ts +151 -7
- package/src/presentation/turnReasoning.ts +21 -0
- package/src/presentation/turnViewTypes.ts +19 -1
- package/src/proxy/ProxySessionController.ts +457 -27
- package/src/runtime/types.ts +21 -0
- package/src/state/StateStore.ts +45 -2
- package/src/state/TurnPreviewJournal.ts +398 -0
- package/src/state/migrations.ts +10 -0
- package/src/updates/AutomaticUpdatePreparer.ts +21 -7
- package/src/updates/ManualUpdateController.ts +116 -0
- package/src/updates/PublishedRelease.ts +49 -3
- package/src/utils/ProtocolLineWarning.ts +13 -0
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import path from "node:path";
|
|
3
|
+
import { enableDiagramFences, DIAGRAM_PREVIEW_CLIENT_SCRIPT, DIAGRAM_PREVIEW_CSS } from "./DiagramPreview.js";
|
|
2
4
|
import MarkdownIt from "markdown-it";
|
|
5
|
+
import { highlightPreviewCode, PREVIEW_SYNTAX_CSS } from "./PreviewSyntaxHighlight.js";
|
|
3
6
|
import type { ToolState } from "../runtime/types.js";
|
|
4
|
-
import type { FileSummary, TurnActivity, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
|
|
7
|
+
import type { FileSummary, TurnActivity, TurnReasoningItem, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
|
|
5
8
|
import { displayFilePath, displayToolCommand, formatShellCommandForDisplay, toolStatusIcon } from "../feishu/CardRenderer.js";
|
|
9
|
+
import { turnReasoningItems } from "../presentation/turnReasoning.js";
|
|
6
10
|
import { isFileUrl, rewriteMarkdownFileLinks } from "./MarkdownFileLinks.js";
|
|
7
11
|
|
|
8
12
|
const MARKDOWN = new MarkdownIt({
|
|
@@ -10,7 +14,9 @@ const MARKDOWN = new MarkdownIt({
|
|
|
10
14
|
html: false,
|
|
11
15
|
linkify: true,
|
|
12
16
|
typographer: false,
|
|
17
|
+
highlight: highlightPreviewCode,
|
|
13
18
|
});
|
|
19
|
+
enableDiagramFences(MARKDOWN);
|
|
14
20
|
const validateLink = MARKDOWN.validateLink.bind(MARKDOWN);
|
|
15
21
|
MARKDOWN.validateLink = (url) => isFileUrl(url) || validateLink(url);
|
|
16
22
|
const defaultLinkOpen = MARKDOWN.renderer.rules.link_open
|
|
@@ -33,6 +39,7 @@ const PREVIEW_LABELS = {
|
|
|
33
39
|
unknown: "未知",
|
|
34
40
|
plan: "计划",
|
|
35
41
|
error: "错误",
|
|
42
|
+
reasoning: "思考",
|
|
36
43
|
finalAnswer: "最终回答",
|
|
37
44
|
generating: "回答生成中",
|
|
38
45
|
command: "命令",
|
|
@@ -47,7 +54,6 @@ const PREVIEW_LABELS = {
|
|
|
47
54
|
tools: "个工具",
|
|
48
55
|
turnTokens: "本轮",
|
|
49
56
|
totalTokens: "总计",
|
|
50
|
-
cachedTokens: "缓存命中",
|
|
51
57
|
nonCachedTokens: "非缓存",
|
|
52
58
|
compactionTokens: "压缩",
|
|
53
59
|
compactionAfterTokens: "压缩后",
|
|
@@ -56,7 +62,7 @@ const PREVIEW_LABELS = {
|
|
|
56
62
|
statuses: {
|
|
57
63
|
starting: "正在启动",
|
|
58
64
|
running: "正在处理",
|
|
59
|
-
tool_running: "
|
|
65
|
+
tool_running: "正在处理",
|
|
60
66
|
waiting_for_approval: "等待确认",
|
|
61
67
|
completed: "已完成",
|
|
62
68
|
cancelled: "已停止",
|
|
@@ -74,6 +80,7 @@ const PREVIEW_LABELS = {
|
|
|
74
80
|
unknown: "Unknown",
|
|
75
81
|
plan: "Plan",
|
|
76
82
|
error: "Error",
|
|
83
|
+
reasoning: "Reasoning",
|
|
77
84
|
finalAnswer: "Final answer",
|
|
78
85
|
generating: "Generating answer",
|
|
79
86
|
command: "Command",
|
|
@@ -88,7 +95,6 @@ const PREVIEW_LABELS = {
|
|
|
88
95
|
tools: "tools",
|
|
89
96
|
turnTokens: "Turn",
|
|
90
97
|
totalTokens: "Total",
|
|
91
|
-
cachedTokens: "Cache hit",
|
|
92
98
|
nonCachedTokens: "Non-cached",
|
|
93
99
|
compactionTokens: "Compaction",
|
|
94
100
|
compactionAfterTokens: "After compaction",
|
|
@@ -97,7 +103,7 @@ const PREVIEW_LABELS = {
|
|
|
97
103
|
statuses: {
|
|
98
104
|
starting: "Starting",
|
|
99
105
|
running: "Processing",
|
|
100
|
-
tool_running: "
|
|
106
|
+
tool_running: "Processing",
|
|
101
107
|
waiting_for_approval: "Waiting for approval",
|
|
102
108
|
completed: "Completed",
|
|
103
109
|
cancelled: "Stopped",
|
|
@@ -128,13 +134,13 @@ export function detectTurnPreviewLanguage(
|
|
|
128
134
|
return /^zh(?:[-_]|$)/iu.test(candidates[0]?.tag ?? "") ? "zh" : "en";
|
|
129
135
|
}
|
|
130
136
|
|
|
131
|
-
export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
|
|
137
|
+
export const TURN_PREVIEW_CLIENT_SCRIPT = DIAGRAM_PREVIEW_CLIENT_SCRIPT + `(() => {
|
|
132
138
|
const eventsUrl = document.body.dataset.eventsUrl;
|
|
133
139
|
const content = document.getElementById("turn-content");
|
|
134
140
|
const metadata = document.getElementById("turn-metadata");
|
|
135
141
|
const status = document.getElementById("turn-status");
|
|
136
142
|
const live = document.getElementById("turn-live");
|
|
137
|
-
if (!eventsUrl || !content || !metadata || !status || !live
|
|
143
|
+
if (!eventsUrl || !content || !metadata || !status || !live) return;
|
|
138
144
|
|
|
139
145
|
const isChinese = /^zh/i.test(document.documentElement.lang);
|
|
140
146
|
const labels = {
|
|
@@ -143,9 +149,13 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
|
|
|
143
149
|
waitingForUpdates: isChinese ? "等待更新" : "Waiting for updates",
|
|
144
150
|
unavailable: isChinese ? "暂时不可用" : "Temporarily unavailable",
|
|
145
151
|
reconnecting: isChinese ? "正在重连" : "Reconnecting",
|
|
152
|
+
loading: isChinese ? "正在加载…" : "Loading…",
|
|
153
|
+
loadFailed: isChinese ? "加载失败,请重试。" : "Could not load details.",
|
|
154
|
+
retry: isChinese ? "重试" : "Retry",
|
|
146
155
|
};
|
|
147
156
|
|
|
148
157
|
let source;
|
|
158
|
+
const terminalAtLoad = document.body.dataset.terminal === "true";
|
|
149
159
|
const updateElapsed = () => {
|
|
150
160
|
document.querySelectorAll("[data-live-elapsed]").forEach((element) => {
|
|
151
161
|
const startedAt = Number(element.dataset.startedAt);
|
|
@@ -179,41 +189,284 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
|
|
|
179
189
|
});
|
|
180
190
|
};
|
|
181
191
|
updateElapsed();
|
|
182
|
-
setInterval(updateElapsed, 1000);
|
|
192
|
+
const clock = terminalAtLoad ? undefined : setInterval(updateElapsed, 1000);
|
|
183
193
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
const
|
|
194
|
+
const nodeKey = (node) => {
|
|
195
|
+
if (node.nodeType !== 1) return undefined;
|
|
196
|
+
for (const attribute of ["data-preview-key", "data-activity", "data-activity-id", "data-scroll-id", "data-reasoning-field", "data-reasoning-part"]) {
|
|
197
|
+
if (node.hasAttribute(attribute)) return attribute + ":" + node.getAttribute(attribute);
|
|
198
|
+
}
|
|
199
|
+
if (node.tagName === "IMG") return "image:" + node.getAttribute("src");
|
|
200
|
+
// Keep Markdown image paragraphs/links anchored when text is inserted before them.
|
|
201
|
+
if (["P", "A", "FIGURE", "PICTURE"].includes(node.tagName)) {
|
|
202
|
+
const image = node.querySelector("img");
|
|
203
|
+
if (image) return node.tagName + ":image:" + image.getAttribute("src");
|
|
204
|
+
}
|
|
205
|
+
for (const name of ["markdown", "tool-command", "tool-image", "files", "tool-header", "tool-body", "tool-footer"]) {
|
|
206
|
+
if (node.classList.contains(name)) return node.tagName + ":" + name;
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
};
|
|
210
|
+
const compatible = (current, next) => current.nodeType === next.nodeType && current.nodeName === next.nodeName;
|
|
211
|
+
const patchNode = (current, next) => {
|
|
212
|
+
// Summary snapshots never own a detail body that has already been fetched.
|
|
213
|
+
if (current.nodeType === 1 && current.hasAttribute("data-detail-body") && next.hasAttribute("data-detail-body")) return;
|
|
214
|
+
if (current.isEqualNode(next)) return;
|
|
215
|
+
if (current.nodeType !== 1) {
|
|
216
|
+
if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (current.hasAttribute("data-diagram") && next.hasAttribute("data-diagram")) {
|
|
220
|
+
const code = current.querySelector(".diagram-source code");
|
|
221
|
+
const nextCode = next.querySelector(".diagram-source code");
|
|
222
|
+
if (code && nextCode && code.textContent !== nextCode.textContent) {
|
|
223
|
+
code.textContent = nextCode.textContent;
|
|
224
|
+
window.agentBotDiagrams.refresh(current);
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const preserveOpen = current.tagName === "DETAILS";
|
|
229
|
+
for (const attribute of Array.from(current.attributes)) {
|
|
230
|
+
if (preserveOpen && attribute.name === "open") continue;
|
|
231
|
+
if (!next.hasAttribute(attribute.name)) current.removeAttribute(attribute.name);
|
|
232
|
+
}
|
|
233
|
+
for (const attribute of Array.from(next.attributes)) {
|
|
234
|
+
if (preserveOpen && attribute.name === "open") continue;
|
|
235
|
+
if (current.getAttribute(attribute.name) !== attribute.value) current.setAttribute(attribute.name, attribute.value);
|
|
236
|
+
}
|
|
237
|
+
patchChildren(current, next);
|
|
238
|
+
};
|
|
239
|
+
const patchChildren = (parent, next) => {
|
|
240
|
+
const nextChildren = Array.from(next.childNodes);
|
|
241
|
+
const nextKeys = new Set(nextChildren.map(nodeKey).filter((key) => key !== undefined));
|
|
242
|
+
const remaining = new Set(Array.from(parent.childNodes));
|
|
243
|
+
const keyed = new Map();
|
|
244
|
+
for (const child of remaining) {
|
|
245
|
+
const key = nodeKey(child);
|
|
246
|
+
if (key === undefined) continue;
|
|
247
|
+
if (!nextKeys.has(key)) {
|
|
248
|
+
child.remove();
|
|
249
|
+
remaining.delete(child);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const matches = keyed.get(key) || [];
|
|
253
|
+
matches.push(child);
|
|
254
|
+
keyed.set(key, matches);
|
|
255
|
+
}
|
|
256
|
+
let cursor = parent.firstChild;
|
|
257
|
+
for (const child of nextChildren) {
|
|
258
|
+
const key = nodeKey(child);
|
|
259
|
+
const matches = key === undefined ? undefined : keyed.get(key);
|
|
260
|
+
const current = key === undefined
|
|
261
|
+
? cursor && nodeKey(cursor) === undefined && compatible(cursor, child) ? cursor : undefined
|
|
262
|
+
: matches?.find((candidate) => remaining.has(candidate) && compatible(candidate, child));
|
|
263
|
+
if (current) {
|
|
264
|
+
remaining.delete(current);
|
|
265
|
+
// Drop obsolete text/markup before a retained block without detaching that block.
|
|
266
|
+
while (cursor && cursor !== current && nodeKey(cursor) === undefined) {
|
|
267
|
+
const obsolete = cursor;
|
|
268
|
+
cursor = cursor.nextSibling;
|
|
269
|
+
remaining.delete(obsolete);
|
|
270
|
+
obsolete.remove();
|
|
271
|
+
}
|
|
272
|
+
if (current !== cursor) {
|
|
273
|
+
if (typeof parent.moveBefore === "function" && current.isConnected) parent.moveBefore(current, cursor);
|
|
274
|
+
else parent.insertBefore(current, cursor);
|
|
275
|
+
}
|
|
276
|
+
patchNode(current, child);
|
|
277
|
+
cursor = current.nextSibling;
|
|
278
|
+
} else {
|
|
279
|
+
const added = child.cloneNode(true);
|
|
280
|
+
parent.insertBefore(added, cursor);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const child of remaining) child.remove();
|
|
284
|
+
};
|
|
285
|
+
const patchMarkup = (element, markup) => {
|
|
286
|
+
const template = document.createElement("template");
|
|
287
|
+
template.innerHTML = markup;
|
|
288
|
+
patchChildren(element, template.content);
|
|
289
|
+
};
|
|
290
|
+
const detailStates = new WeakMap();
|
|
291
|
+
const detailState = (details) => {
|
|
292
|
+
if (!detailStates.has(details)) detailStates.set(details, {});
|
|
293
|
+
return detailStates.get(details);
|
|
294
|
+
};
|
|
295
|
+
const loadDetails = async (details, retry = false) => {
|
|
296
|
+
if (!details.open || !details.isConnected) return;
|
|
297
|
+
const state = detailState(details);
|
|
298
|
+
const revision = details.dataset.detailRevision;
|
|
299
|
+
if (state.loadedRevision === revision) state.loadedFor = revision;
|
|
300
|
+
if (state.pending || state.loadedFor === revision || (!retry && state.failedRevision === revision)) return;
|
|
301
|
+
const body = details.querySelector("[data-detail-body]");
|
|
302
|
+
const target = details.querySelector("[data-detail-content]");
|
|
303
|
+
const message = details.querySelector("[data-detail-message]");
|
|
304
|
+
if (!body || !target || !message) return;
|
|
305
|
+
const controller = new AbortController();
|
|
306
|
+
const request = { controller, revision };
|
|
307
|
+
state.pending = request;
|
|
308
|
+
state.failedRevision = undefined;
|
|
309
|
+
body.setAttribute("aria-busy", "true");
|
|
310
|
+
message.textContent = state.loadedRevision ? "" : labels.loading;
|
|
311
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
312
|
+
try {
|
|
313
|
+
const url = new URL(eventsUrl, window.location.href);
|
|
314
|
+
url.searchParams.delete("events");
|
|
315
|
+
url.searchParams.set("detail", details.dataset.detailKey);
|
|
316
|
+
if (state.cursor !== undefined) url.searchParams.set("detailAfter", String(state.cursor));
|
|
317
|
+
const response = await fetch(url, { signal: controller.signal, cache: "no-store" });
|
|
318
|
+
if (!response.ok) throw new Error("Detail HTTP " + response.status);
|
|
319
|
+
const result = await response.json();
|
|
320
|
+
if (result.key !== details.dataset.detailKey || typeof result.content !== "string" || typeof result.revision !== "string") throw new Error("Invalid detail response");
|
|
321
|
+
if (state.pending !== request || !details.open || !details.isConnected) return;
|
|
322
|
+
if (details.dataset.detailRevision !== revision && result.revision !== details.dataset.detailRevision) return;
|
|
323
|
+
const positions = Array.from(target.querySelectorAll("[data-scroll-id]"), (item) => [item, item.scrollLeft, item.scrollTop]);
|
|
324
|
+
if (typeof result.outputAppend === "string") {
|
|
325
|
+
const output = target.querySelector(".tool-output:not(.error-output)");
|
|
326
|
+
if (!output) throw new Error("Output delta without a loaded body");
|
|
327
|
+
output.append(document.createTextNode(result.outputAppend));
|
|
328
|
+
const footer = target.querySelector(".tool-footer");
|
|
329
|
+
if (footer && result.footer) patchMarkup(footer, result.footer);
|
|
330
|
+
} else patchMarkup(target, result.content);
|
|
331
|
+
state.cursor = result.cursor;
|
|
332
|
+
window.agentBotDiagrams?.scan();
|
|
333
|
+
for (const [item, left, top] of positions) { if (item.isConnected) { item.scrollLeft = left; item.scrollTop = top; } }
|
|
334
|
+
state.loadedRevision = result.revision;
|
|
335
|
+
state.loadedFor = details.dataset.detailRevision;
|
|
336
|
+
message.textContent = "";
|
|
337
|
+
updateElapsed();
|
|
338
|
+
} catch {
|
|
339
|
+
if (state.pending !== request || !details.open || !details.isConnected) return;
|
|
340
|
+
state.cursor = undefined;
|
|
341
|
+
state.failedRevision = revision;
|
|
342
|
+
message.textContent = labels.loadFailed;
|
|
343
|
+
const button = document.createElement("button");
|
|
344
|
+
button.type = "button";
|
|
345
|
+
button.textContent = labels.retry;
|
|
346
|
+
button.addEventListener("click", () => { void loadDetails(details, true); });
|
|
347
|
+
message.append(button);
|
|
348
|
+
} finally {
|
|
349
|
+
clearTimeout(timeout);
|
|
350
|
+
if (state.pending === request) {
|
|
351
|
+
state.pending = undefined;
|
|
352
|
+
body.removeAttribute("aria-busy");
|
|
353
|
+
if (details.dataset.detailRevision !== revision) void loadDetails(details);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
content.addEventListener("toggle", (event) => {
|
|
358
|
+
const details = event.target;
|
|
359
|
+
if (!details.matches?.("details[data-detail-key]")) return;
|
|
360
|
+
if (details.open) { void loadDetails(details, true); return; }
|
|
361
|
+
const state = detailState(details);
|
|
362
|
+
state.pending?.controller.abort();
|
|
363
|
+
state.pending = undefined;
|
|
364
|
+
details.querySelector("[data-detail-body]")?.removeAttribute("aria-busy");
|
|
365
|
+
const message = details.querySelector("[data-detail-message]");
|
|
366
|
+
if (message) message.textContent = "";
|
|
367
|
+
}, true);
|
|
368
|
+
const refreshDetails = () => {
|
|
369
|
+
for (const details of content.querySelectorAll("details[data-detail-key][open]")) void loadDetails(details);
|
|
370
|
+
};
|
|
371
|
+
window.addEventListener("pagehide", () => {
|
|
372
|
+
source?.close();
|
|
373
|
+
clearInterval(clock);
|
|
374
|
+
for (const details of content.querySelectorAll("details[data-detail-key]")) detailStates.get(details)?.pending?.controller.abort();
|
|
375
|
+
}, { once: true });
|
|
376
|
+
let previousContent;
|
|
377
|
+
let previousMetadata;
|
|
378
|
+
const applySnapshot = (update) => {
|
|
379
|
+
const contentChanged = update.content !== previousContent;
|
|
380
|
+
const scrollPositions = new Map(Array.from(contentChanged ? content.querySelectorAll("[data-scroll-id]") : [], (item) => [item.dataset.scrollId, [item.scrollLeft, item.scrollTop]]));
|
|
187
381
|
const top = window.scrollY;
|
|
188
382
|
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
189
383
|
const atBottom = maxTop - top <= 32;
|
|
190
|
-
|
|
191
|
-
|
|
384
|
+
if (contentChanged) {
|
|
385
|
+
patchMarkup(content, update.content);
|
|
386
|
+
window.agentBotDiagrams.scan();
|
|
387
|
+
previousContent = update.content;
|
|
388
|
+
}
|
|
389
|
+
if (update.metadata !== previousMetadata) {
|
|
390
|
+
patchMarkup(metadata, update.metadata);
|
|
391
|
+
previousMetadata = update.metadata;
|
|
392
|
+
}
|
|
192
393
|
status.textContent = update.statusLabel;
|
|
193
394
|
status.className = "status " + update.status;
|
|
194
|
-
for (const item of content.querySelectorAll("details[data-activity-id]")) {
|
|
195
|
-
if (disclosures.has(item.dataset.activityId)) item.open = disclosures.get(item.dataset.activityId);
|
|
196
|
-
}
|
|
197
395
|
for (const item of content.querySelectorAll("[data-scroll-id]")) {
|
|
198
396
|
const position = scrollPositions.get(item.dataset.scrollId);
|
|
199
397
|
if (position) { item.scrollLeft = position[0]; item.scrollTop = position[1]; }
|
|
200
398
|
}
|
|
201
399
|
updateElapsed();
|
|
400
|
+
refreshDetails();
|
|
401
|
+
if (!contentChanged) return;
|
|
202
402
|
requestAnimationFrame(() => {
|
|
203
403
|
const nextMaxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
204
404
|
window.scrollTo(0, atBottom ? nextMaxTop : Math.min(top, nextMaxTop));
|
|
205
405
|
});
|
|
206
406
|
};
|
|
207
407
|
|
|
408
|
+
const applyPatch = (update) => {
|
|
409
|
+
const template = document.createElement("template");
|
|
410
|
+
template.innerHTML = update.content;
|
|
411
|
+
const incoming = template.content;
|
|
412
|
+
const timeline = content.querySelector('[data-preview-key="timeline"]');
|
|
413
|
+
const nextTimeline = incoming.querySelector('[data-preview-key="timeline"]');
|
|
414
|
+
for (const id of update.removed || []) {
|
|
415
|
+
for (const node of timeline.children) if (node.dataset.activity === id || node.dataset.activityId === id) node.remove();
|
|
416
|
+
}
|
|
417
|
+
for (const node of Array.from(nextTimeline.children)) {
|
|
418
|
+
const id = node.dataset.activity || node.dataset.activityId;
|
|
419
|
+
if (!id) continue;
|
|
420
|
+
timeline.querySelector(".empty")?.remove();
|
|
421
|
+
const current = Array.from(timeline.children).find((item) => (item.dataset.activity || item.dataset.activityId) === id);
|
|
422
|
+
if (current) patchNode(current, node);
|
|
423
|
+
else {
|
|
424
|
+
const nextId = update.before?.[id];
|
|
425
|
+
const anchor = Array.from(timeline.children).find((child) => (child.dataset.activity || child.dataset.activityId) === nextId);
|
|
426
|
+
timeline.insertBefore(node, anchor || null);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
timeline.hidden = !timeline.children.length;
|
|
430
|
+
const incomingKeys = new Set(Array.from(incoming.children, (node) => node.dataset.previewKey));
|
|
431
|
+
for (const node of Array.from(incoming.children)) {
|
|
432
|
+
const key = nodeKey(node);
|
|
433
|
+
if (node.dataset.previewKey === "timeline" || !key) continue;
|
|
434
|
+
const current = Array.from(content.children).find((item) => nodeKey(item) === key);
|
|
435
|
+
if (current) patchNode(current, node);
|
|
436
|
+
else {
|
|
437
|
+
const result = content.querySelector('[data-preview-key="result"]');
|
|
438
|
+
content.insertBefore(node, node.dataset.activityId === "turn:files-summary" ? result : null);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
for (const key of ["approval", "error", "plan", "result"]) {
|
|
442
|
+
if (!incomingKeys.has(key)) content.querySelector('[data-preview-key="' + key + '"]')?.remove();
|
|
443
|
+
}
|
|
444
|
+
patchMarkup(metadata, update.metadata);
|
|
445
|
+
status.textContent = update.statusLabel;
|
|
446
|
+
status.className = "status " + update.status;
|
|
447
|
+
window.agentBotDiagrams.scan();
|
|
448
|
+
refreshDetails();
|
|
449
|
+
updateElapsed();
|
|
450
|
+
};
|
|
451
|
+
if (terminalAtLoad || typeof EventSource !== "function") return;
|
|
208
452
|
source = new EventSource(eventsUrl);
|
|
453
|
+
source.addEventListener("patch", (event) => {
|
|
454
|
+
try {
|
|
455
|
+
const update = JSON.parse(event.data);
|
|
456
|
+
applyPatch(update);
|
|
457
|
+
live.textContent = update.terminal ? "" : labels.live;
|
|
458
|
+
live.className = update.terminal ? "live terminal" : "live connected";
|
|
459
|
+
if (update.terminal) { source.close(); clearInterval(clock); }
|
|
460
|
+
} catch { live.textContent = labels.waitingForUpdates; }
|
|
461
|
+
});
|
|
209
462
|
source.addEventListener("update", (event) => {
|
|
210
463
|
try {
|
|
211
464
|
const update = JSON.parse(event.data);
|
|
212
465
|
if (typeof update.content !== "string" || typeof update.metadata !== "string") return;
|
|
213
|
-
|
|
466
|
+
applySnapshot(update);
|
|
214
467
|
live.textContent = update.terminal ? "" : labels.live;
|
|
215
468
|
live.className = update.terminal ? "live terminal" : "live connected";
|
|
216
|
-
if (update.terminal) source.close();
|
|
469
|
+
if (update.terminal) { source.close(); clearInterval(clock); }
|
|
217
470
|
} catch {
|
|
218
471
|
live.textContent = labels.waitingForUpdates;
|
|
219
472
|
live.className = "live";
|
|
@@ -227,7 +480,6 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = `(() => {
|
|
|
227
480
|
live.textContent = labels.reconnecting;
|
|
228
481
|
live.className = "live disconnected";
|
|
229
482
|
};
|
|
230
|
-
window.addEventListener("pagehide", () => source.close(), { once: true });
|
|
231
483
|
})();
|
|
232
484
|
`;
|
|
233
485
|
|
|
@@ -256,11 +508,12 @@ export function renderTurnPreviewPage(input: {
|
|
|
256
508
|
state: TurnViewState;
|
|
257
509
|
eventsUrl: string;
|
|
258
510
|
scriptPath: string;
|
|
511
|
+
diagramScriptPath?: string;
|
|
259
512
|
localFileUrl?: (filePath: string) => string | undefined;
|
|
260
513
|
language?: TurnPreviewLanguage;
|
|
261
514
|
}): string {
|
|
262
515
|
const language = input.language ?? "zh";
|
|
263
|
-
const snapshot = renderTurnPreviewSnapshot(input.state, input.localFileUrl, language);
|
|
516
|
+
const snapshot = renderTurnPreviewSnapshot(input.state, input.localFileUrl, language, { deferDetails: true });
|
|
264
517
|
const title = previewTitle(input.state);
|
|
265
518
|
return `<!doctype html>
|
|
266
519
|
<html lang="${language === "zh" ? "zh-CN" : "en"}">
|
|
@@ -268,9 +521,9 @@ export function renderTurnPreviewPage(input: {
|
|
|
268
521
|
<meta charset="utf-8">
|
|
269
522
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
270
523
|
<title>${escapeHtml(title)} · Agent Bot</title>
|
|
271
|
-
<style>${TURN_PREVIEW_CSS}</style>
|
|
524
|
+
<style>${TURN_PREVIEW_CSS}${DIAGRAM_PREVIEW_CSS}${PREVIEW_SYNTAX_CSS}</style>
|
|
272
525
|
</head>
|
|
273
|
-
<body data-events-url="${escapeAttribute(input.eventsUrl)}">
|
|
526
|
+
<body data-terminal="${snapshot.terminal}" data-events-url="${escapeAttribute(input.eventsUrl)}" data-diagram-script="${escapeAttribute(input.diagramScriptPath ?? "")}">
|
|
274
527
|
<header class="page-header">
|
|
275
528
|
<div class="header-inner">
|
|
276
529
|
<div class="header-top">
|
|
@@ -287,10 +540,27 @@ export function renderTurnPreviewPage(input: {
|
|
|
287
540
|
</html>`;
|
|
288
541
|
}
|
|
289
542
|
|
|
543
|
+
export function renderTurnPreviewPatch(
|
|
544
|
+
state: TurnViewState, changed: Set<string>, removed: Set<string>,
|
|
545
|
+
localFileUrl: (filePath: string) => string | undefined, language: TurnPreviewLanguage,
|
|
546
|
+
): TurnPreviewSnapshot & { removed: string[]; before: Record<string, string> } {
|
|
547
|
+
const partial = { ...state,
|
|
548
|
+
activities: state.activities.filter((a) => changed.has(a.id)),
|
|
549
|
+
reasoningItems: state.reasoningItems?.filter((r) => changed.has(`reasoning:${r.itemId}`)),
|
|
550
|
+
};
|
|
551
|
+
const order = [
|
|
552
|
+
...(state.reasoningItems ?? []).filter((r) => !r.afterActivityId).map((r) => `reasoning:${r.itemId}`),
|
|
553
|
+
...state.activities.flatMap((a) => [a.id, ...(state.reasoningItems ?? []).filter((r) => r.afterActivityId === a.id).map((r) => `reasoning:${r.itemId}`)]),
|
|
554
|
+
];
|
|
555
|
+
const before = Object.fromEntries(order.flatMap((id, i) => changed.has(id) && order[i + 1] ? [[id, order[i + 1]]] : []));
|
|
556
|
+
return { ...renderTurnPreviewSnapshot(partial, localFileUrl, language, { deferDetails: true }), removed: [...removed], before };
|
|
557
|
+
}
|
|
558
|
+
|
|
290
559
|
export function renderTurnPreviewSnapshot(
|
|
291
560
|
state: TurnViewState,
|
|
292
561
|
localFileUrl?: (filePath: string) => string | undefined,
|
|
293
562
|
language: TurnPreviewLanguage = "zh",
|
|
563
|
+
options: { deferDetails?: boolean } = {},
|
|
294
564
|
): TurnPreviewSnapshot {
|
|
295
565
|
const labels = previewLabels(language);
|
|
296
566
|
const resolveFileUrl = localFileUrl ? (filePath: string): string | undefined => {
|
|
@@ -298,25 +568,35 @@ export function renderTurnPreviewSnapshot(
|
|
|
298
568
|
if (!state.projectCwd || !path.isAbsolute(state.projectCwd)) return undefined;
|
|
299
569
|
return localFileUrl(path.resolve(state.projectCwd, filePath));
|
|
300
570
|
} : undefined;
|
|
301
|
-
const renderText = (value: string): string => renderMarkdown(value, state.projectCwd, localFileUrl);
|
|
302
|
-
const timeline = renderTimeline(state
|
|
571
|
+
const renderText = (value: string): string => renderMarkdown(value, state.projectCwd, localFileUrl, language);
|
|
572
|
+
const timeline = renderTimeline(state, renderText, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd, options.deferDetails);
|
|
573
|
+
const terminal = isTerminal(state.status);
|
|
574
|
+
const waitingForProgress = !terminal && !state.finalResponse && !state.assistantText
|
|
575
|
+
&& !state.error && !state.approval && state.status !== "waiting_for_approval"
|
|
576
|
+
&& state.plan.length === 0 && state.fileSummary.length === 0;
|
|
577
|
+
const timelineContent = timeline || (waitingForProgress
|
|
578
|
+
? `<div class="empty">${escapeHtml(language === "zh" ? "正在等待 Agent 返回进度…" : "Waiting for Agent progress…")}</div>`
|
|
579
|
+
: "");
|
|
303
580
|
const sections = [
|
|
304
|
-
state.prompt ? renderMessageActivity("prompt", state.prompt, "user prompt", renderText) : "",
|
|
581
|
+
state.prompt || state.promptImagePaths?.length ? renderMessageActivity("prompt", state.prompt ?? "", "user prompt", renderText, renderMessageImages(state.promptImagePaths, resolveFileUrl, language)) : "",
|
|
582
|
+
state.historyDetailError
|
|
583
|
+
? `<section class="notice" data-preview-key="history-detail"><h2>${language === "zh" ? "历史执行详情读取失败" : "Failed to load historical execution details"}</h2><pre>${escapeHtml(state.historyDetailError)}</pre><p>${language === "zh" ? "刷新页面重试;本轮执行状态未改变。" : "Refresh to retry; the Turn's execution status is unchanged."}</p></section>`
|
|
584
|
+
: state.historyDetail === "summary"
|
|
585
|
+
? `<section class="notice" data-preview-key="history-detail">${language === "zh" ? "当前仅保存轮次摘要,尚未加载历史执行详情。" : "Only a Turn summary is saved; execution details have not been loaded."}</section>` : "",
|
|
305
586
|
state.plan.length > 0 ? renderPlan(state, language) : "",
|
|
306
587
|
state.activitiesTruncated
|
|
307
|
-
? `<div class="notice">${escapeHtml(labels.truncated)}</div>`
|
|
588
|
+
? `<div class="notice" data-preview-key="truncated">${escapeHtml(labels.truncated)}</div>`
|
|
308
589
|
: "",
|
|
309
|
-
|
|
310
|
-
state.fileSummary.length > 0 ? renderFileSummary(state, language, resolveFileUrl) : "",
|
|
311
|
-
state.approval ? `<section class="notice"><h2>${escapeHtml(state.approval.title)}</h2><div class="markdown">${renderText(state.approval.reason ?? "")}</div><p>${escapeHtml(language === "zh" ? "请在飞书任务卡片中确认或拒绝。" : "Approve or reject using the task card in Feishu.")}</p></section>` : "",
|
|
312
|
-
state.error ? `<section class="result error-result"><h2>${escapeHtml(labels.error)}</h2><pre>${escapeHtml(state.error)}</pre></section>` : "",
|
|
590
|
+
`<section class="timeline" data-preview-key="timeline"${timelineContent ? "" : " hidden"}>${timelineContent}</section>`,
|
|
591
|
+
state.fileSummary.length > 0 ? renderFileSummary(state, language, resolveFileUrl, options.deferDetails) : "",
|
|
592
|
+
state.approval ? `<section class="notice" data-preview-key="approval"><h2>${escapeHtml(state.approval.title)}</h2><div class="markdown">${renderText(state.approval.reason ?? "")}</div><p>${escapeHtml(language === "zh" ? "请在飞书任务卡片中确认或拒绝。" : "Approve or reject using the task card in Feishu.")}</p></section>` : "",
|
|
593
|
+
state.error ? `<section class="result error-result" data-preview-key="error"><h2>${escapeHtml(labels.error)}</h2><pre>${escapeHtml(state.error)}</pre></section>` : "",
|
|
313
594
|
state.finalResponse
|
|
314
|
-
? `<section class="result final-result"><h2>${escapeHtml(labels.finalAnswer)}</h2><div class="markdown">${renderText(state.finalResponse)}</div></section>`
|
|
595
|
+
? `<section class="result final-result" data-preview-key="result"><h2>${escapeHtml(labels.finalAnswer)}</h2><div class="markdown">${renderText(state.finalResponse)}</div></section>`
|
|
315
596
|
: state.assistantText
|
|
316
|
-
? `<section class="result"><h2>${escapeHtml(labels.generating)}</h2><div class="markdown">${renderText(state.assistantText)}</div></section>`
|
|
597
|
+
? `<section class="result" data-preview-key="result"><h2>${escapeHtml(labels.generating)}</h2><div class="markdown">${renderText(state.assistantText)}</div></section>`
|
|
317
598
|
: "",
|
|
318
599
|
].filter(Boolean).join("");
|
|
319
|
-
const terminal = isTerminal(state.status);
|
|
320
600
|
return {
|
|
321
601
|
content: sections,
|
|
322
602
|
metadata: renderMetadata(state, language),
|
|
@@ -333,22 +613,104 @@ function renderPlan(state: TurnViewState, language: TurnPreviewLanguage): string
|
|
|
333
613
|
const marker = step.status === "completed" ? "✓" : step.status === "in_progress" ? "↻" : "○";
|
|
334
614
|
return `<li class="${step.status}"><span>${marker}</span><span>${escapeHtml(step.text)}</span></li>`;
|
|
335
615
|
}).join("");
|
|
336
|
-
return `<section class="plan"><h2>${escapeHtml(labels.plan)} <span>${completed}/${state.plan.length}</span></h2><ol>${items}</ol></section>`;
|
|
616
|
+
return `<section class="plan" data-preview-key="plan"><h2>${escapeHtml(labels.plan)} <span>${completed}/${state.plan.length}</span></h2><ol>${items}</ol></section>`;
|
|
337
617
|
}
|
|
338
618
|
|
|
339
619
|
function renderTimeline(
|
|
340
|
-
|
|
620
|
+
state: TurnViewState,
|
|
341
621
|
renderText: (value: string) => string,
|
|
342
622
|
localFileUrl?: (filePath: string) => string | undefined,
|
|
343
623
|
fullToolOutputs?: Record<string, string>,
|
|
344
624
|
fullToolErrors?: Record<string, string>,
|
|
345
625
|
language: TurnPreviewLanguage = "zh",
|
|
346
626
|
projectCwd?: string,
|
|
627
|
+
deferDetails = false,
|
|
347
628
|
): string {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
629
|
+
const activities = state.activities ?? [];
|
|
630
|
+
const positions = new Map(activities.map((activity, index) => [activity.id, index]));
|
|
631
|
+
const reasoning = new Map<number, TurnReasoningItem[]>();
|
|
632
|
+
for (const item of turnReasoningItems(state)) {
|
|
633
|
+
const position = item.afterActivityId ? positions.get(item.afterActivityId) ?? -1 : -1;
|
|
634
|
+
const items = reasoning.get(position) ?? [];
|
|
635
|
+
items.push(item);
|
|
636
|
+
reasoning.set(position, items);
|
|
637
|
+
}
|
|
638
|
+
const renderReasoning = (position: number): string => (reasoning.get(position) ?? [])
|
|
639
|
+
.map((item) => renderReasoningItem(item, state.projectCwd, localFileUrl, language, deferDetails)).join("");
|
|
640
|
+
return renderReasoning(-1) + activities.map((activity, index) => {
|
|
641
|
+
const visible = activity.kind === "reasoning"
|
|
642
|
+
? activity.id.startsWith("commentary:") ? { ...activity, kind: "assistant" as const } : undefined
|
|
643
|
+
: activity;
|
|
644
|
+
return (visible ? renderActivity(visible, renderText, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd, deferDetails) : "")
|
|
645
|
+
+ renderReasoning(index);
|
|
646
|
+
}).join("");
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function reasoningPresentation(item: TurnReasoningItem): {
|
|
650
|
+
title?: string;
|
|
651
|
+
sections: Array<{ field: "summary" | "content"; parts: Array<{ index: number; text: string }> }>;
|
|
652
|
+
} {
|
|
653
|
+
let title: string | undefined;
|
|
654
|
+
const sections = (["summary", "content"] as const).map((field) => ({
|
|
655
|
+
field,
|
|
656
|
+
parts: item[field].map((text, index) => {
|
|
657
|
+
const heading = !title && text?.trim() ? leadingReasoningHeading(text) : undefined;
|
|
658
|
+
if (heading) title = heading.title;
|
|
659
|
+
return { index, text: heading?.body ?? text };
|
|
660
|
+
}).filter((part) => part.text?.trim()),
|
|
661
|
+
}));
|
|
662
|
+
return { title, sections };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function leadingReasoningHeading(text: string): { title: string; body: string } | undefined {
|
|
666
|
+
const first = /^(?:[ \t]*\r?\n)*([^\r\n]*)(?:\r?\n|$)/u.exec(text);
|
|
667
|
+
if (!first || !/^ {0,3}(?:#{1,6}[ \t]+|\*\*|__)/u.test(first[1])) return undefined;
|
|
668
|
+
// Parse only a possible title line; collapsed panels must not render the full Markdown.
|
|
669
|
+
const [block, inline] = MARKDOWN.parse(first[1], {});
|
|
670
|
+
const tokens = inline?.children?.filter((token) => token.type !== "text" || token.content !== "") ?? [];
|
|
671
|
+
const strongTitle = block?.type === "paragraph_open"
|
|
672
|
+
&& tokens[0]?.type === "strong_open" && tokens.at(-1)?.type === "strong_close"
|
|
673
|
+
&& tokens.slice(1, -1).every((token) => token.level > 0);
|
|
674
|
+
if (block?.type !== "heading_open" && !strongTitle) return undefined;
|
|
675
|
+
const title = tokens.map((token) => token.content).join("").trim();
|
|
676
|
+
if (!title) return undefined;
|
|
677
|
+
// Keep linked/image headings in the body so moving their text never discards an attachment or link.
|
|
678
|
+
const keepHeading = tokens.some((token) => token.type === "link_open" || token.type === "image");
|
|
679
|
+
return { title, body: keepHeading ? text : text.slice(first[0].length) };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function renderReasoningItem(
|
|
683
|
+
item: TurnReasoningItem,
|
|
684
|
+
cwd: string | undefined,
|
|
685
|
+
localFileUrl: ((filePath: string) => string | undefined) | undefined,
|
|
686
|
+
language: TurnPreviewLanguage,
|
|
687
|
+
deferDetails: boolean,
|
|
688
|
+
): string {
|
|
689
|
+
const presentation = reasoningPresentation(item);
|
|
690
|
+
if (!presentation.title && !presentation.sections.some((section) => section.parts.length > 0)) return "";
|
|
691
|
+
const title = presentation.title ?? previewLabels(language).reasoning;
|
|
692
|
+
const lazy = deferDetails ? lazyDetailAttributes(`reasoning:${item.itemId}`, reasoningRevision(item, cwd)) : "";
|
|
693
|
+
const body = deferDetails ? lazyDetailBody() : renderReasoningBody(item, cwd, localFileUrl, language, presentation);
|
|
694
|
+
return `<details class="reasoning-step" data-activity-id="reasoning:${escapeAttribute(item.itemId)}"${lazy}><summary class="reasoning-header" title="${escapeAttribute(title)}"><span class="reasoning-title">💭 ${escapeHtml(title)}</span></summary>${body}</details>`;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function renderReasoningBody(
|
|
698
|
+
item: TurnReasoningItem,
|
|
699
|
+
cwd: string | undefined,
|
|
700
|
+
localFileUrl: ((filePath: string) => string | undefined) | undefined,
|
|
701
|
+
language: TurnPreviewLanguage,
|
|
702
|
+
presentation = reasoningPresentation(item),
|
|
703
|
+
): string {
|
|
704
|
+
return presentation.sections.map(({ field, parts }) => {
|
|
705
|
+
if (parts.length === 0) return "";
|
|
706
|
+
const sections = parts.map(({ index, text }) => `<div class="markdown reasoning-part" data-reasoning-part="${index}">${renderMarkdown(text, cwd, localFileUrl, language)}</div>`).join("");
|
|
707
|
+
return `<section class="reasoning-section" data-reasoning-field="${field}">${sections}</section>`;
|
|
708
|
+
}).join("");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function reasoningRevision(item: TurnReasoningItem, cwd?: string): string {
|
|
712
|
+
if (item.previewRevision !== undefined) return String(item.previewRevision);
|
|
713
|
+
return createHash("sha256").update(JSON.stringify([item.summary, item.content, cwd])).digest("hex").slice(0, 24);
|
|
352
714
|
}
|
|
353
715
|
|
|
354
716
|
function renderActivity(
|
|
@@ -359,6 +721,7 @@ function renderActivity(
|
|
|
359
721
|
fullToolErrors?: Record<string, string>,
|
|
360
722
|
language: TurnPreviewLanguage = "zh",
|
|
361
723
|
projectCwd?: string,
|
|
724
|
+
deferDetails = false,
|
|
362
725
|
): string {
|
|
363
726
|
switch (activity.kind) {
|
|
364
727
|
case "assistant":
|
|
@@ -367,14 +730,32 @@ function renderActivity(
|
|
|
367
730
|
}
|
|
368
731
|
return renderMessageActivity(activity.id, activity.text, "commentary", renderText);
|
|
369
732
|
case "user":
|
|
370
|
-
return renderMessageActivity(activity.id, activity.text, "user", renderText);
|
|
733
|
+
return renderMessageActivity(activity.id, activity.text, "user", renderText, renderMessageImages(activity.localImagePaths, localFileUrl, language));
|
|
371
734
|
case "tool":
|
|
372
|
-
return renderToolActivity(activity.id, activity.tool, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd);
|
|
735
|
+
return renderToolActivity(activity.id, activity.tool, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd, deferDetails);
|
|
373
736
|
}
|
|
374
737
|
}
|
|
375
738
|
|
|
376
|
-
function renderMessageActivity(id: string, text: string, kind: string, renderText: (value: string) => string): string {
|
|
377
|
-
return `<article class="activity message ${kind}" data-activity="${escapeAttribute(id)}"><div class="markdown">${renderText(text)}</div></article>`;
|
|
739
|
+
function renderMessageActivity(id: string, text: string, kind: string, renderText: (value: string) => string, images = ""): string {
|
|
740
|
+
return `<article class="activity message ${kind}" data-activity="${escapeAttribute(id)}"><div class="markdown">${renderText(text)}${images}</div></article>`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function renderMessageImages(paths: string[] | undefined, localFileUrl: ((filePath: string) => string | undefined) | undefined, language: TurnPreviewLanguage): string {
|
|
744
|
+
if (!paths?.length) return "";
|
|
745
|
+
return [...new Set(paths)].map((filePath, index) => {
|
|
746
|
+
const label = language === "zh" ? `图片 ${index + 1}` : `Image ${index + 1}`;
|
|
747
|
+
const unavailable = `<p class="muted">${escapeHtml(label)}${language === "zh" ? "不可用" : " unavailable"}</p>`;
|
|
748
|
+
const url = localFileUrl?.(filePath);
|
|
749
|
+
if (!url) return unavailable;
|
|
750
|
+
try {
|
|
751
|
+
const raw = new URL(url);
|
|
752
|
+
if (raw.protocol !== "http:" && raw.protocol !== "https:") return unavailable;
|
|
753
|
+
raw.searchParams.set("raw", "1");
|
|
754
|
+
return `<p class="message-image"><img src="${escapeAttribute(raw.toString())}" alt="${escapeAttribute(label)}" loading="lazy"></p>`;
|
|
755
|
+
} catch {
|
|
756
|
+
return unavailable;
|
|
757
|
+
}
|
|
758
|
+
}).join("");
|
|
378
759
|
}
|
|
379
760
|
|
|
380
761
|
function renderToolActivity(
|
|
@@ -385,6 +766,7 @@ function renderToolActivity(
|
|
|
385
766
|
fullToolErrors?: Record<string, string>,
|
|
386
767
|
language: TurnPreviewLanguage = "zh",
|
|
387
768
|
projectCwd?: string,
|
|
769
|
+
deferDetails = false,
|
|
388
770
|
): string {
|
|
389
771
|
const labels = previewLabels(language);
|
|
390
772
|
const command = tool.command ? (displayToolCommand(tool.command) || tool.command.trim()) : undefined;
|
|
@@ -395,6 +777,34 @@ function renderToolActivity(
|
|
|
395
777
|
? formatToolCommand(command)
|
|
396
778
|
: undefined;
|
|
397
779
|
const title = displayCommand ? summarizeToolCommand(displayCommand) : toolTitle(tool, command, repl, language);
|
|
780
|
+
const titleMarkup = displayCommand
|
|
781
|
+
? `<code class="tool-command-title">${escapeHtml(title)}</code>`
|
|
782
|
+
: `<span class="tool-title">${escapeHtml(title)}</span>`;
|
|
783
|
+
const timing = renderToolTiming(tool, "header");
|
|
784
|
+
const statusLabel = toolStatusLabel(tool.status, language);
|
|
785
|
+
const icon = `<span class="tool-status-icon" role="img" aria-label="${escapeAttribute(statusLabel)}" title="${escapeAttribute(statusLabel)}">${toolStatusIcon(tool.status)}</span>`;
|
|
786
|
+
const header = `<summary class="tool-header">${icon}<span class="tool-summary-content">${titleMarkup}</span><span class="tool-meta">${timing}</span></summary>`;
|
|
787
|
+
const body = deferDetails
|
|
788
|
+
? lazyDetailBody()
|
|
789
|
+
: renderToolBody(activityId, tool, localFileUrl, fullToolOutputs, fullToolErrors, language, projectCwd);
|
|
790
|
+
const lazy = deferDetails ? lazyDetailAttributes(`tool:${activityId}`, toolDetailRevision(tool, fullToolOutputs, fullToolErrors, projectCwd)) : "";
|
|
791
|
+
return `<article class="activity tool" data-activity="${escapeAttribute(activityId)}"><details class="tool-step" data-tool-status="${tool.status}" data-activity-id="${escapeAttribute(activityId)}"${lazy}>${header}${body}</details></article>`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function renderToolBody(
|
|
795
|
+
activityId: string,
|
|
796
|
+
tool: ToolState,
|
|
797
|
+
localFileUrl?: (filePath: string) => string | undefined,
|
|
798
|
+
fullToolOutputs?: Record<string, string>,
|
|
799
|
+
fullToolErrors?: Record<string, string>,
|
|
800
|
+
language: TurnPreviewLanguage = "zh",
|
|
801
|
+
projectCwd?: string,
|
|
802
|
+
): string {
|
|
803
|
+
const labels = previewLabels(language);
|
|
804
|
+
const command = tool.command ? (displayToolCommand(tool.command) || tool.command.trim()) : undefined;
|
|
805
|
+
const repl = isReplTool(tool);
|
|
806
|
+
const displayCommand = repl ? formatReplCommand(command ?? "") : command ? formatToolCommand(command) : undefined;
|
|
807
|
+
const status = `<span class="tool-state ${tool.status}">${escapeHtml(toolStatusLabel(tool.status, language))}</span>`;
|
|
398
808
|
const output = fullToolOutputs?.[tool.id] ?? tool.output;
|
|
399
809
|
const error = fullToolErrors?.[tool.id] ?? tool.error;
|
|
400
810
|
const sameError = Boolean(output && error && normalizeOutput(output) === normalizeOutput(error));
|
|
@@ -413,21 +823,18 @@ function renderToolActivity(
|
|
|
413
823
|
? `<ul class="files" aria-label="${escapeAttribute(labels.files)}">${tool.files.map((file) => renderFileEntry(file, localFileUrl, projectCwd)).join("")}</ul>`
|
|
414
824
|
: "",
|
|
415
825
|
].filter(Boolean).join("");
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
const status = `<span class="tool-state ${tool.status}">${escapeHtml(
|
|
423
|
-
const header = `<summary class="tool-header">${icon}<span class="tool-summary-content">${titleMarkup}</span><span class="tool-meta">${timing}</span></summary>`;
|
|
826
|
+
const footer = renderToolFooter(tool, (displayOutput?.length ?? 0) + (displayError?.length ?? 0), language);
|
|
827
|
+
return `<div class="tool-body"><div class="tool-content" data-scroll-id="${escapeAttribute(`${activityId}:content`)}" tabindex="0">${detailParts || `<div class="muted">${escapeHtml(labels.noToolDetails)}</div>`}</div><div class="tool-footer">${footer}</div></div>`;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export function renderToolFooter(tool: ToolState, characterCount: number, language: TurnPreviewLanguage): string {
|
|
831
|
+
const labels = previewLabels(language);
|
|
832
|
+
const status = `<span class="tool-state ${tool.status}">${escapeHtml(toolStatusLabel(tool.status, language))}</span>`;
|
|
424
833
|
const startedAt = tool.startedAt === undefined ? undefined : new Date(tool.startedAt);
|
|
425
834
|
const startTime = startedAt
|
|
426
835
|
? `<time datetime="${startedAt.toISOString()}" title="${escapeAttribute(labels.startingTime)}">${new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" }).format(startedAt)}</time>`
|
|
427
836
|
: `<span title="${escapeAttribute(labels.startingTime)}">${escapeHtml(labels.unknown)}</span>`;
|
|
428
|
-
|
|
429
|
-
const footer = `<div class="tool-footer">${status}${startTime}${renderToolTiming(tool, "footer")}<span>${formatNumber(characterCount)} ${escapeHtml(labels.characters)}</span></div>`;
|
|
430
|
-
return `<article class="activity tool"><details class="tool-step" data-tool-status="${tool.status}" data-activity-id="${escapeAttribute(activityId)}">${header}<div class="tool-body"><div class="tool-content" data-scroll-id="${escapeAttribute(`${activityId}:content`)}" tabindex="0">${detailParts || `<div class="muted">${escapeHtml(labels.noToolDetails)}</div>`}</div>${footer}</div></details></article>`;
|
|
837
|
+
return `${status}${startTime}${renderToolTiming(tool, "footer")}<span>${formatNumber(characterCount)} ${escapeHtml(labels.characters)}</span>`;
|
|
431
838
|
}
|
|
432
839
|
|
|
433
840
|
function toolTitle(tool: ToolState, command?: string, repl = false, language: TurnPreviewLanguage = "zh"): string {
|
|
@@ -599,10 +1006,69 @@ function renderFileSummary(
|
|
|
599
1006
|
state: TurnViewState,
|
|
600
1007
|
language: TurnPreviewLanguage,
|
|
601
1008
|
localFileUrl?: (filePath: string) => string | undefined,
|
|
1009
|
+
deferDetails = false,
|
|
602
1010
|
): string {
|
|
603
1011
|
const labels = previewLabels(language);
|
|
604
|
-
const
|
|
605
|
-
|
|
1012
|
+
const body = deferDetails ? lazyDetailBody() : renderFileSummaryBody(state, localFileUrl);
|
|
1013
|
+
const lazy = deferDetails ? lazyDetailAttributes("files", fileSummaryRevision(state)) : "";
|
|
1014
|
+
return `<details class="files-summary" data-activity-id="turn:files-summary"${lazy}><summary><h2>${escapeHtml(labels.fileChanges)} <span>${state.fileSummary.length}</span></h2></summary>${body}</details>`;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function renderFileSummaryBody(state: TurnViewState, localFileUrl?: (filePath: string) => string | undefined): string {
|
|
1018
|
+
return `<ul class="files">${state.fileSummary.map((file) => renderFileEntry(file, localFileUrl, state.projectCwd)).join("")}</ul>`;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function lazyDetailAttributes(key: string, revision: string): string {
|
|
1022
|
+
return ` data-detail-key="${escapeAttribute(key)}" data-detail-revision="${revision}"`;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function lazyDetailBody(): string {
|
|
1026
|
+
return '<div data-detail-body><div class="detail-message" data-detail-message role="status"></div><div data-detail-content></div></div>';
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function toolDetailRevision(tool: ToolState, outputs?: Record<string, string>, errors?: Record<string, string>, cwd?: string): string {
|
|
1030
|
+
if (tool.previewRevision !== undefined) return String(tool.previewRevision);
|
|
1031
|
+
return createHash("sha256").update(JSON.stringify([tool, outputs?.[tool.id], errors?.[tool.id], cwd])).digest("hex").slice(0, 24);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function fileSummaryRevision(state: TurnViewState): string {
|
|
1035
|
+
return createHash("sha256").update(JSON.stringify([state.fileSummary, state.projectCwd])).digest("hex").slice(0, 24);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export interface TurnPreviewDetail {
|
|
1039
|
+
key: string;
|
|
1040
|
+
revision: string;
|
|
1041
|
+
content: string;
|
|
1042
|
+
cursor?: number;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
export function renderTurnPreviewDetail(
|
|
1046
|
+
state: TurnViewState,
|
|
1047
|
+
key: string,
|
|
1048
|
+
localFileUrl?: (filePath: string) => string | undefined,
|
|
1049
|
+
language: TurnPreviewLanguage = "zh",
|
|
1050
|
+
): TurnPreviewDetail | undefined {
|
|
1051
|
+
const resolveFileUrl = localFileUrl ? (filePath: string): string | undefined => {
|
|
1052
|
+
if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath)) return localFileUrl(filePath);
|
|
1053
|
+
if (!state.projectCwd || !path.isAbsolute(state.projectCwd)) return undefined;
|
|
1054
|
+
return localFileUrl(path.resolve(state.projectCwd, filePath));
|
|
1055
|
+
} : undefined;
|
|
1056
|
+
if (key === "files" && state.fileSummary.length > 0) {
|
|
1057
|
+
return { key, revision: fileSummaryRevision(state), content: renderFileSummaryBody(state, resolveFileUrl) };
|
|
1058
|
+
}
|
|
1059
|
+
if (key.startsWith("reasoning:")) {
|
|
1060
|
+
const item = turnReasoningItems(state).find((item) => `reasoning:${item.itemId}` === key);
|
|
1061
|
+
if (!item || ![...item.summary, ...item.content].some((text) => text?.trim())) return undefined;
|
|
1062
|
+
return { key, revision: reasoningRevision(item, state.projectCwd),
|
|
1063
|
+
content: renderReasoningBody(item, state.projectCwd, localFileUrl, language) };
|
|
1064
|
+
}
|
|
1065
|
+
const activity = state.activities?.find((item) => item.kind === "tool" && `tool:${item.id}` === key);
|
|
1066
|
+
if (activity?.kind !== "tool") return undefined;
|
|
1067
|
+
return {
|
|
1068
|
+
key,
|
|
1069
|
+
revision: toolDetailRevision(activity.tool, state.fullToolOutputs, state.fullToolErrors, state.projectCwd),
|
|
1070
|
+
content: renderToolBody(activity.id, activity.tool, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd),
|
|
1071
|
+
};
|
|
606
1072
|
}
|
|
607
1073
|
|
|
608
1074
|
function renderFileEntry(file: FileSummary, localFileUrl?: (filePath: string) => string | undefined, projectCwd?: string): string {
|
|
@@ -628,12 +1094,12 @@ function renderMetadata(state: TurnViewState, language: TurnPreviewLanguage): st
|
|
|
628
1094
|
const elapsed = `<span>${escapeHtml(labels.elapsed)}<strong${isTerminal(state.status) ? "" : ` data-live-elapsed data-started-at="${state.startedAt}"`}>${completedAt === undefined && isTerminal(state.status) ? escapeHtml(labels.unknown) : formatDuration((completedAt ?? Date.now()) - state.startedAt)}</strong></span>`;
|
|
629
1095
|
return [
|
|
630
1096
|
elapsed,
|
|
1097
|
+
state.modelProvider?.trim() ? `<span title="Provider">${escapeHtml(state.modelProvider.trim())}</span>` : "",
|
|
631
1098
|
state.model?.trim() ? `<span title="${escapeAttribute(labels.model)}">${escapeHtml(state.model.trim())}</span>` : "",
|
|
632
1099
|
state.totalTokens === undefined ? "" : state.cachedInputTokens === undefined
|
|
633
1100
|
? `<span title="${escapeAttribute(labels.turnTokens)}">${formatTokenCount(state.totalTokens)} tokens</span>`
|
|
634
1101
|
: `<span title="${escapeAttribute(labels.nonCachedTokens)}: ${formatNumber(state.totalTokens)} tokens">${escapeHtml(labels.nonCachedTokens)} ${formatTokenCount(state.totalTokens)} tokens</span>`,
|
|
635
1102
|
state.totalTokensIncludingCache === undefined ? "" : `<span title="${escapeAttribute(labels.totalTokens)}: ${formatNumber(state.totalTokensIncludingCache)} tokens">${escapeHtml(labels.totalTokens)} ${formatTokenCount(state.totalTokensIncludingCache)} tokens</span>`,
|
|
636
|
-
state.cachedInputTokens === undefined ? "" : `<span title="${escapeAttribute(labels.cachedTokens)}: ${formatNumber(state.cachedInputTokens)} tokens">${escapeHtml(labels.cachedTokens)} ${formatTokenCount(state.cachedInputTokens)} tokens</span>`,
|
|
637
1103
|
state.contextCompactionAfterTokens === undefined ? "" : state.contextCompactionBeforeTokens === undefined
|
|
638
1104
|
? `<span title="${escapeAttribute(labels.compactionAfterTokens)}">${formatTokenCount(state.contextCompactionAfterTokens)} tokens</span>`
|
|
639
1105
|
: `<span title="${escapeAttribute(labels.compactionTokens)}">${formatTokenCount(state.contextCompactionBeforeTokens)} → ${formatTokenCount(state.contextCompactionAfterTokens)} tokens</span>`,
|
|
@@ -646,8 +1112,8 @@ function previewTitle(state: TurnViewState): string {
|
|
|
646
1112
|
return value.length > 80 ? `${value.slice(0, 79)}…` : value;
|
|
647
1113
|
}
|
|
648
1114
|
|
|
649
|
-
function renderMarkdown(value: string, projectCwd?: string, localFileUrl?: (filePath: string) => string | undefined): string {
|
|
650
|
-
const environment = {};
|
|
1115
|
+
function renderMarkdown(value: string, projectCwd?: string, localFileUrl?: (filePath: string) => string | undefined, language: TurnPreviewLanguage = "zh"): string {
|
|
1116
|
+
const environment = { language };
|
|
651
1117
|
const tokens = MARKDOWN.parse(value, environment);
|
|
652
1118
|
// Only the directory is used to resolve relative links; no Markdown file is created.
|
|
653
1119
|
const markdownPath = projectCwd && path.isAbsolute(projectCwd) ? path.join(projectCwd, "turn.md") : undefined;
|
|
@@ -712,6 +1178,10 @@ function escapeAttribute(value: string): string {
|
|
|
712
1178
|
const TURN_PREVIEW_CSS = `
|
|
713
1179
|
:root { color-scheme: light dark; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; --page: #fff; --surface: #f5f6f7; --code: #fafafa; --text: #25272b; --muted: #62666d; --line: #e0e2e5; --accent: #245bc0; --success: #257449; --danger: #b52c34; --scrollbar-thumb: #d4d7dc; --scrollbar-thumb-hover: #afb4bc; }
|
|
714
1180
|
* { box-sizing: border-box; letter-spacing: 0; }
|
|
1181
|
+
html { scrollbar-gutter: stable; }
|
|
1182
|
+
@supports not (scrollbar-gutter: stable) {
|
|
1183
|
+
html { overflow-y: scroll; }
|
|
1184
|
+
}
|
|
715
1185
|
body { margin: 0; background: var(--page); color: var(--text); font-size: 13px; line-height: 1.55; }
|
|
716
1186
|
.page-header { position: sticky; top: 0; z-index: 3; background: var(--page); border-bottom: 1px solid var(--line); }
|
|
717
1187
|
.header-inner { display: flex; flex-wrap: wrap; align-items: center; gap: 3px 12px; max-width: 1320px; margin: auto; padding: 6px 20px; }
|
|
@@ -756,6 +1226,11 @@ main { max-width: 1320px; margin: 0 auto; padding: 0 20px 40px; }
|
|
|
756
1226
|
.message > .markdown, .result > .markdown { font-size: 14px; font-weight: 400; }
|
|
757
1227
|
.markdown > :first-child { margin-top: 0; }
|
|
758
1228
|
.markdown > :last-child { margin-bottom: 0; }
|
|
1229
|
+
.reasoning-step { margin:8px 0; border:1px solid var(--line); border-radius:7px; min-width:0; }
|
|
1230
|
+
.reasoning-header { cursor:pointer; padding:8px 10px; color:var(--muted); }
|
|
1231
|
+
.reasoning-title { min-width:0; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
1232
|
+
.reasoning-section { margin:0 10px 10px; max-height:30em; overflow:auto; overflow-wrap:anywhere; }
|
|
1233
|
+
.reasoning-part + .reasoning-part { border-top:1px solid var(--line); padding-top:8px; }
|
|
759
1234
|
.markdown p, .markdown ul, .markdown ol, .markdown pre, .markdown blockquote { margin: 0 0 6px; }
|
|
760
1235
|
.markdown ul, .markdown ol { padding-left: 1.5em; }
|
|
761
1236
|
.markdown h1, .markdown h2, .markdown h3 { margin: 12px 0 6px; font-size: 14px; }
|
|
@@ -830,6 +1305,9 @@ summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px;
|
|
|
830
1305
|
.additions { color: var(--success); }
|
|
831
1306
|
.deletions { color: var(--danger); }
|
|
832
1307
|
.file-delta { display: inline-flex; flex: none; gap: 6px; font: 12px "Cascadia Mono", Consolas, monospace; }
|
|
1308
|
+
.detail-message { padding: 8px 12px; color: var(--muted); }
|
|
1309
|
+
.detail-message:empty { display: none; }
|
|
1310
|
+
.detail-message button { margin-left: 8px; cursor: pointer; }
|
|
833
1311
|
.tool-image img { display: block; max-width: 100%; max-height: 720px; }
|
|
834
1312
|
.final-result h2 { color: var(--success); }
|
|
835
1313
|
@media (max-width: 700px) {
|