@keyou007/agent-bot 0.1.24-alpha.1 → 0.1.24-alpha.3

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.
Files changed (99) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +33 -7
  3. package/README.zh.md +32 -7
  4. package/dist/acp/AcpJsonRpcConnection.js +3 -1
  5. package/dist/acp/AcpJsonRpcConnection.js.map +1 -1
  6. package/dist/cli/SelfUpdateRunner.js +2 -0
  7. package/dist/cli/SelfUpdateRunner.js.map +1 -1
  8. package/dist/cli/SelfUpdater.js +2 -0
  9. package/dist/cli/SelfUpdater.js.map +1 -1
  10. package/dist/codex/AppServerConnection.js +3 -1
  11. package/dist/codex/AppServerConnection.js.map +1 -1
  12. package/dist/codex/CodexEventMapper.js +41 -3
  13. package/dist/codex/CodexEventMapper.js.map +1 -1
  14. package/dist/codex/CodexRuntime.js +95 -3
  15. package/dist/codex/CodexRuntime.js.map +1 -1
  16. package/dist/commands/CommandRouter.js +5 -0
  17. package/dist/commands/CommandRouter.js.map +1 -1
  18. package/dist/feishu/CardRenderer.js +49 -23
  19. package/dist/feishu/CardRenderer.js.map +1 -1
  20. package/dist/feishu/FeishuConnector.js +2 -0
  21. package/dist/feishu/FeishuConnector.js.map +1 -1
  22. package/dist/feishu/FeishuMarkdown.js +70 -4
  23. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  24. package/dist/feishu/FeishuMessageClient.js +44 -0
  25. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  26. package/dist/feishu/FeishuTurnPresenter.js +114 -37
  27. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  28. package/dist/feishu/RecentMessageContext.js +70 -0
  29. package/dist/feishu/RecentMessageContext.js.map +1 -0
  30. package/dist/index.js +62 -24
  31. package/dist/index.js.map +1 -1
  32. package/dist/local-files/DiagramPreview.js +171 -0
  33. package/dist/local-files/DiagramPreview.js.map +1 -0
  34. package/dist/local-files/LocalFileViewerServer.js +141 -3
  35. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  36. package/dist/local-files/PreviewSyntaxHighlight.js +57 -0
  37. package/dist/local-files/PreviewSyntaxHighlight.js.map +1 -0
  38. package/dist/local-files/TurnPreviewPage.js +499 -60
  39. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  40. package/dist/presentation/OutboundRouter.js +18 -9
  41. package/dist/presentation/OutboundRouter.js.map +1 -1
  42. package/dist/presentation/TurnStateReducer.js +157 -8
  43. package/dist/presentation/TurnStateReducer.js.map +1 -1
  44. package/dist/presentation/turnReasoning.js +23 -0
  45. package/dist/presentation/turnReasoning.js.map +1 -0
  46. package/dist/proxy/ProxySessionController.js +461 -49
  47. package/dist/proxy/ProxySessionController.js.map +1 -1
  48. package/dist/state/StateStore.js +43 -2
  49. package/dist/state/StateStore.js.map +1 -1
  50. package/dist/state/TurnPreviewJournal.js +469 -0
  51. package/dist/state/TurnPreviewJournal.js.map +1 -0
  52. package/dist/state/migrations.js +10 -0
  53. package/dist/state/migrations.js.map +1 -1
  54. package/dist/updates/AutomaticUpdatePreparer.js +16 -7
  55. package/dist/updates/AutomaticUpdatePreparer.js.map +1 -1
  56. package/dist/updates/ManualUpdateController.js +102 -0
  57. package/dist/updates/ManualUpdateController.js.map +1 -0
  58. package/dist/updates/PublishedRelease.js +42 -4
  59. package/dist/updates/PublishedRelease.js.map +1 -1
  60. package/dist/utils/ProtocolLineWarning.js +15 -0
  61. package/dist/utils/ProtocolLineWarning.js.map +1 -0
  62. package/docs/technical-reference.md +21 -1
  63. package/docs/technical-reference.zh.md +21 -1
  64. package/npm-shrinkwrap.json +1401 -10
  65. package/package.json +3 -1
  66. package/skills/agent-bot/SKILL.md +9 -2
  67. package/src/acp/AcpJsonRpcConnection.ts +3 -1
  68. package/src/cli/SelfUpdateRunner.ts +3 -0
  69. package/src/cli/SelfUpdater.ts +2 -0
  70. package/src/codex/AppServerConnection.ts +3 -1
  71. package/src/codex/CodexEventMapper.ts +40 -3
  72. package/src/codex/CodexRuntime.ts +91 -4
  73. package/src/commands/CommandRouter.ts +4 -0
  74. package/src/commands/commandTypes.ts +1 -0
  75. package/src/feishu/CardRenderer.ts +47 -21
  76. package/src/feishu/FeishuConnector.ts +2 -0
  77. package/src/feishu/FeishuMarkdown.ts +69 -3
  78. package/src/feishu/FeishuMessageClient.ts +44 -0
  79. package/src/feishu/FeishuTurnPresenter.ts +107 -31
  80. package/src/feishu/RecentMessageContext.ts +90 -0
  81. package/src/feishu/types.ts +5 -0
  82. package/src/index.ts +62 -22
  83. package/src/local-files/DiagramPreview.ts +173 -0
  84. package/src/local-files/LocalFileViewerServer.ts +129 -2
  85. package/src/local-files/PreviewSyntaxHighlight.ts +58 -0
  86. package/src/local-files/TurnPreviewPage.ts +539 -57
  87. package/src/presentation/OutboundRouter.ts +25 -7
  88. package/src/presentation/TurnStateReducer.ts +153 -9
  89. package/src/presentation/turnReasoning.ts +21 -0
  90. package/src/presentation/turnViewTypes.ts +19 -1
  91. package/src/proxy/ProxySessionController.ts +453 -40
  92. package/src/runtime/types.ts +21 -0
  93. package/src/state/StateStore.ts +45 -2
  94. package/src/state/TurnPreviewJournal.ts +398 -0
  95. package/src/state/migrations.ts +10 -0
  96. package/src/updates/AutomaticUpdatePreparer.ts +21 -7
  97. package/src/updates/ManualUpdateController.ts +116 -0
  98. package/src/updates/PublishedRelease.ts +49 -3
  99. package/src/utils/ProtocolLineWarning.ts +13 -0
package/src/index.ts CHANGED
@@ -29,9 +29,10 @@ import { ProxySessionController } from "./proxy/ProxySessionController.js";
29
29
  import type { AgentEnvironmentContext } from "./runtime/agentEnvironment.js";
30
30
  import { createAgentRuntimeRegistry } from "./runtime/createAgentRuntimeRegistry.js";
31
31
  import { StateStore } from "./state/StateStore.js";
32
+ import { ManualUpdateController } from "./updates/ManualUpdateController.js";
32
33
  import { DailyUpdateMonitor } from "./updates/DailyUpdateMonitor.js";
33
- import { readLatestStableVersion, readReleaseNotes } from "./updates/PublishedRelease.js";
34
- import { checkAutomaticUpdateSupport, prepareAutomaticUpdate } from "./updates/AutomaticUpdatePreparer.js";
34
+ import { readLatestStableVersion, readReleaseNotes, readPublishedReleases } from "./updates/PublishedRelease.js";
35
+ import { checkAutomaticUpdateSupport, prepareAutomaticUpdate, prepareSelectedUpdate } from "./updates/AutomaticUpdatePreparer.js";
35
36
  import { StartupNotifier } from "./startup/StartupNotifier.js";
36
37
  import { SessionMetadataHydrator } from "./startup/SessionMetadataHydrator.js";
37
38
  import { startFeishu } from "./startup/startFeishu.js";
@@ -56,6 +57,7 @@ import {
56
57
  import { refreshedSystemEnvironment } from "./supervision/systemEnvironment.js";
57
58
  import {
58
59
  assertSelfUpdatePlanPath,
60
+ finalizeSelfUpdatePlan,
59
61
  launchSelfUpdateRunner,
60
62
  readPendingSelfUpdate,
61
63
  releaseSelfUpdatePlan,
@@ -75,15 +77,18 @@ const consoleOnly = process.env.AGENT_BOT_CONSOLE_ONLY === "1";
75
77
  const config = loadConfig();
76
78
  const transport = consoleOnly ? "console" : requireServerFeishuTransport(config.feishu);
77
79
  const logger = createLogger(config);
78
- const store = new StateStore(config.storage.sqlitePath);
80
+ const store = new StateStore(config.storage.sqlitePath, (error) => logger.error({ error }, "Failed to persist Turn preview journal."));
79
81
  let localFileViewer: LocalFileViewerServer | undefined;
82
+ let loadTurnDetails: ((turnId: string) => Promise<unknown>) | undefined;
80
83
  if (config.fileViewer.enabled) {
81
84
  const viewer = new LocalFileViewerServer({
82
85
  host: config.fileViewer.host,
83
86
  port: config.fileViewer.port,
84
87
  publicBaseUrl: config.fileViewer.publicBaseUrl,
85
88
  stateDirectory: path.join(path.dirname(config.storage.sqlitePath), "file-viewer"),
89
+ previewJournal: store.previews,
86
90
  getTurnSnapshot: (turnId) => store.getTurnSnapshot(turnId),
91
+ loadTurnSnapshot: (turnId) => loadTurnDetails?.(turnId) ?? Promise.resolve(store.getTurnSnapshot(turnId)),
87
92
  });
88
93
  try {
89
94
  const address = await viewer.start();
@@ -153,6 +158,7 @@ const outbound = new OutboundRouter(routes);
153
158
  let shuttingDown = false;
154
159
  let restartRequested = false;
155
160
  let pendingSelfUpdatePlanPath: string | undefined;
161
+ let preparingSelfUpdate = false;
156
162
  const safeRestart = new SafeRestartScheduler({
157
163
  readActivity: () => store.getServerActivityState(),
158
164
  onReady: (reason, notificationTargets) => pendingSelfUpdatePlanPath
@@ -168,29 +174,62 @@ if (feishuOutbound && config.updates?.enabled !== false) {
168
174
  readLatest: readLatestStableVersion,
169
175
  readNotes: readReleaseNotes,
170
176
  checkSupport: () => checkAutomaticUpdateSupport({ home: agentBotHome(), configPath: activeConfigPath }),
171
- hasPendingUpdate: () => shuttingDown || restartRequested || !!pendingSelfUpdatePlanPath || safeRestart.scheduled,
177
+ hasPendingUpdate: () => preparingSelfUpdate || shuttingDown || restartRequested || !!pendingSelfUpdatePlanPath || safeRestart.scheduled,
172
178
  pendingVersion: () => pendingSelfUpdatePlanPath ? readPendingSelfUpdate(agentBotHome())?.plan.toVersion : undefined,
173
- applyUpdate: async (version) => {
174
- const prepared = await prepareAutomaticUpdate(version, { home: agentBotHome(), configPath: activeConfigPath });
175
- try {
176
- if (shuttingDown || restartRequested || pendingSelfUpdatePlanPath || safeRestart.scheduled) {
177
- throw new Error("服务正在停止或已有更新/重启等待执行,本次自动更新已停止。");
178
- }
179
- const target = privateRestartNotificationTarget(config.feishu.userOpenId);
180
- if (prepared.status === "prepared") {
181
- assertSelfUpdatePlanPath(prepared.planPath, agentBotHome());
182
- pendingSelfUpdatePlanPath = prepared.planPath;
183
- }
184
- safeRestart.schedule(`Agent Bot 自动更新到 ${version}`, target);
185
- } catch (error) {
186
- if (prepared.status === "prepared") releaseSelfUpdatePlan(prepared.planPath);
187
- throw error;
188
- }
189
- },
179
+ applyUpdate: (version) => prepareAndScheduleUpdate(version, privateRestartNotificationTarget(config.feishu.userOpenId)),
190
180
  });
191
181
  }
182
+ const manualUpdates = new ManualUpdateController({
183
+ store, outbound, logger, currentVersion: agentBotVersion,
184
+ ownerOpenId: () => config.feishu.userOpenId,
185
+ readReleases: readPublishedReleases,
186
+ checkSupport: () => checkAutomaticUpdateSupport({ home: agentBotHome(), configPath: activeConfigPath }),
187
+ hasPendingUpdate: () => preparingSelfUpdate || shuttingDown || restartRequested || !!pendingSelfUpdatePlanPath || safeRestart.scheduled,
188
+ applyUpdate: (version, contextKey, replyTarget) => prepareAndScheduleUpdate(version, {
189
+ contextKey,
190
+ ...(replyTarget ? { replyMessageId: replyTarget.messageId } : {}),
191
+ }, true),
192
+ });
193
+
194
+ async function prepareAndScheduleUpdate(version: string, target: RestartNotificationTarget, manual = false): Promise<void> {
195
+ if (preparingSelfUpdate || shuttingDown || restartRequested || pendingSelfUpdatePlanPath || safeRestart.scheduled) {
196
+ throw new Error("已有更新或重启正在进行,请稍后重试。");
197
+ }
198
+ preparingSelfUpdate = true;
199
+ try {
200
+ const prepare = manual ? prepareSelectedUpdate : prepareAutomaticUpdate;
201
+ const prepared = await prepare(version, { home: agentBotHome(), configPath: activeConfigPath });
202
+ try {
203
+ if (shuttingDown || restartRequested || pendingSelfUpdatePlanPath || safeRestart.scheduled) {
204
+ throw new Error("服务正在停止或已有更新/重启等待执行,本次更新已停止。");
205
+ }
206
+ if (prepared.status === "prepared") {
207
+ assertSelfUpdatePlanPath(prepared.planPath, agentBotHome());
208
+ finalizeSelfUpdatePlan(prepared.planPath, {
209
+ controlEndpoint: controlEndpoint(config.storage.sqlitePath),
210
+ databasePath: config.storage.sqlitePath,
211
+ restartService: true,
212
+ notificationTarget: target,
213
+ });
214
+ pendingSelfUpdatePlanPath = prepared.planPath;
215
+ }
216
+ safeRestart.schedule(`Agent Bot ${manual ? "更新" : "自动更新"}到 ${version}`, target);
217
+ } catch (error) {
218
+ if (prepared.status === "prepared") {
219
+ if (pendingSelfUpdatePlanPath === prepared.planPath) pendingSelfUpdatePlanPath = undefined;
220
+ releaseSelfUpdatePlan(prepared.planPath);
221
+ }
222
+ throw error;
223
+ }
224
+ } finally {
225
+ preparingSelfUpdate = false;
226
+ }
227
+ }
228
+
192
229
  const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
193
230
  supervised,
231
+ showUpdates: (contextKey, userId) => manualUpdates.show(contextKey, userId),
232
+ selectUpdate: (action, replyTarget) => manualUpdates.select(action, replyTarget),
194
233
  restart: requestRestart,
195
234
  forceSafeRestart: async (scheduleId) => {
196
235
  if (shuttingDown || restartRequested) return false;
@@ -226,6 +265,7 @@ const controller = new ProxySessionController(config, store, runtimes, outbound,
226
265
  logger.info({ agentName, changed }, "Stored Agent execution defaults.");
227
266
  },
228
267
  });
268
+ loadTurnDetails = (turnId) => controller.loadTurnDetails(turnId);
229
269
 
230
270
  if (feishuOutbound) {
231
271
  feishuConnector = new FeishuConnector(
@@ -278,7 +318,7 @@ function recoverPendingSelfUpdate(): void {
278
318
  if (!pending || pendingSelfUpdatePlanPath || restartRequested || safeRestart.scheduled) return;
279
319
  let notificationTarget: RestartNotificationTarget | undefined;
280
320
  try {
281
- notificationTarget = inferServerRestartTarget(pending.plan.notificationSessionId);
321
+ notificationTarget = pending.plan.notificationTarget ?? inferServerRestartTarget(pending.plan.notificationSessionId);
282
322
  } catch (error) {
283
323
  logger.warn({ error }, "Could not restore the self-update notification target; update recovery will continue.");
284
324
  }
@@ -0,0 +1,173 @@
1
+ import type MarkdownIt from "markdown-it";
2
+
3
+ export function enableDiagramFences(markdown: InstanceType<typeof MarkdownIt>): void {
4
+ const original = markdown.renderer.rules.fence!;
5
+ markdown.renderer.rules.fence = (tokens, index, options, environment, renderer) => {
6
+ const token = tokens[index]!;
7
+ if (!/^(mermaid|flowchart)$/iu.test(token.info.trim())) return original(tokens, index, options, environment, renderer);
8
+ const zh = environment?.language !== "en";
9
+ const source = markdown.utils.escapeHtml(token.content);
10
+ return `<div class="diagram" data-diagram data-diagram-size="fit" data-preview-key="diagram:${index}">
11
+ <div class="diagram-toolbar" role="group" aria-label="${zh ? "图表显示模式" : "Diagram display mode"}">
12
+ <button type="button" data-diagram-mode="preview" aria-pressed="true">${zh ? "预览" : "Preview"}</button>
13
+ <button type="button" data-diagram-mode="source" aria-pressed="false">${zh ? "源码" : "Source"}</button>
14
+ <button type="button" data-diagram-size-toggle hidden>${zh ? "原大" : "Actual size"}</button>
15
+ </div>
16
+ <p class="diagram-notice" role="status">${zh ? "正在渲染图表…" : "Rendering diagram…"}</p>
17
+ <div class="diagram-preview" role="img" aria-label="${zh ? "Mermaid 图表" : "Mermaid diagram"}"></div>
18
+ <pre class="diagram-source" hidden><code>${source}</code></pre>
19
+ <noscript><pre><code>${source}</code></pre></noscript>
20
+ </div>`;
21
+ };
22
+ }
23
+
24
+ export const DIAGRAM_PREVIEW_CSS = `
25
+ .diagram { --diagram-max-height:min(640px, 70vh); border:1px solid var(--border, #d8dee8); border-radius:12px; overflow:hidden; margin:12px 0; }
26
+ .diagram-toolbar { display:flex; flex-wrap:wrap; gap:4px; padding:6px 8px; border-bottom:1px solid var(--border, #d8dee8); }
27
+ .diagram-toolbar button { font:inherit; font-size:12px; color:inherit; background:transparent; border:1px solid transparent; border-radius:6px; padding:3px 10px; cursor:pointer; }
28
+ .diagram-toolbar button[aria-pressed="true"] { border-color:currentColor; }
29
+ .diagram-toolbar [data-diagram-size-toggle] { margin-left:auto; }
30
+ .diagram-toolbar button:focus-visible { outline:2px solid #4285d4; outline-offset:1px; }
31
+ .diagram-notice { font-size:12px; opacity:.75; margin:8px 12px!important; }
32
+ .diagram-preview { padding:12px; overflow:auto; max-height:var(--diagram-max-height); }
33
+ .diagram-preview svg { display:block; margin:auto; max-width:none!important; height:auto; width:min(100%, var(--diagram-width, 100%))!important; }
34
+ .diagram[data-diagram-size="actual"] .diagram-preview svg { width:var(--diagram-width, 100%)!important; }
35
+ @media (max-width:640px), (pointer:coarse) {
36
+ .diagram { --diagram-max-height:min(480px, 55vh); }
37
+ @supports (height:1svh) { .diagram { --diagram-max-height:min(480px, 55svh); } }
38
+ }
39
+ .diagram .diagram-source { margin:0; border:0; border-radius:0; max-height:30em; overflow:auto; white-space:pre; }
40
+ .diagram [hidden] { display:none!important; }
41
+ @media (hover:none), (pointer:coarse) { .diagram-preview, .diagram-source { scrollbar-width:none; } .diagram-preview::-webkit-scrollbar, .diagram-source::-webkit-scrollbar { display:none; } }
42
+ `;
43
+
44
+ export const DIAGRAM_PREVIEW_CLIENT_SCRIPT = String.raw`(() => {
45
+ const zh = /^zh/i.test(document.documentElement.lang);
46
+ const loading = zh ? "正在渲染图表…" : "Rendering diagram…";
47
+ const failed = zh ? "图表暂时无法渲染,已显示源码。" : "Diagram could not be rendered. Showing source.";
48
+ const states = new WeakMap();
49
+ let library;
50
+ let sequence = 0;
51
+ const loadLibrary = () => {
52
+ if (!library) library = new Promise((resolve, reject) => {
53
+ const url = document.body.dataset.diagramScript;
54
+ if (!url) { reject(new Error("Diagram renderer unavailable")); return; }
55
+ const script = document.createElement("script");
56
+ script.src = url;
57
+ const timeout = setTimeout(() => {
58
+ script.remove();
59
+ reject(new Error("Diagram renderer timed out"));
60
+ }, 15000);
61
+ script.onload = () => {
62
+ clearTimeout(timeout);
63
+ const mermaid = window.mermaid;
64
+ if (!mermaid) { reject(new Error("Diagram renderer unavailable")); return; }
65
+ try {
66
+ mermaid.initialize({
67
+ startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true,
68
+ maxTextSize: 50000, maxEdges: 500, htmlLabels: false,
69
+ flowchart: { htmlLabels: false },
70
+ theme: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "default",
71
+ secure: ["securityLevel", "startOnLoad", "maxTextSize", "maxEdges", "suppressErrorRendering", "htmlLabels", "flowchart", "secure"],
72
+ });
73
+ resolve(mermaid);
74
+ } catch (error) { reject(error); }
75
+ };
76
+ script.onerror = () => { clearTimeout(timeout); script.remove(); reject(new Error("Diagram renderer unavailable")); };
77
+ document.head.appendChild(script);
78
+ }).catch((error) => { library = undefined; throw error; });
79
+ return library;
80
+ };
81
+ const show = (block, state) => {
82
+ const mode = state.error ? "source" : state.mode;
83
+ block.querySelector(".diagram-preview").hidden = mode !== "preview";
84
+ block.querySelector(".diagram-source").hidden = mode !== "source";
85
+ block.dataset.diagramSize = state.size;
86
+ const sizeButton = block.querySelector("[data-diagram-size-toggle]");
87
+ sizeButton.hidden = mode !== "preview" || state.rendered !== state.source;
88
+ sizeButton.textContent = state.size === "actual" ? (zh ? "适应" : "Fit") : (zh ? "原大" : "Actual size");
89
+ sizeButton.setAttribute("aria-label", state.size === "actual"
90
+ ? (zh ? "适应容器宽度" : "Fit diagram to width")
91
+ : (zh ? "按原始尺寸查看图表" : "View diagram at actual size"));
92
+ const notice = block.querySelector(".diagram-notice");
93
+ notice.hidden = !state.error && (state.rendered === state.source || mode === "source");
94
+ notice.textContent = state.error ? failed : loading;
95
+ block.querySelectorAll("[data-diagram-mode]").forEach((button) => {
96
+ button.setAttribute("aria-pressed", String(button.dataset.diagramMode === mode));
97
+ });
98
+ };
99
+ const render = async (block, state, revision) => {
100
+ const source = state.source;
101
+ let container;
102
+ try {
103
+ // Keep model-authored Mermaid config from weakening the renderer's safety policy.
104
+ if (source.length > 50000 || /^\s*---(?:\r?\n|$)/u.test(source) || /%%\s*\{/u.test(source)) {
105
+ throw new Error("Diagram configuration or size is unsupported");
106
+ }
107
+ const mermaid = await loadLibrary();
108
+ if (!block.isConnected || state.revision !== revision) return;
109
+ container = document.createElement("div");
110
+ container.style.cssText = "position:absolute;left:-100000px;top:0;width:1000px;visibility:hidden;pointer-events:none";
111
+ document.body.appendChild(container);
112
+ const result = await mermaid.render("agentbot-diagram-" + (++sequence), source, container);
113
+ if (!block.isConnected || state.revision !== revision) return;
114
+ const template = document.createElement("template");
115
+ template.innerHTML = result.svg;
116
+ // Mermaid's strict renderer sanitizes SVG. Also prohibit links, images and callbacks.
117
+ template.content.querySelectorAll("script,foreignObject,iframe,object,embed,image").forEach((node) => node.remove());
118
+ template.content.querySelectorAll("*").forEach((node) => {
119
+ for (const attr of Array.from(node.attributes)) {
120
+ if (/^on/i.test(attr.name) || /^(href|xlink:href|src)$/i.test(attr.name) && !attr.value.startsWith("#")) node.removeAttribute(attr.name);
121
+ }
122
+ });
123
+ const svg = template.content.querySelector("svg");
124
+ if (!svg) throw new Error("Missing SVG");
125
+ const viewBox = (svg.getAttribute("viewBox") || "").split(/[ ,]+/).map(Number);
126
+ if (viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
127
+ svg.style.setProperty("--diagram-width", viewBox[2] + "px");
128
+ }
129
+ block.querySelector(".diagram-preview").replaceChildren(template.content);
130
+ state.rendered = source;
131
+ state.error = false;
132
+ show(block, state);
133
+ } catch {
134
+ if (block.isConnected && state.revision === revision) { state.error = true; show(block, state); }
135
+ } finally { if (container) container.remove(); }
136
+ };
137
+ const schedule = (block, state) => {
138
+ clearTimeout(state.timer);
139
+ state.revision += 1;
140
+ if (state.mode !== "preview" || state.rendered === state.source) return;
141
+ const revision = state.revision;
142
+ state.timer = setTimeout(() => void render(block, state, revision), 180);
143
+ };
144
+ const refresh = (block) => {
145
+ const source = block.querySelector(".diagram-source code").textContent;
146
+ let state = states.get(block);
147
+ if (!state) {
148
+ state = { source, mode: "preview", size: "fit", revision: 0, error: false };
149
+ states.set(block, state);
150
+ block.querySelector("[data-diagram-size-toggle]").addEventListener("click", () => {
151
+ state.size = state.size === "actual" ? "fit" : "actual";
152
+ const preview = block.querySelector(".diagram-preview");
153
+ preview.scrollLeft = 0;
154
+ preview.scrollTop = 0;
155
+ show(block, state);
156
+ });
157
+ block.querySelectorAll("[data-diagram-mode]").forEach((button) => {
158
+ button.addEventListener("click", () => {
159
+ state.mode = button.dataset.diagramMode;
160
+ if (state.mode === "preview") state.error = false;
161
+ show(block, state);
162
+ schedule(block, state);
163
+ });
164
+ });
165
+ } else if (state.source === source) { return; }
166
+ else { state.source = source; state.error = false; }
167
+ show(block, state);
168
+ schedule(block, state);
169
+ };
170
+ const scan = () => document.querySelectorAll("[data-diagram]").forEach(refresh);
171
+ window.agentBotDiagrams = { refresh, scan };
172
+ scan();
173
+ })();`;
@@ -1,5 +1,7 @@
1
+ import { TurnPreviewProjection, type TurnPreviewJournal } from "../state/TurnPreviewJournal.js";
1
2
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
3
  import fs from "node:fs";
4
+ import { createRequire } from "node:module";
3
5
  import http, { type IncomingMessage, type ServerResponse } from "node:http";
4
6
  import path from "node:path";
5
7
  import hljs from "highlight.js/lib/common";
@@ -16,10 +18,15 @@ import {
16
18
  isTurnPreviewState,
17
19
  detectTurnPreviewLanguage,
18
20
  renderTurnPreviewPage,
21
+ renderTurnPreviewPatch,
22
+ renderToolFooter,
23
+ renderTurnPreviewDetail,
19
24
  renderTurnPreviewSnapshot,
20
25
  TURN_PREVIEW_CLIENT_SCRIPT,
21
26
  } from "./TurnPreviewPage.js";
22
27
 
28
+ const require = createRequire(import.meta.url);
29
+ let mermaidSource: string | undefined;
23
30
  const SECRET_FILE = "secret";
24
31
  const PORT_FILE = "port";
25
32
  const SHORT_TOKEN_BYTES = 12;
@@ -230,7 +237,9 @@ export interface LocalFileViewerServerOptions {
230
237
  port: number;
231
238
  publicBaseUrl?: string;
232
239
  stateDirectory: string;
240
+ previewJournal?: TurnPreviewJournal;
233
241
  getTurnSnapshot?: (turnId: string) => unknown;
242
+ loadTurnSnapshot?: (turnId: string) => Promise<unknown>;
234
243
  turnPreviewPollIntervalMs?: number;
235
244
  }
236
245
 
@@ -376,6 +385,11 @@ export class LocalFileViewerServer {
376
385
  this.sendJavascript(response, request.method === "HEAD", TURN_PREVIEW_CLIENT_SCRIPT);
377
386
  return;
378
387
  }
388
+ if (route === "/assets/mermaid.js") {
389
+ mermaidSource ??= fs.readFileSync(require.resolve("mermaid/dist/mermaid.min.js"), "utf8");
390
+ this.sendJavascript(response, request.method === "HEAD", mermaidSource);
391
+ return;
392
+ }
379
393
  const turnPreviewMatch = new RegExp(`^/turn-preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
380
394
  if (turnPreviewMatch) {
381
395
  const turnId = this.verifyTurnPreviewToken(
@@ -386,23 +400,68 @@ export class LocalFileViewerServer {
386
400
  this.sendHtml(response, 403, errorPage("Turn 链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
387
401
  return;
388
402
  }
389
- const snapshot = this.options.getTurnSnapshot?.(turnId);
403
+ const journal = this.options.previewJournal;
404
+ const recorded = journal?.has(turnId);
405
+ if (recorded && requestUrl.searchParams.get("events") === "1") {
406
+ this.serveJournalEvents(request, response, turnId, journal!);
407
+ return;
408
+ }
409
+ const detailKey = requestUrl.searchParams.get("detail");
410
+ let snapshot = recorded
411
+ ? journal!.load(turnId, detailKey ?? false)
412
+ : this.options.getTurnSnapshot?.(turnId);
413
+ if (!recorded && this.options.loadTurnSnapshot && request.method !== "HEAD"
414
+ && requestUrl.searchParams.get("events") !== "1" && !requestUrl.searchParams.has("detail")) {
415
+ const saved = snapshot;
416
+ try {
417
+ snapshot = await this.options.loadTurnSnapshot(turnId);
418
+ } catch (error) {
419
+ const message = error instanceof Error ? error.message : String(error);
420
+ if (!isTurnPreviewState(saved)) {
421
+ this.sendHtml(response, 502, errorPage("历史执行详情读取失败", `${message} 刷新页面重试;任务状态未改变。`), false);
422
+ return;
423
+ }
424
+ snapshot = { ...saved, historyDetailError: message };
425
+ }
426
+ }
390
427
  if (!isTurnPreviewState(snapshot)) {
391
428
  this.sendHtml(response, 404, errorPage("Turn 不存在", "这次执行可能尚未保存或已被清理。"), request.method === "HEAD");
392
429
  return;
393
430
  }
431
+ if (requestUrl.searchParams.has("detail")) {
432
+ const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
433
+ const key = requestUrl.searchParams.get("detail") ?? "";
434
+ const tool = snapshot.activities.find((a) => a.kind === "tool" && `tool:${a.id}` === key);
435
+ const after = requestUrl.searchParams.get("detailAfter");
436
+ const outputAppend = recorded && after && tool?.kind === "tool" && tool.tool.kind === "command" && !tool.tool.error
437
+ && !/repl/i.test(tool.tool.title) && snapshot.previewCursor !== undefined
438
+ ? journal!.outputAppend(turnId, tool.id, Number(after), snapshot.previewCursor) : undefined;
439
+ const detail = outputAppend !== undefined && tool?.kind === "tool"
440
+ && (snapshot.fullToolOutputs?.[tool.id]?.length ?? 0) > outputAppend.length
441
+ ? { key, revision: String(tool.tool.previewRevision), content: "", outputAppend,
442
+ footer: renderToolFooter(tool.tool, snapshot.fullToolOutputs?.[tool.id]?.length ?? 0, language) }
443
+ : renderTurnPreviewDetail(snapshot, key, (filePath) => this.createFileUrl(filePath), language);
444
+ setSecurityHeaders(response);
445
+ response.statusCode = detail ? 200 : 404;
446
+ response.setHeader("Cache-Control", "no-store");
447
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
448
+ response.end(request.method === "HEAD" ? undefined : JSON.stringify(detail ? { ...detail, cursor: snapshot.previewCursor } : { error: "Turn detail not found." }));
449
+ return;
450
+ }
394
451
  if (requestUrl.searchParams.get("events") === "1") {
395
452
  this.serveTurnEvents(request, response, turnId);
396
453
  return;
397
454
  }
398
455
  const eventsUrl = new URL(this.createTurnPreviewUrl(turnId)!);
399
456
  eventsUrl.searchParams.set("events", "1");
457
+ if (snapshot.previewCursor !== undefined) eventsUrl.searchParams.set("after", String(snapshot.previewCursor));
400
458
  const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
401
459
  const html = renderTurnPreviewPage({
402
460
  state: snapshot,
403
461
  eventsUrl: eventsUrl.toString(),
404
462
  scriptPath: `${this.basePath}/assets/turn-preview.js`.replace(/\/{2,}/gu, "/"),
405
463
  localFileUrl: (filePath) => this.createFileUrl(filePath),
464
+ diagramScriptPath: `${this.basePath}/assets/mermaid.js`.replace(/\/{2,}/gu, "/"),
406
465
  language,
407
466
  });
408
467
  this.sendHtml(response, 200, html, request.method === "HEAD");
@@ -683,6 +742,74 @@ export class LocalFileViewerServer {
683
742
  response.once("close", cleanup);
684
743
  }
685
744
 
745
+ private serveJournalEvents(request: IncomingMessage, response: ServerResponse, turnId: string, journal: TurnPreviewJournal): void {
746
+ journal.flush(turnId);
747
+ setSecurityHeaders(response);
748
+ response.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-store", "X-Accel-Buffering": "no" });
749
+ if (request.method === "HEAD") { response.end(); return; }
750
+ response.flushHeaders();
751
+ const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
752
+ const projection = new TurnPreviewProjection(false);
753
+ let offset = 0;
754
+ let closed = false;
755
+ let initial = true;
756
+ const cursor = request.headers["last-event-id"] ?? new URL(request.url!, "http://localhost").searchParams.get("after");
757
+ if (typeof cursor === "string" && /^\d+$/u.test(cursor) && Number.isSafeInteger(Number(cursor)) && Number(cursor) > 0
758
+ && Number(cursor) <= fs.statSync(journal.file(turnId)).size) {
759
+ while (offset < Number(cursor)) {
760
+ const batch = journal.read(turnId, offset, 256 * 1024, Number(cursor));
761
+ for (const record of batch.records) projection.apply(record, ++projection.revision);
762
+ if (batch.offset === offset) break;
763
+ offset = batch.offset;
764
+ }
765
+ initial = offset !== Number(cursor);
766
+ }
767
+ let waitingDrain = false;
768
+ const poll = () => {
769
+ if (closed || waitingDrain) return;
770
+ try {
771
+ projection.changed.clear();
772
+ projection.removed.clear();
773
+ const batch = journal.read(turnId, offset);
774
+ if (!batch.records.length) {
775
+ const state = projection.state;
776
+ if (state && ["completed", "cancelled", "failed"].includes(state.status)) {
777
+ const update = renderTurnPreviewPatch(state, new Set(), new Set(), (file) => this.createFileUrl(file), language);
778
+ response.write(`id: ${offset}\nevent: patch\ndata: ${JSON.stringify(update)}\n\n`);
779
+ cleanup(); response.end();
780
+ }
781
+ return;
782
+ }
783
+ offset = batch.offset;
784
+ for (const record of batch.records) projection.apply(record, ++projection.revision);
785
+ const state = projection.state;
786
+ if (!state) return;
787
+ const render = initial
788
+ ? renderTurnPreviewSnapshot(state, (file) => this.createFileUrl(file), language, { deferDetails: true })
789
+ : renderTurnPreviewPatch(state, projection.changed, projection.removed, (file) => this.createFileUrl(file), language);
790
+ // A terminal record can share a batch with later metadata. Drain the captured prefix first.
791
+ render.terminal = render.terminal && batch.end;
792
+ waitingDrain = !response.write(`id: ${offset}\nevent: ${initial ? "update" : "patch"}\ndata: ${JSON.stringify(render)}\n\n`);
793
+ initial = false;
794
+ if (render.terminal) { cleanup(); response.end(); }
795
+ else if (!batch.end) setImmediate(poll);
796
+ } catch (error) {
797
+ writeServerSentEvent(response, "unavailable", JSON.stringify({ message: "Turn preview could not be read." }));
798
+ cleanup();
799
+ response.end();
800
+ }
801
+ };
802
+ const poller = setInterval(poll, Math.max(50, this.options.turnPreviewPollIntervalMs ?? 1_000));
803
+ poller.unref();
804
+ const keepAlive = setInterval(() => { if (!closed && !waitingDrain) response.write(": keep-alive\n\n"); }, 15_000);
805
+ keepAlive.unref();
806
+ const cleanup = () => { closed = true; clearInterval(poller); clearInterval(keepAlive); };
807
+ response.on("drain", () => { waitingDrain = false; poll(); });
808
+ response.once("close", cleanup);
809
+ request.once("aborted", cleanup);
810
+ poll();
811
+ }
812
+
686
813
  private serveTurnEvents(request: IncomingMessage, response: ServerResponse, turnId: string): void {
687
814
  setSecurityHeaders(response);
688
815
  response.statusCode = 200;
@@ -710,7 +837,7 @@ export class LocalFileViewerServer {
710
837
  const serialized = JSON.stringify(state);
711
838
  if (serialized === previousSnapshot) return;
712
839
  previousSnapshot = serialized;
713
- const snapshot = renderTurnPreviewSnapshot(state, (filePath) => this.createFileUrl(filePath), language);
840
+ const snapshot = renderTurnPreviewSnapshot(state, (filePath) => this.createFileUrl(filePath), language, { deferDetails: true });
714
841
  writeServerSentEvent(response, "update", JSON.stringify(snapshot));
715
842
  };
716
843
  sendSnapshot();
@@ -0,0 +1,58 @@
1
+ import hljs from "highlight.js/lib/common";
2
+ import dockerfile from "highlight.js/lib/languages/dockerfile";
3
+ import dos from "highlight.js/lib/languages/dos";
4
+ import powershell from "highlight.js/lib/languages/powershell";
5
+
6
+ hljs.registerLanguage("dockerfile", dockerfile);
7
+ hljs.registerLanguage("dos", dos);
8
+ hljs.registerLanguage("powershell", powershell);
9
+
10
+ const LANGUAGE_ALIASES: Readonly<Record<string, string>> = { zsh: "bash", pwsh: "powershell" };
11
+ export const MAX_PREVIEW_HIGHLIGHT_BYTES = 64 * 1024;
12
+ const MAX_CACHE_BYTES = 1024 * 1024;
13
+ const MAX_CACHE_ENTRIES = 32;
14
+ const cache = new Map<string, { html: string; bytes: number }>();
15
+ let cacheBytes = 0;
16
+
17
+ // Returning an empty string asks MarkdownIt to escape the original code unchanged.
18
+ export function highlightPreviewCode(value: string, language: string): string {
19
+ const name = language.trim().toLowerCase();
20
+ const resolved = Object.hasOwn(LANGUAGE_ALIASES, name) ? LANGUAGE_ALIASES[name]! : name;
21
+ if (!resolved || !hljs.getLanguage(resolved) || Buffer.byteLength(value, "utf8") > MAX_PREVIEW_HIGHLIGHT_BYTES) return "";
22
+ const key = `${resolved}\0${value}`;
23
+ const cached = cache.get(key);
24
+ if (cached) {
25
+ cache.delete(key);
26
+ cache.set(key, cached);
27
+ return cached.html;
28
+ }
29
+ try {
30
+ const html = hljs.highlight(value, { language: resolved, ignoreIllegals: true }).value;
31
+ const bytes = Buffer.byteLength(key, "utf8") + Buffer.byteLength(html, "utf8");
32
+ if (bytes <= MAX_CACHE_BYTES) {
33
+ while (cache.size >= MAX_CACHE_ENTRIES || cacheBytes + bytes > MAX_CACHE_BYTES) {
34
+ const oldest = cache.keys().next().value!;
35
+ cacheBytes -= cache.get(oldest)!.bytes;
36
+ cache.delete(oldest);
37
+ }
38
+ cache.set(key, { html, bytes });
39
+ cacheBytes += bytes;
40
+ }
41
+ return html;
42
+ } catch {
43
+ return "";
44
+ }
45
+ }
46
+
47
+ export const PREVIEW_SYNTAX_CSS = `
48
+ .markdown pre { --syntax-comment:#667080; --syntax-keyword:#9639a5; --syntax-string:#087f5b; --syntax-number:#986801; --syntax-title:#005cc5; --syntax-deletion:#b31d28; }
49
+ .markdown pre .hljs-comment, .markdown pre .hljs-quote { color:var(--syntax-comment); }
50
+ .markdown pre .hljs-keyword, .markdown pre .hljs-selector-tag, .markdown pre .hljs-literal, .markdown pre .hljs-section, .markdown pre .hljs-link { color:var(--syntax-keyword); }
51
+ .markdown pre .hljs-string, .markdown pre .hljs-regexp, .markdown pre .hljs-name, .markdown pre .hljs-type, .markdown pre .hljs-attribute, .markdown pre .hljs-symbol, .markdown pre .hljs-bullet, .markdown pre .hljs-addition, .markdown pre .hljs-variable, .markdown pre .hljs-template-tag, .markdown pre .hljs-template-variable { color:var(--syntax-string); }
52
+ .markdown pre .hljs-number, .markdown pre .hljs-meta, .markdown pre .hljs-built_in, .markdown pre .hljs-builtin-name, .markdown pre .hljs-params, .markdown pre .hljs-attr { color:var(--syntax-number); }
53
+ .markdown pre .hljs-title { color:var(--syntax-title); }
54
+ .markdown pre .hljs-deletion { color:var(--syntax-deletion); }
55
+ @media (prefers-color-scheme:dark) {
56
+ .markdown pre { --syntax-comment:#8b949e; --syntax-keyword:#ff7b72; --syntax-string:#a5d6ff; --syntax-number:#d2a8ff; --syntax-title:#79c0ff; --syntax-deletion:#ffdcd7; }
57
+ }
58
+ `;