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

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 (40) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +15 -3
  3. package/README.zh.md +15 -3
  4. package/dist/codex/AppServerPermissions.js +34 -0
  5. package/dist/codex/AppServerPermissions.js.map +1 -0
  6. package/dist/codex/CodexLocalActivityDetector.js +37 -5
  7. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  8. package/dist/codex/CodexRuntime.js +37 -13
  9. package/dist/codex/CodexRuntime.js.map +1 -1
  10. package/dist/codex/ThreadWriterProcess.js +19 -5
  11. package/dist/codex/ThreadWriterProcess.js.map +1 -1
  12. package/dist/feishu/CardRenderer.js +80 -64
  13. package/dist/feishu/CardRenderer.js.map +1 -1
  14. package/dist/local-files/CsvPreview.js +148 -0
  15. package/dist/local-files/CsvPreview.js.map +1 -0
  16. package/dist/local-files/LocalFileViewerServer.js +53 -10
  17. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  18. package/dist/local-files/TurnPreviewPage.js +122 -11
  19. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  20. package/dist/presentation/TurnStateReducer.js +39 -0
  21. package/dist/presentation/TurnStateReducer.js.map +1 -1
  22. package/dist/proxy/ProxySessionController.js +152 -46
  23. package/dist/proxy/ProxySessionController.js.map +1 -1
  24. package/docs/technical-reference.md +5 -3
  25. package/docs/technical-reference.zh.md +4 -2
  26. package/npm-shrinkwrap.json +2 -2
  27. package/package.json +1 -1
  28. package/skills/agent-bot/SKILL.md +6 -0
  29. package/src/codex/AppServerPermissions.ts +39 -0
  30. package/src/codex/CodexLocalActivityDetector.ts +34 -4
  31. package/src/codex/CodexRuntime.ts +44 -17
  32. package/src/codex/ThreadWriterProcess.ts +17 -9
  33. package/src/feishu/CardRenderer.ts +78 -64
  34. package/src/local-files/CsvPreview.ts +132 -0
  35. package/src/local-files/LocalFileViewerServer.ts +54 -11
  36. package/src/local-files/TurnPreviewPage.ts +125 -12
  37. package/src/presentation/TurnStateReducer.ts +41 -0
  38. package/src/presentation/turnViewTypes.ts +4 -0
  39. package/src/proxy/ProxySessionController.ts +137 -48
  40. package/src/runtime/types.ts +2 -0
@@ -4,6 +4,7 @@ import type { JsonValue } from "../acp/acpTypes.js";
4
4
  import type { RuntimeSession } from "../acp/AcpSessionManager.js";
5
5
  import type {
6
6
  ApprovalDecision,
7
+ ApprovalRequest,
7
8
  ModelOption,
8
9
  ModelProviderOption,
9
10
  PermissionMode,
@@ -197,6 +198,7 @@ export interface SessionTaskCardEntry {
197
198
  reference: string;
198
199
  summary: string;
199
200
  detailLines: string[];
201
+ stopAction?: TaskListCardAction;
200
202
  actions?: TaskListCardAction[];
201
203
  current?: boolean;
202
204
  }
@@ -1698,38 +1700,7 @@ function renderTurnElements(
1698
1700
  elements.push(...renderActivities(visibleActivities, state.projectCwd));
1699
1701
  if (state.fileSummary.length > 0) elements.push(fileSummaryPanel(state));
1700
1702
 
1701
- if (state.approval) {
1702
- const request = state.approval;
1703
- elements.push(markdown([
1704
- `**${request.title}**`,
1705
- request.command ? codeBlock(request.command, 800) : undefined,
1706
- request.reason,
1707
- ].filter(Boolean).join("\n")));
1708
- elements.push({
1709
- tag: "column_set",
1710
- flex_mode: "flow",
1711
- horizontal_spacing: "8px",
1712
- columns: request.options.map((option) => ({
1713
- tag: "column",
1714
- width: "auto",
1715
- elements: [{
1716
- tag: "button",
1717
- text: { tag: "plain_text", content: request.kind === "mode_change" ? option.label : approvalDecisionLabel(option.id) },
1718
- type: option.id === "accept" || option.id === "acceptForSession" ? "primary" : option.id === "cancel" ? "danger" : "default",
1719
- behaviors: [{
1720
- type: "callback",
1721
- value: {
1722
- action: "approval",
1723
- sessionId: state.sessionId,
1724
- turnId: state.turnId,
1725
- requestId: request.id,
1726
- decision: option.id,
1727
- },
1728
- }],
1729
- }],
1730
- })),
1731
- });
1732
- }
1703
+ if (state.approval) elements.push(...renderApprovalElements(state, state.approval));
1733
1704
  if (state.error) elements.push(markdown(codeBlock(state.error, 2_000)));
1734
1705
  const showAssistantText = state.assistantText && assistantTextMode === "always";
1735
1706
  if (showAssistantText) {
@@ -1769,38 +1740,7 @@ function renderGroupedTurnElements(
1769
1740
  elements.push(...renderGroupedActivityGroups(visibleGroups, state.projectCwd));
1770
1741
  if (state.fileSummary.length > 0) elements.push(fileSummaryPanel(state));
1771
1742
 
1772
- if (state.approval) {
1773
- const request = state.approval;
1774
- elements.push(markdown([
1775
- `**${request.title}**`,
1776
- request.command ? codeBlock(request.command, 800) : undefined,
1777
- request.reason,
1778
- ].filter(Boolean).join("\n")));
1779
- elements.push({
1780
- tag: "column_set",
1781
- flex_mode: "flow",
1782
- horizontal_spacing: "8px",
1783
- columns: request.options.map((option) => ({
1784
- tag: "column",
1785
- width: "auto",
1786
- elements: [{
1787
- tag: "button",
1788
- text: { tag: "plain_text", content: request.kind === "mode_change" ? option.label : approvalDecisionLabel(option.id) },
1789
- type: option.id === "accept" || option.id === "acceptForSession" ? "primary" : option.id === "cancel" ? "danger" : "default",
1790
- behaviors: [{
1791
- type: "callback",
1792
- value: {
1793
- action: "approval",
1794
- sessionId: state.sessionId,
1795
- turnId: state.turnId,
1796
- requestId: request.id,
1797
- decision: option.id,
1798
- },
1799
- }],
1800
- }],
1801
- })),
1802
- });
1803
- }
1743
+ if (state.approval) elements.push(...renderApprovalElements(state, state.approval));
1804
1744
  if (state.error) elements.push(markdown(codeBlock(state.error, 2_000)));
1805
1745
  const showAssistantText = state.assistantText && assistantTextMode === "always";
1806
1746
  if (showAssistantText) {
@@ -1812,6 +1752,79 @@ function renderGroupedTurnElements(
1812
1752
  return elements;
1813
1753
  }
1814
1754
 
1755
+ function renderApprovalElements(state: TurnViewState, request: ApprovalRequest): Record<string, unknown>[] {
1756
+ const elements: Record<string, unknown>[] = [];
1757
+ const command = request.command?.trim();
1758
+ if (command && request.kind !== "mode_change") {
1759
+ elements.push(markdown("**命令执行确认**"));
1760
+ const reason = request.reason?.trim()
1761
+ || (request.title.trim() !== command ? request.title.trim() : "");
1762
+ if (reason && reason !== command) {
1763
+ const preview = approvalTextPreview(reason, 1_000, escapeApprovalText);
1764
+ elements.push(markdown(escapeApprovalText(preview)));
1765
+ if (preview !== reason) elements.push(markdown("<font color='grey'>说明较长,请在 Preview 中查看完整内容。</font>"));
1766
+ }
1767
+ const display = displayToolCommand(command) ?? command;
1768
+ const summary = truncateText(display.replace(/\s+/gu, " "), 64);
1769
+ const preview = approvalTextPreview(command, 2_200);
1770
+ elements.push(collapsiblePanel(`查看命令 · ${summary}`, [
1771
+ markdown(codeBlock(preview, preview.length)),
1772
+ ...(preview !== command ? [markdown("<font color='grey'>命令过长,卡片仅显示开头。请在 Preview 中查看完整命令后再授权。</font>")] : []),
1773
+ ], {
1774
+ elementId: `approval_${createHash("sha256").update(request.id).digest("hex").slice(0, 11)}`,
1775
+ compact: true,
1776
+ borderColor: "grey",
1777
+ }));
1778
+ } else {
1779
+ elements.push(markdown([
1780
+ `**${request.title}**`,
1781
+ request.command ? codeBlock(request.command, 800) : undefined,
1782
+ request.reason,
1783
+ ].filter(Boolean).join("\n")));
1784
+ }
1785
+ elements.push({
1786
+ tag: "column_set",
1787
+ flex_mode: "flow",
1788
+ horizontal_spacing: "8px",
1789
+ columns: request.options.map((option) => ({
1790
+ tag: "column",
1791
+ width: "auto",
1792
+ elements: [{
1793
+ tag: "button",
1794
+ text: { tag: "plain_text", content: request.kind === "mode_change" ? option.label : approvalDecisionLabel(option.id) },
1795
+ type: option.id === "accept" || option.id === "acceptForSession" ? "primary" : option.id === "cancel" ? "danger" : "default",
1796
+ behaviors: [{
1797
+ type: "callback",
1798
+ value: {
1799
+ action: "approval",
1800
+ sessionId: state.sessionId,
1801
+ turnId: state.turnId,
1802
+ requestId: request.id,
1803
+ decision: option.id,
1804
+ },
1805
+ }],
1806
+ }],
1807
+ })),
1808
+ });
1809
+ return elements;
1810
+ }
1811
+
1812
+ function approvalTextPreview(value: string, maxBytes: number, encode = (text: string): string => text): string {
1813
+ if (Buffer.byteLength(JSON.stringify(encode(value)), "utf8") <= maxBytes) return value;
1814
+ let bytes = 5;
1815
+ let result = "";
1816
+ for (const character of value) {
1817
+ bytes += Buffer.byteLength(JSON.stringify(encode(character)), "utf8") - 2;
1818
+ if (bytes > maxBytes) break;
1819
+ result += character;
1820
+ }
1821
+ return `${result}...`;
1822
+ }
1823
+
1824
+ function escapeApprovalText(value: string): string {
1825
+ return escapeCardHtml(value).replace(/[\\`*_[\]#]/gu, (character) => `&#${character.codePointAt(0)};`);
1826
+ }
1827
+
1815
1828
  function emptyTurnText(status: TurnViewStatus, agentLabel?: string): string {
1816
1829
  const label = agentLabel?.trim().replace(/[\r\n]+/g, " ") || "Agent";
1817
1830
  if (status === "starting") return `正在连接 ${label}…`;
@@ -2298,6 +2311,7 @@ function collapsiblePanel(
2298
2311
 
2299
2312
  function sessionTaskPanel(entry: SessionTaskCardEntry): Record<string, unknown> {
2300
2313
  const elements: Record<string, unknown>[] = [markdown(entry.detailLines.join("\n"))];
2314
+ if (entry.stopAction) elements.push(sessionActionButton(entry.stopAction));
2301
2315
  if (entry.actions?.length) elements.push(sessionActionOverflow(entry.actions));
2302
2316
  return collapsiblePanel(entry.summary, elements, {
2303
2317
  elementId: sessionTaskPanelElementId(entry.reference),
@@ -0,0 +1,132 @@
1
+ const MAX_ROWS = 500;
2
+ const MAX_COLUMNS = 50;
3
+ const MAX_CELL_CHARACTERS = 4_000;
4
+
5
+ export interface CsvPreview {
6
+ rows: string[][];
7
+ notices: string[];
8
+ }
9
+
10
+ export function csvSourcePreview(source: string): { text: string; truncated: boolean } {
11
+ const text = source.replace(/\r\n?/gu, "\n");
12
+ let offset = 0;
13
+ for (let line = 0; line < 2_000; line += 1) {
14
+ const newline = text.indexOf("\n", offset);
15
+ if (newline < 0 || newline === text.length - 1) return { text, truncated: false };
16
+ offset = newline + 1;
17
+ }
18
+ return { text: text.slice(0, offset - 1), truncated: true };
19
+ }
20
+
21
+ // Parse records rather than physical lines: quoted CSV fields may contain newlines.
22
+ export function parseCsvPreview(source: string, sourceTruncated = false): CsvPreview {
23
+ const text = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
24
+ const rows: string[][] = [];
25
+ const notices = new Set<string>();
26
+ let row: string[] = [];
27
+ let field = "";
28
+ let fieldCharacters = 0;
29
+ let state: "start" | "plain" | "quoted" | "closed" = "start";
30
+ let recordStarted = false;
31
+ const append = (character: string): void => {
32
+ if (row.length >= MAX_COLUMNS) return;
33
+ if (fieldCharacters < MAX_CELL_CHARACTERS) { field += character; fieldCharacters += 1; }
34
+ else notices.add(`过长单元格仅显示前 ${MAX_CELL_CHARACTERS} 个字符。`);
35
+ };
36
+ const endField = (): void => {
37
+ if (row.length < MAX_COLUMNS) row.push(field);
38
+ else notices.add(`表格仅显示前 ${MAX_COLUMNS} 列。`);
39
+ field = "";
40
+ fieldCharacters = 0;
41
+ state = "start";
42
+ };
43
+ if (sourceTruncated) notices.add("文件较大,仅读取开头 2 MiB;末尾不完整的记录不显示。可下载完整文件。");
44
+ let invalid = false;
45
+ for (let index = 0; index < text.length; index += 1) {
46
+ const character = String.fromCodePoint(text.codePointAt(index)!);
47
+ if (character.length === 2) index += 1;
48
+ recordStarted = true;
49
+ if (state === "quoted") {
50
+ if (character === '"') state = "closed";
51
+ else append(character);
52
+ continue;
53
+ }
54
+ if (character === '"') {
55
+ if (state === "start") state = "quoted";
56
+ else if (state === "closed") { append(character); state = "quoted"; }
57
+ else { invalid = true; break; }
58
+ } else if (character === ",") {
59
+ endField();
60
+ } else if (character === "\r" || character === "\n") {
61
+ if (character === "\r" && text[index + 1] === "\n") index += 1;
62
+ endField();
63
+ rows.push(row);
64
+ row = [];
65
+ recordStarted = false;
66
+ if (rows.length === MAX_ROWS && index + 1 < text.length) {
67
+ notices.add(`表格仅显示前 ${MAX_ROWS} 行。`);
68
+ return { rows, notices: [...notices] };
69
+ }
70
+ } else if (state === "closed") {
71
+ invalid = true;
72
+ break;
73
+ } else {
74
+ state = "plain";
75
+ append(character);
76
+ }
77
+ }
78
+ if (invalid || (!sourceTruncated && state === "quoted")) {
79
+ notices.add(`第 ${rows.length + 1} 条记录的引号格式不完整或不合法,已停止解析。请切换代码查看原文。`);
80
+ } else if (recordStarted && !sourceTruncated) {
81
+ endField();
82
+ rows.push(row);
83
+ }
84
+ return { rows, notices: [...notices] };
85
+ }
86
+
87
+ export function renderCsvPreview(source: string, sourceTruncated = false): string {
88
+ const { rows, notices } = parseCsvPreview(source, sourceTruncated);
89
+ const columns = Math.max(0, ...rows.map((row) => row.length));
90
+ const noticesHtml = notices.map((notice) => `<div class="notice">${escapeHtml(notice)}</div>`).join("");
91
+ if (!rows.length) {
92
+ return `<section data-view-panel="rendered">${noticesHtml}<div class="csv-summary">${notices.length ? "没有可显示的完整记录。" : "CSV 文件为空。"}</div></section>`;
93
+ }
94
+ const headings = Array.from({ length: columns }, (_, index) => {
95
+ const label = index < 26 ? String.fromCharCode(65 + index) : `A${String.fromCharCode(65 + index - 26)}`;
96
+ return `<th scope="col">${label}</th>`;
97
+ }).join("");
98
+ const body = rows.map((row, index) => {
99
+ const cells = row.map((value) => `<td><div class="csv-cell">${escapeHtml(value)}</div></td>`).join("");
100
+ const missing = columns - row.length;
101
+ return `<tr><th scope="row">${index + 1}</th>${cells}${missing ? `<td colspan="${missing}" class="csv-missing"></td>` : ""}</tr>`;
102
+ }).join("");
103
+ return `<section data-view-panel="rendered">${noticesHtml}<div class="csv-summary">已显示 ${rows.length} 行 · 最多 ${columns} 列 · 首行保留为数据</div><div class="csv-table-scroll" role="region" aria-label="CSV 表格" tabindex="0"><table class="csv-table"><thead><tr><th scope="col" aria-label="行号">#</th>${headings}</tr></thead><tbody>${body}</tbody></table></div></section>`;
104
+ }
105
+
106
+ function escapeHtml(value: string): string {
107
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
108
+ }
109
+
110
+ export const CSV_PREVIEW_CSS = `
111
+ .csv-summary { padding: 10px 12px; color: #646a73; font-size: 12px; }
112
+ .csv-table-scroll { isolation: isolate; max-width: 100%; max-height: calc(100dvh - var(--viewer-header-offset) - 40px); overflow: auto; background: #fff; }
113
+ .csv-table-scroll:focus-visible { outline: 2px solid #1456f0; outline-offset: -2px; }
114
+ .csv-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; line-height: 1.5; }
115
+ .csv-table th, .csv-table td { padding: 7px 12px; text-align: left; vertical-align: top; border-right: 1px solid #dfe3e8; border-bottom: 1px solid #dfe3e8; }
116
+ .csv-table th { background: #f0f3f8; font-weight: 600; }
117
+ .csv-table thead th { position: sticky; top: 0; z-index: 1; }
118
+ .csv-table tr > th:first-child { position: sticky; left: 0; text-align: right; min-width: 3.5em; }
119
+ .csv-table thead th:first-child { z-index: 2; }
120
+ .csv-table tbody tr:nth-child(even) td { background: #f9fafc; }
121
+ .csv-table tbody tr:hover td { background: #edf3ff; }
122
+ .csv-cell { min-width: 4em; max-width: 32rem; white-space: pre-wrap; overflow-wrap: anywhere; }
123
+ @media (max-width: 640px) { .csv-cell { max-width: 20rem; } }
124
+ @media (prefers-color-scheme: dark) {
125
+ .csv-summary { color: #a6a9ad; }
126
+ .csv-table-scroll { background: #202124; }
127
+ .csv-table th, .csv-table td { border-color: #55585c; }
128
+ .csv-table th { background: #292a2d; }
129
+ .csv-table tbody tr:nth-child(even) td { background: #252629; }
130
+ .csv-table tbody tr:hover td { background: #292f3d; }
131
+ }
132
+ `;
@@ -9,6 +9,8 @@ import dos from "highlight.js/lib/languages/dos";
9
9
  import dockerfile from "highlight.js/lib/languages/dockerfile";
10
10
  import powershell from "highlight.js/lib/languages/powershell";
11
11
  import MarkdownIt from "markdown-it";
12
+ import { CSV_PREVIEW_CSS, renderCsvPreview, csvSourcePreview } from "./CsvPreview.js";
13
+ import { enableDiagramFences, DIAGRAM_PREVIEW_CLIENT_SCRIPT, DIAGRAM_PREVIEW_CSS } from "./DiagramPreview.js";
12
14
  import { isFileUrl, rewriteMarkdownFileLinks } from "./MarkdownFileLinks.js";
13
15
  import {
14
16
  selectPreferredNetworkAddress,
@@ -56,6 +58,7 @@ const MARKDOWN_RENDERER = new MarkdownIt({
56
58
  return `<pre class="markdown-code-block hljs"><code${languageAttribute}>${highlighted}</code></pre>`;
57
59
  },
58
60
  });
61
+ enableDiagramFences(MARKDOWN_RENDERER);
59
62
  MARKDOWN_RENDERER.renderer.rules.table_open = () =>
60
63
  '<div class="markdown-table-scroll" role="region" aria-label="Markdown 表格" tabindex="0"><table>\n';
61
64
  MARKDOWN_RENDERER.renderer.rules.table_close = () => '</table></div>\n';
@@ -79,7 +82,7 @@ const HIGHLIGHT_LANGUAGE_BY_FILENAME: Readonly<Record<string, string>> = {
79
82
  "dockerfile": "dockerfile",
80
83
  "makefile": "makefile",
81
84
  };
82
- const VIEWER_CLIENT_SCRIPT = `(() => {
85
+ const VIEWER_CLIENT_SCRIPT = DIAGRAM_PREVIEW_CLIENT_SCRIPT + `(() => {
83
86
  const eventsUrl = document.body.dataset.eventsUrl;
84
87
  const content = document.getElementById("viewer-content");
85
88
  const metadata = document.getElementById("viewer-metadata");
@@ -157,14 +160,15 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
157
160
  requestAnimationFrame(positionHashTarget);
158
161
  if (!eventsUrl || typeof EventSource !== "function") return;
159
162
 
160
- const restoreScroll = (top, left, atBottom, codeScrollLeft, tableScrollLeft) => {
163
+ const restoreScroll = (top, left, atBottom, codeScrollLeft, tablePositions) => {
161
164
  requestAnimationFrame(() => {
162
165
  const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
163
166
  window.scrollTo(left, atBottom ? maxTop : Math.min(top, maxTop));
164
167
  const code = content.querySelector(".code");
165
168
  if (code) code.scrollLeft = codeScrollLeft;
166
- content.querySelectorAll(".markdown-table-scroll").forEach((table, index) => {
167
- table.scrollLeft = tableScrollLeft[index] ?? 0;
169
+ content.querySelectorAll(".markdown-table-scroll, .csv-table-scroll").forEach((table, index) => {
170
+ table.scrollLeft = tablePositions[index]?.left ?? 0;
171
+ table.scrollTop = tablePositions[index]?.top ?? 0;
168
172
  });
169
173
  });
170
174
  };
@@ -174,12 +178,40 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
174
178
  const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
175
179
  const atBottom = maxTop - top <= 24;
176
180
  const codeScrollLeft = content.querySelector(".code")?.scrollLeft ?? 0;
177
- const tableScrollLeft = Array.from(content.querySelectorAll(".markdown-table-scroll"), (table) => table.scrollLeft);
178
- content.innerHTML = nextContent;
181
+ const tablePositions = Array.from(content.querySelectorAll(".markdown-table-scroll, .csv-table-scroll"), (table) => ({ left: table.scrollLeft, top: table.scrollTop }));
182
+ const template = document.createElement("template");
183
+ template.innerHTML = nextContent;
184
+ const previousDiagrams = new Set(content.querySelectorAll("[data-diagram]"));
185
+ const nextDiagrams = Array.from(template.content.querySelectorAll("[data-diagram]"));
186
+ const retainedDiagrams = new Map();
187
+ const diagramSource = (block) => block.querySelector(".diagram-source code").textContent;
188
+ // Match unchanged diagrams first so inserted text or diagrams do not reset their state.
189
+ for (const next of nextDiagrams) {
190
+ const current = Array.from(previousDiagrams).find((block) => diagramSource(block) === diagramSource(next));
191
+ if (current) { retainedDiagrams.set(next, current); previousDiagrams.delete(current); }
192
+ }
193
+ for (const next of nextDiagrams) {
194
+ if (retainedDiagrams.has(next)) continue;
195
+ const current = Array.from(previousDiagrams).find((block) => block.dataset.previewKey === next.dataset.previewKey);
196
+ if (current) { retainedDiagrams.set(next, current); previousDiagrams.delete(current); }
197
+ }
198
+ const diagramScroll = [];
199
+ for (const [next, current] of retainedDiagrams) {
200
+ for (const panel of current.querySelectorAll(".diagram-preview, .diagram-source")) {
201
+ diagramScroll.push([panel, panel.scrollLeft, panel.scrollTop]);
202
+ }
203
+ const source = current.querySelector(".diagram-source code");
204
+ if (source.textContent !== diagramSource(next)) source.textContent = diagramSource(next);
205
+ current.dataset.previewKey = next.dataset.previewKey;
206
+ next.replaceWith(current);
207
+ }
208
+ content.replaceChildren(template.content);
179
209
  metadata.innerHTML = nextMetadata;
180
210
  syncViewMode();
211
+ window.agentBotDiagrams.scan();
212
+ for (const [panel, left, top] of diagramScroll) { panel.scrollLeft = left; panel.scrollTop = top; }
181
213
  highlightHashTarget();
182
- restoreScroll(top, left, atBottom, codeScrollLeft, tableScrollLeft);
214
+ restoreScroll(top, left, atBottom, codeScrollLeft, tablePositions);
183
215
  };
184
216
 
185
217
  const source = new EventSource(eventsUrl);
@@ -187,7 +219,7 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
187
219
  try {
188
220
  const update = JSON.parse(event.data);
189
221
  if (typeof update.content === "string" && typeof update.metadata === "string") {
190
- if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown" && update.viewMode !== "html";
222
+ if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown" && update.viewMode !== "html" && update.viewMode !== "csv";
191
223
  replaceContent(update.content, update.metadata);
192
224
  }
193
225
  } catch {
@@ -229,7 +261,7 @@ interface DirectoryViewerEntry {
229
261
  interface RenderedFileSnapshot {
230
262
  content: string;
231
263
  metadata: string[];
232
- viewMode?: "markdown" | "html";
264
+ viewMode?: "markdown" | "html" | "csv";
233
265
  }
234
266
 
235
267
  export interface LocalFileViewerServerOptions {
@@ -611,6 +643,7 @@ export class LocalFileViewerServer {
611
643
  ],
612
644
  content: snapshot.content,
613
645
  viewMode: snapshot.viewMode,
646
+ diagramScriptPath: isMarkdownFile(filePath) ? `${this.basePath}/assets/mermaid.js`.replace(/\/{2,}/gu, "/") : undefined,
614
647
  liveUpdates: {
615
648
  eventsUrl: eventsUrl.toString(),
616
649
  scriptPath: `${this.basePath}/assets/viewer.js`.replace(/\/{2,}/gu, "/"),
@@ -640,6 +673,11 @@ export class LocalFileViewerServer {
640
673
  appendUrlQueryParameter(renderUrl, "render", "html");
641
674
  appendUrlQueryParameter(renderUrl, "version", `${stat.mtimeMs}-${stat.ctimeMs}-${stat.size}`);
642
675
  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>`;
676
+ } else if (extension === ".csv") {
677
+ viewMode = "csv";
678
+ const source = csvSourcePreview(preview.text);
679
+ const sourceNotice = source.truncated ? '<div class="notice">代码仅显示前 2000 个物理行,可打开原始文件或下载完整文件。</div>' : "";
680
+ content = `${renderCsvPreview(preview.text, preview.truncated)}<section data-view-panel="code">${notice}${sourceNotice}${renderText(source.text, filePath)}</section>`;
643
681
  } else {
644
682
  viewMode = isMarkdownFile(filePath) ? "markdown" : undefined;
645
683
  const previewContent = viewMode === "markdown"
@@ -984,6 +1022,7 @@ function renderViewerPage(input: {
984
1022
  actions: Array<{ href: string; label: string }>;
985
1023
  content: string;
986
1024
  viewMode?: RenderedFileSnapshot["viewMode"];
1025
+ diagramScriptPath?: string;
987
1026
  liveUpdates?: { eventsUrl: string; scriptPath: string };
988
1027
  }): string {
989
1028
  const metadata = renderMetadata(input.metadata);
@@ -1008,7 +1047,7 @@ function renderViewerPage(input: {
1008
1047
  <meta charset="utf-8">
1009
1048
  <meta name="viewport" content="width=device-width, initial-scale=1">
1010
1049
  <title>${escapeHtml(input.title)} · Agent Bot</title>
1011
- <style>
1050
+ <style>${input.diagramScriptPath ? DIAGRAM_PREVIEW_CSS : ""}
1012
1051
  :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
1052
  * { box-sizing: border-box; }
1014
1053
  body { margin: 0; background: #f5f7fa; color: #1f2329; }
@@ -1056,6 +1095,8 @@ function renderViewerPage(input: {
1056
1095
  .markdown-body code { padding: .14em .35em; border-radius: 4px; background: #f2f3f5; font-family: var(--viewer-code-font); font-size: .9em; }
1057
1096
  .markdown-body .markdown-code-block { margin: 0 0 1em; padding: 14px 16px; overflow: auto; border-radius: 6px; background: #f6f8fa; line-height: 1.55; }
1058
1097
  .markdown-body .markdown-code-block code { padding: 0; background: transparent; font-size: 13px; }
1098
+ .markdown-body .diagram-source { padding: 14px 16px; }
1099
+ .markdown-body .diagram-source code { padding: 0; background: transparent; font-size: 13px; }
1059
1100
  .markdown-table-scroll { max-width: 100%; margin: 0 0 1em; overflow-x: auto; }
1060
1101
  .markdown-table-scroll:focus-visible { outline: 2px solid #1456f0; outline-offset: 2px; }
1061
1102
  .markdown-body table { width: max-content; margin: 0; border-collapse: collapse; white-space: nowrap; overflow-wrap: normal; word-break: normal; }
@@ -1063,6 +1104,7 @@ function renderViewerPage(input: {
1063
1104
  .markdown-body th { background: #f5f7fa; }
1064
1105
  .markdown-body img { max-width: 100%; height: auto; }
1065
1106
  .markdown-body hr { height: 1px; margin: 1.5em 0; border: 0; background: #dfe3e8; }
1107
+ ${CSV_PREVIEW_CSS}
1066
1108
  .media { display: grid; place-items: center; min-height: 220px; }
1067
1109
  .media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
1068
1110
  .media audio { width: min(720px, 100%); }
@@ -1092,6 +1134,7 @@ function renderViewerPage(input: {
1092
1134
  .view-switch button.is-active { background: #3a3b3d; color: #8ab4ff; box-shadow: none; }
1093
1135
  .code, .directory-list { background: #202124; color: #e8eaed; border-color: #3a3b3d; }
1094
1136
  .markdown-body { background: #202124; color: #e8eaed; }
1137
+ .markdown-body .diagram { --border: #55585c; }
1095
1138
  .markdown-body h1, .markdown-body h2 { border-color: #3a3b3d; }
1096
1139
  .markdown-body blockquote { color: #a6a9ad; border-color: #55585c; }
1097
1140
  .markdown-body a { color: #8ab4ff; }
@@ -1115,7 +1158,7 @@ function renderViewerPage(input: {
1115
1158
  }
1116
1159
  </style>
1117
1160
  </head>
1118
- <body${input.viewMode ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
1161
+ <body${input.viewMode ? ' data-view-mode="rendered"' : ""}${liveAttributes}${input.diagramScriptPath ? ` data-diagram-script="${escapeAttribute(input.diagramScriptPath)}"` : ""}>
1119
1162
  <header id="viewer-header">
1120
1163
  <h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
1121
1164
  <div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${toolbar}</div>