@keyou007/agent-bot 0.1.20-alpha.1 → 0.1.21

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 (92) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +27 -4
  3. package/README.zh.md +23 -4
  4. package/dist/cli/AgentPrerequisites.js +11 -47
  5. package/dist/cli/AgentPrerequisites.js.map +1 -1
  6. package/dist/cli/DefaultAgentSelection.js +1 -1
  7. package/dist/cli/DefaultAgentSelection.js.map +1 -1
  8. package/dist/cli.js +15 -0
  9. package/dist/cli.js.map +1 -1
  10. package/dist/codex/CodexEventMapper.js +21 -1
  11. package/dist/codex/CodexEventMapper.js.map +1 -1
  12. package/dist/codex/CodexLocalActivityDetector.js +32 -10
  13. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  14. package/dist/codex/CodexProcessManager.js +56 -7
  15. package/dist/codex/CodexProcessManager.js.map +1 -1
  16. package/dist/codex/CodexRuntime.js +361 -168
  17. package/dist/codex/CodexRuntime.js.map +1 -1
  18. package/dist/codex/CodexVersion.js +18 -0
  19. package/dist/codex/CodexVersion.js.map +1 -0
  20. package/dist/codex/ProviderModels.js +159 -0
  21. package/dist/codex/ProviderModels.js.map +1 -0
  22. package/dist/codex/ProviderSwitch.js +32 -0
  23. package/dist/codex/ProviderSwitch.js.map +1 -0
  24. package/dist/commands/CommandRouter.js +8 -3
  25. package/dist/commands/CommandRouter.js.map +1 -1
  26. package/dist/console/ConsoleTurnPresenter.js +2 -1
  27. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  28. package/dist/feishu/CardRenderer.js +82 -27
  29. package/dist/feishu/CardRenderer.js.map +1 -1
  30. package/dist/feishu/FeishuConnector.js +17 -5
  31. package/dist/feishu/FeishuConnector.js.map +1 -1
  32. package/dist/feishu/FeishuTurnPresenter.js +34 -8
  33. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  34. package/dist/feishu/InboundText.js +11 -0
  35. package/dist/feishu/InboundText.js.map +1 -1
  36. package/dist/feishu/MergedForwardMessage.js +17 -5
  37. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  38. package/dist/index.js +3 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/local-files/LocalFileViewerServer.js +164 -19
  41. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  42. package/dist/local-files/MarkdownFileLinks.js +65 -0
  43. package/dist/local-files/MarkdownFileLinks.js.map +1 -0
  44. package/dist/local-files/TurnPreviewPage.js +725 -0
  45. package/dist/local-files/TurnPreviewPage.js.map +1 -0
  46. package/dist/presentation/OutboundRouter.js +6 -1
  47. package/dist/presentation/OutboundRouter.js.map +1 -1
  48. package/dist/presentation/TurnStateReducer.js +142 -3
  49. package/dist/presentation/TurnStateReducer.js.map +1 -1
  50. package/dist/proxy/ProxySessionController.js +573 -325
  51. package/dist/proxy/ProxySessionController.js.map +1 -1
  52. package/dist/state/StateStore.js +65 -65
  53. package/dist/state/StateStore.js.map +1 -1
  54. package/dist/utils/formatStorageSize.js +15 -0
  55. package/dist/utils/formatStorageSize.js.map +1 -0
  56. package/dist/utils/semanticVersion.js +39 -0
  57. package/dist/utils/semanticVersion.js.map +1 -0
  58. package/docs/technical-reference.md +11 -3
  59. package/docs/technical-reference.zh.md +11 -3
  60. package/npm-shrinkwrap.json +2 -2
  61. package/package.json +1 -1
  62. package/skills/agent-bot/SKILL.md +11 -1
  63. package/src/cli/AgentPrerequisites.ts +14 -40
  64. package/src/cli/DefaultAgentSelection.ts +1 -1
  65. package/src/cli.ts +13 -0
  66. package/src/codex/CodexEventMapper.ts +31 -1
  67. package/src/codex/CodexLocalActivityDetector.ts +41 -9
  68. package/src/codex/CodexProcessManager.ts +48 -7
  69. package/src/codex/CodexRuntime.ts +397 -204
  70. package/src/codex/CodexVersion.ts +18 -0
  71. package/src/codex/ProviderModels.ts +185 -0
  72. package/src/codex/ProviderSwitch.ts +36 -0
  73. package/src/commands/CommandRouter.ts +7 -2
  74. package/src/commands/commandTypes.ts +1 -1
  75. package/src/console/ConsoleTurnPresenter.ts +3 -0
  76. package/src/feishu/CardRenderer.ts +84 -43
  77. package/src/feishu/FeishuConnector.ts +15 -5
  78. package/src/feishu/FeishuTurnPresenter.ts +46 -4
  79. package/src/feishu/InboundText.ts +11 -0
  80. package/src/feishu/MergedForwardMessage.ts +15 -5
  81. package/src/index.ts +3 -1
  82. package/src/local-files/LocalFileViewerServer.ts +179 -20
  83. package/src/local-files/MarkdownFileLinks.ts +64 -0
  84. package/src/local-files/TurnPreviewPage.ts +792 -0
  85. package/src/presentation/OutboundRouter.ts +8 -0
  86. package/src/presentation/TurnStateReducer.ts +175 -2
  87. package/src/presentation/turnViewTypes.ts +13 -0
  88. package/src/proxy/ProxySessionController.ts +547 -297
  89. package/src/runtime/types.ts +33 -3
  90. package/src/state/StateStore.ts +86 -90
  91. package/src/utils/formatStorageSize.ts +13 -0
  92. package/src/utils/semanticVersion.ts +28 -0
@@ -51,6 +51,7 @@ export interface FeishuTurnPresenterOptions {
51
51
  onError?: (error: unknown) => void;
52
52
  finalRetryBackoffMs?: number[];
53
53
  localFileUrl?: LocalFileUrlResolver;
54
+ turnPreviewUrl?: (turnId: string) => string | undefined;
54
55
  }
55
56
 
56
57
  const MAX_FINAL_TABLES_PER_CARD = 5;
@@ -76,6 +77,7 @@ export class FeishuTurnPresenter {
76
77
  private readonly sessionTitles = new Map<string, string>();
77
78
  private readonly sessionCwds = new Map<string, string>();
78
79
  private readonly sessionAgentLabels = new Map<string, string>();
80
+ private readonly sessionModels = new Map<string, string>();
79
81
  private readonly entries = new Map<string, TurnEntry>();
80
82
  private readonly pendingEntries = new Map<string, TurnEntry>();
81
83
  private readonly renderer: CardRenderer;
@@ -95,11 +97,25 @@ export class FeishuTurnPresenter {
95
97
  taskTitle?: string,
96
98
  projectCwd?: string,
97
99
  agentLabel?: string,
100
+ model?: string,
98
101
  ): void {
99
102
  this.sessionContexts.set(sessionId, contextKey);
100
103
  if (taskTitle) this.sessionTitles.set(sessionId, taskTitle);
101
104
  if (projectCwd) this.sessionCwds.set(sessionId, projectCwd);
102
105
  if (agentLabel) this.sessionAgentLabels.set(sessionId, agentLabel);
106
+ if (model) {
107
+ this.updateSessionModel(sessionId, model);
108
+ }
109
+ }
110
+
111
+ updateSessionModel(sessionId: string, model?: string): void {
112
+ if (!model) return;
113
+ this.sessionModels.set(sessionId, model);
114
+ const pending = this.pendingEntries.get(sessionId);
115
+ if (!pending || pending.state.model === model) return;
116
+ pending.state = { ...pending.state, model };
117
+ this.store.saveTurnSnapshot(pending.state.turnId, sessionId, pending.state, pending.contextKey);
118
+ if (!pending.historySnapshot) pending.scheduler?.update(pending.state, "critical");
103
119
  }
104
120
 
105
121
  updateSessionTitle(sessionId: string, taskTitle: string): void {
@@ -117,6 +133,7 @@ export class FeishuTurnPresenter {
117
133
  this.sessionTitles.delete(sessionId);
118
134
  this.sessionCwds.delete(sessionId);
119
135
  this.sessionAgentLabels.delete(sessionId);
136
+ this.sessionModels.delete(sessionId);
120
137
  }
121
138
 
122
139
  async startPendingTurn(
@@ -147,6 +164,7 @@ export class FeishuTurnPresenter {
147
164
  this.sessionCwds.get(sessionId),
148
165
  prompt,
149
166
  this.sessionAgentLabels.get(sessionId),
167
+ this.sessionModels.get(sessionId),
150
168
  );
151
169
  const entry = { contextKey, state, initializing: Promise.resolve() } as TurnEntry;
152
170
  this.entries.set(state.turnId, entry);
@@ -257,6 +275,7 @@ export class FeishuTurnPresenter {
257
275
  this.sessionCwds.get(sessionId),
258
276
  undefined,
259
277
  this.sessionAgentLabels.get(sessionId),
278
+ this.sessionModels.get(sessionId),
260
279
  ),
261
280
  status: "running" as const,
262
281
  };
@@ -292,6 +311,7 @@ export class FeishuTurnPresenter {
292
311
  pending.state.projectCwd,
293
312
  pending.state.prompt,
294
313
  pending.state.agentLabel,
314
+ pending.state.model,
295
315
  ),
296
316
  event,
297
317
  );
@@ -428,6 +448,7 @@ export class FeishuTurnPresenter {
428
448
  this.sessionCwds.get(event.sessionId),
429
449
  undefined,
430
450
  this.sessionAgentLabels.get(event.sessionId),
451
+ this.sessionModels.get(event.sessionId),
431
452
  );
432
453
  const state = reduceTurnEvent(
433
454
  initial,
@@ -476,15 +497,30 @@ export class FeishuTurnPresenter {
476
497
  }
477
498
 
478
499
  private renderTurn(state: TurnViewState): Record<string, unknown> {
479
- return this.renderer.renderTurn(normalizeTurnCardMarkdown(state, this.options.localFileUrl));
500
+ return this.renderer.renderTurn(
501
+ normalizeTurnCardMarkdown(state, this.options.localFileUrl),
502
+ this.turnPreviewUrl(state),
503
+ );
480
504
  }
481
505
 
482
506
  private renderTurnDetails(state: TurnViewState): Record<string, unknown> {
483
- return this.renderer.renderTurnDetails(normalizeTurnCardMarkdown(state, this.options.localFileUrl));
507
+ return this.renderer.renderTurnDetails(
508
+ normalizeTurnCardMarkdown(state, this.options.localFileUrl),
509
+ this.turnPreviewUrl(state),
510
+ );
484
511
  }
485
512
 
486
513
  private renderActivityHistory(state: TurnViewState, page: number): Record<string, unknown> {
487
- return this.renderer.renderActivityHistory(normalizeTurnCardMarkdown(state, this.options.localFileUrl), page);
514
+ return this.renderer.renderActivityHistory(
515
+ normalizeTurnCardMarkdown(state, this.options.localFileUrl),
516
+ page,
517
+ );
518
+ }
519
+
520
+ private turnPreviewUrl(state: TurnViewState): string | undefined {
521
+ return state.turnId.startsWith("pending_")
522
+ ? undefined
523
+ : this.options.turnPreviewUrl?.(state.turnId);
488
524
  }
489
525
 
490
526
  private startElapsedUpdates(entry: TurnEntry): void {
@@ -642,7 +678,13 @@ function brandedFinalChunks(response: string, maxLength: number): string[] {
642
678
  }
643
679
 
644
680
  function eventPriority(event: AgentEvent): "normal" | "critical" {
645
- if (event.type === "turn_started" || event.type === "approval_requested" || event.type === "approval_resolved" || event.type === "tool_started") {
681
+ if (
682
+ event.type === "turn_started"
683
+ || event.type === "approval_requested"
684
+ || event.type === "approval_resolved"
685
+ || event.type === "tool_started"
686
+ || event.type === "context_compaction"
687
+ ) {
646
688
  return "critical";
647
689
  }
648
690
  return "normal";
@@ -10,3 +10,14 @@ export function normalizeFeishuPostText(value: string): string {
10
10
  .replace(/\n{3,}/gu, "\n\n")
11
11
  .trim();
12
12
  }
13
+
14
+ export function renderFeishuCodeBlock(text: string, language: unknown): string {
15
+ const code = text.replace(/\r\n?/gu, "\n").replace(/\n+$/gu, "");
16
+ if (!code) return "";
17
+ const longestBacktickRun = Math.max(0, ...[...code.matchAll(/`+/gu)].map((match) => match[0].length));
18
+ const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
19
+ const languageHint = typeof language === "string" && /^[A-Za-z0-9_+#.-]+$/u.test(language.trim())
20
+ ? language.trim().toLowerCase()
21
+ : "";
22
+ return `${fence}${languageHint}\n${code}\n${fence}`;
23
+ }
@@ -1,4 +1,4 @@
1
- import { normalizeFeishuPostText } from "./InboundText.js";
1
+ import { normalizeFeishuPostText, renderFeishuCodeBlock } from "./InboundText.js";
2
2
 
3
3
  export const MAX_MERGED_FORWARD_PROMPT_CHARS = 48_000;
4
4
 
@@ -266,10 +266,12 @@ function renderPost(
266
266
  const paragraphs: string[] = [];
267
267
  const title = normalizeFeishuPostText(textValue(locale.title));
268
268
  if (title) paragraphs.push(title);
269
- if (!Array.isArray(locale.content)) return paragraphs.join("\n");
270
- for (const row of locale.content) {
269
+ const rows = Array.isArray(locale.content) ? locale.content : locale.content_v2;
270
+ if (!Array.isArray(rows)) return paragraphs.join("\n");
271
+ for (const row of rows) {
271
272
  if (!Array.isArray(row)) continue;
272
273
  let rowText = "";
274
+ let preserveFormatting = false;
273
275
  for (const rawElement of row) {
274
276
  if (!isRecord(rawElement)) continue;
275
277
  const tag = textValue(rawElement.tag);
@@ -279,6 +281,12 @@ function renderPost(
279
281
  const text = textValue(rawElement.text);
280
282
  const href = textValue(rawElement.href);
281
283
  rowText += href && href !== text ? `${text || href} (${href})` : text || href;
284
+ } else if (tag === "md") {
285
+ rowText += textValue(rawElement.text);
286
+ preserveFormatting = true;
287
+ } else if (tag === "code_block") {
288
+ rowText += renderFeishuCodeBlock(textValue(rawElement.text), rawElement.language);
289
+ preserveFormatting = true;
282
290
  } else if (tag === "at") {
283
291
  rowText += renderMention(rawElement, mentions);
284
292
  } else if (tag === "img") {
@@ -287,7 +295,9 @@ function renderPost(
287
295
  rowText += "[媒体]";
288
296
  }
289
297
  }
290
- const normalized = normalizeFeishuPostText(rowText);
298
+ const normalized = preserveFormatting
299
+ ? rowText.replace(/\r\n?/gu, "\n").trim()
300
+ : normalizeFeishuPostText(rowText);
291
301
  if (normalized) paragraphs.push(normalized);
292
302
  }
293
303
  return paragraphs.join("\n");
@@ -314,7 +324,7 @@ function replaceMentions(text: string, mentions: MergedForwardMessageItem["menti
314
324
  }
315
325
 
316
326
  function selectPostLocale(content: Record<string, unknown>): Record<string, unknown> | undefined {
317
- if (Array.isArray(content.content)) return content;
327
+ if (Array.isArray(content.content) || Array.isArray(content.content_v2)) return content;
318
328
  for (const locale of ["zh_cn", "en_us", "ja_jp"]) {
319
329
  if (isRecord(content[locale])) return content[locale];
320
330
  }
package/src/index.ts CHANGED
@@ -80,6 +80,7 @@ if (config.fileViewer.enabled) {
80
80
  port: config.fileViewer.port,
81
81
  publicBaseUrl: config.fileViewer.publicBaseUrl,
82
82
  stateDirectory: path.join(path.dirname(config.storage.sqlitePath), "file-viewer"),
83
+ getTurnSnapshot: (turnId) => store.getTurnSnapshot(turnId),
83
84
  });
84
85
  try {
85
86
  const address = await viewer.start();
@@ -116,6 +117,7 @@ if (feishuOutbound) {
116
117
  criticalGapMs: 500,
117
118
  onError: (error) => logger.warn({ error }, "Failed to update Agent progress card."),
118
119
  localFileUrl: (filePath, reference) => localFileViewer?.createFileUrl(filePath, reference),
120
+ turnPreviewUrl: (turnId) => localFileViewer?.createTurnPreviewUrl(turnId),
119
121
  });
120
122
  routes.push({ matches: (contextKey) => !contextKey.startsWith("console:"), outbound: feishuOutbound, presenter });
121
123
  const defaultAgentName = config.defaults.agent!;
@@ -483,7 +485,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
483
485
  ),
484
486
  };
485
487
  case "task_turns":
486
- return { ok: true, data: controller.controlListTaskTurns(request.localSessionId) };
488
+ return { ok: true, data: await controller.controlListTaskTurns(request.localSessionId) };
487
489
  case "task_reset":
488
490
  return { ok: true, data: await controller.controlResetTaskToTurn(request.localSessionId, request.turnId) };
489
491
  case "task_mute":
@@ -7,10 +7,18 @@ import dos from "highlight.js/lib/languages/dos";
7
7
  import dockerfile from "highlight.js/lib/languages/dockerfile";
8
8
  import powershell from "highlight.js/lib/languages/powershell";
9
9
  import MarkdownIt from "markdown-it";
10
+ import { isFileUrl, rewriteMarkdownFileLinks } from "./MarkdownFileLinks.js";
10
11
  import {
11
12
  selectPreferredNetworkAddress,
12
13
  type NetworkConnectionKind,
13
14
  } from "./NetworkAddressSelector.js";
15
+ import {
16
+ isTurnPreviewState,
17
+ detectTurnPreviewLanguage,
18
+ renderTurnPreviewPage,
19
+ renderTurnPreviewSnapshot,
20
+ TURN_PREVIEW_CLIENT_SCRIPT,
21
+ } from "./TurnPreviewPage.js";
14
22
 
15
23
  const SECRET_FILE = "secret";
16
24
  const PORT_FILE = "port";
@@ -21,6 +29,7 @@ const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
21
29
  const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
22
30
  const DIRECTORY_PAGE_SIZE = 100;
23
31
  const MARKDOWN_EXTENSIONS = new Set([".markdown", ".md", ".mdown", ".mkd", ".mkdn"]);
32
+ const HTML_EXTENSIONS = new Set([".html", ".htm"]);
24
33
  hljs.registerLanguage("dos", dos);
25
34
  hljs.registerLanguage("dockerfile", dockerfile);
26
35
  hljs.registerLanguage("powershell", powershell);
@@ -43,6 +52,8 @@ const MARKDOWN_RENDERER = new MarkdownIt({
43
52
  MARKDOWN_RENDERER.renderer.rules.table_open = () =>
44
53
  '<div class="markdown-table-scroll" role="region" aria-label="Markdown 表格" tabindex="0"><table>\n';
45
54
  MARKDOWN_RENDERER.renderer.rules.table_close = () => '</table></div>\n';
55
+ const validateMarkdownLink = MARKDOWN_RENDERER.validateLink.bind(MARKDOWN_RENDERER);
56
+ MARKDOWN_RENDERER.validateLink = (url) => isFileUrl(url) || validateMarkdownLink(url);
46
57
 
47
58
  const HIGHLIGHT_LANGUAGE_BY_EXTENSION: Readonly<Record<string, string>> = {
48
59
  ".bash": "bash", ".bat": "dos", ".c": "c", ".cc": "cpp", ".cmd": "dos", ".cpp": "cpp",
@@ -132,7 +143,10 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
132
143
  if (typeof ResizeObserver === "function") {
133
144
  new ResizeObserver(updateHeaderOffset).observe(header);
134
145
  }
135
- window.addEventListener("hashchange", () => requestAnimationFrame(positionHashTarget));
146
+ window.addEventListener("hashchange", () => {
147
+ if (/^#L\\d+$/u.test(window.location.hash)) setViewMode("code");
148
+ else requestAnimationFrame(positionHashTarget);
149
+ });
136
150
  requestAnimationFrame(positionHashTarget);
137
151
  if (!eventsUrl || typeof EventSource !== "function") return;
138
152
 
@@ -166,7 +180,7 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
166
180
  try {
167
181
  const update = JSON.parse(event.data);
168
182
  if (typeof update.content === "string" && typeof update.metadata === "string") {
169
- if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown";
183
+ if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown" && update.viewMode !== "html";
170
184
  replaceContent(update.content, update.metadata);
171
185
  }
172
186
  } catch {
@@ -208,7 +222,7 @@ interface DirectoryViewerEntry {
208
222
  interface RenderedFileSnapshot {
209
223
  content: string;
210
224
  metadata: string[];
211
- viewMode?: "markdown";
225
+ viewMode?: "markdown" | "html";
212
226
  }
213
227
 
214
228
  export interface LocalFileViewerServerOptions {
@@ -216,6 +230,8 @@ export interface LocalFileViewerServerOptions {
216
230
  port: number;
217
231
  publicBaseUrl?: string;
218
232
  stateDirectory: string;
233
+ getTurnSnapshot?: (turnId: string) => unknown;
234
+ turnPreviewPollIntervalMs?: number;
219
235
  }
220
236
 
221
237
  export interface LocalFileViewerAddress {
@@ -327,6 +343,18 @@ export class LocalFileViewerServer {
327
343
  return url.toString();
328
344
  }
329
345
 
346
+ createTurnPreviewUrl(turnId: string): string | undefined {
347
+ const normalizedTurnId = normalizeTurnId(turnId);
348
+ if (!this.address || !this.secret || !this.options.getTurnSnapshot || !normalizedTurnId) return undefined;
349
+ const url = new URL(this.address.publicBaseUrl);
350
+ url.pathname = `${this.basePath}/turn-preview/${this.createShortToken(`turn:${normalizedTurnId}`)}`
351
+ .replace(/\/{2,}/gu, "/");
352
+ url.search = "";
353
+ url.hash = "";
354
+ url.searchParams.set("turn", normalizedTurnId);
355
+ return url.toString();
356
+ }
357
+
330
358
  private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
331
359
  if (request.method !== "GET" && request.method !== "HEAD") {
332
360
  response.setHeader("Allow", "GET, HEAD");
@@ -344,6 +372,42 @@ export class LocalFileViewerServer {
344
372
  this.sendJavascript(response, request.method === "HEAD");
345
373
  return;
346
374
  }
375
+ if (route === "/assets/turn-preview.js") {
376
+ this.sendJavascript(response, request.method === "HEAD", TURN_PREVIEW_CLIENT_SCRIPT);
377
+ return;
378
+ }
379
+ const turnPreviewMatch = new RegExp(`^/turn-preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
380
+ if (turnPreviewMatch) {
381
+ const turnId = this.verifyTurnPreviewToken(
382
+ turnPreviewMatch[1] ?? "",
383
+ requestUrl.searchParams.get("turn") ?? "",
384
+ );
385
+ if (!turnId) {
386
+ this.sendHtml(response, 403, errorPage("Turn 链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
387
+ return;
388
+ }
389
+ const snapshot = this.options.getTurnSnapshot?.(turnId);
390
+ if (!isTurnPreviewState(snapshot)) {
391
+ this.sendHtml(response, 404, errorPage("Turn 不存在", "这次执行可能尚未保存或已被清理。"), request.method === "HEAD");
392
+ return;
393
+ }
394
+ if (requestUrl.searchParams.get("events") === "1") {
395
+ this.serveTurnEvents(request, response, turnId);
396
+ return;
397
+ }
398
+ const eventsUrl = new URL(this.createTurnPreviewUrl(turnId)!);
399
+ eventsUrl.searchParams.set("events", "1");
400
+ const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
401
+ const html = renderTurnPreviewPage({
402
+ state: snapshot,
403
+ eventsUrl: eventsUrl.toString(),
404
+ scriptPath: `${this.basePath}/assets/turn-preview.js`.replace(/\/{2,}/gu, "/"),
405
+ localFileUrl: (filePath) => this.createFileUrl(filePath),
406
+ language,
407
+ });
408
+ this.sendHtml(response, 200, html, request.method === "HEAD");
409
+ return;
410
+ }
347
411
  const previewMatch = new RegExp(`^/preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
348
412
  const legacyMatch = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
349
413
  const previewPath = previewMatch?.[1]
@@ -381,12 +445,13 @@ export class LocalFileViewerServer {
381
445
  }
382
446
 
383
447
  const raw = legacyMatch?.[1] === "raw" || requestUrl.searchParams.get("raw") === "1";
384
- if (raw) {
448
+ const renderHtml = !raw && requestUrl.searchParams.get("render") === "html";
449
+ if (raw || renderHtml) {
385
450
  if (stat.isDirectory()) {
386
451
  this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
387
452
  return;
388
453
  }
389
- this.serveRawFile(request, response, filePath, stat, requestUrl.searchParams.get("download") === "1");
454
+ this.serveRawFile(request, response, filePath, stat, requestUrl.searchParams.get("download") === "1", renderHtml);
390
455
  return;
391
456
  }
392
457
 
@@ -424,6 +489,17 @@ export class LocalFileViewerServer {
424
489
  : undefined;
425
490
  }
426
491
 
492
+ private verifyTurnPreviewToken(token: string, rawTurnId: string): string | undefined {
493
+ const turnId = normalizeTurnId(rawTurnId);
494
+ if (!turnId || !this.secret) return undefined;
495
+ const expected = this.createShortToken(`turn:${turnId}`);
496
+ const actualBuffer = Buffer.from(token, "utf8");
497
+ const expectedBuffer = Buffer.from(expected, "utf8");
498
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
499
+ ? turnId
500
+ : undefined;
501
+ }
502
+
427
503
  private sign(token: string): string {
428
504
  return createHmac("sha256", this.secret!).update(token).digest("hex");
429
505
  }
@@ -493,12 +569,25 @@ export class LocalFileViewerServer {
493
569
  appendUrlQueryParameter(rawUrl, "raw", "1");
494
570
 
495
571
  let content: string;
572
+ let viewMode: RenderedFileSnapshot["viewMode"];
496
573
  if (classification.kind === "text") {
497
574
  const preview = readTextPreview(filePath, stat.size, classification.encoding);
498
- const previewContent = isMarkdownFile(filePath)
499
- ? renderMarkdownDocument(preview.text, filePath)
500
- : renderText(preview.text, filePath);
501
- content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${previewContent}`;
575
+ const notice = preview.truncated
576
+ ? '<div class="notice">文件较大,源码仅显示开头 2 MiB。可打开原始文件或下载完整文件。</div>'
577
+ : "";
578
+ if (HTML_EXTENSIONS.has(extension)) {
579
+ viewMode = "html";
580
+ const renderUrl = this.createPreviewUrl(filePath);
581
+ appendUrlQueryParameter(renderUrl, "render", "html");
582
+ appendUrlQueryParameter(renderUrl, "version", `${stat.mtimeMs}-${stat.ctimeMs}-${stat.size}`);
583
+ 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>`;
584
+ } else {
585
+ viewMode = isMarkdownFile(filePath) ? "markdown" : undefined;
586
+ const previewContent = viewMode === "markdown"
587
+ ? renderMarkdownDocument(preview.text, filePath, (target) => this.createPreviewUrl(target))
588
+ : renderText(preview.text, filePath);
589
+ content = `${notice}${previewContent}`;
590
+ }
502
591
  } else if (contentType.startsWith("image/")) {
503
592
  content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
504
593
  } else if (contentType === "application/pdf") {
@@ -513,9 +602,7 @@ export class LocalFileViewerServer {
513
602
  return {
514
603
  content,
515
604
  metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
516
- ...(classification.kind === "text" && isMarkdownFile(filePath)
517
- ? { viewMode: "markdown" as const }
518
- : {}),
605
+ ...(viewMode ? { viewMode } : {}),
519
606
  };
520
607
  }
521
608
 
@@ -584,6 +671,57 @@ export class LocalFileViewerServer {
584
671
  response.once("close", cleanup);
585
672
  }
586
673
 
674
+ private serveTurnEvents(request: IncomingMessage, response: ServerResponse, turnId: string): void {
675
+ setSecurityHeaders(response);
676
+ response.statusCode = 200;
677
+ response.setHeader("Cache-Control", "no-cache, no-store");
678
+ response.setHeader("Connection", "keep-alive");
679
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
680
+ response.setHeader("X-Accel-Buffering", "no");
681
+ if (request.method === "HEAD") {
682
+ response.end();
683
+ return;
684
+ }
685
+ response.flushHeaders();
686
+ response.write("retry: 2000\n\n");
687
+ const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
688
+
689
+ let closed = false;
690
+ let previousSnapshot = "";
691
+ const sendSnapshot = () => {
692
+ if (closed || response.destroyed) return;
693
+ const state = this.options.getTurnSnapshot?.(turnId);
694
+ if (!isTurnPreviewState(state)) {
695
+ writeServerSentEvent(response, "unavailable", JSON.stringify({ message: "Turn 暂时无法读取。" }));
696
+ return;
697
+ }
698
+ const serialized = JSON.stringify(state);
699
+ if (serialized === previousSnapshot) return;
700
+ previousSnapshot = serialized;
701
+ const snapshot = renderTurnPreviewSnapshot(state, (filePath) => this.createFileUrl(filePath), language);
702
+ writeServerSentEvent(response, "update", JSON.stringify(snapshot));
703
+ };
704
+ sendSnapshot();
705
+ const poller = setInterval(
706
+ sendSnapshot,
707
+ Math.max(50, this.options.turnPreviewPollIntervalMs ?? 1_000),
708
+ );
709
+ poller.unref();
710
+ const keepAlive = setInterval(() => {
711
+ if (!closed && !response.destroyed) response.write(": keep-alive\n\n");
712
+ }, 15_000);
713
+ keepAlive.unref();
714
+
715
+ const cleanup = () => {
716
+ if (closed) return;
717
+ closed = true;
718
+ clearInterval(poller);
719
+ clearInterval(keepAlive);
720
+ };
721
+ request.once("aborted", cleanup);
722
+ response.once("close", cleanup);
723
+ }
724
+
587
725
  private renderDirectoryViewer(directory: string, stat: fs.Stats, requestedPage: number): string {
588
726
  const entries = fs.readdirSync(directory, { withFileTypes: true })
589
727
  .flatMap((entry): DirectoryViewerEntry[] => {
@@ -632,9 +770,14 @@ export class LocalFileViewerServer {
632
770
  filePath: string,
633
771
  stat: fs.Stats,
634
772
  download: boolean,
773
+ renderHtml = false,
635
774
  ): void {
636
775
  const extension = path.extname(filePath).toLowerCase();
637
776
  const classification = classifyFileContent(filePath, stat.size);
777
+ if (renderHtml && (!HTML_EXTENSIONS.has(extension) || classification.kind !== "text")) {
778
+ this.sendHtml(response, 400, errorPage("无法预览 HTML", "只有 HTML 文本文件支持此预览模式。"), request.method === "HEAD");
779
+ return;
780
+ }
638
781
  const contentType = classification.kind === "text"
639
782
  ? textContentTypeFor(extension, classification.encoding)
640
783
  : binaryContentTypeFor(extension);
@@ -642,6 +785,10 @@ export class LocalFileViewerServer {
642
785
  const fileName = path.basename(filePath);
643
786
  const disposition = download ? "attachment" : "inline";
644
787
  setSecurityHeaders(response);
788
+ if (renderHtml) {
789
+ // A response-level sandbox also isolates this document when opened outside the viewer iframe.
790
+ 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'");
791
+ }
645
792
  response.setHeader("Accept-Ranges", "bytes");
646
793
  response.setHeader("Cache-Control", "no-store");
647
794
  response.setHeader("Content-Type", contentType);
@@ -680,8 +827,8 @@ export class LocalFileViewerServer {
680
827
  response.end(headOnly ? undefined : body);
681
828
  }
682
829
 
683
- private sendJavascript(response: ServerResponse, headOnly: boolean): void {
684
- const body = Buffer.from(VIEWER_CLIENT_SCRIPT, "utf8");
830
+ private sendJavascript(response: ServerResponse, headOnly: boolean, source = VIEWER_CLIENT_SCRIPT): void {
831
+ const body = Buffer.from(source, "utf8");
685
832
  setSecurityHeaders(response);
686
833
  response.statusCode = 200;
687
834
  response.setHeader("Cache-Control", "no-store");
@@ -697,14 +844,14 @@ function renderViewerPage(input: {
697
844
  metadata: string[];
698
845
  actions: Array<{ href: string; label: string }>;
699
846
  content: string;
700
- viewMode?: "markdown";
847
+ viewMode?: RenderedFileSnapshot["viewMode"];
701
848
  liveUpdates?: { eventsUrl: string; scriptPath: string };
702
849
  }): string {
703
850
  const metadata = renderMetadata(input.metadata);
704
851
  const actions = input.actions.length > 0
705
852
  ? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
706
853
  : "";
707
- const viewSwitch = input.viewMode === "markdown"
854
+ const viewSwitch = input.viewMode
708
855
  ? '<div class="view-switch" id="viewer-view-switch" role="tablist" aria-label="预览模式"><button class="is-active" type="button" role="tab" aria-selected="true" data-view-mode-button="rendered">预览</button><button type="button" role="tab" aria-selected="false" data-view-mode-button="code">代码</button></div>'
709
856
  : "";
710
857
  const toolbar = viewSwitch || actions
@@ -781,6 +928,7 @@ function renderViewerPage(input: {
781
928
  .media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
782
929
  .media audio { width: min(720px, 100%); }
783
930
  .document { width: 100%; height: calc(100vh - 70px); border: 0; border-radius: 0; background: #fff; }
931
+ .html-preview { display: block; width: 100%; height: calc(100dvh - var(--viewer-header-offset)); min-height: 240px; border: 0; background: #fff; color-scheme: normal; }
784
932
  .directory-list { overflow: hidden; border: 0; border-radius: 0; background: #fff; }
785
933
  .directory-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 18px; align-items: center; min-height: 44px; padding: 8px 14px; color: inherit; text-decoration: none; border-bottom: 1px solid #eceff3; }
786
934
  .directory-entry:last-child { border-bottom: 0; }
@@ -828,7 +976,7 @@ function renderViewerPage(input: {
828
976
  }
829
977
  </style>
830
978
  </head>
831
- <body${input.viewMode === "markdown" ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
979
+ <body${input.viewMode ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
832
980
  <header id="viewer-header">
833
981
  <h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
834
982
  <div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${toolbar}</div>
@@ -1084,8 +1232,12 @@ function renderText(value: string, filePath: string): string {
1084
1232
  }).join("")}</code></pre>`;
1085
1233
  }
1086
1234
 
1087
- function renderMarkdownDocument(value: string, filePath: string): string {
1088
- return `<article class="markdown-body" data-view-panel="rendered">${MARKDOWN_RENDERER.render(value)}</article><section data-view-panel="code">${renderText(value, filePath)}</section>`;
1235
+ function renderMarkdownDocument(value: string, filePath: string, createPreviewUrl: (filePath: string) => URL): string {
1236
+ const environment = {};
1237
+ const tokens = MARKDOWN_RENDERER.parse(value, environment);
1238
+ rewriteMarkdownFileLinks(tokens, filePath, createPreviewUrl);
1239
+ const rendered = MARKDOWN_RENDERER.renderer.render(tokens, MARKDOWN_RENDERER.options, environment);
1240
+ return `<article class="markdown-body" data-view-panel="rendered">${rendered}</article><section data-view-panel="code">${renderText(value, filePath)}</section>`;
1089
1241
  }
1090
1242
 
1091
1243
  function isMarkdownFile(filePath: string): boolean {
@@ -1211,7 +1363,7 @@ function writeServerSentEvent(response: ServerResponse, event: string, data: str
1211
1363
  }
1212
1364
 
1213
1365
  function setSecurityHeaders(response: ServerResponse): void {
1214
- response.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; object-src 'self'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
1366
+ response.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; object-src 'self'; frame-src 'self'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
1215
1367
  response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
1216
1368
  response.setHeader("Referrer-Policy", "no-referrer");
1217
1369
  response.setHeader("X-Content-Type-Options", "nosniff");
@@ -1226,6 +1378,13 @@ function escapeAttribute(value: string): string {
1226
1378
  return escapeHtml(value);
1227
1379
  }
1228
1380
 
1381
+ function normalizeTurnId(value: string): string | undefined {
1382
+ const normalized = value.trim();
1383
+ return normalized && normalized.length <= 256 && !normalized.includes("\0")
1384
+ ? normalized
1385
+ : undefined;
1386
+ }
1387
+
1229
1388
  function errorPage(title: string, message: string): string {
1230
1389
  return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} · Agent Bot</title><style>body{font-family:system-ui,sans-serif;max-width:720px;margin:12vh auto;padding:0 24px;color:#1f2329}h1{font-size:24px}p{color:#646a73}</style></head><body><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p></body></html>`;
1231
1390
  }
@@ -0,0 +1,64 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import type { Token } from "markdown-it";
4
+
5
+ export function isFileUrl(value: string): boolean {
6
+ return /^file:/iu.test(value);
7
+ }
8
+
9
+ export function rewriteMarkdownFileLinks(
10
+ tokens: Token[],
11
+ markdownPath: string,
12
+ createPreviewUrl: (filePath: string) => URL,
13
+ ): void {
14
+ for (const token of tokens) {
15
+ const attribute = token.type === "link_open" ? "href" : token.type === "image" ? "src" : undefined;
16
+ const target = attribute ? token.attrGet(attribute) : null;
17
+ if (attribute && typeof target === "string" && target) {
18
+ const local = resolveLocalTarget(target, markdownPath);
19
+ if (local) {
20
+ const url = createPreviewUrl(local.filePath);
21
+ url.hash = local.hash;
22
+ if (token.type === "image") url.searchParams.set("raw", "1");
23
+ token.attrSet(attribute, url.toString());
24
+ } else if (isFileUrl(target)) {
25
+ // File URLs are accepted by the parser only to convert them to signed HTTP URLs.
26
+ token.attrs = token.attrs?.filter(([name]) => name !== attribute) ?? null;
27
+ }
28
+ }
29
+ if (token.children) rewriteMarkdownFileLinks(token.children, markdownPath, createPreviewUrl);
30
+ }
31
+ }
32
+
33
+ function resolveLocalTarget(target: string, markdownPath: string): { filePath: string; hash: string } | undefined {
34
+ if (/^(?:#|\?|\/\/)/u.test(target)) return undefined;
35
+ const windows = process.platform === "win32";
36
+ const normalized = windows ? target.replace(/%5c/giu, "/").replaceAll("\\", "/") : target;
37
+ const drivePath = /^\/?[a-z]:\//iu.test(normalized);
38
+ const relativeFileWithLine = /^[^:/]+\.[^:/]+:\d+(?::\d+)?(?:#.*)?$/u.test(normalized);
39
+ if (!drivePath && !isFileUrl(normalized) && !relativeFileWithLine && /^[a-z][a-z\d+.-]*:/iu.test(normalized)) return undefined;
40
+ const pathEnd = normalized.search(/[?#]/u);
41
+ const pathTarget = pathEnd < 0 ? normalized : normalized.slice(0, pathEnd);
42
+ const reference = /:(\d+)(?::\d+)?$/u.exec(pathTarget);
43
+ const withoutReference = reference
44
+ ? normalized.slice(0, reference.index) + normalized.slice(reference.index + reference[0].length)
45
+ : normalized;
46
+ if (drivePath && !windows) return undefined;
47
+
48
+ try {
49
+ const url = drivePath
50
+ ? new URL(`file:///${withoutReference.replace(/^\//u, "")}`)
51
+ : new URL(withoutReference, pathToFileURL(markdownPath));
52
+ if (url.protocol !== "file:") return undefined;
53
+ const filePath = fileURLToPath(url);
54
+ if (!path.isAbsolute(filePath) || /[\x00-\x1f\x7f]/u.test(filePath)) return undefined;
55
+ const line = /^#L(\d+)(?:C\d+)?(?:-L?\d+(?:C\d+)?)?$/u.exec(url.hash)?.[1]
56
+ ?? (url.hash ? undefined : reference?.[1]);
57
+ const hash = line && Number.isSafeInteger(Number(line)) && Number(line) > 0
58
+ ? `#L${Number(line)}`
59
+ : url.hash;
60
+ return { filePath: path.resolve(filePath), hash };
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }