@keyou007/agent-bot 0.1.24-alpha.4 → 0.1.24-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +42 -6
  3. package/README.zh.md +42 -6
  4. package/SECURITY.md +12 -0
  5. package/config.example.yaml +5 -0
  6. package/dist/cli/controlProtocol.js.map +1 -1
  7. package/dist/cli/serverStatus.js +1 -0
  8. package/dist/cli/serverStatus.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/codex/AppServerPermissions.js +34 -0
  12. package/dist/codex/AppServerPermissions.js.map +1 -0
  13. package/dist/codex/CodexLocalActivityDetector.js +37 -5
  14. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  15. package/dist/codex/CodexRuntime.js +238 -127
  16. package/dist/codex/CodexRuntime.js.map +1 -1
  17. package/dist/codex/ProviderSwitch.js +12 -13
  18. package/dist/codex/ProviderSwitch.js.map +1 -1
  19. package/dist/codex/ThreadWriterProcess.js +19 -5
  20. package/dist/codex/ThreadWriterProcess.js.map +1 -1
  21. package/dist/config/AgentExecutionDefaultsStore.js +4 -0
  22. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  23. package/dist/config/loadConfig.js +4 -0
  24. package/dist/config/loadConfig.js.map +1 -1
  25. package/dist/config/schema.js +3 -0
  26. package/dist/config/schema.js.map +1 -1
  27. package/dist/feishu/CardRenderer.js +146 -72
  28. package/dist/feishu/CardRenderer.js.map +1 -1
  29. package/dist/feishu/FeishuConnector.js +1 -0
  30. package/dist/feishu/FeishuConnector.js.map +1 -1
  31. package/dist/feishu/FeishuTurnPresenter.js +121 -17
  32. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  33. package/dist/index.js +43 -6
  34. package/dist/index.js.map +1 -1
  35. package/dist/local-files/CsvPreview.js +148 -0
  36. package/dist/local-files/CsvPreview.js.map +1 -0
  37. package/dist/local-files/LocalFileViewerServer.js +104 -14
  38. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  39. package/dist/local-files/TurnPreviewPage.js +128 -12
  40. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  41. package/dist/presentation/TurnStateReducer.js +80 -6
  42. package/dist/presentation/TurnStateReducer.js.map +1 -1
  43. package/dist/proxy/ProxySessionController.js +767 -260
  44. package/dist/proxy/ProxySessionController.js.map +1 -1
  45. package/dist/proxy/SessionOperationQueue.js +14 -0
  46. package/dist/proxy/SessionOperationQueue.js.map +1 -0
  47. package/dist/runtime/AcpRuntimeAdapter.js +4 -2
  48. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  49. package/dist/state/StateStore.js +22 -0
  50. package/dist/state/StateStore.js.map +1 -1
  51. package/dist/state/TurnPreviewJournal.js +19 -7
  52. package/dist/state/TurnPreviewJournal.js.map +1 -1
  53. package/dist/state/migrations.js +6 -0
  54. package/dist/state/migrations.js.map +1 -1
  55. package/dist/supervision/SafeRestartNotifier.js +1 -0
  56. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  57. package/dist/supervision/SafeRestartScheduler.js +19 -6
  58. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  59. package/dist/terminal/TerminalCards.js +13 -0
  60. package/dist/terminal/TerminalCards.js.map +1 -0
  61. package/dist/terminal/TerminalLinks.js +76 -0
  62. package/dist/terminal/TerminalLinks.js.map +1 -0
  63. package/dist/terminal/TerminalPage.js +212 -0
  64. package/dist/terminal/TerminalPage.js.map +1 -0
  65. package/dist/terminal/TerminalProcess.js +71 -0
  66. package/dist/terminal/TerminalProcess.js.map +1 -0
  67. package/dist/terminal/TerminalServer.js +639 -0
  68. package/dist/terminal/TerminalServer.js.map +1 -0
  69. package/docs/technical-reference.md +53 -3
  70. package/docs/technical-reference.zh.md +52 -2
  71. package/npm-shrinkwrap.json +55 -5
  72. package/package.json +11 -4
  73. package/skills/agent-bot/SKILL.md +15 -2
  74. package/src/cli/controlProtocol.ts +2 -1
  75. package/src/cli/serverStatus.ts +1 -0
  76. package/src/cli.ts +6 -1
  77. package/src/codex/AppServerPermissions.ts +39 -0
  78. package/src/codex/CodexLocalActivityDetector.ts +34 -4
  79. package/src/codex/CodexRuntime.ts +243 -139
  80. package/src/codex/ProviderSwitch.ts +11 -12
  81. package/src/codex/ThreadWriterProcess.ts +17 -9
  82. package/src/config/AgentExecutionDefaultsStore.ts +2 -0
  83. package/src/config/loadConfig.ts +4 -0
  84. package/src/config/schema.ts +5 -1
  85. package/src/feishu/CardRenderer.ts +159 -72
  86. package/src/feishu/FeishuConnector.ts +1 -0
  87. package/src/feishu/FeishuTurnPresenter.ts +108 -16
  88. package/src/feishu/types.ts +1 -0
  89. package/src/index.ts +44 -7
  90. package/src/local-files/CsvPreview.ts +132 -0
  91. package/src/local-files/LocalFileViewerServer.ts +102 -15
  92. package/src/local-files/TurnPreviewPage.ts +131 -13
  93. package/src/presentation/TurnStateReducer.ts +87 -7
  94. package/src/presentation/turnViewTypes.ts +9 -0
  95. package/src/proxy/ProxySessionController.ts +762 -259
  96. package/src/proxy/SessionOperationQueue.ts +13 -0
  97. package/src/runtime/AcpRuntimeAdapter.ts +5 -3
  98. package/src/runtime/types.ts +10 -3
  99. package/src/state/StateStore.ts +38 -1
  100. package/src/state/TurnPreviewJournal.ts +19 -7
  101. package/src/state/migrations.ts +6 -0
  102. package/src/supervision/SafeRestartNotifier.ts +1 -0
  103. package/src/supervision/SafeRestartScheduler.ts +18 -6
  104. package/src/terminal/TerminalCards.ts +18 -0
  105. package/src/terminal/TerminalLinks.ts +61 -0
  106. package/src/terminal/TerminalPage.ts +213 -0
  107. package/src/terminal/TerminalProcess.ts +71 -0
  108. package/src/terminal/TerminalServer.ts +513 -0
@@ -3,12 +3,15 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import { createRequire } from "node:module";
5
5
  import http, { type IncomingMessage, type ServerResponse } from "node:http";
6
+ import type { Duplex } from "node:stream";
6
7
  import path from "node:path";
7
8
  import hljs from "highlight.js/lib/common";
8
9
  import dos from "highlight.js/lib/languages/dos";
9
10
  import dockerfile from "highlight.js/lib/languages/dockerfile";
10
11
  import powershell from "highlight.js/lib/languages/powershell";
11
12
  import MarkdownIt from "markdown-it";
13
+ import { CSV_PREVIEW_CSS, renderCsvPreview, csvSourcePreview } from "./CsvPreview.js";
14
+ import { enableDiagramFences, DIAGRAM_PREVIEW_CLIENT_SCRIPT, DIAGRAM_PREVIEW_CSS } from "./DiagramPreview.js";
12
15
  import { isFileUrl, rewriteMarkdownFileLinks } from "./MarkdownFileLinks.js";
13
16
  import {
14
17
  selectPreferredNetworkAddress,
@@ -56,6 +59,7 @@ const MARKDOWN_RENDERER = new MarkdownIt({
56
59
  return `<pre class="markdown-code-block hljs"><code${languageAttribute}>${highlighted}</code></pre>`;
57
60
  },
58
61
  });
62
+ enableDiagramFences(MARKDOWN_RENDERER);
59
63
  MARKDOWN_RENDERER.renderer.rules.table_open = () =>
60
64
  '<div class="markdown-table-scroll" role="region" aria-label="Markdown 表格" tabindex="0"><table>\n';
61
65
  MARKDOWN_RENDERER.renderer.rules.table_close = () => '</table></div>\n';
@@ -79,7 +83,7 @@ const HIGHLIGHT_LANGUAGE_BY_FILENAME: Readonly<Record<string, string>> = {
79
83
  "dockerfile": "dockerfile",
80
84
  "makefile": "makefile",
81
85
  };
82
- const VIEWER_CLIENT_SCRIPT = `(() => {
86
+ const VIEWER_CLIENT_SCRIPT = DIAGRAM_PREVIEW_CLIENT_SCRIPT + `(() => {
83
87
  const eventsUrl = document.body.dataset.eventsUrl;
84
88
  const content = document.getElementById("viewer-content");
85
89
  const metadata = document.getElementById("viewer-metadata");
@@ -157,14 +161,15 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
157
161
  requestAnimationFrame(positionHashTarget);
158
162
  if (!eventsUrl || typeof EventSource !== "function") return;
159
163
 
160
- const restoreScroll = (top, left, atBottom, codeScrollLeft, tableScrollLeft) => {
164
+ const restoreScroll = (top, left, atBottom, codeScrollLeft, tablePositions) => {
161
165
  requestAnimationFrame(() => {
162
166
  const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
163
167
  window.scrollTo(left, atBottom ? maxTop : Math.min(top, maxTop));
164
168
  const code = content.querySelector(".code");
165
169
  if (code) code.scrollLeft = codeScrollLeft;
166
- content.querySelectorAll(".markdown-table-scroll").forEach((table, index) => {
167
- table.scrollLeft = tableScrollLeft[index] ?? 0;
170
+ content.querySelectorAll(".markdown-table-scroll, .csv-table-scroll").forEach((table, index) => {
171
+ table.scrollLeft = tablePositions[index]?.left ?? 0;
172
+ table.scrollTop = tablePositions[index]?.top ?? 0;
168
173
  });
169
174
  });
170
175
  };
@@ -174,12 +179,40 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
174
179
  const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
175
180
  const atBottom = maxTop - top <= 24;
176
181
  const codeScrollLeft = content.querySelector(".code")?.scrollLeft ?? 0;
177
- const tableScrollLeft = Array.from(content.querySelectorAll(".markdown-table-scroll"), (table) => table.scrollLeft);
178
- content.innerHTML = nextContent;
182
+ const tablePositions = Array.from(content.querySelectorAll(".markdown-table-scroll, .csv-table-scroll"), (table) => ({ left: table.scrollLeft, top: table.scrollTop }));
183
+ const template = document.createElement("template");
184
+ template.innerHTML = nextContent;
185
+ const previousDiagrams = new Set(content.querySelectorAll("[data-diagram]"));
186
+ const nextDiagrams = Array.from(template.content.querySelectorAll("[data-diagram]"));
187
+ const retainedDiagrams = new Map();
188
+ const diagramSource = (block) => block.querySelector(".diagram-source code").textContent;
189
+ // Match unchanged diagrams first so inserted text or diagrams do not reset their state.
190
+ for (const next of nextDiagrams) {
191
+ const current = Array.from(previousDiagrams).find((block) => diagramSource(block) === diagramSource(next));
192
+ if (current) { retainedDiagrams.set(next, current); previousDiagrams.delete(current); }
193
+ }
194
+ for (const next of nextDiagrams) {
195
+ if (retainedDiagrams.has(next)) continue;
196
+ const current = Array.from(previousDiagrams).find((block) => block.dataset.previewKey === next.dataset.previewKey);
197
+ if (current) { retainedDiagrams.set(next, current); previousDiagrams.delete(current); }
198
+ }
199
+ const diagramScroll = [];
200
+ for (const [next, current] of retainedDiagrams) {
201
+ for (const panel of current.querySelectorAll(".diagram-preview, .diagram-source")) {
202
+ diagramScroll.push([panel, panel.scrollLeft, panel.scrollTop]);
203
+ }
204
+ const source = current.querySelector(".diagram-source code");
205
+ if (source.textContent !== diagramSource(next)) source.textContent = diagramSource(next);
206
+ current.dataset.previewKey = next.dataset.previewKey;
207
+ next.replaceWith(current);
208
+ }
209
+ content.replaceChildren(template.content);
179
210
  metadata.innerHTML = nextMetadata;
180
211
  syncViewMode();
212
+ window.agentBotDiagrams.scan();
213
+ for (const [panel, left, top] of diagramScroll) { panel.scrollLeft = left; panel.scrollTop = top; }
181
214
  highlightHashTarget();
182
- restoreScroll(top, left, atBottom, codeScrollLeft, tableScrollLeft);
215
+ restoreScroll(top, left, atBottom, codeScrollLeft, tablePositions);
183
216
  };
184
217
 
185
218
  const source = new EventSource(eventsUrl);
@@ -187,7 +220,7 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
187
220
  try {
188
221
  const update = JSON.parse(event.data);
189
222
  if (typeof update.content === "string" && typeof update.metadata === "string") {
190
- if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown" && update.viewMode !== "html";
223
+ if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown" && update.viewMode !== "html" && update.viewMode !== "csv";
191
224
  replaceContent(update.content, update.metadata);
192
225
  }
193
226
  } catch {
@@ -229,7 +262,7 @@ interface DirectoryViewerEntry {
229
262
  interface RenderedFileSnapshot {
230
263
  content: string;
231
264
  metadata: string[];
232
- viewMode?: "markdown" | "html";
265
+ viewMode?: "markdown" | "html" | "csv";
233
266
  }
234
267
 
235
268
  export interface LocalFileViewerServerOptions {
@@ -251,11 +284,18 @@ export interface LocalFileViewerAddress {
251
284
  publicConnectionKind?: NetworkConnectionKind;
252
285
  }
253
286
 
287
+ export interface LocalFileViewerService {
288
+ handleRequest(request: IncomingMessage, response: ServerResponse): void;
289
+ handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void;
290
+ close(): Promise<void>;
291
+ }
292
+
254
293
  export class LocalFileViewerServer {
255
294
  private server?: http.Server;
256
295
  private secret?: Buffer;
257
296
  private address?: LocalFileViewerAddress;
258
297
  private basePath = "";
298
+ private readonly services = new Map<string, LocalFileViewerService>();
259
299
 
260
300
  constructor(private readonly options: LocalFileViewerServerOptions) {}
261
301
 
@@ -283,6 +323,11 @@ export class LocalFileViewerServer {
283
323
  });
284
324
  });
285
325
  server.on("clientError", (_error, socket) => socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"));
326
+ server.on("upgrade", (request, socket, head) => {
327
+ const service = this.serviceFor(request);
328
+ if (service) service.handleUpgrade(request, socket, head);
329
+ else socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
330
+ });
286
331
 
287
332
  let selectedPort = preferredPort;
288
333
  try {
@@ -329,12 +374,36 @@ export class LocalFileViewerServer {
329
374
  }
330
375
 
331
376
  async close(): Promise<void> {
377
+ await Promise.all([...this.services.values()].map((service) => service.close()));
378
+ this.services.clear();
332
379
  const server = this.server;
333
380
  this.server = undefined;
334
381
  this.address = undefined;
335
382
  if (server) await closeServer(server);
336
383
  }
337
384
 
385
+ mountService(name: string, service: LocalFileViewerService): { publicBaseUrl: string; unmount(): void } {
386
+ if (!this.address || !this.server) throw new Error("Preview server is not running.");
387
+ if (!/^[a-z][a-z0-9-]*$/u.test(name) || ["preview", "turn-preview", "assets", "view", "raw"].includes(name)) {
388
+ throw new Error("Invalid Preview service route.");
389
+ }
390
+ const basePath = `${this.basePath}/${name}`;
391
+ if (this.services.has(basePath)) throw new Error("Preview service route is already mounted.");
392
+ this.services.set(basePath, service);
393
+ const url = new URL(this.address.publicBaseUrl);
394
+ url.pathname = basePath;
395
+ return { publicBaseUrl: url.toString(), unmount: () => {
396
+ if (this.services.get(basePath) === service) this.services.delete(basePath);
397
+ } };
398
+ }
399
+
400
+ private serviceFor(request: IncomingMessage): LocalFileViewerService | undefined {
401
+ try {
402
+ const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
403
+ return [...this.services].find(([base]) => pathname === base || pathname.startsWith(`${base}/`))?.[1];
404
+ } catch { return undefined; }
405
+ }
406
+
338
407
  createFileUrl(filePath: string, reference?: string): string | undefined {
339
408
  if (!this.address || !this.secret) return undefined;
340
409
  const absolutePath = normalizeAbsolutePath(filePath);
@@ -365,6 +434,8 @@ export class LocalFileViewerServer {
365
434
  }
366
435
 
367
436
  private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
437
+ const service = this.serviceFor(request);
438
+ if (service) { service.handleRequest(request, response); return; }
368
439
  if (request.method !== "GET" && request.method !== "HEAD") {
369
440
  response.setHeader("Allow", "GET, HEAD");
370
441
  this.sendHtml(response, 405, errorPage("不支持的请求", "该服务只接受只读请求。"), request.method === "HEAD");
@@ -401,14 +472,16 @@ export class LocalFileViewerServer {
401
472
  return;
402
473
  }
403
474
  const journal = this.options.previewJournal;
404
- const recorded = journal?.has(turnId);
475
+ const stored = journal?.has(turnId) ? undefined : this.options.getTurnSnapshot?.(turnId);
476
+ const journalId = isTurnPreviewState(stored) ? stored.previewTurnId ?? turnId : turnId;
477
+ const recorded = journal?.has(journalId);
405
478
  if (recorded && requestUrl.searchParams.get("events") === "1") {
406
- this.serveJournalEvents(request, response, turnId, journal!);
479
+ this.serveJournalEvents(request, response, journalId, journal!);
407
480
  return;
408
481
  }
409
482
  const detailKey = requestUrl.searchParams.get("detail");
410
483
  let snapshot = recorded
411
- ? journal!.load(turnId, detailKey ?? false)
484
+ ? journal!.load(journalId, detailKey ?? false)
412
485
  : this.options.getTurnSnapshot?.(turnId);
413
486
  if (!recorded && this.options.loadTurnSnapshot && request.method !== "HEAD"
414
487
  && requestUrl.searchParams.get("events") !== "1" && !requestUrl.searchParams.has("detail")) {
@@ -435,7 +508,7 @@ export class LocalFileViewerServer {
435
508
  const after = requestUrl.searchParams.get("detailAfter");
436
509
  const outputAppend = recorded && after && tool?.kind === "tool" && tool.tool.kind === "command" && !tool.tool.error
437
510
  && !/repl/i.test(tool.tool.title) && snapshot.previewCursor !== undefined
438
- ? journal!.outputAppend(turnId, tool.id, Number(after), snapshot.previewCursor) : undefined;
511
+ ? journal!.outputAppend(journalId, tool.id, Number(after), snapshot.previewCursor) : undefined;
439
512
  const detail = outputAppend !== undefined && tool?.kind === "tool"
440
513
  && (snapshot.fullToolOutputs?.[tool.id]?.length ?? 0) > outputAppend.length
441
514
  ? { key, revision: String(tool.tool.previewRevision), content: "", outputAppend,
@@ -611,6 +684,7 @@ export class LocalFileViewerServer {
611
684
  ],
612
685
  content: snapshot.content,
613
686
  viewMode: snapshot.viewMode,
687
+ diagramScriptPath: isMarkdownFile(filePath) ? `${this.basePath}/assets/mermaid.js`.replace(/\/{2,}/gu, "/") : undefined,
614
688
  liveUpdates: {
615
689
  eventsUrl: eventsUrl.toString(),
616
690
  scriptPath: `${this.basePath}/assets/viewer.js`.replace(/\/{2,}/gu, "/"),
@@ -640,6 +714,11 @@ export class LocalFileViewerServer {
640
714
  appendUrlQueryParameter(renderUrl, "render", "html");
641
715
  appendUrlQueryParameter(renderUrl, "version", `${stat.mtimeMs}-${stat.ctimeMs}-${stat.size}`);
642
716
  content = `<iframe class="html-preview" data-view-panel="rendered" title="HTML 预览" sandbox="allow-scripts" referrerpolicy="no-referrer" src="${escapeAttribute(renderUrl.toString())}"></iframe><section data-view-panel="code">${notice}${renderText(preview.text, filePath)}</section>`;
717
+ } else if (extension === ".csv") {
718
+ viewMode = "csv";
719
+ const source = csvSourcePreview(preview.text);
720
+ const sourceNotice = source.truncated ? '<div class="notice">代码仅显示前 2000 个物理行,可打开原始文件或下载完整文件。</div>' : "";
721
+ content = `${renderCsvPreview(preview.text, preview.truncated)}<section data-view-panel="code">${notice}${sourceNotice}${renderText(source.text, filePath)}</section>`;
643
722
  } else {
644
723
  viewMode = isMarkdownFile(filePath) ? "markdown" : undefined;
645
724
  const previewContent = viewMode === "markdown"
@@ -927,6 +1006,9 @@ export class LocalFileViewerServer {
927
1006
  if (renderHtml) {
928
1007
  // A response-level sandbox also isolates this document when opened outside the viewer iframe.
929
1008
  response.setHeader("Content-Security-Policy", "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
1009
+ } else {
1010
+ // Raw HTML/SVG/XML must not gain the shared origin's storage or execution privileges.
1011
+ response.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
930
1012
  }
931
1013
  response.setHeader("Accept-Ranges", "bytes");
932
1014
  response.setHeader("Cache-Control", "no-store");
@@ -984,6 +1066,7 @@ function renderViewerPage(input: {
984
1066
  actions: Array<{ href: string; label: string }>;
985
1067
  content: string;
986
1068
  viewMode?: RenderedFileSnapshot["viewMode"];
1069
+ diagramScriptPath?: string;
987
1070
  liveUpdates?: { eventsUrl: string; scriptPath: string };
988
1071
  }): string {
989
1072
  const metadata = renderMetadata(input.metadata);
@@ -1008,7 +1091,7 @@ function renderViewerPage(input: {
1008
1091
  <meta charset="utf-8">
1009
1092
  <meta name="viewport" content="width=device-width, initial-scale=1">
1010
1093
  <title>${escapeHtml(input.title)} · Agent Bot</title>
1011
- <style>
1094
+ <style>${input.diagramScriptPath ? DIAGRAM_PREVIEW_CSS : ""}
1012
1095
  :root { --viewer-header-offset: 56px; --viewer-code-font: "Cascadia Mono", "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", "Sarasa Mono SC", "Noto Sans Mono CJK SC", "Microsoft YaHei Mono", NSimSun, monospace; color-scheme: light dark; font-family: Inter, "Segoe UI", system-ui, sans-serif; }
1013
1096
  * { box-sizing: border-box; }
1014
1097
  body { margin: 0; background: #f5f7fa; color: #1f2329; }
@@ -1056,6 +1139,8 @@ function renderViewerPage(input: {
1056
1139
  .markdown-body code { padding: .14em .35em; border-radius: 4px; background: #f2f3f5; font-family: var(--viewer-code-font); font-size: .9em; }
1057
1140
  .markdown-body .markdown-code-block { margin: 0 0 1em; padding: 14px 16px; overflow: auto; border-radius: 6px; background: #f6f8fa; line-height: 1.55; }
1058
1141
  .markdown-body .markdown-code-block code { padding: 0; background: transparent; font-size: 13px; }
1142
+ .markdown-body .diagram-source { padding: 14px 16px; }
1143
+ .markdown-body .diagram-source code { padding: 0; background: transparent; font-size: 13px; }
1059
1144
  .markdown-table-scroll { max-width: 100%; margin: 0 0 1em; overflow-x: auto; }
1060
1145
  .markdown-table-scroll:focus-visible { outline: 2px solid #1456f0; outline-offset: 2px; }
1061
1146
  .markdown-body table { width: max-content; margin: 0; border-collapse: collapse; white-space: nowrap; overflow-wrap: normal; word-break: normal; }
@@ -1063,6 +1148,7 @@ function renderViewerPage(input: {
1063
1148
  .markdown-body th { background: #f5f7fa; }
1064
1149
  .markdown-body img { max-width: 100%; height: auto; }
1065
1150
  .markdown-body hr { height: 1px; margin: 1.5em 0; border: 0; background: #dfe3e8; }
1151
+ ${CSV_PREVIEW_CSS}
1066
1152
  .media { display: grid; place-items: center; min-height: 220px; }
1067
1153
  .media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
1068
1154
  .media audio { width: min(720px, 100%); }
@@ -1092,6 +1178,7 @@ function renderViewerPage(input: {
1092
1178
  .view-switch button.is-active { background: #3a3b3d; color: #8ab4ff; box-shadow: none; }
1093
1179
  .code, .directory-list { background: #202124; color: #e8eaed; border-color: #3a3b3d; }
1094
1180
  .markdown-body { background: #202124; color: #e8eaed; }
1181
+ .markdown-body .diagram { --border: #55585c; }
1095
1182
  .markdown-body h1, .markdown-body h2 { border-color: #3a3b3d; }
1096
1183
  .markdown-body blockquote { color: #a6a9ad; border-color: #55585c; }
1097
1184
  .markdown-body a { color: #8ab4ff; }
@@ -1115,7 +1202,7 @@ function renderViewerPage(input: {
1115
1202
  }
1116
1203
  </style>
1117
1204
  </head>
1118
- <body${input.viewMode ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
1205
+ <body${input.viewMode ? ' data-view-mode="rendered"' : ""}${liveAttributes}${input.diagramScriptPath ? ` data-diagram-script="${escapeAttribute(input.diagramScriptPath)}"` : ""}>
1119
1206
  <header id="viewer-header">
1120
1207
  <h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
1121
1208
  <div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${toolbar}</div>
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { approvalActivityId } from "../presentation/TurnStateReducer.js";
2
3
  import path from "node:path";
3
4
  import { enableDiagramFences, DIAGRAM_PREVIEW_CLIENT_SCRIPT, DIAGRAM_PREVIEW_CSS } from "./DiagramPreview.js";
4
5
  import MarkdownIt from "markdown-it";
5
6
  import { highlightPreviewCode, PREVIEW_SYNTAX_CSS } from "./PreviewSyntaxHighlight.js";
6
- import type { ToolState } from "../runtime/types.js";
7
+ import type { ApprovalRequest, ToolState } from "../runtime/types.js";
7
8
  import type { FileSummary, TurnActivity, TurnReasoningItem, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
8
9
  import { displayFilePath, displayToolCommand, formatShellCommandForDisplay, toolStatusIcon } from "../feishu/CardRenderer.js";
9
10
  import { turnReasoningItems } from "../presentation/turnReasoning.js";
@@ -41,6 +42,8 @@ const PREVIEW_LABELS = {
41
42
  error: "错误",
42
43
  reasoning: "思考",
43
44
  finalAnswer: "最终回答",
45
+ downloadMarkdown: "下载 Markdown",
46
+ copyMarkdown: "复制 Markdown",
44
47
  generating: "回答生成中",
45
48
  command: "命令",
46
49
  result: "结果",
@@ -51,12 +54,13 @@ const PREVIEW_LABELS = {
51
54
  noToolDetails: "没有可显示的工具详情。",
52
55
  fileChanges: "文件变更",
53
56
  model: "模型",
57
+ modelCalls: "模型调用",
58
+ modelCallsDescription: "本轮有效 Token 用量更新次数",
59
+ contextTokens: "上下文",
54
60
  tools: "个工具",
55
61
  turnTokens: "本轮",
56
62
  totalTokens: "总计",
57
63
  nonCachedTokens: "非缓存",
58
- compactionTokens: "压缩",
59
- compactionAfterTokens: "压缩后",
60
64
  characters: "字符",
61
65
  truncated: "较早的活动已被运行时截断;本页展示 Agent Bot 当前保存的完整快照。",
62
66
  statuses: {
@@ -82,6 +86,8 @@ const PREVIEW_LABELS = {
82
86
  error: "Error",
83
87
  reasoning: "Reasoning",
84
88
  finalAnswer: "Final answer",
89
+ downloadMarkdown: "Download Markdown",
90
+ copyMarkdown: "Copy Markdown",
85
91
  generating: "Generating answer",
86
92
  command: "Command",
87
93
  result: "Result",
@@ -92,12 +98,13 @@ const PREVIEW_LABELS = {
92
98
  noToolDetails: "No tool details available.",
93
99
  fileChanges: "File changes",
94
100
  model: "Model",
101
+ modelCalls: "Model calls",
102
+ modelCallsDescription: "Effective token usage updates in this turn",
103
+ contextTokens: "Context",
95
104
  tools: "tools",
96
105
  turnTokens: "Turn",
97
106
  totalTokens: "Total",
98
107
  nonCachedTokens: "Non-cached",
99
- compactionTokens: "Compaction",
100
- compactionAfterTokens: "After compaction",
101
108
  characters: "characters",
102
109
  truncated: "Earlier activities were truncated by the runtime; this page shows the complete snapshot currently saved by Agent Bot.",
103
110
  statuses: {
@@ -152,7 +159,76 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = DIAGRAM_PREVIEW_CLIENT_SCRIPT + `(() =
152
159
  loading: isChinese ? "正在加载…" : "Loading…",
153
160
  loadFailed: isChinese ? "加载失败,请重试。" : "Could not load details.",
154
161
  retry: isChinese ? "重试" : "Retry",
162
+ copied: isChinese ? "已复制 Markdown" : "Markdown copied",
163
+ downloaded: isChinese ? "已开始下载" : "Download started",
164
+ copyFailed: isChinese ? "复制失败,请检查浏览器剪贴板权限后重试。" : "Copy failed. Check clipboard permissions and retry.",
165
+ downloadFailed: isChinese ? "下载失败,请重试。" : "Download failed. Please retry.",
155
166
  };
167
+ const copyMarkdownFallback = (text) => {
168
+ const active = document.activeElement;
169
+ const selection = window.getSelection?.();
170
+ const ranges = selection ? Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i).cloneRange()) : [];
171
+ const textarea = document.createElement("textarea");
172
+ textarea.value = text;
173
+ textarea.readOnly = true;
174
+ textarea.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none";
175
+ document.body.append(textarea);
176
+ try {
177
+ textarea.focus({ preventScroll: true });
178
+ textarea.select();
179
+ textarea.setSelectionRange(0, text.length);
180
+ if (!document.execCommand?.("copy")) throw new Error("Clipboard unavailable");
181
+ } finally {
182
+ textarea.remove();
183
+ active?.focus?.({ preventScroll: true });
184
+ if (selection) {
185
+ selection.removeAllRanges();
186
+ for (const range of ranges) selection.addRange(range);
187
+ }
188
+ }
189
+ };
190
+ const copyMarkdown = async (text) => {
191
+ if (window.navigator?.clipboard?.writeText) {
192
+ try { await window.navigator.clipboard.writeText(text); return; } catch {}
193
+ }
194
+ copyMarkdownFallback(text);
195
+ };
196
+ const answerActionsInFlight = new WeakSet();
197
+ content.addEventListener("click", async (event) => {
198
+ const button = event.target.closest?.("button[data-answer-action]");
199
+ const section = button?.closest(".final-result[data-answer-markdown]");
200
+ if (!section || !content.contains(section) || answerActionsInFlight.has(button)) return;
201
+ const action = button.dataset.answerAction;
202
+ if (action !== "copy" && action !== "download") return;
203
+ event.preventDefault();
204
+ const feedback = section.querySelector("[data-answer-feedback]");
205
+ answerActionsInFlight.add(button);
206
+ button.disabled = true;
207
+ try {
208
+ const markdown = new TextDecoder("utf-8", { ignoreBOM: true }).decode(Uint8Array.from(atob(section.dataset.answerMarkdown), (char) => char.charCodeAt(0)));
209
+ if (action === "copy") await copyMarkdown(markdown);
210
+ else {
211
+ const url = URL.createObjectURL(new Blob([markdown], { type: "text/markdown;charset=utf-8" }));
212
+ const link = document.createElement("a");
213
+ try {
214
+ link.href = url;
215
+ link.download = section.dataset.answerFilename;
216
+ link.hidden = true;
217
+ document.body.append(link);
218
+ link.click();
219
+ } finally {
220
+ link.remove();
221
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
222
+ }
223
+ }
224
+ if (feedback) feedback.textContent = action === "copy" ? labels.copied : labels.downloaded;
225
+ } catch {
226
+ if (feedback) feedback.textContent = action === "copy" ? labels.copyFailed : labels.downloadFailed;
227
+ } finally {
228
+ button.disabled = false;
229
+ answerActionsInFlight.delete(button);
230
+ }
231
+ });
156
232
 
157
233
  let source;
158
234
  const terminalAtLoad = document.body.dataset.terminal === "true";
@@ -589,10 +665,10 @@ export function renderTurnPreviewSnapshot(
589
665
  : "",
590
666
  `<section class="timeline" data-preview-key="timeline"${timelineContent ? "" : " hidden"}>${timelineContent}</section>`,
591
667
  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>` : "",
668
+ state.approval ? renderApprovalNotice(state.approval, renderText, language) : "",
593
669
  state.error ? `<section class="result error-result" data-preview-key="error"><h2>${escapeHtml(labels.error)}</h2><pre>${escapeHtml(state.error)}</pre></section>` : "",
594
670
  state.finalResponse
595
- ? `<section class="result final-result" data-preview-key="result"><h2>${escapeHtml(labels.finalAnswer)}</h2><div class="markdown">${renderText(state.finalResponse)}</div></section>`
671
+ ? renderFinalAnswer(state.finalResponse, state.turnId, renderText, language)
596
672
  : state.assistantText
597
673
  ? `<section class="result" data-preview-key="result"><h2>${escapeHtml(labels.generating)}</h2><div class="markdown">${renderText(state.assistantText)}</div></section>`
598
674
  : "",
@@ -606,6 +682,34 @@ export function renderTurnPreviewSnapshot(
606
682
  };
607
683
  }
608
684
 
685
+ function renderApprovalNotice(request: ApprovalRequest, renderText: (value: string) => string, language: TurnPreviewLanguage): string {
686
+ const command = request.command?.trim();
687
+ const commandApproval = command && request.kind !== "mode_change";
688
+ const title = commandApproval ? language === "zh" ? "命令执行确认" : "Command approval" : request.title;
689
+ const reason = commandApproval
690
+ ? request.reason?.trim() || (request.title.trim() !== command ? request.title : "")
691
+ : request.reason ?? "";
692
+ const description = commandApproval
693
+ ? `<p class="approval-reason">${escapeHtml(reason === command ? "" : reason)}</p>`
694
+ : `<div class="markdown">${renderText(reason)}</div>`;
695
+ const details = commandApproval
696
+ ? `<details class="approval-details" data-activity-id="approval:${escapeAttribute(request.id)}"><summary>${language === "zh" ? "查看完整命令" : "View full command"}</summary><pre data-scroll-id="approval:${escapeAttribute(request.id)}:command">${escapeHtml(request.command!)}</pre></details>` : "";
697
+ const instruction = request.automatic
698
+ ? language === "zh" ? "自动执行模式:正在自动同意。" : "Auto mode: approving automatically."
699
+ : language === "zh" ? "请在飞书任务卡片中确认或拒绝。" : "Approve or reject using the task card in Feishu.";
700
+ return `<section class="notice" data-preview-key="approval"><h2>${escapeHtml(title)}</h2>${description}${details}<p>${instruction}</p></section>`;
701
+ }
702
+
703
+ function renderFinalAnswer(markdown: string, turnId: string, renderText: (value: string) => string, language: TurnPreviewLanguage): string {
704
+ const labels = previewLabels(language);
705
+ const filename = `turn-${turnId.replace(/[^A-Za-z0-9_-]/gu, "_").slice(0, 80) || "answer"}.md`;
706
+ const icon = (paths: string): string => `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${paths}</svg>`;
707
+ const button = (action: string, label: string, paths: string): string => `<button type="button" class="answer-action" data-answer-action="${action}" title="${escapeAttribute(label)}" aria-label="${escapeAttribute(label)}">${icon(paths)}</button>`;
708
+ const actions = button("download", labels.downloadMarkdown, '<path d="M12 3v12m-5-5 5 5 5-5M5 16v5h14v-5"/>')
709
+ + button("copy", labels.copyMarkdown, '<rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/>');
710
+ return `<section class="result final-result" data-preview-key="result" data-answer-markdown="${Buffer.from(markdown, "utf8").toString("base64")}" data-answer-filename="${filename}"><div class="result-heading"><h2>${escapeHtml(labels.finalAnswer)}</h2>${actions}<span class="answer-feedback" data-answer-feedback role="status" aria-live="polite"></span></div><div class="markdown">${renderText(markdown)}</div></section>`;
711
+ }
712
+
609
713
  function renderPlan(state: TurnViewState, language: TurnPreviewLanguage): string {
610
714
  const labels = previewLabels(language);
611
715
  const completed = state.plan.filter((step) => step.status === "completed").length;
@@ -626,7 +730,8 @@ function renderTimeline(
626
730
  projectCwd?: string,
627
731
  deferDetails = false,
628
732
  ): string {
629
- const activities = state.activities ?? [];
733
+ const activities = state.approval
734
+ ? state.activities.filter((a) => a.id !== approvalActivityId(state.turnId, state.approval!.id)) : state.activities ?? [];
630
735
  const positions = new Map(activities.map((activity, index) => [activity.id, index]));
631
736
  const reasoning = new Map<number, TurnReasoningItem[]>();
632
737
  for (const item of turnReasoningItems(state)) {
@@ -1092,7 +1197,7 @@ function renderMetadata(state: TurnViewState, language: TurnPreviewLanguage): st
1092
1197
  ? state.startedAt + state.durationMs
1093
1198
  : undefined);
1094
1199
  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>`;
1095
- return [
1200
+ const fields = [
1096
1201
  elapsed,
1097
1202
  state.modelProvider?.trim() ? `<span title="Provider">${escapeHtml(state.modelProvider.trim())}</span>` : "",
1098
1203
  state.model?.trim() ? `<span title="${escapeAttribute(labels.model)}">${escapeHtml(state.model.trim())}</span>` : "",
@@ -1100,11 +1205,13 @@ function renderMetadata(state: TurnViewState, language: TurnPreviewLanguage): st
1100
1205
  ? `<span title="${escapeAttribute(labels.turnTokens)}">${formatTokenCount(state.totalTokens)} tokens</span>`
1101
1206
  : `<span title="${escapeAttribute(labels.nonCachedTokens)}: ${formatNumber(state.totalTokens)} tokens">${escapeHtml(labels.nonCachedTokens)} ${formatTokenCount(state.totalTokens)} tokens</span>`,
1102
1207
  state.totalTokensIncludingCache === undefined ? "" : `<span title="${escapeAttribute(labels.totalTokens)}: ${formatNumber(state.totalTokensIncludingCache)} tokens">${escapeHtml(labels.totalTokens)} ${formatTokenCount(state.totalTokensIncludingCache)} tokens</span>`,
1103
- state.contextCompactionAfterTokens === undefined ? "" : state.contextCompactionBeforeTokens === undefined
1104
- ? `<span title="${escapeAttribute(labels.compactionAfterTokens)}">${formatTokenCount(state.contextCompactionAfterTokens)} tokens</span>`
1105
- : `<span title="${escapeAttribute(labels.compactionTokens)}">${formatTokenCount(state.contextCompactionBeforeTokens)} → ${formatTokenCount(state.contextCompactionAfterTokens)} tokens</span>`,
1208
+ state.latestContextTokens !== undefined && Number.isFinite(state.latestContextTokens) && state.latestContextTokens >= 0
1209
+ ? `<span title="${escapeAttribute(labels.contextTokens)}: ${formatNumber(state.latestContextTokens)} tokens">${escapeHtml(labels.contextTokens)} ${formatTokenCount(state.latestContextTokens)} tokens</span>` : "",
1210
+ state.modelCallCount !== undefined && Number.isSafeInteger(state.modelCallCount) && state.modelCallCount >= 0
1211
+ ? `<span title="${escapeAttribute(labels.modelCallsDescription)}">${escapeHtml(labels.modelCalls)} ${formatNumber(state.modelCallCount)}${language === "zh" ? " 次" : ""}</span>` : "",
1106
1212
  (state.totalToolCount ?? 0) > 0 ? `<span>${formatNumber(state.totalToolCount ?? 0)} ${escapeHtml(labels.tools)}</span>` : "",
1107
- ].filter(Boolean).join("");
1213
+ ].filter(Boolean);
1214
+ return fields.join("");
1108
1215
  }
1109
1216
 
1110
1217
  function previewTitle(state: TurnViewState): string {
@@ -1310,6 +1417,17 @@ summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px;
1310
1417
  .detail-message button { margin-left: 8px; cursor: pointer; }
1311
1418
  .tool-image img { display: block; max-width: 100%; max-height: 720px; }
1312
1419
  .final-result h2 { color: var(--success); }
1420
+ .approval-reason { white-space: pre-wrap; overflow-wrap: anywhere; }
1421
+ .approval-reason:empty { display: none; }
1422
+ .approval-details { margin: 8px 0; }
1423
+ .approval-details pre { max-height: 30em; }
1424
+ .result-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-bottom: 8px; }
1425
+ .result-heading h2 { margin: 0 4px 0 0; }
1426
+ .answer-action { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 30px; height: 30px; padding: 0; border: 1px solid transparent; border-radius: 5px; background: transparent; color: var(--muted); cursor: pointer; }
1427
+ .answer-action:hover { color: var(--accent); background: var(--surface); }
1428
+ .answer-action:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1429
+ .answer-action:disabled { opacity: 0.5; cursor: wait; }
1430
+ .answer-feedback { min-width: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
1313
1431
  @media (max-width: 700px) {
1314
1432
  .header-inner { padding: 5px 12px; }
1315
1433
  .header-top { flex-basis: 100%; }