@keyou007/agent-bot 0.1.17-alpha.2 → 0.1.18-alpha.0

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.
@@ -2,6 +2,11 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import http, { type IncomingMessage, type ServerResponse } from "node:http";
4
4
  import path from "node:path";
5
+ import hljs from "highlight.js/lib/common";
6
+ import dos from "highlight.js/lib/languages/dos";
7
+ import dockerfile from "highlight.js/lib/languages/dockerfile";
8
+ import powershell from "highlight.js/lib/languages/powershell";
9
+ import MarkdownIt from "markdown-it";
5
10
  import {
6
11
  selectPreferredNetworkAddress,
7
12
  type NetworkConnectionKind,
@@ -9,9 +14,174 @@ import {
9
14
 
10
15
  const SECRET_FILE = "secret";
11
16
  const PORT_FILE = "port";
17
+ const SHORT_TOKEN_BYTES = 12;
18
+ const SHORT_TOKEN_LENGTH = 16;
12
19
  const MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024;
13
20
  const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
21
+ const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
14
22
  const DIRECTORY_PAGE_SIZE = 100;
23
+ const MARKDOWN_EXTENSIONS = new Set([".markdown", ".md", ".mdown", ".mkd", ".mkdn"]);
24
+ hljs.registerLanguage("dos", dos);
25
+ hljs.registerLanguage("dockerfile", dockerfile);
26
+ hljs.registerLanguage("powershell", powershell);
27
+
28
+ const MARKDOWN_RENDERER = new MarkdownIt({
29
+ breaks: false,
30
+ html: false,
31
+ linkify: true,
32
+ typographer: false,
33
+ highlight(value, language) {
34
+ const highlighted = language && hljs.getLanguage(language)
35
+ ? hljs.highlight(value, { language, ignoreIllegals: true }).value
36
+ : escapeHtml(value);
37
+ const languageAttribute = language && hljs.getLanguage(language)
38
+ ? ` class="language-${escapeAttribute(language)}"`
39
+ : "";
40
+ return `<pre class="markdown-code-block hljs"><code${languageAttribute}>${highlighted}</code></pre>`;
41
+ },
42
+ });
43
+
44
+ const HIGHLIGHT_LANGUAGE_BY_EXTENSION: Readonly<Record<string, string>> = {
45
+ ".bash": "bash", ".bat": "dos", ".c": "c", ".cc": "cpp", ".cmd": "dos", ".cpp": "cpp",
46
+ ".cs": "csharp", ".css": "css", ".cxx": "cpp", ".diff": "diff", ".go": "go", ".h": "cpp",
47
+ ".hpp": "cpp", ".htm": "xml", ".html": "xml", ".ini": "ini", ".ipynb": "json", ".java": "java",
48
+ ".js": "javascript", ".json": "json", ".jsonl": "json", ".jsx": "javascript", ".kt": "kotlin",
49
+ ".kts": "kotlin", ".less": "less", ".lua": "lua", ".markdown": "markdown", ".md": "markdown",
50
+ ".mjs": "javascript", ".patch": "diff", ".php": "php", ".pl": "perl", ".ps1": "powershell",
51
+ ".py": "python", ".r": "r", ".rb": "ruby", ".rs": "rust", ".scss": "scss", ".sh": "bash",
52
+ ".sql": "sql", ".swift": "swift", ".toml": "ini", ".ts": "typescript", ".tsx": "typescript",
53
+ ".vue": "xml", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".zsh": "bash",
54
+ };
55
+ const HIGHLIGHT_LANGUAGE_BY_FILENAME: Readonly<Record<string, string>> = {
56
+ ".env": "ini",
57
+ ".gitignore": "ini",
58
+ "dockerfile": "dockerfile",
59
+ "makefile": "makefile",
60
+ };
61
+ const VIEWER_CLIENT_SCRIPT = `(() => {
62
+ const eventsUrl = document.body.dataset.eventsUrl;
63
+ const content = document.getElementById("viewer-content");
64
+ const metadata = document.getElementById("viewer-metadata");
65
+ const header = document.getElementById("viewer-header");
66
+ const title = document.getElementById("viewer-title");
67
+ const viewSwitch = document.getElementById("viewer-view-switch");
68
+ const viewButtons = Array.from(document.querySelectorAll("[data-view-mode-button]"));
69
+ if (!content || !metadata || !header || !title) return;
70
+ const filePath = title.dataset.filePath || title.textContent || "";
71
+
72
+ const syncViewMode = () => {
73
+ const mode = document.body.dataset.viewMode === "code" ? "code" : "rendered";
74
+ for (const button of viewButtons) {
75
+ const active = button.dataset.viewModeButton === mode;
76
+ button.classList.toggle("is-active", active);
77
+ button.setAttribute("aria-selected", String(active));
78
+ }
79
+ for (const panel of content.querySelectorAll("[data-view-panel]")) {
80
+ panel.setAttribute("aria-hidden", String(panel.dataset.viewPanel !== mode));
81
+ }
82
+ };
83
+ const setViewMode = (mode) => {
84
+ document.body.dataset.viewMode = mode === "code" ? "code" : "rendered";
85
+ syncViewMode();
86
+ if (mode === "code") requestAnimationFrame(positionHashTarget);
87
+ };
88
+ for (const button of viewButtons) {
89
+ button.addEventListener("click", () => setViewMode(button.dataset.viewModeButton));
90
+ }
91
+
92
+ title.addEventListener("pointerdown", (event) => {
93
+ if (event.button !== 0) return;
94
+ event.preventDefault();
95
+ const selection = window.getSelection();
96
+ if (!selection) return;
97
+ const range = document.createRange();
98
+ range.selectNodeContents(title);
99
+ selection.removeAllRanges();
100
+ selection.addRange(range);
101
+ });
102
+
103
+ const updateHeaderOffset = () => {
104
+ const offset = Math.ceil(header.getBoundingClientRect().height) + 8;
105
+ document.documentElement.style.setProperty("--viewer-header-offset", offset + "px");
106
+ };
107
+ const highlightHashTarget = () => {
108
+ document.querySelector(".line.is-target-line")?.classList.remove("is-target-line");
109
+ let id;
110
+ try {
111
+ id = decodeURIComponent(window.location.hash.slice(1));
112
+ } catch {
113
+ title.textContent = filePath;
114
+ return null;
115
+ }
116
+ const lineNumber = /^L(\\d+)$/.exec(id)?.[1];
117
+ title.textContent = lineNumber ? filePath + ":" + lineNumber : filePath;
118
+ if (!lineNumber) return null;
119
+ const target = document.getElementById(id);
120
+ target?.classList.add("is-target-line");
121
+ return target;
122
+ };
123
+ const positionHashTarget = () => {
124
+ highlightHashTarget()?.scrollIntoView({ block: "start" });
125
+ };
126
+ if (/^#L\\d+$/u.test(window.location.hash)) document.body.dataset.viewMode = "code";
127
+ syncViewMode();
128
+ updateHeaderOffset();
129
+ if (typeof ResizeObserver === "function") {
130
+ new ResizeObserver(updateHeaderOffset).observe(header);
131
+ }
132
+ window.addEventListener("hashchange", () => requestAnimationFrame(positionHashTarget));
133
+ requestAnimationFrame(positionHashTarget);
134
+ if (!eventsUrl || typeof EventSource !== "function") return;
135
+
136
+ const restoreScroll = (top, left, atBottom, codeScrollLeft) => {
137
+ requestAnimationFrame(() => {
138
+ const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
139
+ window.scrollTo(left, atBottom ? maxTop : Math.min(top, maxTop));
140
+ const code = content.querySelector(".code");
141
+ if (code) code.scrollLeft = codeScrollLeft;
142
+ });
143
+ };
144
+ const replaceContent = (nextContent, nextMetadata) => {
145
+ const top = window.scrollY;
146
+ const left = window.scrollX;
147
+ const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
148
+ const atBottom = maxTop - top <= 24;
149
+ const codeScrollLeft = content.querySelector(".code")?.scrollLeft ?? 0;
150
+ content.innerHTML = nextContent;
151
+ metadata.innerHTML = nextMetadata;
152
+ syncViewMode();
153
+ highlightHashTarget();
154
+ restoreScroll(top, left, atBottom, codeScrollLeft);
155
+ };
156
+
157
+ const source = new EventSource(eventsUrl);
158
+ source.addEventListener("update", (event) => {
159
+ try {
160
+ const update = JSON.parse(event.data);
161
+ if (typeof update.content === "string" && typeof update.metadata === "string") {
162
+ if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown";
163
+ replaceContent(update.content, update.metadata);
164
+ }
165
+ } catch {
166
+ // A later valid event will repair the view.
167
+ }
168
+ });
169
+ source.addEventListener("unavailable", (event) => {
170
+ let message = "文件暂时无法读取,服务会继续等待它恢复。";
171
+ try {
172
+ const update = JSON.parse(event.data);
173
+ if (typeof update.message === "string") message = update.message;
174
+ } catch {
175
+ // Keep the fallback message.
176
+ }
177
+ const notice = document.createElement("div");
178
+ notice.className = "notice";
179
+ notice.textContent = message;
180
+ replaceContent(notice.outerHTML, metadata.innerHTML);
181
+ });
182
+ window.addEventListener("pagehide", () => source.close(), { once: true });
183
+ })();
184
+ `;
15
185
 
16
186
  type TextEncoding = "utf-8" | "utf-16le" | "utf-16be" | "gb18030";
17
187
 
@@ -28,6 +198,12 @@ interface DirectoryViewerEntry {
28
198
  kind: DirectoryViewerEntryKind;
29
199
  }
30
200
 
201
+ interface RenderedFileSnapshot {
202
+ content: string;
203
+ metadata: string[];
204
+ viewMode?: "markdown";
205
+ }
206
+
31
207
  export interface LocalFileViewerServerOptions {
32
208
  host: string;
33
209
  port: number;
@@ -138,8 +314,7 @@ export class LocalFileViewerServer {
138
314
  return undefined;
139
315
  }
140
316
 
141
- const token = Buffer.from(absolutePath, "utf8").toString("base64url");
142
- const url = this.createSignedUrl("view", token);
317
+ const url = this.createPreviewUrl(absolutePath);
143
318
  const line = parseLineReference(reference);
144
319
  if (line !== undefined) url.hash = `L${line}`;
145
320
  return url.toString();
@@ -154,15 +329,27 @@ export class LocalFileViewerServer {
154
329
 
155
330
  const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
156
331
  const route = routeWithinBasePath(requestUrl.pathname, this.basePath);
157
- const match = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
158
- if (!match?.[1] || !match[2]) {
332
+ if (!route.startsWith("/")) {
333
+ this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
334
+ return;
335
+ }
336
+ if (route === "/assets/viewer.js") {
337
+ this.sendJavascript(response, request.method === "HEAD");
338
+ return;
339
+ }
340
+ const previewMatch = new RegExp(`^/preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
341
+ const legacyMatch = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
342
+ const previewPath = previewMatch?.[1]
343
+ ? this.verifyPreviewToken(previewMatch[1], requestUrl.searchParams.get("path") ?? "")
344
+ : undefined;
345
+ const legacyPath = legacyMatch?.[2]
346
+ ? this.verifyLegacyToken(legacyMatch[2], requestUrl.searchParams.get("sig") ?? "")
347
+ : undefined;
348
+ const filePath = previewPath ?? legacyPath;
349
+ if (!previewMatch && !legacyMatch) {
159
350
  this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
160
351
  return;
161
352
  }
162
-
163
- const token = match[2];
164
- const signature = requestUrl.searchParams.get("sig") ?? "";
165
- const filePath = this.verifyToken(token, signature);
166
353
  if (!filePath) {
167
354
  this.sendHtml(response, 403, errorPage("文件链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
168
355
  return;
@@ -177,7 +364,17 @@ export class LocalFileViewerServer {
177
364
  return;
178
365
  }
179
366
 
180
- if (match[1] === "raw") {
367
+ if (requestUrl.searchParams.get("events") === "1") {
368
+ if (!stat.isFile()) {
369
+ this.sendHtml(response, 400, errorPage("无法监听目录", "实时更新目前只适用于文件。"), request.method === "HEAD");
370
+ return;
371
+ }
372
+ this.serveFileEvents(request, response, filePath);
373
+ return;
374
+ }
375
+
376
+ const raw = legacyMatch?.[1] === "raw" || requestUrl.searchParams.get("raw") === "1";
377
+ if (raw) {
181
378
  if (stat.isDirectory()) {
182
379
  this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
183
380
  return;
@@ -187,12 +384,12 @@ export class LocalFileViewerServer {
187
384
  }
188
385
 
189
386
  const html = stat.isDirectory()
190
- ? this.renderDirectoryViewer(filePath, stat, token, parseDirectoryPage(requestUrl.searchParams.get("page")))
191
- : this.renderViewer(filePath, stat, token, signature);
387
+ ? this.renderDirectoryViewer(filePath, stat, parseDirectoryPage(requestUrl.searchParams.get("page")))
388
+ : this.renderViewer(filePath, stat);
192
389
  this.sendHtml(response, 200, html, request.method === "HEAD");
193
390
  }
194
391
 
195
- private verifyToken(token: string, signature: string): string | undefined {
392
+ private verifyLegacyToken(token: string, signature: string): string | undefined {
196
393
  if (!this.secret || !/^[a-f0-9]{64}$/u.test(signature)) return undefined;
197
394
  const expected = this.sign(token);
198
395
  const actualBuffer = Buffer.from(signature, "hex");
@@ -209,11 +406,22 @@ export class LocalFileViewerServer {
209
406
  return normalizeAbsolutePath(decoded);
210
407
  }
211
408
 
409
+ private verifyPreviewToken(token: string, rawPath: string): string | undefined {
410
+ const filePath = normalizeAbsolutePath(rawPath);
411
+ if (!filePath || !this.secret) return undefined;
412
+ const expected = this.createShortToken(filePath);
413
+ const actualBuffer = Buffer.from(token, "utf8");
414
+ const expectedBuffer = Buffer.from(expected, "utf8");
415
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
416
+ ? filePath
417
+ : undefined;
418
+ }
419
+
212
420
  private sign(token: string): string {
213
421
  return createHmac("sha256", this.secret!).update(token).digest("hex");
214
422
  }
215
423
 
216
- private createSignedUrl(route: "view" | "raw", token: string): URL {
424
+ private createLegacySignedUrl(route: "view" | "raw", token: string): URL {
217
425
  const url = new URL(this.address!.publicBaseUrl);
218
426
  url.pathname = `${this.basePath}/${route}/${token}`.replace(/\/{2,}/gu, "/");
219
427
  url.search = "";
@@ -222,20 +430,68 @@ export class LocalFileViewerServer {
222
430
  return url;
223
431
  }
224
432
 
225
- private renderViewer(filePath: string, stat: fs.Stats, token: string, signature: string): string {
433
+ private createPreviewUrl(filePath: string): URL {
434
+ const url = new URL(this.address!.publicBaseUrl);
435
+ url.pathname = `${this.basePath}/preview/${this.createShortToken(filePath)}`
436
+ .replace(/\/{2,}/gu, "/");
437
+ url.search = `?path=${encodeReadableQueryPath(filePath)}`;
438
+ url.hash = "";
439
+ return url;
440
+ }
441
+
442
+ private createShortToken(filePath: string): string {
443
+ return createHmac("sha256", this.secret!)
444
+ .update(filePath)
445
+ .digest()
446
+ .subarray(0, SHORT_TOKEN_BYTES)
447
+ .toString("base64url");
448
+ }
449
+
450
+ private renderViewer(filePath: string, stat: fs.Stats): string {
451
+ const fileName = path.basename(filePath);
452
+ const rawUrl = new URL(this.createFileUrl(filePath)!);
453
+ rawUrl.hash = "";
454
+ appendUrlQueryParameter(rawUrl, "raw", "1");
455
+ const downloadUrl = new URL(rawUrl);
456
+ appendUrlQueryParameter(downloadUrl, "download", "1");
457
+ const eventsUrl = new URL(this.createFileUrl(filePath)!);
458
+ eventsUrl.hash = "";
459
+ appendUrlQueryParameter(eventsUrl, "events", "1");
460
+ const snapshot = this.renderFileSnapshot(filePath, stat);
461
+
462
+ return renderViewerPage({
463
+ title: fileName,
464
+ filePath,
465
+ metadata: snapshot.metadata,
466
+ actions: [
467
+ { href: rawUrl.toString(), label: "打开原始文件" },
468
+ { href: downloadUrl.toString(), label: "下载" },
469
+ ],
470
+ content: snapshot.content,
471
+ viewMode: snapshot.viewMode,
472
+ liveUpdates: {
473
+ eventsUrl: eventsUrl.toString(),
474
+ scriptPath: `${this.basePath}/assets/viewer.js`.replace(/\/{2,}/gu, "/"),
475
+ },
476
+ });
477
+ }
478
+
479
+ private renderFileSnapshot(filePath: string, stat: fs.Stats): RenderedFileSnapshot {
226
480
  const fileName = path.basename(filePath);
227
481
  const extension = path.extname(filePath).toLowerCase();
228
482
  const classification = classifyFileContent(filePath, stat.size);
229
483
  const contentType = binaryContentTypeFor(extension);
230
- const rawUrl = this.createSignedUrl("raw", token);
231
- rawUrl.searchParams.set("sig", signature);
232
- const downloadUrl = new URL(rawUrl);
233
- downloadUrl.searchParams.set("download", "1");
484
+ const rawUrl = new URL(this.createFileUrl(filePath)!);
485
+ rawUrl.hash = "";
486
+ appendUrlQueryParameter(rawUrl, "raw", "1");
234
487
 
235
488
  let content: string;
236
489
  if (classification.kind === "text") {
237
490
  const preview = readTextPreview(filePath, stat.size, classification.encoding);
238
- content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${renderText(preview.text)}`;
491
+ const previewContent = isMarkdownFile(filePath)
492
+ ? renderMarkdownDocument(preview.text, filePath)
493
+ : renderText(preview.text, filePath);
494
+ content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${previewContent}`;
239
495
  } else if (contentType.startsWith("image/")) {
240
496
  content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
241
497
  } else if (contentType === "application/pdf") {
@@ -247,20 +503,81 @@ export class LocalFileViewerServer {
247
503
  } else {
248
504
  content = '<div class="unsupported">该文件是二进制格式,无法直接预览。可以打开原始文件或下载到本地。</div>';
249
505
  }
250
-
251
- return renderViewerPage({
252
- title: fileName,
253
- filePath,
254
- metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
255
- actions: [
256
- { href: rawUrl.toString(), label: "打开原始文件" },
257
- { href: downloadUrl.toString(), label: "下载" },
258
- ],
506
+ return {
259
507
  content,
260
- });
508
+ metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
509
+ ...(classification.kind === "text" && isMarkdownFile(filePath)
510
+ ? { viewMode: "markdown" as const }
511
+ : {}),
512
+ };
513
+ }
514
+
515
+ private serveFileEvents(request: IncomingMessage, response: ServerResponse, filePath: string): void {
516
+ setSecurityHeaders(response);
517
+ response.statusCode = 200;
518
+ response.setHeader("Cache-Control", "no-cache, no-store");
519
+ response.setHeader("Connection", "keep-alive");
520
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
521
+ response.setHeader("X-Accel-Buffering", "no");
522
+ if (request.method === "HEAD") {
523
+ response.end();
524
+ return;
525
+ }
526
+ response.flushHeaders();
527
+ response.write("retry: 2000\n\n");
528
+
529
+ let closed = false;
530
+ let pendingUpdate: NodeJS.Timeout | undefined;
531
+ const sendSnapshot = () => {
532
+ if (closed || response.destroyed) return;
533
+ try {
534
+ const stat = fs.statSync(filePath);
535
+ if (!stat.isFile()) throw new Error("Path is no longer a file");
536
+ const snapshot = this.renderFileSnapshot(filePath, stat);
537
+ writeServerSentEvent(response, "update", JSON.stringify({
538
+ content: snapshot.content,
539
+ metadata: renderMetadata(snapshot.metadata),
540
+ viewMode: snapshot.viewMode ?? null,
541
+ }));
542
+ } catch {
543
+ writeServerSentEvent(response, "unavailable", JSON.stringify({
544
+ message: "文件可能已被移动、重命名或删除。",
545
+ }));
546
+ }
547
+ };
548
+ const scheduleSnapshot = () => {
549
+ if (pendingUpdate) clearTimeout(pendingUpdate);
550
+ pendingUpdate = setTimeout(sendSnapshot, 100);
551
+ pendingUpdate.unref();
552
+ };
553
+ const watcher = (current: fs.Stats, previous: fs.Stats) => {
554
+ if (
555
+ current.mtimeMs === previous.mtimeMs
556
+ && current.ctimeMs === previous.ctimeMs
557
+ && current.size === previous.size
558
+ && current.nlink === previous.nlink
559
+ ) return;
560
+ scheduleSnapshot();
561
+ };
562
+ fs.watchFile(filePath, { interval: 1_000, persistent: false }, watcher);
563
+ sendSnapshot();
564
+ const keepAlive = setInterval(() => {
565
+ if (!closed && !response.destroyed) response.write(": keep-alive\n\n");
566
+ }, 15_000);
567
+ keepAlive.unref();
568
+
569
+ const cleanup = () => {
570
+ if (closed) return;
571
+ closed = true;
572
+ if (pendingUpdate) clearTimeout(pendingUpdate);
573
+ clearInterval(keepAlive);
574
+ fs.unwatchFile(filePath, watcher);
575
+ };
576
+ request.once("aborted", cleanup);
577
+ response.once("close", cleanup);
261
578
  }
262
579
 
263
- private renderDirectoryViewer(directory: string, stat: fs.Stats, token: string, requestedPage: number): string {
580
+ private renderDirectoryViewer(directory: string, stat: fs.Stats, requestedPage: number): string {
264
581
  const entries = fs.readdirSync(directory, { withFileTypes: true })
265
582
  .flatMap((entry): DirectoryViewerEntry[] => {
266
583
  const entryPath = path.join(directory, entry.name);
@@ -287,8 +604,8 @@ export class LocalFileViewerServer {
287
604
  : '<div class="empty-directory">这个目录是空的。</div>';
288
605
  const pagination = totalPages > 1
289
606
  ? renderDirectoryPagination(page, totalPages, (targetPage) => {
290
- const url = this.createSignedUrl("view", token);
291
- url.searchParams.set("page", String(targetPage));
607
+ const url = new URL(this.createFileUrl(directory)!);
608
+ appendUrlQueryParameter(url, "page", String(targetPage));
292
609
  return url.toString();
293
610
  })
294
611
  : "";
@@ -355,6 +672,16 @@ export class LocalFileViewerServer {
355
672
  response.setHeader("Content-Length", body.length);
356
673
  response.end(headOnly ? undefined : body);
357
674
  }
675
+
676
+ private sendJavascript(response: ServerResponse, headOnly: boolean): void {
677
+ const body = Buffer.from(VIEWER_CLIENT_SCRIPT, "utf8");
678
+ setSecurityHeaders(response);
679
+ response.statusCode = 200;
680
+ response.setHeader("Cache-Control", "no-store");
681
+ response.setHeader("Content-Type", "text/javascript; charset=utf-8");
682
+ response.setHeader("Content-Length", body.length);
683
+ response.end(headOnly ? undefined : body);
684
+ }
358
685
  }
359
686
 
360
687
  function renderViewerPage(input: {
@@ -363,11 +690,25 @@ function renderViewerPage(input: {
363
690
  metadata: string[];
364
691
  actions: Array<{ href: string; label: string }>;
365
692
  content: string;
693
+ viewMode?: "markdown";
694
+ liveUpdates?: { eventsUrl: string; scriptPath: string };
366
695
  }): string {
367
- const metadata = input.metadata.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
696
+ const metadata = renderMetadata(input.metadata);
368
697
  const actions = input.actions.length > 0
369
698
  ? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
370
699
  : "";
700
+ const viewSwitch = input.viewMode === "markdown"
701
+ ? '<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>'
702
+ : "";
703
+ const toolbar = viewSwitch || actions
704
+ ? `<div class="toolbar">${viewSwitch}${actions}</div>`
705
+ : "";
706
+ const liveAttributes = input.liveUpdates
707
+ ? ` data-events-url="${escapeAttribute(input.liveUpdates.eventsUrl)}"`
708
+ : "";
709
+ const liveScript = input.liveUpdates
710
+ ? `<script src="${escapeAttribute(input.liveUpdates.scriptPath)}" defer></script>`
711
+ : "";
371
712
  return `<!doctype html>
372
713
  <html lang="zh-CN">
373
714
  <head>
@@ -375,26 +716,63 @@ function renderViewerPage(input: {
375
716
  <meta name="viewport" content="width=device-width, initial-scale=1">
376
717
  <title>${escapeHtml(input.title)} · Agent Bot</title>
377
718
  <style>
378
- :root { color-scheme: light dark; font-family: Inter, "Segoe UI", system-ui, sans-serif; }
719
+ :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; }
379
720
  * { box-sizing: border-box; }
380
721
  body { margin: 0; background: #f5f7fa; color: #1f2329; }
381
- header { position: sticky; top: 0; z-index: 2; padding: 14px 22px; background: rgba(255,255,255,.96); border-bottom: 1px solid #dfe3e8; }
382
- h1 { margin: 0; font-size: 18px; font-weight: 650; overflow-wrap: anywhere; }
383
- .path { margin-top: 5px; color: #646a73; font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
384
- .meta { display: flex; gap: 12px; align-items: center; margin-top: 10px; color: #646a73; font-size: 12px; }
385
- .actions { margin-left: auto; display: flex; gap: 8px; }
386
- .actions a { color: #1456f0; text-decoration: none; padding: 5px 9px; border: 1px solid #c9d0db; border-radius: 5px; background: #fff; }
387
- main { padding: 18px 22px 30px; }
388
- .notice, .unsupported { margin-bottom: 12px; padding: 12px 14px; border: 1px solid #f3cf8f; background: #fff7e6; border-radius: 6px; }
389
- .code { margin: 0; padding: 12px 0; overflow: auto; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; color: #1f2329; font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
390
- .line { display: block; min-height: 1.55em; padding-right: 16px; }
391
- .line:target { background: #fff1b8; }
722
+ header { position: sticky; top: 0; z-index: 2; padding: 7px 12px; background: rgba(255,255,255,.96); border-bottom: 1px solid #dfe3e8; }
723
+ h1 { margin: 0; cursor: text; font: 600 13px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
724
+ .meta { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; min-height: 26px; margin-top: 3px; color: #646a73; font-size: 11px; }
725
+ .metadata-values { display: contents; }
726
+ .toolbar { margin-left: auto; display: flex; gap: 8px; align-items: center; }
727
+ .actions { display: flex; gap: 8px; }
728
+ .actions a { color: #1456f0; text-decoration: none; padding: 3px 7px; border: 1px solid #c9d0db; border-radius: 4px; background: #fff; }
729
+ .view-switch { display: inline-flex; padding: 2px; border: 1px solid #c9d0db; border-radius: 5px; background: #f2f3f5; }
730
+ .view-switch[hidden] { display: none; }
731
+ .view-switch button { min-width: 46px; padding: 2px 8px; border: 0; border-radius: 3px; background: transparent; color: #646a73; cursor: pointer; font: inherit; }
732
+ .view-switch button.is-active { background: #fff; color: #1456f0; box-shadow: 0 1px 3px rgba(31,35,41,.16); }
733
+ main { padding: 0; }
734
+ .notice, .unsupported { margin: 8px; padding: 10px 12px; border: 1px solid #f3cf8f; background: #fff7e6; border-radius: 5px; }
735
+ .code { margin: 0; padding: 6px 0; overflow: auto; border: 0; border-radius: 0; background: #fff; color: #1f2329; font-size: 13px; line-height: 1.5; tab-size: 2; }
736
+ .code, .code code, .code .line, .code .line * { font-family: var(--viewer-code-font) !important; font-variant-ligatures: none; font-feature-settings: "liga" 0, "calt" 0; }
737
+ .line { display: block; min-height: 1.55em; padding-right: 16px; scroll-margin-top: var(--viewer-header-offset); }
738
+ .line:target, .line.is-target-line { background: #fff1b8; box-shadow: inset 4px 0 #e6a700; }
392
739
  .line-number { display: inline-block; width: 4.5em; margin-right: 12px; padding-right: 12px; text-align: right; color: #8f959e; border-right: 1px solid #eceff3; text-decoration: none; user-select: none; }
740
+ .hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
741
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #a626a4; }
742
+ .hljs-string, .hljs-regexp, .hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition, .hljs-variable, .hljs-template-tag, .hljs-template-variable { color: #087f5b; }
743
+ .hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #986801; }
744
+ .hljs-title.function_, .hljs-title.class_ { color: #005cc5; }
745
+ .hljs-deletion { color: #b31d28; background: #ffeef0; }
746
+ .hljs-addition { background: #e6ffed; }
747
+ .hljs-emphasis { font-style: italic; }
748
+ .hljs-strong { font-weight: 700; }
749
+ body[data-view-mode="rendered"] [data-view-panel="code"], body[data-view-mode="code"] [data-view-panel="rendered"] { display: none; }
750
+ .markdown-body { max-width: 980px; margin: 0 auto; padding: 20px 28px 48px; background: #fff; font-size: 15px; line-height: 1.72; overflow-wrap: anywhere; }
751
+ .markdown-body > :first-child { margin-top: 0; }
752
+ .markdown-body > :last-child { margin-bottom: 0; }
753
+ .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { margin: 1.45em 0 .6em; line-height: 1.28; }
754
+ .markdown-body h1 { padding-bottom: .35em; border-bottom: 1px solid #dfe3e8; font-size: 2em; }
755
+ .markdown-body h2 { padding-bottom: .3em; border-bottom: 1px solid #eceff3; font-size: 1.5em; }
756
+ .markdown-body h3 { font-size: 1.25em; }
757
+ .markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body table, .markdown-body pre { margin: 0 0 1em; }
758
+ .markdown-body ul, .markdown-body ol { padding-left: 1.8em; }
759
+ .markdown-body li + li { margin-top: .28em; }
760
+ .markdown-body blockquote { margin-left: 0; padding: .15em 1em; border-left: 4px solid #c9d0db; color: #646a73; }
761
+ .markdown-body a { color: #1456f0; text-decoration: none; }
762
+ .markdown-body a:hover { text-decoration: underline; }
763
+ .markdown-body code { padding: .14em .35em; border-radius: 4px; background: #f2f3f5; font-family: var(--viewer-code-font); font-size: .9em; }
764
+ .markdown-body .markdown-code-block { margin: 0 0 1em; padding: 14px 16px; overflow: auto; border-radius: 6px; background: #f6f8fa; line-height: 1.55; }
765
+ .markdown-body .markdown-code-block code { padding: 0; background: transparent; font-size: 13px; }
766
+ .markdown-body table { display: block; max-width: 100%; overflow: auto; border-collapse: collapse; }
767
+ .markdown-body th, .markdown-body td { padding: 7px 12px; border: 1px solid #dfe3e8; text-align: left; }
768
+ .markdown-body th { background: #f5f7fa; }
769
+ .markdown-body img { max-width: 100%; height: auto; }
770
+ .markdown-body hr { height: 1px; margin: 1.5em 0; border: 0; background: #dfe3e8; }
393
771
  .media { display: grid; place-items: center; min-height: 220px; }
394
- .media img, .media video { max-width: 100%; max-height: calc(100vh - 180px); }
772
+ .media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
395
773
  .media audio { width: min(720px, 100%); }
396
- .document { width: 100%; height: calc(100vh - 150px); border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; }
397
- .directory-list { overflow: hidden; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; }
774
+ .document { width: 100%; height: calc(100vh - 70px); border: 0; border-radius: 0; background: #fff; }
775
+ .directory-list { overflow: hidden; border: 0; border-radius: 0; background: #fff; }
398
776
  .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; }
399
777
  .directory-entry:last-child { border-bottom: 0; }
400
778
  .directory-entry:hover { background: #f0f4ff; }
@@ -410,29 +788,52 @@ function renderViewerPage(input: {
410
788
  @media (prefers-color-scheme: dark) {
411
789
  body { background: #17181a; color: #e5e6eb; }
412
790
  header { background: rgba(32,33,36,.96); border-color: #3a3b3d; }
413
- .path, .meta, .pagination { color: #a6a9ad; }
791
+ .meta, .pagination { color: #a6a9ad; }
414
792
  .actions a, .pagination a { color: #8ab4ff; }
415
793
  .actions a { border-color: #55585c; background: #292a2d; }
794
+ .view-switch { border-color: #55585c; background: #292a2d; }
795
+ .view-switch button { color: #a6a9ad; }
796
+ .view-switch button.is-active { background: #3a3b3d; color: #8ab4ff; box-shadow: none; }
416
797
  .code, .directory-list { background: #202124; color: #e8eaed; border-color: #3a3b3d; }
798
+ .markdown-body { background: #202124; color: #e8eaed; }
799
+ .markdown-body h1, .markdown-body h2 { border-color: #3a3b3d; }
800
+ .markdown-body blockquote { color: #a6a9ad; border-color: #55585c; }
801
+ .markdown-body a { color: #8ab4ff; }
802
+ .markdown-body code { background: #303134; }
803
+ .markdown-body .markdown-code-block { background: #282a2d; }
804
+ .markdown-body th, .markdown-body td { border-color: #55585c; }
805
+ .markdown-body th { background: #292a2d; }
806
+ .markdown-body hr { background: #3a3b3d; }
417
807
  .directory-entry { border-color: #3a3b3d; }
418
808
  .directory-entry:hover { background: #292f3d; }
419
809
  .line-number { color: #8b9098; border-color: #3a3b3d; }
420
- .line:target { background: #594c20; }
810
+ .line:target, .line.is-target-line { background: #594c20; box-shadow: inset 4px 0 #d29922; }
811
+ .hljs-comment, .hljs-quote { color: #8b949e; }
812
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #ff7b72; }
813
+ .hljs-string, .hljs-regexp, .hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition, .hljs-variable, .hljs-template-tag, .hljs-template-variable { color: #a5d6ff; }
814
+ .hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #d2a8ff; }
815
+ .hljs-title.function_, .hljs-title.class_ { color: #d2a8ff; }
816
+ .hljs-deletion { color: #ffdcd7; background: #67060c; }
817
+ .hljs-addition { color: #aff5b4; background: #033a16; }
421
818
  .notice, .unsupported { color: #f5d58a; background: #3b321c; border-color: #705b28; }
422
819
  }
423
820
  </style>
424
821
  </head>
425
- <body>
426
- <header>
427
- <h1>${escapeHtml(input.title)}</h1>
428
- <div class="path">${escapeHtml(input.filePath)}</div>
429
- <div class="meta">${metadata}${actions}</div>
822
+ <body${input.viewMode === "markdown" ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
823
+ <header id="viewer-header">
824
+ <h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
825
+ <div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${toolbar}</div>
430
826
  </header>
431
- <main>${input.content}</main>
827
+ <main id="viewer-content">${input.content}</main>
828
+ ${liveScript}
432
829
  </body>
433
830
  </html>`;
434
831
  }
435
832
 
833
+ function renderMetadata(metadata: string[]): string {
834
+ return metadata.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
835
+ }
836
+
436
837
  function directoryViewerEntryKind(filePath: string, stat: fs.Stats): DirectoryViewerEntryKind {
437
838
  if (stat.isDirectory()) return "directory";
438
839
  if (classifyFileContent(filePath, stat.size).kind === "text") return "text";
@@ -509,6 +910,17 @@ function normalizeAbsolutePath(filePath: string): string | undefined {
509
910
  return path.resolve(normalized);
510
911
  }
511
912
 
913
+ function encodeReadableQueryPath(filePath: string): string {
914
+ return encodeURIComponent(filePath.replaceAll("\\", "/"))
915
+ .replaceAll("%3A", ":")
916
+ .replaceAll("%2F", "/");
917
+ }
918
+
919
+ function appendUrlQueryParameter(url: URL, name: string, value: string): void {
920
+ const parameter = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
921
+ url.search = url.search ? `${url.search}&${parameter}` : `?${parameter}`;
922
+ }
923
+
512
924
  function normalizePublicBaseUrl(value: string): string {
513
925
  const url = new URL(value);
514
926
  if (url.protocol !== "http:" && url.protocol !== "https:") {
@@ -650,14 +1062,70 @@ function hasTextLikeCharacters(value: string): boolean {
650
1062
  return controls <= 2 || controls / Math.max(1, characters) <= 0.02;
651
1063
  }
652
1064
 
653
- function renderText(value: string): string {
654
- const lines = value.split(/\r?\n/u);
655
- return `<pre class="code"><code>${lines.map((line, index) => {
1065
+ function renderText(value: string, filePath: string): string {
1066
+ const normalized = value.replace(/\r\n?/gu, "\n");
1067
+ const language = syntaxLanguageFor(filePath);
1068
+ const lines = language && Buffer.byteLength(normalized, "utf8") <= MAX_SYNTAX_HIGHLIGHT_BYTES
1069
+ ? highlightLines(normalized, language)
1070
+ : normalized.split("\n").map(escapeHtml);
1071
+ const languageClass = language ? ` class="language-${escapeAttribute(language)}"` : "";
1072
+ return `<pre class="code hljs"><code${languageClass}>${lines.map((line, index) => {
656
1073
  const lineNumber = index + 1;
657
- return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${escapeHtml(line)}</span>`;
1074
+ return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${line}</span>`;
658
1075
  }).join("")}</code></pre>`;
659
1076
  }
660
1077
 
1078
+ function renderMarkdownDocument(value: string, filePath: string): string {
1079
+ return `<article class="markdown-body" data-view-panel="rendered">${MARKDOWN_RENDERER.render(value)}</article><section data-view-panel="code">${renderText(value, filePath)}</section>`;
1080
+ }
1081
+
1082
+ function isMarkdownFile(filePath: string): boolean {
1083
+ return MARKDOWN_EXTENSIONS.has(path.extname(filePath).toLowerCase());
1084
+ }
1085
+
1086
+ function syntaxLanguageFor(filePath: string): string | undefined {
1087
+ const fileName = path.basename(filePath).toLowerCase();
1088
+ return HIGHLIGHT_LANGUAGE_BY_FILENAME[fileName]
1089
+ ?? HIGHLIGHT_LANGUAGE_BY_EXTENSION[path.extname(fileName)];
1090
+ }
1091
+
1092
+ function highlightLines(value: string, language: string): string[] {
1093
+ try {
1094
+ return splitHighlightedLines(hljs.highlight(value, { language, ignoreIllegals: true }).value);
1095
+ } catch {
1096
+ return value.split("\n").map(escapeHtml);
1097
+ }
1098
+ }
1099
+
1100
+ function splitHighlightedLines(value: string): string[] {
1101
+ const lines: string[] = [];
1102
+ const openTags: string[] = [];
1103
+ const tokenPattern = /<span class="[^"]+">|<\/span>|\n/gu;
1104
+ let current = "";
1105
+ let offset = 0;
1106
+ for (const match of value.matchAll(tokenPattern)) {
1107
+ const token = match[0];
1108
+ const index = match.index ?? offset;
1109
+ current += value.slice(offset, index);
1110
+ offset = index + token.length;
1111
+ if (token === "\n") {
1112
+ current += "</span>".repeat(openTags.length);
1113
+ lines.push(current);
1114
+ current = openTags.join("");
1115
+ } else if (token === "</span>") {
1116
+ current += token;
1117
+ openTags.pop();
1118
+ } else {
1119
+ current += token;
1120
+ openTags.push(token);
1121
+ }
1122
+ }
1123
+ current += value.slice(offset);
1124
+ current += "</span>".repeat(openTags.length);
1125
+ lines.push(current);
1126
+ return lines;
1127
+ }
1128
+
661
1129
  function contentTypeFor(extension: string): string {
662
1130
  const known: Record<string, string> = {
663
1131
  ".aac": "audio/aac", ".avi": "video/x-msvideo", ".bmp": "image/bmp", ".css": "text/css; charset=utf-8",
@@ -726,8 +1194,15 @@ function formatFileSize(size: number): string {
726
1194
  return `${(size / 1024 / 1024 / 1024).toFixed(1)} GiB`;
727
1195
  }
728
1196
 
1197
+ function writeServerSentEvent(response: ServerResponse, event: string, data: string): void {
1198
+ if (response.destroyed || response.writableEnded) return;
1199
+ response.write(`event: ${event}\n`);
1200
+ for (const line of data.split(/\r?\n/u)) response.write(`data: ${line}\n`);
1201
+ response.write("\n");
1202
+ }
1203
+
729
1204
  function setSecurityHeaders(response: ServerResponse): void {
730
- response.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; object-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
1205
+ 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'");
731
1206
  response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
732
1207
  response.setHeader("Referrer-Policy", "no-referrer");
733
1208
  response.setHeader("X-Content-Type-Options", "nosniff");