@keyou007/agent-bot 0.1.20 → 0.1.22

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 (62) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +19 -5
  3. package/README.zh.md +19 -5
  4. package/dist/codex/CodexEventMapper.js +21 -1
  5. package/dist/codex/CodexEventMapper.js.map +1 -1
  6. package/dist/codex/CodexProcessManager.js +3 -0
  7. package/dist/codex/CodexProcessManager.js.map +1 -1
  8. package/dist/codex/CodexRuntime.js +136 -4
  9. package/dist/codex/CodexRuntime.js.map +1 -1
  10. package/dist/codex/ProviderModels.js +159 -0
  11. package/dist/codex/ProviderModels.js.map +1 -0
  12. package/dist/commands/CommandRouter.js +8 -3
  13. package/dist/commands/CommandRouter.js.map +1 -1
  14. package/dist/console/ConsoleTurnPresenter.js +2 -1
  15. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  16. package/dist/feishu/CardRenderer.js +47 -17
  17. package/dist/feishu/CardRenderer.js.map +1 -1
  18. package/dist/feishu/FeishuTurnPresenter.js +34 -8
  19. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/local-files/LocalFileViewerServer.js +175 -26
  23. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  24. package/dist/local-files/MarkdownFileLinks.js +12 -5
  25. package/dist/local-files/MarkdownFileLinks.js.map +1 -1
  26. package/dist/local-files/TurnPreviewPage.js +752 -0
  27. package/dist/local-files/TurnPreviewPage.js.map +1 -0
  28. package/dist/presentation/OutboundRouter.js +6 -1
  29. package/dist/presentation/OutboundRouter.js.map +1 -1
  30. package/dist/presentation/TurnStateReducer.js +142 -3
  31. package/dist/presentation/TurnStateReducer.js.map +1 -1
  32. package/dist/proxy/ProxySessionController.js +186 -80
  33. package/dist/proxy/ProxySessionController.js.map +1 -1
  34. package/dist/state/StateStore.js +7 -0
  35. package/dist/state/StateStore.js.map +1 -1
  36. package/dist/utils/formatStorageSize.js +15 -0
  37. package/dist/utils/formatStorageSize.js.map +1 -0
  38. package/docs/technical-reference.md +7 -1
  39. package/docs/technical-reference.zh.md +7 -1
  40. package/npm-shrinkwrap.json +2 -2
  41. package/package.json +1 -1
  42. package/skills/agent-bot/SKILL.md +5 -1
  43. package/src/codex/CodexEventMapper.ts +31 -1
  44. package/src/codex/CodexProcessManager.ts +4 -0
  45. package/src/codex/CodexRuntime.ts +153 -9
  46. package/src/codex/ProviderModels.ts +185 -0
  47. package/src/commands/CommandRouter.ts +7 -2
  48. package/src/commands/commandTypes.ts +1 -1
  49. package/src/console/ConsoleTurnPresenter.ts +3 -0
  50. package/src/feishu/CardRenderer.ts +48 -17
  51. package/src/feishu/FeishuTurnPresenter.ts +46 -4
  52. package/src/index.ts +2 -0
  53. package/src/local-files/LocalFileViewerServer.ts +189 -28
  54. package/src/local-files/MarkdownFileLinks.ts +12 -7
  55. package/src/local-files/TurnPreviewPage.ts +821 -0
  56. package/src/presentation/OutboundRouter.ts +8 -0
  57. package/src/presentation/TurnStateReducer.ts +175 -2
  58. package/src/presentation/turnViewTypes.ts +13 -0
  59. package/src/proxy/ProxySessionController.ts +186 -67
  60. package/src/runtime/types.ts +18 -1
  61. package/src/state/StateStore.ts +9 -0
  62. package/src/utils/formatStorageSize.ts +13 -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";
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!;
@@ -12,6 +12,13 @@ import {
12
12
  selectPreferredNetworkAddress,
13
13
  type NetworkConnectionKind,
14
14
  } from "./NetworkAddressSelector.js";
15
+ import {
16
+ isTurnPreviewState,
17
+ detectTurnPreviewLanguage,
18
+ renderTurnPreviewPage,
19
+ renderTurnPreviewSnapshot,
20
+ TURN_PREVIEW_CLIENT_SCRIPT,
21
+ } from "./TurnPreviewPage.js";
15
22
 
16
23
  const SECRET_FILE = "secret";
17
24
  const PORT_FILE = "port";
@@ -22,6 +29,7 @@ const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
22
29
  const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
23
30
  const DIRECTORY_PAGE_SIZE = 100;
24
31
  const MARKDOWN_EXTENSIONS = new Set([".markdown", ".md", ".mdown", ".mkd", ".mkdn"]);
32
+ const HTML_EXTENSIONS = new Set([".html", ".htm"]);
25
33
  hljs.registerLanguage("dos", dos);
26
34
  hljs.registerLanguage("dockerfile", dockerfile);
27
35
  hljs.registerLanguage("powershell", powershell);
@@ -172,7 +180,7 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
172
180
  try {
173
181
  const update = JSON.parse(event.data);
174
182
  if (typeof update.content === "string" && typeof update.metadata === "string") {
175
- if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown";
183
+ if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown" && update.viewMode !== "html";
176
184
  replaceContent(update.content, update.metadata);
177
185
  }
178
186
  } catch {
@@ -214,7 +222,7 @@ interface DirectoryViewerEntry {
214
222
  interface RenderedFileSnapshot {
215
223
  content: string;
216
224
  metadata: string[];
217
- viewMode?: "markdown";
225
+ viewMode?: "markdown" | "html";
218
226
  }
219
227
 
220
228
  export interface LocalFileViewerServerOptions {
@@ -222,6 +230,8 @@ export interface LocalFileViewerServerOptions {
222
230
  port: number;
223
231
  publicBaseUrl?: string;
224
232
  stateDirectory: string;
233
+ getTurnSnapshot?: (turnId: string) => unknown;
234
+ turnPreviewPollIntervalMs?: number;
225
235
  }
226
236
 
227
237
  export interface LocalFileViewerAddress {
@@ -333,6 +343,18 @@ export class LocalFileViewerServer {
333
343
  return url.toString();
334
344
  }
335
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
+
336
358
  private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
337
359
  if (request.method !== "GET" && request.method !== "HEAD") {
338
360
  response.setHeader("Allow", "GET, HEAD");
@@ -350,6 +372,42 @@ export class LocalFileViewerServer {
350
372
  this.sendJavascript(response, request.method === "HEAD");
351
373
  return;
352
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
+ }
353
411
  const previewMatch = new RegExp(`^/preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
354
412
  const legacyMatch = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
355
413
  const previewPath = previewMatch?.[1]
@@ -387,12 +445,13 @@ export class LocalFileViewerServer {
387
445
  }
388
446
 
389
447
  const raw = legacyMatch?.[1] === "raw" || requestUrl.searchParams.get("raw") === "1";
390
- if (raw) {
448
+ const renderHtml = !raw && requestUrl.searchParams.get("render") === "html";
449
+ if (raw || renderHtml) {
391
450
  if (stat.isDirectory()) {
392
451
  this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
393
452
  return;
394
453
  }
395
- this.serveRawFile(request, response, filePath, stat, requestUrl.searchParams.get("download") === "1");
454
+ this.serveRawFile(request, response, filePath, stat, requestUrl.searchParams.get("download") === "1", renderHtml);
396
455
  return;
397
456
  }
398
457
 
@@ -430,6 +489,17 @@ export class LocalFileViewerServer {
430
489
  : undefined;
431
490
  }
432
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
+
433
503
  private sign(token: string): string {
434
504
  return createHmac("sha256", this.secret!).update(token).digest("hex");
435
505
  }
@@ -499,12 +569,25 @@ export class LocalFileViewerServer {
499
569
  appendUrlQueryParameter(rawUrl, "raw", "1");
500
570
 
501
571
  let content: string;
572
+ let viewMode: RenderedFileSnapshot["viewMode"];
502
573
  if (classification.kind === "text") {
503
574
  const preview = readTextPreview(filePath, stat.size, classification.encoding);
504
- const previewContent = isMarkdownFile(filePath)
505
- ? renderMarkdownDocument(preview.text, filePath, (target) => this.createPreviewUrl(target))
506
- : renderText(preview.text, filePath);
507
- 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
+ }
508
591
  } else if (contentType.startsWith("image/")) {
509
592
  content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
510
593
  } else if (contentType === "application/pdf") {
@@ -519,9 +602,7 @@ export class LocalFileViewerServer {
519
602
  return {
520
603
  content,
521
604
  metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
522
- ...(classification.kind === "text" && isMarkdownFile(filePath)
523
- ? { viewMode: "markdown" as const }
524
- : {}),
605
+ ...(viewMode ? { viewMode } : {}),
525
606
  };
526
607
  }
527
608
 
@@ -541,10 +622,12 @@ export class LocalFileViewerServer {
541
622
 
542
623
  let closed = false;
543
624
  let pendingUpdate: NodeJS.Timeout | undefined;
625
+ let previousStat: fs.Stats | undefined;
544
626
  const sendSnapshot = () => {
545
627
  if (closed || response.destroyed) return;
546
628
  try {
547
629
  const stat = fs.statSync(filePath);
630
+ previousStat = stat;
548
631
  if (!stat.isFile()) throw new Error("Path is no longer a file");
549
632
  const snapshot = this.renderFileSnapshot(filePath, stat);
550
633
  writeServerSentEvent(response, "update", JSON.stringify({
@@ -563,17 +646,27 @@ export class LocalFileViewerServer {
563
646
  pendingUpdate = setTimeout(sendSnapshot, 100);
564
647
  pendingUpdate.unref();
565
648
  };
566
- const watcher = (current: fs.Stats, previous: fs.Stats) => {
567
- if (
568
- current.mtimeMs === previous.mtimeMs
569
- && current.ctimeMs === previous.ctimeMs
570
- && current.size === previous.size
571
- && current.nlink === previous.nlink
572
- ) return;
573
- scheduleSnapshot();
574
- };
575
- fs.watchFile(filePath, { interval: 1_000, persistent: false }, watcher);
576
649
  sendSnapshot();
650
+ // Compare against the sent snapshot so edits during watcher startup cannot be missed.
651
+ let checking = false;
652
+ const poller = setInterval(() => {
653
+ if (closed || checking) return;
654
+ checking = true;
655
+ fs.stat(filePath, (error, stat) => {
656
+ checking = false;
657
+ if (closed) return;
658
+ const current = error ? undefined : stat;
659
+ if (
660
+ current?.mtimeMs === previousStat?.mtimeMs
661
+ && current?.ctimeMs === previousStat?.ctimeMs
662
+ && current?.size === previousStat?.size
663
+ && current?.nlink === previousStat?.nlink
664
+ ) return;
665
+ previousStat = current;
666
+ scheduleSnapshot();
667
+ });
668
+ }, 1_000);
669
+ poller.unref();
577
670
  const keepAlive = setInterval(() => {
578
671
  if (!closed && !response.destroyed) response.write(": keep-alive\n\n");
579
672
  }, 15_000);
@@ -584,7 +677,58 @@ export class LocalFileViewerServer {
584
677
  closed = true;
585
678
  if (pendingUpdate) clearTimeout(pendingUpdate);
586
679
  clearInterval(keepAlive);
587
- fs.unwatchFile(filePath, watcher);
680
+ clearInterval(poller);
681
+ };
682
+ request.once("aborted", cleanup);
683
+ response.once("close", cleanup);
684
+ }
685
+
686
+ private serveTurnEvents(request: IncomingMessage, response: ServerResponse, turnId: string): void {
687
+ setSecurityHeaders(response);
688
+ response.statusCode = 200;
689
+ response.setHeader("Cache-Control", "no-cache, no-store");
690
+ response.setHeader("Connection", "keep-alive");
691
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
692
+ response.setHeader("X-Accel-Buffering", "no");
693
+ if (request.method === "HEAD") {
694
+ response.end();
695
+ return;
696
+ }
697
+ response.flushHeaders();
698
+ response.write("retry: 2000\n\n");
699
+ const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
700
+
701
+ let closed = false;
702
+ let previousSnapshot = "";
703
+ const sendSnapshot = () => {
704
+ if (closed || response.destroyed) return;
705
+ const state = this.options.getTurnSnapshot?.(turnId);
706
+ if (!isTurnPreviewState(state)) {
707
+ writeServerSentEvent(response, "unavailable", JSON.stringify({ message: "Turn 暂时无法读取。" }));
708
+ return;
709
+ }
710
+ const serialized = JSON.stringify(state);
711
+ if (serialized === previousSnapshot) return;
712
+ previousSnapshot = serialized;
713
+ const snapshot = renderTurnPreviewSnapshot(state, (filePath) => this.createFileUrl(filePath), language);
714
+ writeServerSentEvent(response, "update", JSON.stringify(snapshot));
715
+ };
716
+ sendSnapshot();
717
+ const poller = setInterval(
718
+ sendSnapshot,
719
+ Math.max(50, this.options.turnPreviewPollIntervalMs ?? 1_000),
720
+ );
721
+ poller.unref();
722
+ const keepAlive = setInterval(() => {
723
+ if (!closed && !response.destroyed) response.write(": keep-alive\n\n");
724
+ }, 15_000);
725
+ keepAlive.unref();
726
+
727
+ const cleanup = () => {
728
+ if (closed) return;
729
+ closed = true;
730
+ clearInterval(poller);
731
+ clearInterval(keepAlive);
588
732
  };
589
733
  request.once("aborted", cleanup);
590
734
  response.once("close", cleanup);
@@ -638,9 +782,14 @@ export class LocalFileViewerServer {
638
782
  filePath: string,
639
783
  stat: fs.Stats,
640
784
  download: boolean,
785
+ renderHtml = false,
641
786
  ): void {
642
787
  const extension = path.extname(filePath).toLowerCase();
643
788
  const classification = classifyFileContent(filePath, stat.size);
789
+ if (renderHtml && (!HTML_EXTENSIONS.has(extension) || classification.kind !== "text")) {
790
+ this.sendHtml(response, 400, errorPage("无法预览 HTML", "只有 HTML 文本文件支持此预览模式。"), request.method === "HEAD");
791
+ return;
792
+ }
644
793
  const contentType = classification.kind === "text"
645
794
  ? textContentTypeFor(extension, classification.encoding)
646
795
  : binaryContentTypeFor(extension);
@@ -648,6 +797,10 @@ export class LocalFileViewerServer {
648
797
  const fileName = path.basename(filePath);
649
798
  const disposition = download ? "attachment" : "inline";
650
799
  setSecurityHeaders(response);
800
+ if (renderHtml) {
801
+ // A response-level sandbox also isolates this document when opened outside the viewer iframe.
802
+ 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'");
803
+ }
651
804
  response.setHeader("Accept-Ranges", "bytes");
652
805
  response.setHeader("Cache-Control", "no-store");
653
806
  response.setHeader("Content-Type", contentType);
@@ -686,8 +839,8 @@ export class LocalFileViewerServer {
686
839
  response.end(headOnly ? undefined : body);
687
840
  }
688
841
 
689
- private sendJavascript(response: ServerResponse, headOnly: boolean): void {
690
- const body = Buffer.from(VIEWER_CLIENT_SCRIPT, "utf8");
842
+ private sendJavascript(response: ServerResponse, headOnly: boolean, source = VIEWER_CLIENT_SCRIPT): void {
843
+ const body = Buffer.from(source, "utf8");
691
844
  setSecurityHeaders(response);
692
845
  response.statusCode = 200;
693
846
  response.setHeader("Cache-Control", "no-store");
@@ -703,14 +856,14 @@ function renderViewerPage(input: {
703
856
  metadata: string[];
704
857
  actions: Array<{ href: string; label: string }>;
705
858
  content: string;
706
- viewMode?: "markdown";
859
+ viewMode?: RenderedFileSnapshot["viewMode"];
707
860
  liveUpdates?: { eventsUrl: string; scriptPath: string };
708
861
  }): string {
709
862
  const metadata = renderMetadata(input.metadata);
710
863
  const actions = input.actions.length > 0
711
864
  ? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
712
865
  : "";
713
- const viewSwitch = input.viewMode === "markdown"
866
+ const viewSwitch = input.viewMode
714
867
  ? '<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>'
715
868
  : "";
716
869
  const toolbar = viewSwitch || actions
@@ -787,6 +940,7 @@ function renderViewerPage(input: {
787
940
  .media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
788
941
  .media audio { width: min(720px, 100%); }
789
942
  .document { width: 100%; height: calc(100vh - 70px); border: 0; border-radius: 0; background: #fff; }
943
+ .html-preview { display: block; width: 100%; height: calc(100dvh - var(--viewer-header-offset)); min-height: 240px; border: 0; background: #fff; color-scheme: normal; }
790
944
  .directory-list { overflow: hidden; border: 0; border-radius: 0; background: #fff; }
791
945
  .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; }
792
946
  .directory-entry:last-child { border-bottom: 0; }
@@ -834,7 +988,7 @@ function renderViewerPage(input: {
834
988
  }
835
989
  </style>
836
990
  </head>
837
- <body${input.viewMode === "markdown" ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
991
+ <body${input.viewMode ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
838
992
  <header id="viewer-header">
839
993
  <h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
840
994
  <div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${toolbar}</div>
@@ -1221,7 +1375,7 @@ function writeServerSentEvent(response: ServerResponse, event: string, data: str
1221
1375
  }
1222
1376
 
1223
1377
  function setSecurityHeaders(response: ServerResponse): void {
1224
- 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'");
1378
+ 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'");
1225
1379
  response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
1226
1380
  response.setHeader("Referrer-Policy", "no-referrer");
1227
1381
  response.setHeader("X-Content-Type-Options", "nosniff");
@@ -1236,6 +1390,13 @@ function escapeAttribute(value: string): string {
1236
1390
  return escapeHtml(value);
1237
1391
  }
1238
1392
 
1393
+ function normalizeTurnId(value: string): string | undefined {
1394
+ const normalized = value.trim();
1395
+ return normalized && normalized.length <= 256 && !normalized.includes("\0")
1396
+ ? normalized
1397
+ : undefined;
1398
+ }
1399
+
1239
1400
  function errorPage(title: string, message: string): string {
1240
1401
  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>`;
1241
1402
  }
@@ -8,8 +8,8 @@ export function isFileUrl(value: string): boolean {
8
8
 
9
9
  export function rewriteMarkdownFileLinks(
10
10
  tokens: Token[],
11
- markdownPath: string,
12
- createPreviewUrl: (filePath: string) => URL,
11
+ markdownPath: string | undefined,
12
+ createPreviewUrl: (filePath: string) => URL | undefined,
13
13
  ): void {
14
14
  for (const token of tokens) {
15
15
  const attribute = token.type === "link_open" ? "href" : token.type === "image" ? "src" : undefined;
@@ -18,9 +18,13 @@ export function rewriteMarkdownFileLinks(
18
18
  const local = resolveLocalTarget(target, markdownPath);
19
19
  if (local) {
20
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());
21
+ if (url) {
22
+ url.hash = local.hash;
23
+ if (token.type === "image") url.searchParams.set("raw", "1");
24
+ token.attrSet(attribute, url.toString());
25
+ } else {
26
+ token.attrs = token.attrs?.filter(([name]) => name !== attribute) ?? null;
27
+ }
24
28
  } else if (isFileUrl(target)) {
25
29
  // File URLs are accepted by the parser only to convert them to signed HTTP URLs.
26
30
  token.attrs = token.attrs?.filter(([name]) => name !== attribute) ?? null;
@@ -30,7 +34,7 @@ export function rewriteMarkdownFileLinks(
30
34
  }
31
35
  }
32
36
 
33
- function resolveLocalTarget(target: string, markdownPath: string): { filePath: string; hash: string } | undefined {
37
+ function resolveLocalTarget(target: string, markdownPath: string | undefined): { filePath: string; hash: string } | undefined {
34
38
  if (/^(?:#|\?|\/\/)/u.test(target)) return undefined;
35
39
  const windows = process.platform === "win32";
36
40
  const normalized = windows ? target.replace(/%5c/giu, "/").replaceAll("\\", "/") : target;
@@ -44,11 +48,12 @@ function resolveLocalTarget(target: string, markdownPath: string): { filePath: s
44
48
  ? normalized.slice(0, reference.index) + normalized.slice(reference.index + reference[0].length)
45
49
  : normalized;
46
50
  if (drivePath && !windows) return undefined;
51
+ if (!markdownPath && !drivePath && !isFileUrl(normalized) && !path.isAbsolute(normalized)) return undefined;
47
52
 
48
53
  try {
49
54
  const url = drivePath
50
55
  ? new URL(`file:///${withoutReference.replace(/^\//u, "")}`)
51
- : new URL(withoutReference, pathToFileURL(markdownPath));
56
+ : new URL(withoutReference, markdownPath ? pathToFileURL(markdownPath) : "file:///");
52
57
  if (url.protocol !== "file:") return undefined;
53
58
  const filePath = fileURLToPath(url);
54
59
  if (!path.isAbsolute(filePath) || /[\x00-\x1f\x7f]/u.test(filePath)) return undefined;