@keyou007/agent-bot 0.1.17-alpha.1 → 0.1.17

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 (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -1
  3. package/README.zh.md +3 -1
  4. package/config.example.yaml +12 -0
  5. package/dist/config/loadConfig.js +10 -0
  6. package/dist/config/loadConfig.js.map +1 -1
  7. package/dist/config/schema.js +6 -0
  8. package/dist/config/schema.js.map +1 -1
  9. package/dist/feishu/CardRenderer.js +6 -16
  10. package/dist/feishu/CardRenderer.js.map +1 -1
  11. package/dist/feishu/FeishuMarkdown.js +42 -13
  12. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  13. package/dist/feishu/FeishuTurnPresenter.js +33 -10
  14. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  15. package/dist/feishu/LocalImageMarkdown.js +16 -2
  16. package/dist/feishu/LocalImageMarkdown.js.map +1 -1
  17. package/dist/index.js +33 -5
  18. package/dist/index.js.map +1 -1
  19. package/dist/local-files/LocalFileViewerServer.js +1064 -0
  20. package/dist/local-files/LocalFileViewerServer.js.map +1 -0
  21. package/dist/local-files/NetworkAddressSelector.js +97 -0
  22. package/dist/local-files/NetworkAddressSelector.js.map +1 -0
  23. package/dist/proxy/ProxySessionController.js +81 -34
  24. package/dist/proxy/ProxySessionController.js.map +1 -1
  25. package/dist/startup/StartupNotifier.js +7 -4
  26. package/dist/startup/StartupNotifier.js.map +1 -1
  27. package/dist/supervision/SafeRestartScheduler.js +15 -2
  28. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  29. package/docs/technical-reference.md +10 -0
  30. package/docs/technical-reference.zh.md +10 -0
  31. package/npm-shrinkwrap.json +12 -2
  32. package/package.json +2 -1
  33. package/skills/agent-bot/SKILL.md +1 -1
  34. package/src/config/loadConfig.ts +10 -0
  35. package/src/config/schema.ts +9 -0
  36. package/src/feishu/CardRenderer.ts +6 -22
  37. package/src/feishu/FeishuMarkdown.ts +51 -12
  38. package/src/feishu/FeishuTurnPresenter.ts +41 -11
  39. package/src/feishu/LocalImageMarkdown.ts +23 -2
  40. package/src/index.ts +33 -4
  41. package/src/local-files/LocalFileViewerServer.ts +1142 -0
  42. package/src/local-files/NetworkAddressSelector.ts +111 -0
  43. package/src/proxy/ProxySessionController.ts +75 -24
  44. package/src/startup/StartupNotifier.ts +9 -4
  45. package/src/supervision/SafeRestartScheduler.ts +23 -2
@@ -0,0 +1,1064 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
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 { selectPreferredNetworkAddress, } from "./NetworkAddressSelector.js";
10
+ const SECRET_FILE = "secret";
11
+ const PORT_FILE = "port";
12
+ const SHORT_TOKEN_BYTES = 12;
13
+ const SHORT_TOKEN_LENGTH = 16;
14
+ const MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024;
15
+ const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
16
+ const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
17
+ const DIRECTORY_PAGE_SIZE = 100;
18
+ hljs.registerLanguage("dos", dos);
19
+ hljs.registerLanguage("dockerfile", dockerfile);
20
+ hljs.registerLanguage("powershell", powershell);
21
+ const HIGHLIGHT_LANGUAGE_BY_EXTENSION = {
22
+ ".bash": "bash", ".bat": "dos", ".c": "c", ".cc": "cpp", ".cmd": "dos", ".cpp": "cpp",
23
+ ".cs": "csharp", ".css": "css", ".cxx": "cpp", ".diff": "diff", ".go": "go", ".h": "cpp",
24
+ ".hpp": "cpp", ".htm": "xml", ".html": "xml", ".ini": "ini", ".ipynb": "json", ".java": "java",
25
+ ".js": "javascript", ".json": "json", ".jsonl": "json", ".jsx": "javascript", ".kt": "kotlin",
26
+ ".kts": "kotlin", ".less": "less", ".lua": "lua", ".markdown": "markdown", ".md": "markdown",
27
+ ".mjs": "javascript", ".patch": "diff", ".php": "php", ".pl": "perl", ".ps1": "powershell",
28
+ ".py": "python", ".r": "r", ".rb": "ruby", ".rs": "rust", ".scss": "scss", ".sh": "bash",
29
+ ".sql": "sql", ".swift": "swift", ".toml": "ini", ".ts": "typescript", ".tsx": "typescript",
30
+ ".vue": "xml", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".zsh": "bash",
31
+ };
32
+ const HIGHLIGHT_LANGUAGE_BY_FILENAME = {
33
+ ".env": "ini",
34
+ ".gitignore": "ini",
35
+ "dockerfile": "dockerfile",
36
+ "makefile": "makefile",
37
+ };
38
+ const VIEWER_CLIENT_SCRIPT = `(() => {
39
+ const eventsUrl = document.body.dataset.eventsUrl;
40
+ const content = document.getElementById("viewer-content");
41
+ const metadata = document.getElementById("viewer-metadata");
42
+ const header = document.getElementById("viewer-header");
43
+ const title = document.getElementById("viewer-title");
44
+ if (!content || !metadata || !header || !title) return;
45
+ const filePath = title.dataset.filePath || title.textContent || "";
46
+
47
+ title.addEventListener("pointerdown", (event) => {
48
+ if (event.button !== 0) return;
49
+ event.preventDefault();
50
+ const selection = window.getSelection();
51
+ if (!selection) return;
52
+ const range = document.createRange();
53
+ range.selectNodeContents(title);
54
+ selection.removeAllRanges();
55
+ selection.addRange(range);
56
+ });
57
+
58
+ const updateHeaderOffset = () => {
59
+ const offset = Math.ceil(header.getBoundingClientRect().height) + 8;
60
+ document.documentElement.style.setProperty("--viewer-header-offset", offset + "px");
61
+ };
62
+ const highlightHashTarget = () => {
63
+ document.querySelector(".line.is-target-line")?.classList.remove("is-target-line");
64
+ let id;
65
+ try {
66
+ id = decodeURIComponent(window.location.hash.slice(1));
67
+ } catch {
68
+ title.textContent = filePath;
69
+ return null;
70
+ }
71
+ const lineNumber = /^L(\\d+)$/.exec(id)?.[1];
72
+ title.textContent = lineNumber ? filePath + ":" + lineNumber : filePath;
73
+ if (!lineNumber) return null;
74
+ const target = document.getElementById(id);
75
+ target?.classList.add("is-target-line");
76
+ return target;
77
+ };
78
+ const positionHashTarget = () => {
79
+ highlightHashTarget()?.scrollIntoView({ block: "start" });
80
+ };
81
+ updateHeaderOffset();
82
+ if (typeof ResizeObserver === "function") {
83
+ new ResizeObserver(updateHeaderOffset).observe(header);
84
+ }
85
+ window.addEventListener("hashchange", () => requestAnimationFrame(positionHashTarget));
86
+ requestAnimationFrame(positionHashTarget);
87
+ if (!eventsUrl || typeof EventSource !== "function") return;
88
+
89
+ const restoreScroll = (top, left, atBottom, codeScrollLeft) => {
90
+ requestAnimationFrame(() => {
91
+ const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
92
+ window.scrollTo(left, atBottom ? maxTop : Math.min(top, maxTop));
93
+ const code = content.querySelector(".code");
94
+ if (code) code.scrollLeft = codeScrollLeft;
95
+ });
96
+ };
97
+ const replaceContent = (nextContent, nextMetadata) => {
98
+ const top = window.scrollY;
99
+ const left = window.scrollX;
100
+ const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
101
+ const atBottom = maxTop - top <= 24;
102
+ const codeScrollLeft = content.querySelector(".code")?.scrollLeft ?? 0;
103
+ content.innerHTML = nextContent;
104
+ metadata.innerHTML = nextMetadata;
105
+ highlightHashTarget();
106
+ restoreScroll(top, left, atBottom, codeScrollLeft);
107
+ };
108
+
109
+ const source = new EventSource(eventsUrl);
110
+ source.addEventListener("update", (event) => {
111
+ try {
112
+ const update = JSON.parse(event.data);
113
+ if (typeof update.content === "string" && typeof update.metadata === "string") {
114
+ replaceContent(update.content, update.metadata);
115
+ }
116
+ } catch {
117
+ // A later valid event will repair the view.
118
+ }
119
+ });
120
+ source.addEventListener("unavailable", (event) => {
121
+ let message = "文件暂时无法读取,服务会继续等待它恢复。";
122
+ try {
123
+ const update = JSON.parse(event.data);
124
+ if (typeof update.message === "string") message = update.message;
125
+ } catch {
126
+ // Keep the fallback message.
127
+ }
128
+ const notice = document.createElement("div");
129
+ notice.className = "notice";
130
+ notice.textContent = message;
131
+ replaceContent(notice.outerHTML, metadata.innerHTML);
132
+ });
133
+ window.addEventListener("pagehide", () => source.close(), { once: true });
134
+ })();
135
+ `;
136
+ export class LocalFileViewerServer {
137
+ options;
138
+ server;
139
+ secret;
140
+ address;
141
+ basePath = "";
142
+ constructor(options) {
143
+ this.options = options;
144
+ }
145
+ async start() {
146
+ if (this.address)
147
+ return this.address;
148
+ fs.mkdirSync(this.options.stateDirectory, { recursive: true });
149
+ this.secret = readOrCreateSecret(path.join(this.options.stateDirectory, SECRET_FILE));
150
+ const configuredPublicBaseUrl = this.options.publicBaseUrl
151
+ ? normalizePublicBaseUrl(this.options.publicBaseUrl)
152
+ : undefined;
153
+ const configuredPort = this.options.port;
154
+ const persistedPort = configuredPort === 0
155
+ ? readPersistedPort(path.join(this.options.stateDirectory, PORT_FILE))
156
+ : undefined;
157
+ const preferredPort = configuredPort || persistedPort || 0;
158
+ const server = http.createServer((request, response) => {
159
+ void this.handleRequest(request, response).catch((error) => {
160
+ if (response.headersSent) {
161
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
162
+ return;
163
+ }
164
+ this.sendHtml(response, 500, errorPage("无法读取文件", "本地文件查看服务处理请求时发生错误。"));
165
+ });
166
+ });
167
+ server.on("clientError", (_error, socket) => socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"));
168
+ let selectedPort = preferredPort;
169
+ try {
170
+ await listen(server, this.options.host, selectedPort);
171
+ }
172
+ catch (error) {
173
+ if (configuredPort !== 0 || preferredPort === 0 || !isAddressInUse(error))
174
+ throw error;
175
+ selectedPort = 0;
176
+ await listen(server, this.options.host, selectedPort);
177
+ }
178
+ const boundAddress = server.address();
179
+ if (!boundAddress || typeof boundAddress === "string") {
180
+ await closeServer(server);
181
+ throw new Error("Local file viewer did not bind to a TCP port.");
182
+ }
183
+ try {
184
+ const selectedNetwork = configuredPublicBaseUrl || !isWildcardHost(this.options.host)
185
+ ? undefined
186
+ : selectPreferredNetworkAddress();
187
+ const publicHost = selectedNetwork?.address ?? urlHost(this.options.host);
188
+ const publicBaseUrl = configuredPublicBaseUrl
189
+ ?? normalizePublicBaseUrl(`http://${urlHost(publicHost)}:${boundAddress.port}`);
190
+ this.basePath = normalizeBasePath(new URL(publicBaseUrl).pathname);
191
+ if (configuredPort === 0 && persistedPort !== boundAddress.port) {
192
+ fs.writeFileSync(path.join(this.options.stateDirectory, PORT_FILE), `${boundAddress.port}\n`, {
193
+ encoding: "utf8",
194
+ mode: 0o600,
195
+ });
196
+ }
197
+ this.server = server;
198
+ this.address = {
199
+ host: this.options.host,
200
+ port: boundAddress.port,
201
+ publicBaseUrl,
202
+ publicInterface: selectedNetwork?.interfaceName,
203
+ publicConnectionKind: selectedNetwork?.kind,
204
+ };
205
+ }
206
+ catch (error) {
207
+ await closeServer(server);
208
+ throw error;
209
+ }
210
+ return this.address;
211
+ }
212
+ async close() {
213
+ const server = this.server;
214
+ this.server = undefined;
215
+ this.address = undefined;
216
+ if (server)
217
+ await closeServer(server);
218
+ }
219
+ createFileUrl(filePath, reference) {
220
+ if (!this.address || !this.secret)
221
+ return undefined;
222
+ const absolutePath = normalizeAbsolutePath(filePath);
223
+ if (!absolutePath)
224
+ return undefined;
225
+ try {
226
+ const stat = fs.statSync(absolutePath);
227
+ if (!stat.isFile() && !stat.isDirectory())
228
+ return undefined;
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
233
+ const url = this.createPreviewUrl(absolutePath);
234
+ const line = parseLineReference(reference);
235
+ if (line !== undefined)
236
+ url.hash = `L${line}`;
237
+ return url.toString();
238
+ }
239
+ async handleRequest(request, response) {
240
+ if (request.method !== "GET" && request.method !== "HEAD") {
241
+ response.setHeader("Allow", "GET, HEAD");
242
+ this.sendHtml(response, 405, errorPage("不支持的请求", "该服务只接受只读请求。"), request.method === "HEAD");
243
+ return;
244
+ }
245
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
246
+ const route = routeWithinBasePath(requestUrl.pathname, this.basePath);
247
+ if (!route.startsWith("/")) {
248
+ this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
249
+ return;
250
+ }
251
+ if (route === "/assets/viewer.js") {
252
+ this.sendJavascript(response, request.method === "HEAD");
253
+ return;
254
+ }
255
+ const previewMatch = new RegExp(`^/preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
256
+ const legacyMatch = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
257
+ const previewPath = previewMatch?.[1]
258
+ ? this.verifyPreviewToken(previewMatch[1], requestUrl.searchParams.get("path") ?? "")
259
+ : undefined;
260
+ const legacyPath = legacyMatch?.[2]
261
+ ? this.verifyLegacyToken(legacyMatch[2], requestUrl.searchParams.get("sig") ?? "")
262
+ : undefined;
263
+ const filePath = previewPath ?? legacyPath;
264
+ if (!previewMatch && !legacyMatch) {
265
+ this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
266
+ return;
267
+ }
268
+ if (!filePath) {
269
+ this.sendHtml(response, 403, errorPage("文件链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
270
+ return;
271
+ }
272
+ let stat;
273
+ try {
274
+ stat = fs.statSync(filePath);
275
+ if (!stat.isFile() && !stat.isDirectory())
276
+ throw new Error("Unsupported path type");
277
+ }
278
+ catch {
279
+ this.sendHtml(response, 404, errorPage("文件不存在", "文件可能已被移动、重命名或删除。"), request.method === "HEAD");
280
+ return;
281
+ }
282
+ if (requestUrl.searchParams.get("events") === "1") {
283
+ if (!stat.isFile()) {
284
+ this.sendHtml(response, 400, errorPage("无法监听目录", "实时更新目前只适用于文件。"), request.method === "HEAD");
285
+ return;
286
+ }
287
+ this.serveFileEvents(request, response, filePath);
288
+ return;
289
+ }
290
+ const raw = legacyMatch?.[1] === "raw" || requestUrl.searchParams.get("raw") === "1";
291
+ if (raw) {
292
+ if (stat.isDirectory()) {
293
+ this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
294
+ return;
295
+ }
296
+ this.serveRawFile(request, response, filePath, stat, requestUrl.searchParams.get("download") === "1");
297
+ return;
298
+ }
299
+ const html = stat.isDirectory()
300
+ ? this.renderDirectoryViewer(filePath, stat, parseDirectoryPage(requestUrl.searchParams.get("page")))
301
+ : this.renderViewer(filePath, stat);
302
+ this.sendHtml(response, 200, html, request.method === "HEAD");
303
+ }
304
+ verifyLegacyToken(token, signature) {
305
+ if (!this.secret || !/^[a-f0-9]{64}$/u.test(signature))
306
+ return undefined;
307
+ const expected = this.sign(token);
308
+ const actualBuffer = Buffer.from(signature, "hex");
309
+ const expectedBuffer = Buffer.from(expected, "hex");
310
+ if (actualBuffer.length !== expectedBuffer.length || !timingSafeEqual(actualBuffer, expectedBuffer))
311
+ return undefined;
312
+ let decoded;
313
+ try {
314
+ decoded = Buffer.from(token, "base64url").toString("utf8");
315
+ }
316
+ catch {
317
+ return undefined;
318
+ }
319
+ if (!decoded || decoded.includes("\0") || Buffer.from(decoded, "utf8").toString("base64url") !== token)
320
+ return undefined;
321
+ return normalizeAbsolutePath(decoded);
322
+ }
323
+ verifyPreviewToken(token, rawPath) {
324
+ const filePath = normalizeAbsolutePath(rawPath);
325
+ if (!filePath || !this.secret)
326
+ return undefined;
327
+ const expected = this.createShortToken(filePath);
328
+ const actualBuffer = Buffer.from(token, "utf8");
329
+ const expectedBuffer = Buffer.from(expected, "utf8");
330
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
331
+ ? filePath
332
+ : undefined;
333
+ }
334
+ sign(token) {
335
+ return createHmac("sha256", this.secret).update(token).digest("hex");
336
+ }
337
+ createLegacySignedUrl(route, token) {
338
+ const url = new URL(this.address.publicBaseUrl);
339
+ url.pathname = `${this.basePath}/${route}/${token}`.replace(/\/{2,}/gu, "/");
340
+ url.search = "";
341
+ url.hash = "";
342
+ url.searchParams.set("sig", this.sign(token));
343
+ return url;
344
+ }
345
+ createPreviewUrl(filePath) {
346
+ const url = new URL(this.address.publicBaseUrl);
347
+ url.pathname = `${this.basePath}/preview/${this.createShortToken(filePath)}`
348
+ .replace(/\/{2,}/gu, "/");
349
+ url.search = `?path=${encodeReadableQueryPath(filePath)}`;
350
+ url.hash = "";
351
+ return url;
352
+ }
353
+ createShortToken(filePath) {
354
+ return createHmac("sha256", this.secret)
355
+ .update(filePath)
356
+ .digest()
357
+ .subarray(0, SHORT_TOKEN_BYTES)
358
+ .toString("base64url");
359
+ }
360
+ renderViewer(filePath, stat) {
361
+ const fileName = path.basename(filePath);
362
+ const rawUrl = new URL(this.createFileUrl(filePath));
363
+ rawUrl.hash = "";
364
+ appendUrlQueryParameter(rawUrl, "raw", "1");
365
+ const downloadUrl = new URL(rawUrl);
366
+ appendUrlQueryParameter(downloadUrl, "download", "1");
367
+ const eventsUrl = new URL(this.createFileUrl(filePath));
368
+ eventsUrl.hash = "";
369
+ appendUrlQueryParameter(eventsUrl, "events", "1");
370
+ const snapshot = this.renderFileSnapshot(filePath, stat);
371
+ return renderViewerPage({
372
+ title: fileName,
373
+ filePath,
374
+ metadata: snapshot.metadata,
375
+ actions: [
376
+ { href: rawUrl.toString(), label: "打开原始文件" },
377
+ { href: downloadUrl.toString(), label: "下载" },
378
+ ],
379
+ content: snapshot.content,
380
+ liveUpdates: {
381
+ eventsUrl: eventsUrl.toString(),
382
+ scriptPath: `${this.basePath}/assets/viewer.js`.replace(/\/{2,}/gu, "/"),
383
+ },
384
+ });
385
+ }
386
+ renderFileSnapshot(filePath, stat) {
387
+ const fileName = path.basename(filePath);
388
+ const extension = path.extname(filePath).toLowerCase();
389
+ const classification = classifyFileContent(filePath, stat.size);
390
+ const contentType = binaryContentTypeFor(extension);
391
+ const rawUrl = new URL(this.createFileUrl(filePath));
392
+ rawUrl.hash = "";
393
+ appendUrlQueryParameter(rawUrl, "raw", "1");
394
+ let content;
395
+ if (classification.kind === "text") {
396
+ const preview = readTextPreview(filePath, stat.size, classification.encoding);
397
+ content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${renderText(preview.text, filePath)}`;
398
+ }
399
+ else if (contentType.startsWith("image/")) {
400
+ content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
401
+ }
402
+ else if (contentType === "application/pdf") {
403
+ content = `<object class="document" data="${escapeAttribute(rawUrl.toString())}" type="application/pdf"><p>浏览器无法内嵌该 PDF,请打开原始文件。</p></object>`;
404
+ }
405
+ else if (contentType.startsWith("audio/")) {
406
+ content = `<div class="media"><audio controls src="${escapeAttribute(rawUrl.toString())}"></audio></div>`;
407
+ }
408
+ else if (contentType.startsWith("video/")) {
409
+ content = `<div class="media"><video controls src="${escapeAttribute(rawUrl.toString())}"></video></div>`;
410
+ }
411
+ else {
412
+ content = '<div class="unsupported">该文件是二进制格式,无法直接预览。可以打开原始文件或下载到本地。</div>';
413
+ }
414
+ return {
415
+ content,
416
+ metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
417
+ };
418
+ }
419
+ serveFileEvents(request, response, filePath) {
420
+ setSecurityHeaders(response);
421
+ response.statusCode = 200;
422
+ response.setHeader("Cache-Control", "no-cache, no-store");
423
+ response.setHeader("Connection", "keep-alive");
424
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
425
+ response.setHeader("X-Accel-Buffering", "no");
426
+ if (request.method === "HEAD") {
427
+ response.end();
428
+ return;
429
+ }
430
+ response.flushHeaders();
431
+ response.write("retry: 2000\n\n");
432
+ let closed = false;
433
+ let pendingUpdate;
434
+ const sendSnapshot = () => {
435
+ if (closed || response.destroyed)
436
+ return;
437
+ try {
438
+ const stat = fs.statSync(filePath);
439
+ if (!stat.isFile())
440
+ throw new Error("Path is no longer a file");
441
+ const snapshot = this.renderFileSnapshot(filePath, stat);
442
+ writeServerSentEvent(response, "update", JSON.stringify({
443
+ content: snapshot.content,
444
+ metadata: renderMetadata(snapshot.metadata),
445
+ }));
446
+ }
447
+ catch {
448
+ writeServerSentEvent(response, "unavailable", JSON.stringify({
449
+ message: "文件可能已被移动、重命名或删除。",
450
+ }));
451
+ }
452
+ };
453
+ const scheduleSnapshot = () => {
454
+ if (pendingUpdate)
455
+ clearTimeout(pendingUpdate);
456
+ pendingUpdate = setTimeout(sendSnapshot, 100);
457
+ pendingUpdate.unref();
458
+ };
459
+ const watcher = (current, previous) => {
460
+ if (current.mtimeMs === previous.mtimeMs
461
+ && current.ctimeMs === previous.ctimeMs
462
+ && current.size === previous.size
463
+ && current.nlink === previous.nlink)
464
+ return;
465
+ scheduleSnapshot();
466
+ };
467
+ fs.watchFile(filePath, { interval: 1_000, persistent: false }, watcher);
468
+ sendSnapshot();
469
+ const keepAlive = setInterval(() => {
470
+ if (!closed && !response.destroyed)
471
+ response.write(": keep-alive\n\n");
472
+ }, 15_000);
473
+ keepAlive.unref();
474
+ const cleanup = () => {
475
+ if (closed)
476
+ return;
477
+ closed = true;
478
+ if (pendingUpdate)
479
+ clearTimeout(pendingUpdate);
480
+ clearInterval(keepAlive);
481
+ fs.unwatchFile(filePath, watcher);
482
+ };
483
+ request.once("aborted", cleanup);
484
+ response.once("close", cleanup);
485
+ }
486
+ renderDirectoryViewer(directory, stat, requestedPage) {
487
+ const entries = fs.readdirSync(directory, { withFileTypes: true })
488
+ .flatMap((entry) => {
489
+ const entryPath = path.join(directory, entry.name);
490
+ try {
491
+ const entryStat = fs.statSync(entryPath);
492
+ if (!entryStat.isDirectory() && !entryStat.isFile())
493
+ return [];
494
+ return [{
495
+ name: entry.name,
496
+ path: entryPath,
497
+ stat: entryStat,
498
+ kind: directoryViewerEntryKind(entryPath, entryStat),
499
+ }];
500
+ }
501
+ catch {
502
+ return [];
503
+ }
504
+ })
505
+ .sort((left, right) => Number(right.kind === "directory") - Number(left.kind === "directory")
506
+ || left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
507
+ const totalPages = Math.max(1, Math.ceil(entries.length / DIRECTORY_PAGE_SIZE));
508
+ const page = Math.min(requestedPage, totalPages - 1);
509
+ const visibleEntries = entries.slice(page * DIRECTORY_PAGE_SIZE, (page + 1) * DIRECTORY_PAGE_SIZE);
510
+ const rows = visibleEntries.length > 0
511
+ ? visibleEntries.map((entry) => renderDirectoryEntry(entry, this.createFileUrl(entry.path))).join("")
512
+ : '<div class="empty-directory">这个目录是空的。</div>';
513
+ const pagination = totalPages > 1
514
+ ? renderDirectoryPagination(page, totalPages, (targetPage) => {
515
+ const url = new URL(this.createFileUrl(directory));
516
+ appendUrlQueryParameter(url, "page", String(targetPage));
517
+ return url.toString();
518
+ })
519
+ : "";
520
+ const title = path.basename(directory) || directory;
521
+ return renderViewerPage({
522
+ title,
523
+ filePath: directory,
524
+ metadata: [`${entries.length} 项`, stat.mtime.toLocaleString()],
525
+ actions: [],
526
+ content: `<div class="directory-list">${rows}</div>${pagination}`,
527
+ });
528
+ }
529
+ serveRawFile(request, response, filePath, stat, download) {
530
+ const extension = path.extname(filePath).toLowerCase();
531
+ const classification = classifyFileContent(filePath, stat.size);
532
+ const contentType = classification.kind === "text"
533
+ ? textContentTypeFor(extension, classification.encoding)
534
+ : binaryContentTypeFor(extension);
535
+ const range = parseRange(request.headers.range, stat.size);
536
+ const fileName = path.basename(filePath);
537
+ const disposition = download ? "attachment" : "inline";
538
+ setSecurityHeaders(response);
539
+ response.setHeader("Accept-Ranges", "bytes");
540
+ response.setHeader("Cache-Control", "no-store");
541
+ response.setHeader("Content-Type", contentType);
542
+ response.setHeader("Content-Disposition", `${disposition}; filename*=UTF-8''${encodeURIComponent(fileName)}`);
543
+ if (range === "invalid") {
544
+ response.statusCode = 416;
545
+ response.setHeader("Content-Range", `bytes */${stat.size}`);
546
+ response.end();
547
+ return;
548
+ }
549
+ const start = range?.start ?? 0;
550
+ const end = range?.end ?? Math.max(0, stat.size - 1);
551
+ const contentLength = stat.size === 0 ? 0 : end - start + 1;
552
+ response.statusCode = range ? 206 : 200;
553
+ response.setHeader("Content-Length", contentLength);
554
+ if (range)
555
+ response.setHeader("Content-Range", `bytes ${start}-${end}/${stat.size}`);
556
+ if (request.method === "HEAD" || stat.size === 0) {
557
+ response.end();
558
+ return;
559
+ }
560
+ const stream = fs.createReadStream(filePath, { start, end });
561
+ stream.on("error", (error) => response.destroy(error));
562
+ stream.pipe(response);
563
+ }
564
+ sendHtml(response, status, html, headOnly = false) {
565
+ const body = Buffer.from(html, "utf8");
566
+ setSecurityHeaders(response);
567
+ response.statusCode = status;
568
+ response.setHeader("Cache-Control", "no-store");
569
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
570
+ response.setHeader("Content-Length", body.length);
571
+ response.end(headOnly ? undefined : body);
572
+ }
573
+ sendJavascript(response, headOnly) {
574
+ const body = Buffer.from(VIEWER_CLIENT_SCRIPT, "utf8");
575
+ setSecurityHeaders(response);
576
+ response.statusCode = 200;
577
+ response.setHeader("Cache-Control", "no-store");
578
+ response.setHeader("Content-Type", "text/javascript; charset=utf-8");
579
+ response.setHeader("Content-Length", body.length);
580
+ response.end(headOnly ? undefined : body);
581
+ }
582
+ }
583
+ function renderViewerPage(input) {
584
+ const metadata = renderMetadata(input.metadata);
585
+ const actions = input.actions.length > 0
586
+ ? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
587
+ : "";
588
+ const liveAttributes = input.liveUpdates
589
+ ? ` data-events-url="${escapeAttribute(input.liveUpdates.eventsUrl)}"`
590
+ : "";
591
+ const liveScript = input.liveUpdates
592
+ ? `<script src="${escapeAttribute(input.liveUpdates.scriptPath)}" defer></script>`
593
+ : "";
594
+ return `<!doctype html>
595
+ <html lang="zh-CN">
596
+ <head>
597
+ <meta charset="utf-8">
598
+ <meta name="viewport" content="width=device-width, initial-scale=1">
599
+ <title>${escapeHtml(input.title)} · Agent Bot</title>
600
+ <style>
601
+ :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; }
602
+ * { box-sizing: border-box; }
603
+ body { margin: 0; background: #f5f7fa; color: #1f2329; }
604
+ header { position: sticky; top: 0; z-index: 2; padding: 7px 12px; background: rgba(255,255,255,.96); border-bottom: 1px solid #dfe3e8; }
605
+ h1 { margin: 0; cursor: text; font: 600 13px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
606
+ .meta { display: flex; gap: 10px; align-items: center; min-height: 26px; margin-top: 3px; color: #646a73; font-size: 11px; }
607
+ .metadata-values { display: contents; }
608
+ .actions { margin-left: auto; display: flex; gap: 8px; }
609
+ .actions a { color: #1456f0; text-decoration: none; padding: 3px 7px; border: 1px solid #c9d0db; border-radius: 4px; background: #fff; }
610
+ main { padding: 0; }
611
+ .notice, .unsupported { margin: 8px; padding: 10px 12px; border: 1px solid #f3cf8f; background: #fff7e6; border-radius: 5px; }
612
+ .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; }
613
+ .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; }
614
+ .line { display: block; min-height: 1.55em; padding-right: 16px; scroll-margin-top: var(--viewer-header-offset); }
615
+ .line:target, .line.is-target-line { background: #fff1b8; box-shadow: inset 4px 0 #e6a700; }
616
+ .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; }
617
+ .hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
618
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #a626a4; }
619
+ .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; }
620
+ .hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #986801; }
621
+ .hljs-title.function_, .hljs-title.class_ { color: #005cc5; }
622
+ .hljs-deletion { color: #b31d28; background: #ffeef0; }
623
+ .hljs-addition { background: #e6ffed; }
624
+ .hljs-emphasis { font-style: italic; }
625
+ .hljs-strong { font-weight: 700; }
626
+ .media { display: grid; place-items: center; min-height: 220px; }
627
+ .media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
628
+ .media audio { width: min(720px, 100%); }
629
+ .document { width: 100%; height: calc(100vh - 70px); border: 0; border-radius: 0; background: #fff; }
630
+ .directory-list { overflow: hidden; border: 0; border-radius: 0; background: #fff; }
631
+ .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; }
632
+ .directory-entry:last-child { border-bottom: 0; }
633
+ .directory-entry:hover { background: #f0f4ff; }
634
+ .entry-name { min-width: 0; overflow-wrap: anywhere; }
635
+ .entry-kind, .entry-detail { color: #8f959e; font-size: 12px; white-space: nowrap; }
636
+ .empty-directory { padding: 36px 18px; color: #8f959e; text-align: center; }
637
+ .pagination { display: flex; justify-content: center; align-items: center; gap: 14px; margin-top: 16px; color: #646a73; font-size: 13px; }
638
+ .pagination a { color: #1456f0; text-decoration: none; }
639
+ @media (max-width: 640px) {
640
+ .directory-entry { grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; }
641
+ .entry-detail { grid-column: 1 / -1; padding-left: 28px; }
642
+ }
643
+ @media (prefers-color-scheme: dark) {
644
+ body { background: #17181a; color: #e5e6eb; }
645
+ header { background: rgba(32,33,36,.96); border-color: #3a3b3d; }
646
+ .meta, .pagination { color: #a6a9ad; }
647
+ .actions a, .pagination a { color: #8ab4ff; }
648
+ .actions a { border-color: #55585c; background: #292a2d; }
649
+ .code, .directory-list { background: #202124; color: #e8eaed; border-color: #3a3b3d; }
650
+ .directory-entry { border-color: #3a3b3d; }
651
+ .directory-entry:hover { background: #292f3d; }
652
+ .line-number { color: #8b9098; border-color: #3a3b3d; }
653
+ .line:target, .line.is-target-line { background: #594c20; box-shadow: inset 4px 0 #d29922; }
654
+ .hljs-comment, .hljs-quote { color: #8b949e; }
655
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #ff7b72; }
656
+ .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; }
657
+ .hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #d2a8ff; }
658
+ .hljs-title.function_, .hljs-title.class_ { color: #d2a8ff; }
659
+ .hljs-deletion { color: #ffdcd7; background: #67060c; }
660
+ .hljs-addition { color: #aff5b4; background: #033a16; }
661
+ .notice, .unsupported { color: #f5d58a; background: #3b321c; border-color: #705b28; }
662
+ }
663
+ </style>
664
+ </head>
665
+ <body${liveAttributes}>
666
+ <header id="viewer-header">
667
+ <h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
668
+ <div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${actions}</div>
669
+ </header>
670
+ <main id="viewer-content">${input.content}</main>
671
+ ${liveScript}
672
+ </body>
673
+ </html>`;
674
+ }
675
+ function renderMetadata(metadata) {
676
+ return metadata.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
677
+ }
678
+ function directoryViewerEntryKind(filePath, stat) {
679
+ if (stat.isDirectory())
680
+ return "directory";
681
+ if (classifyFileContent(filePath, stat.size).kind === "text")
682
+ return "text";
683
+ const contentType = binaryContentTypeFor(path.extname(filePath).toLowerCase());
684
+ if (contentType.startsWith("image/"))
685
+ return "image";
686
+ if (contentType === "application/pdf")
687
+ return "pdf";
688
+ if (contentType.startsWith("audio/") || contentType.startsWith("video/"))
689
+ return "media";
690
+ return "binary";
691
+ }
692
+ function renderDirectoryEntry(entry, viewerUrl) {
693
+ const labels = {
694
+ directory: { icon: "📁", label: "目录" },
695
+ text: { icon: "📄", label: "文本" },
696
+ image: { icon: "🖼️", label: "图片" },
697
+ pdf: { icon: "📕", label: "PDF" },
698
+ media: { icon: "🎞️", label: "媒体" },
699
+ binary: { icon: "📦", label: "二进制" },
700
+ };
701
+ const display = labels[entry.kind];
702
+ const detail = entry.kind === "directory"
703
+ ? entry.stat.mtime.toLocaleString()
704
+ : `${formatFileSize(entry.stat.size)} · ${entry.stat.mtime.toLocaleString()}`;
705
+ return `<a class="directory-entry" href="${escapeAttribute(viewerUrl)}"><span class="entry-name">${display.icon} ${escapeHtml(entry.name)}</span><span class="entry-kind">${display.label}</span><span class="entry-detail">${escapeHtml(detail)}</span></a>`;
706
+ }
707
+ function renderDirectoryPagination(page, totalPages, pageUrl) {
708
+ const previous = page > 0
709
+ ? `<a href="${escapeAttribute(pageUrl(page - 1))}">上一页</a>`
710
+ : "";
711
+ const next = page + 1 < totalPages
712
+ ? `<a href="${escapeAttribute(pageUrl(page + 1))}">下一页</a>`
713
+ : "";
714
+ return `<nav class="pagination">${previous}<span>第 ${page + 1}/${totalPages} 页</span>${next}</nav>`;
715
+ }
716
+ function readOrCreateSecret(filePath) {
717
+ try {
718
+ const existing = fs.readFileSync(filePath, "utf8").trim();
719
+ if (/^[a-f0-9]{64}$/u.test(existing))
720
+ return Buffer.from(existing, "hex");
721
+ throw new Error(`Invalid local file viewer secret: ${filePath}`);
722
+ }
723
+ catch (error) {
724
+ if (error.code !== "ENOENT")
725
+ throw error;
726
+ }
727
+ const secret = randomBytes(32);
728
+ try {
729
+ fs.writeFileSync(filePath, `${secret.toString("hex")}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
730
+ return secret;
731
+ }
732
+ catch (error) {
733
+ if (error.code !== "EEXIST")
734
+ throw error;
735
+ const existing = fs.readFileSync(filePath, "utf8").trim();
736
+ if (!/^[a-f0-9]{64}$/u.test(existing))
737
+ throw new Error(`Invalid local file viewer secret: ${filePath}`);
738
+ return Buffer.from(existing, "hex");
739
+ }
740
+ }
741
+ function readPersistedPort(filePath) {
742
+ try {
743
+ const port = Number.parseInt(fs.readFileSync(filePath, "utf8").trim(), 10);
744
+ return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : undefined;
745
+ }
746
+ catch {
747
+ return undefined;
748
+ }
749
+ }
750
+ function normalizeAbsolutePath(filePath) {
751
+ const normalized = /^\/[a-z]:[\\/]/iu.test(filePath) ? filePath.slice(1) : filePath;
752
+ if (!path.isAbsolute(normalized))
753
+ return undefined;
754
+ return path.resolve(normalized);
755
+ }
756
+ function encodeReadableQueryPath(filePath) {
757
+ return encodeURIComponent(filePath.replaceAll("\\", "/"))
758
+ .replaceAll("%3A", ":")
759
+ .replaceAll("%2F", "/");
760
+ }
761
+ function appendUrlQueryParameter(url, name, value) {
762
+ const parameter = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
763
+ url.search = url.search ? `${url.search}&${parameter}` : `?${parameter}`;
764
+ }
765
+ function normalizePublicBaseUrl(value) {
766
+ const url = new URL(value);
767
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
768
+ throw new Error("Local file viewer publicBaseUrl must use http or https.");
769
+ }
770
+ url.search = "";
771
+ url.hash = "";
772
+ url.pathname = url.pathname.replace(/\/+$/u, "") || "/";
773
+ return url.toString().replace(/\/$/u, "");
774
+ }
775
+ function normalizeBasePath(value) {
776
+ const pathName = value.replace(/\/+$/u, "");
777
+ return pathName === "/" ? "" : pathName;
778
+ }
779
+ function routeWithinBasePath(pathName, basePath) {
780
+ if (!basePath)
781
+ return pathName;
782
+ if (!pathName.startsWith(`${basePath}/`))
783
+ return "";
784
+ return pathName.slice(basePath.length);
785
+ }
786
+ function urlHost(host) {
787
+ if (host === "0.0.0.0")
788
+ return "127.0.0.1";
789
+ if (host === "::")
790
+ return "[::1]";
791
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
792
+ }
793
+ function isWildcardHost(host) {
794
+ return host === "0.0.0.0" || host === "::";
795
+ }
796
+ function parseLineReference(reference) {
797
+ if (!reference)
798
+ return undefined;
799
+ const match = /^:(\d+)(?::\d+)?$/u.exec(reference);
800
+ if (!match?.[1])
801
+ return undefined;
802
+ const line = Number.parseInt(match[1], 10);
803
+ return Number.isSafeInteger(line) && line > 0 ? line : undefined;
804
+ }
805
+ function parseDirectoryPage(value) {
806
+ if (!value || !/^\d+$/u.test(value))
807
+ return 0;
808
+ const page = Number.parseInt(value, 10);
809
+ return Number.isSafeInteger(page) && page >= 0 ? page : 0;
810
+ }
811
+ function readTextPreview(filePath, size, encoding) {
812
+ const length = Math.min(size, MAX_TEXT_PREVIEW_BYTES);
813
+ const buffer = Buffer.alloc(length);
814
+ const descriptor = fs.openSync(filePath, "r");
815
+ try {
816
+ const bytesRead = fs.readSync(descriptor, buffer, 0, length, 0);
817
+ const truncated = size > bytesRead;
818
+ return {
819
+ text: decodeText(buffer.subarray(0, bytesRead), encoding, !truncated),
820
+ truncated,
821
+ };
822
+ }
823
+ finally {
824
+ fs.closeSync(descriptor);
825
+ }
826
+ }
827
+ export function classifyFileContent(filePath, size = fs.statSync(filePath).size) {
828
+ const length = Math.min(size, MAX_CONTENT_SAMPLE_BYTES);
829
+ if (length === 0)
830
+ return { kind: "text", encoding: "utf-8" };
831
+ const sample = Buffer.alloc(length);
832
+ const descriptor = fs.openSync(filePath, "r");
833
+ try {
834
+ const bytesRead = fs.readSync(descriptor, sample, 0, length, 0);
835
+ return classifyContentSample(sample.subarray(0, bytesRead), bytesRead >= size);
836
+ }
837
+ finally {
838
+ fs.closeSync(descriptor);
839
+ }
840
+ }
841
+ function classifyContentSample(sample, complete) {
842
+ if (sample.length === 0)
843
+ return { kind: "text", encoding: "utf-8" };
844
+ if (sample.length >= 4 && (sample.subarray(0, 4).equals(Buffer.from([0xff, 0xfe, 0x00, 0x00]))
845
+ || sample.subarray(0, 4).equals(Buffer.from([0x00, 0x00, 0xfe, 0xff])))) {
846
+ return { kind: "binary" };
847
+ }
848
+ const bomEncoding = sample[0] === 0xff && sample[1] === 0xfe
849
+ ? "utf-16le"
850
+ : sample[0] === 0xfe && sample[1] === 0xff
851
+ ? "utf-16be"
852
+ : undefined;
853
+ if (bomEncoding) {
854
+ return isTextForEncoding(sample, bomEncoding, complete)
855
+ ? { kind: "text", encoding: bomEncoding }
856
+ : { kind: "binary" };
857
+ }
858
+ if (sample.includes(0))
859
+ return { kind: "binary" };
860
+ for (const encoding of ["utf-8", "gb18030"]) {
861
+ if (isTextForEncoding(sample, encoding, complete))
862
+ return { kind: "text", encoding };
863
+ }
864
+ return { kind: "binary" };
865
+ }
866
+ function isTextForEncoding(sample, encoding, complete) {
867
+ try {
868
+ return hasTextLikeCharacters(decodeText(sample, encoding, complete, true));
869
+ }
870
+ catch {
871
+ return false;
872
+ }
873
+ }
874
+ function decodeText(value, encoding, complete, fatal = false) {
875
+ const decoder = new TextDecoder(encoding, { fatal });
876
+ return decoder.decode(value, { stream: !complete });
877
+ }
878
+ function hasTextLikeCharacters(value) {
879
+ let controls = 0;
880
+ let characters = 0;
881
+ for (const character of value) {
882
+ const codePoint = character.codePointAt(0);
883
+ characters += 1;
884
+ if ((codePoint < 0x20 && ![0x08, 0x09, 0x0a, 0x0c, 0x0d, 0x1b].includes(codePoint))
885
+ || (codePoint >= 0x7f && codePoint <= 0x9f)) {
886
+ controls += 1;
887
+ }
888
+ }
889
+ return controls <= 2 || controls / Math.max(1, characters) <= 0.02;
890
+ }
891
+ function renderText(value, filePath) {
892
+ const normalized = value.replace(/\r\n?/gu, "\n");
893
+ const language = syntaxLanguageFor(filePath);
894
+ const lines = language && Buffer.byteLength(normalized, "utf8") <= MAX_SYNTAX_HIGHLIGHT_BYTES
895
+ ? highlightLines(normalized, language)
896
+ : normalized.split("\n").map(escapeHtml);
897
+ const languageClass = language ? ` class="language-${escapeAttribute(language)}"` : "";
898
+ return `<pre class="code hljs"><code${languageClass}>${lines.map((line, index) => {
899
+ const lineNumber = index + 1;
900
+ return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${line}</span>`;
901
+ }).join("")}</code></pre>`;
902
+ }
903
+ function syntaxLanguageFor(filePath) {
904
+ const fileName = path.basename(filePath).toLowerCase();
905
+ return HIGHLIGHT_LANGUAGE_BY_FILENAME[fileName]
906
+ ?? HIGHLIGHT_LANGUAGE_BY_EXTENSION[path.extname(fileName)];
907
+ }
908
+ function highlightLines(value, language) {
909
+ try {
910
+ return splitHighlightedLines(hljs.highlight(value, { language, ignoreIllegals: true }).value);
911
+ }
912
+ catch {
913
+ return value.split("\n").map(escapeHtml);
914
+ }
915
+ }
916
+ function splitHighlightedLines(value) {
917
+ const lines = [];
918
+ const openTags = [];
919
+ const tokenPattern = /<span class="[^"]+">|<\/span>|\n/gu;
920
+ let current = "";
921
+ let offset = 0;
922
+ for (const match of value.matchAll(tokenPattern)) {
923
+ const token = match[0];
924
+ const index = match.index ?? offset;
925
+ current += value.slice(offset, index);
926
+ offset = index + token.length;
927
+ if (token === "\n") {
928
+ current += "</span>".repeat(openTags.length);
929
+ lines.push(current);
930
+ current = openTags.join("");
931
+ }
932
+ else if (token === "</span>") {
933
+ current += token;
934
+ openTags.pop();
935
+ }
936
+ else {
937
+ current += token;
938
+ openTags.push(token);
939
+ }
940
+ }
941
+ current += value.slice(offset);
942
+ current += "</span>".repeat(openTags.length);
943
+ lines.push(current);
944
+ return lines;
945
+ }
946
+ function contentTypeFor(extension) {
947
+ const known = {
948
+ ".aac": "audio/aac", ".avi": "video/x-msvideo", ".bmp": "image/bmp", ".css": "text/css; charset=utf-8",
949
+ ".csv": "text/csv; charset=utf-8", ".gif": "image/gif", ".htm": "text/html; charset=utf-8",
950
+ ".html": "text/html; charset=utf-8", ".ico": "image/x-icon", ".jpeg": "image/jpeg", ".jpg": "image/jpeg",
951
+ ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".m4a": "audio/mp4",
952
+ ".md": "text/markdown; charset=utf-8", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".mp4": "video/mp4",
953
+ ".mpeg": "video/mpeg", ".oga": "audio/ogg", ".ogg": "audio/ogg", ".ogv": "video/ogg", ".pdf": "application/pdf",
954
+ ".png": "image/png", ".svg": "image/svg+xml", ".tif": "image/tiff", ".tiff": "image/tiff",
955
+ ".ts": "text/plain; charset=utf-8", ".txt": "text/plain; charset=utf-8", ".wav": "audio/wav",
956
+ ".webm": "video/webm", ".webp": "image/webp", ".xml": "application/xml; charset=utf-8",
957
+ ".yaml": "text/yaml; charset=utf-8", ".yml": "text/yaml; charset=utf-8",
958
+ };
959
+ return known[extension] ?? "application/octet-stream";
960
+ }
961
+ function binaryContentTypeFor(extension) {
962
+ const contentType = contentTypeFor(extension);
963
+ return isTextContentType(contentType) ? "application/octet-stream" : contentType;
964
+ }
965
+ function textContentTypeFor(extension, encoding) {
966
+ const contentType = contentTypeFor(extension);
967
+ if (!isTextContentType(contentType))
968
+ return `text/plain; charset=${encoding}`;
969
+ return contentType.replace(/;\s*charset=[^;]+/iu, `; charset=${encoding}`);
970
+ }
971
+ function isTextContentType(contentType) {
972
+ return contentType.startsWith("text/")
973
+ || contentType.includes("json")
974
+ || contentType.includes("xml")
975
+ || contentType.includes("yaml")
976
+ || contentType === "image/svg+xml";
977
+ }
978
+ function parseRange(value, size) {
979
+ if (!value)
980
+ return undefined;
981
+ const match = /^bytes=(\d*)-(\d*)$/u.exec(value.trim());
982
+ if (!match)
983
+ return "invalid";
984
+ const startText = match[1] ?? "";
985
+ const endText = match[2] ?? "";
986
+ if (!startText && !endText)
987
+ return "invalid";
988
+ let start;
989
+ let end;
990
+ if (!startText) {
991
+ const suffix = Number.parseInt(endText, 10);
992
+ if (!Number.isSafeInteger(suffix) || suffix <= 0 || size === 0)
993
+ return "invalid";
994
+ start = Math.max(0, size - suffix);
995
+ end = size - 1;
996
+ }
997
+ else {
998
+ start = Number.parseInt(startText, 10);
999
+ end = endText ? Number.parseInt(endText, 10) : size - 1;
1000
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) {
1001
+ return "invalid";
1002
+ }
1003
+ end = Math.min(end, size - 1);
1004
+ }
1005
+ return { start, end };
1006
+ }
1007
+ function formatFileSize(size) {
1008
+ if (size < 1024)
1009
+ return `${size} B`;
1010
+ if (size < 1024 * 1024)
1011
+ return `${(size / 1024).toFixed(1)} KiB`;
1012
+ if (size < 1024 * 1024 * 1024)
1013
+ return `${(size / 1024 / 1024).toFixed(1)} MiB`;
1014
+ return `${(size / 1024 / 1024 / 1024).toFixed(1)} GiB`;
1015
+ }
1016
+ function writeServerSentEvent(response, event, data) {
1017
+ if (response.destroyed || response.writableEnded)
1018
+ return;
1019
+ response.write(`event: ${event}\n`);
1020
+ for (const line of data.split(/\r?\n/u))
1021
+ response.write(`data: ${line}\n`);
1022
+ response.write("\n");
1023
+ }
1024
+ function setSecurityHeaders(response) {
1025
+ 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'");
1026
+ response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
1027
+ response.setHeader("Referrer-Policy", "no-referrer");
1028
+ response.setHeader("X-Content-Type-Options", "nosniff");
1029
+ response.setHeader("X-Frame-Options", "SAMEORIGIN");
1030
+ }
1031
+ function escapeHtml(value) {
1032
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1033
+ }
1034
+ function escapeAttribute(value) {
1035
+ return escapeHtml(value);
1036
+ }
1037
+ function errorPage(title, message) {
1038
+ 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>`;
1039
+ }
1040
+ function listen(server, host, port) {
1041
+ return new Promise((resolve, reject) => {
1042
+ const onError = (error) => {
1043
+ server.off("listening", onListening);
1044
+ reject(error);
1045
+ };
1046
+ const onListening = () => {
1047
+ server.off("error", onError);
1048
+ resolve();
1049
+ };
1050
+ server.once("error", onError);
1051
+ server.once("listening", onListening);
1052
+ server.listen({ host, port });
1053
+ });
1054
+ }
1055
+ function closeServer(server) {
1056
+ return new Promise((resolve, reject) => {
1057
+ server.close((error) => error ? reject(error) : resolve());
1058
+ server.closeAllConnections();
1059
+ });
1060
+ }
1061
+ function isAddressInUse(error) {
1062
+ return error.code === "EADDRINUSE";
1063
+ }
1064
+ //# sourceMappingURL=LocalFileViewerServer.js.map