@keyou007/agent-bot 0.1.17-alpha.2 → 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.
- package/CHANGELOG.md +4 -0
- package/dist/feishu/CardRenderer.js +6 -16
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/local-files/LocalFileViewerServer.js +416 -58
- package/dist/local-files/LocalFileViewerServer.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +39 -6
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/startup/StartupNotifier.js +7 -4
- package/dist/startup/StartupNotifier.js.map +1 -1
- package/docs/technical-reference.md +2 -0
- package/docs/technical-reference.zh.md +2 -0
- package/npm-shrinkwrap.json +12 -2
- package/package.json +2 -1
- package/src/feishu/CardRenderer.ts +6 -22
- package/src/local-files/LocalFileViewerServer.ts +428 -60
- package/src/proxy/ProxySessionController.ts +43 -5
- package/src/startup/StartupNotifier.ts +9 -4
|
@@ -2,6 +2,10 @@ 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";
|
|
5
9
|
import {
|
|
6
10
|
selectPreferredNetworkAddress,
|
|
7
11
|
type NetworkConnectionKind,
|
|
@@ -9,9 +13,131 @@ import {
|
|
|
9
13
|
|
|
10
14
|
const SECRET_FILE = "secret";
|
|
11
15
|
const PORT_FILE = "port";
|
|
16
|
+
const SHORT_TOKEN_BYTES = 12;
|
|
17
|
+
const SHORT_TOKEN_LENGTH = 16;
|
|
12
18
|
const MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024;
|
|
13
19
|
const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
|
|
20
|
+
const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
|
|
14
21
|
const DIRECTORY_PAGE_SIZE = 100;
|
|
22
|
+
hljs.registerLanguage("dos", dos);
|
|
23
|
+
hljs.registerLanguage("dockerfile", dockerfile);
|
|
24
|
+
hljs.registerLanguage("powershell", powershell);
|
|
25
|
+
|
|
26
|
+
const HIGHLIGHT_LANGUAGE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
27
|
+
".bash": "bash", ".bat": "dos", ".c": "c", ".cc": "cpp", ".cmd": "dos", ".cpp": "cpp",
|
|
28
|
+
".cs": "csharp", ".css": "css", ".cxx": "cpp", ".diff": "diff", ".go": "go", ".h": "cpp",
|
|
29
|
+
".hpp": "cpp", ".htm": "xml", ".html": "xml", ".ini": "ini", ".ipynb": "json", ".java": "java",
|
|
30
|
+
".js": "javascript", ".json": "json", ".jsonl": "json", ".jsx": "javascript", ".kt": "kotlin",
|
|
31
|
+
".kts": "kotlin", ".less": "less", ".lua": "lua", ".markdown": "markdown", ".md": "markdown",
|
|
32
|
+
".mjs": "javascript", ".patch": "diff", ".php": "php", ".pl": "perl", ".ps1": "powershell",
|
|
33
|
+
".py": "python", ".r": "r", ".rb": "ruby", ".rs": "rust", ".scss": "scss", ".sh": "bash",
|
|
34
|
+
".sql": "sql", ".swift": "swift", ".toml": "ini", ".ts": "typescript", ".tsx": "typescript",
|
|
35
|
+
".vue": "xml", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".zsh": "bash",
|
|
36
|
+
};
|
|
37
|
+
const HIGHLIGHT_LANGUAGE_BY_FILENAME: Readonly<Record<string, string>> = {
|
|
38
|
+
".env": "ini",
|
|
39
|
+
".gitignore": "ini",
|
|
40
|
+
"dockerfile": "dockerfile",
|
|
41
|
+
"makefile": "makefile",
|
|
42
|
+
};
|
|
43
|
+
const VIEWER_CLIENT_SCRIPT = `(() => {
|
|
44
|
+
const eventsUrl = document.body.dataset.eventsUrl;
|
|
45
|
+
const content = document.getElementById("viewer-content");
|
|
46
|
+
const metadata = document.getElementById("viewer-metadata");
|
|
47
|
+
const header = document.getElementById("viewer-header");
|
|
48
|
+
const title = document.getElementById("viewer-title");
|
|
49
|
+
if (!content || !metadata || !header || !title) return;
|
|
50
|
+
const filePath = title.dataset.filePath || title.textContent || "";
|
|
51
|
+
|
|
52
|
+
title.addEventListener("pointerdown", (event) => {
|
|
53
|
+
if (event.button !== 0) return;
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
const selection = window.getSelection();
|
|
56
|
+
if (!selection) return;
|
|
57
|
+
const range = document.createRange();
|
|
58
|
+
range.selectNodeContents(title);
|
|
59
|
+
selection.removeAllRanges();
|
|
60
|
+
selection.addRange(range);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const updateHeaderOffset = () => {
|
|
64
|
+
const offset = Math.ceil(header.getBoundingClientRect().height) + 8;
|
|
65
|
+
document.documentElement.style.setProperty("--viewer-header-offset", offset + "px");
|
|
66
|
+
};
|
|
67
|
+
const highlightHashTarget = () => {
|
|
68
|
+
document.querySelector(".line.is-target-line")?.classList.remove("is-target-line");
|
|
69
|
+
let id;
|
|
70
|
+
try {
|
|
71
|
+
id = decodeURIComponent(window.location.hash.slice(1));
|
|
72
|
+
} catch {
|
|
73
|
+
title.textContent = filePath;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const lineNumber = /^L(\\d+)$/.exec(id)?.[1];
|
|
77
|
+
title.textContent = lineNumber ? filePath + ":" + lineNumber : filePath;
|
|
78
|
+
if (!lineNumber) return null;
|
|
79
|
+
const target = document.getElementById(id);
|
|
80
|
+
target?.classList.add("is-target-line");
|
|
81
|
+
return target;
|
|
82
|
+
};
|
|
83
|
+
const positionHashTarget = () => {
|
|
84
|
+
highlightHashTarget()?.scrollIntoView({ block: "start" });
|
|
85
|
+
};
|
|
86
|
+
updateHeaderOffset();
|
|
87
|
+
if (typeof ResizeObserver === "function") {
|
|
88
|
+
new ResizeObserver(updateHeaderOffset).observe(header);
|
|
89
|
+
}
|
|
90
|
+
window.addEventListener("hashchange", () => requestAnimationFrame(positionHashTarget));
|
|
91
|
+
requestAnimationFrame(positionHashTarget);
|
|
92
|
+
if (!eventsUrl || typeof EventSource !== "function") return;
|
|
93
|
+
|
|
94
|
+
const restoreScroll = (top, left, atBottom, codeScrollLeft) => {
|
|
95
|
+
requestAnimationFrame(() => {
|
|
96
|
+
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
97
|
+
window.scrollTo(left, atBottom ? maxTop : Math.min(top, maxTop));
|
|
98
|
+
const code = content.querySelector(".code");
|
|
99
|
+
if (code) code.scrollLeft = codeScrollLeft;
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const replaceContent = (nextContent, nextMetadata) => {
|
|
103
|
+
const top = window.scrollY;
|
|
104
|
+
const left = window.scrollX;
|
|
105
|
+
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
106
|
+
const atBottom = maxTop - top <= 24;
|
|
107
|
+
const codeScrollLeft = content.querySelector(".code")?.scrollLeft ?? 0;
|
|
108
|
+
content.innerHTML = nextContent;
|
|
109
|
+
metadata.innerHTML = nextMetadata;
|
|
110
|
+
highlightHashTarget();
|
|
111
|
+
restoreScroll(top, left, atBottom, codeScrollLeft);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const source = new EventSource(eventsUrl);
|
|
115
|
+
source.addEventListener("update", (event) => {
|
|
116
|
+
try {
|
|
117
|
+
const update = JSON.parse(event.data);
|
|
118
|
+
if (typeof update.content === "string" && typeof update.metadata === "string") {
|
|
119
|
+
replaceContent(update.content, update.metadata);
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// A later valid event will repair the view.
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
source.addEventListener("unavailable", (event) => {
|
|
126
|
+
let message = "文件暂时无法读取,服务会继续等待它恢复。";
|
|
127
|
+
try {
|
|
128
|
+
const update = JSON.parse(event.data);
|
|
129
|
+
if (typeof update.message === "string") message = update.message;
|
|
130
|
+
} catch {
|
|
131
|
+
// Keep the fallback message.
|
|
132
|
+
}
|
|
133
|
+
const notice = document.createElement("div");
|
|
134
|
+
notice.className = "notice";
|
|
135
|
+
notice.textContent = message;
|
|
136
|
+
replaceContent(notice.outerHTML, metadata.innerHTML);
|
|
137
|
+
});
|
|
138
|
+
window.addEventListener("pagehide", () => source.close(), { once: true });
|
|
139
|
+
})();
|
|
140
|
+
`;
|
|
15
141
|
|
|
16
142
|
type TextEncoding = "utf-8" | "utf-16le" | "utf-16be" | "gb18030";
|
|
17
143
|
|
|
@@ -28,6 +154,11 @@ interface DirectoryViewerEntry {
|
|
|
28
154
|
kind: DirectoryViewerEntryKind;
|
|
29
155
|
}
|
|
30
156
|
|
|
157
|
+
interface RenderedFileSnapshot {
|
|
158
|
+
content: string;
|
|
159
|
+
metadata: string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
31
162
|
export interface LocalFileViewerServerOptions {
|
|
32
163
|
host: string;
|
|
33
164
|
port: number;
|
|
@@ -138,8 +269,7 @@ export class LocalFileViewerServer {
|
|
|
138
269
|
return undefined;
|
|
139
270
|
}
|
|
140
271
|
|
|
141
|
-
const
|
|
142
|
-
const url = this.createSignedUrl("view", token);
|
|
272
|
+
const url = this.createPreviewUrl(absolutePath);
|
|
143
273
|
const line = parseLineReference(reference);
|
|
144
274
|
if (line !== undefined) url.hash = `L${line}`;
|
|
145
275
|
return url.toString();
|
|
@@ -154,15 +284,27 @@ export class LocalFileViewerServer {
|
|
|
154
284
|
|
|
155
285
|
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
156
286
|
const route = routeWithinBasePath(requestUrl.pathname, this.basePath);
|
|
157
|
-
|
|
158
|
-
|
|
287
|
+
if (!route.startsWith("/")) {
|
|
288
|
+
this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (route === "/assets/viewer.js") {
|
|
292
|
+
this.sendJavascript(response, request.method === "HEAD");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const previewMatch = new RegExp(`^/preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
|
|
296
|
+
const legacyMatch = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
|
|
297
|
+
const previewPath = previewMatch?.[1]
|
|
298
|
+
? this.verifyPreviewToken(previewMatch[1], requestUrl.searchParams.get("path") ?? "")
|
|
299
|
+
: undefined;
|
|
300
|
+
const legacyPath = legacyMatch?.[2]
|
|
301
|
+
? this.verifyLegacyToken(legacyMatch[2], requestUrl.searchParams.get("sig") ?? "")
|
|
302
|
+
: undefined;
|
|
303
|
+
const filePath = previewPath ?? legacyPath;
|
|
304
|
+
if (!previewMatch && !legacyMatch) {
|
|
159
305
|
this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
|
|
160
306
|
return;
|
|
161
307
|
}
|
|
162
|
-
|
|
163
|
-
const token = match[2];
|
|
164
|
-
const signature = requestUrl.searchParams.get("sig") ?? "";
|
|
165
|
-
const filePath = this.verifyToken(token, signature);
|
|
166
308
|
if (!filePath) {
|
|
167
309
|
this.sendHtml(response, 403, errorPage("文件链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
|
|
168
310
|
return;
|
|
@@ -177,7 +319,17 @@ export class LocalFileViewerServer {
|
|
|
177
319
|
return;
|
|
178
320
|
}
|
|
179
321
|
|
|
180
|
-
if (
|
|
322
|
+
if (requestUrl.searchParams.get("events") === "1") {
|
|
323
|
+
if (!stat.isFile()) {
|
|
324
|
+
this.sendHtml(response, 400, errorPage("无法监听目录", "实时更新目前只适用于文件。"), request.method === "HEAD");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
this.serveFileEvents(request, response, filePath);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const raw = legacyMatch?.[1] === "raw" || requestUrl.searchParams.get("raw") === "1";
|
|
332
|
+
if (raw) {
|
|
181
333
|
if (stat.isDirectory()) {
|
|
182
334
|
this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
|
|
183
335
|
return;
|
|
@@ -187,12 +339,12 @@ export class LocalFileViewerServer {
|
|
|
187
339
|
}
|
|
188
340
|
|
|
189
341
|
const html = stat.isDirectory()
|
|
190
|
-
? this.renderDirectoryViewer(filePath, stat,
|
|
191
|
-
: this.renderViewer(filePath, stat
|
|
342
|
+
? this.renderDirectoryViewer(filePath, stat, parseDirectoryPage(requestUrl.searchParams.get("page")))
|
|
343
|
+
: this.renderViewer(filePath, stat);
|
|
192
344
|
this.sendHtml(response, 200, html, request.method === "HEAD");
|
|
193
345
|
}
|
|
194
346
|
|
|
195
|
-
private
|
|
347
|
+
private verifyLegacyToken(token: string, signature: string): string | undefined {
|
|
196
348
|
if (!this.secret || !/^[a-f0-9]{64}$/u.test(signature)) return undefined;
|
|
197
349
|
const expected = this.sign(token);
|
|
198
350
|
const actualBuffer = Buffer.from(signature, "hex");
|
|
@@ -209,11 +361,22 @@ export class LocalFileViewerServer {
|
|
|
209
361
|
return normalizeAbsolutePath(decoded);
|
|
210
362
|
}
|
|
211
363
|
|
|
364
|
+
private verifyPreviewToken(token: string, rawPath: string): string | undefined {
|
|
365
|
+
const filePath = normalizeAbsolutePath(rawPath);
|
|
366
|
+
if (!filePath || !this.secret) return undefined;
|
|
367
|
+
const expected = this.createShortToken(filePath);
|
|
368
|
+
const actualBuffer = Buffer.from(token, "utf8");
|
|
369
|
+
const expectedBuffer = Buffer.from(expected, "utf8");
|
|
370
|
+
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
|
|
371
|
+
? filePath
|
|
372
|
+
: undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
212
375
|
private sign(token: string): string {
|
|
213
376
|
return createHmac("sha256", this.secret!).update(token).digest("hex");
|
|
214
377
|
}
|
|
215
378
|
|
|
216
|
-
private
|
|
379
|
+
private createLegacySignedUrl(route: "view" | "raw", token: string): URL {
|
|
217
380
|
const url = new URL(this.address!.publicBaseUrl);
|
|
218
381
|
url.pathname = `${this.basePath}/${route}/${token}`.replace(/\/{2,}/gu, "/");
|
|
219
382
|
url.search = "";
|
|
@@ -222,20 +385,64 @@ export class LocalFileViewerServer {
|
|
|
222
385
|
return url;
|
|
223
386
|
}
|
|
224
387
|
|
|
225
|
-
private
|
|
388
|
+
private createPreviewUrl(filePath: string): URL {
|
|
389
|
+
const url = new URL(this.address!.publicBaseUrl);
|
|
390
|
+
url.pathname = `${this.basePath}/preview/${this.createShortToken(filePath)}`
|
|
391
|
+
.replace(/\/{2,}/gu, "/");
|
|
392
|
+
url.search = `?path=${encodeReadableQueryPath(filePath)}`;
|
|
393
|
+
url.hash = "";
|
|
394
|
+
return url;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private createShortToken(filePath: string): string {
|
|
398
|
+
return createHmac("sha256", this.secret!)
|
|
399
|
+
.update(filePath)
|
|
400
|
+
.digest()
|
|
401
|
+
.subarray(0, SHORT_TOKEN_BYTES)
|
|
402
|
+
.toString("base64url");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private renderViewer(filePath: string, stat: fs.Stats): string {
|
|
406
|
+
const fileName = path.basename(filePath);
|
|
407
|
+
const rawUrl = new URL(this.createFileUrl(filePath)!);
|
|
408
|
+
rawUrl.hash = "";
|
|
409
|
+
appendUrlQueryParameter(rawUrl, "raw", "1");
|
|
410
|
+
const downloadUrl = new URL(rawUrl);
|
|
411
|
+
appendUrlQueryParameter(downloadUrl, "download", "1");
|
|
412
|
+
const eventsUrl = new URL(this.createFileUrl(filePath)!);
|
|
413
|
+
eventsUrl.hash = "";
|
|
414
|
+
appendUrlQueryParameter(eventsUrl, "events", "1");
|
|
415
|
+
const snapshot = this.renderFileSnapshot(filePath, stat);
|
|
416
|
+
|
|
417
|
+
return renderViewerPage({
|
|
418
|
+
title: fileName,
|
|
419
|
+
filePath,
|
|
420
|
+
metadata: snapshot.metadata,
|
|
421
|
+
actions: [
|
|
422
|
+
{ href: rawUrl.toString(), label: "打开原始文件" },
|
|
423
|
+
{ href: downloadUrl.toString(), label: "下载" },
|
|
424
|
+
],
|
|
425
|
+
content: snapshot.content,
|
|
426
|
+
liveUpdates: {
|
|
427
|
+
eventsUrl: eventsUrl.toString(),
|
|
428
|
+
scriptPath: `${this.basePath}/assets/viewer.js`.replace(/\/{2,}/gu, "/"),
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private renderFileSnapshot(filePath: string, stat: fs.Stats): RenderedFileSnapshot {
|
|
226
434
|
const fileName = path.basename(filePath);
|
|
227
435
|
const extension = path.extname(filePath).toLowerCase();
|
|
228
436
|
const classification = classifyFileContent(filePath, stat.size);
|
|
229
437
|
const contentType = binaryContentTypeFor(extension);
|
|
230
|
-
const rawUrl = this.
|
|
231
|
-
rawUrl.
|
|
232
|
-
|
|
233
|
-
downloadUrl.searchParams.set("download", "1");
|
|
438
|
+
const rawUrl = new URL(this.createFileUrl(filePath)!);
|
|
439
|
+
rawUrl.hash = "";
|
|
440
|
+
appendUrlQueryParameter(rawUrl, "raw", "1");
|
|
234
441
|
|
|
235
442
|
let content: string;
|
|
236
443
|
if (classification.kind === "text") {
|
|
237
444
|
const preview = readTextPreview(filePath, stat.size, classification.encoding);
|
|
238
|
-
content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${renderText(preview.text)}`;
|
|
445
|
+
content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${renderText(preview.text, filePath)}`;
|
|
239
446
|
} else if (contentType.startsWith("image/")) {
|
|
240
447
|
content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
|
|
241
448
|
} else if (contentType === "application/pdf") {
|
|
@@ -247,20 +454,77 @@ export class LocalFileViewerServer {
|
|
|
247
454
|
} else {
|
|
248
455
|
content = '<div class="unsupported">该文件是二进制格式,无法直接预览。可以打开原始文件或下载到本地。</div>';
|
|
249
456
|
}
|
|
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
|
-
],
|
|
457
|
+
return {
|
|
259
458
|
content,
|
|
260
|
-
|
|
459
|
+
metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private serveFileEvents(request: IncomingMessage, response: ServerResponse, filePath: string): void {
|
|
464
|
+
setSecurityHeaders(response);
|
|
465
|
+
response.statusCode = 200;
|
|
466
|
+
response.setHeader("Cache-Control", "no-cache, no-store");
|
|
467
|
+
response.setHeader("Connection", "keep-alive");
|
|
468
|
+
response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
469
|
+
response.setHeader("X-Accel-Buffering", "no");
|
|
470
|
+
if (request.method === "HEAD") {
|
|
471
|
+
response.end();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
response.flushHeaders();
|
|
475
|
+
response.write("retry: 2000\n\n");
|
|
476
|
+
|
|
477
|
+
let closed = false;
|
|
478
|
+
let pendingUpdate: NodeJS.Timeout | undefined;
|
|
479
|
+
const sendSnapshot = () => {
|
|
480
|
+
if (closed || response.destroyed) return;
|
|
481
|
+
try {
|
|
482
|
+
const stat = fs.statSync(filePath);
|
|
483
|
+
if (!stat.isFile()) throw new Error("Path is no longer a file");
|
|
484
|
+
const snapshot = this.renderFileSnapshot(filePath, stat);
|
|
485
|
+
writeServerSentEvent(response, "update", JSON.stringify({
|
|
486
|
+
content: snapshot.content,
|
|
487
|
+
metadata: renderMetadata(snapshot.metadata),
|
|
488
|
+
}));
|
|
489
|
+
} catch {
|
|
490
|
+
writeServerSentEvent(response, "unavailable", JSON.stringify({
|
|
491
|
+
message: "文件可能已被移动、重命名或删除。",
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const scheduleSnapshot = () => {
|
|
496
|
+
if (pendingUpdate) clearTimeout(pendingUpdate);
|
|
497
|
+
pendingUpdate = setTimeout(sendSnapshot, 100);
|
|
498
|
+
pendingUpdate.unref();
|
|
499
|
+
};
|
|
500
|
+
const watcher = (current: fs.Stats, previous: fs.Stats) => {
|
|
501
|
+
if (
|
|
502
|
+
current.mtimeMs === previous.mtimeMs
|
|
503
|
+
&& current.ctimeMs === previous.ctimeMs
|
|
504
|
+
&& current.size === previous.size
|
|
505
|
+
&& current.nlink === previous.nlink
|
|
506
|
+
) return;
|
|
507
|
+
scheduleSnapshot();
|
|
508
|
+
};
|
|
509
|
+
fs.watchFile(filePath, { interval: 1_000, persistent: false }, watcher);
|
|
510
|
+
sendSnapshot();
|
|
511
|
+
const keepAlive = setInterval(() => {
|
|
512
|
+
if (!closed && !response.destroyed) response.write(": keep-alive\n\n");
|
|
513
|
+
}, 15_000);
|
|
514
|
+
keepAlive.unref();
|
|
515
|
+
|
|
516
|
+
const cleanup = () => {
|
|
517
|
+
if (closed) return;
|
|
518
|
+
closed = true;
|
|
519
|
+
if (pendingUpdate) clearTimeout(pendingUpdate);
|
|
520
|
+
clearInterval(keepAlive);
|
|
521
|
+
fs.unwatchFile(filePath, watcher);
|
|
522
|
+
};
|
|
523
|
+
request.once("aborted", cleanup);
|
|
524
|
+
response.once("close", cleanup);
|
|
261
525
|
}
|
|
262
526
|
|
|
263
|
-
private renderDirectoryViewer(directory: string, stat: fs.Stats,
|
|
527
|
+
private renderDirectoryViewer(directory: string, stat: fs.Stats, requestedPage: number): string {
|
|
264
528
|
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
|
265
529
|
.flatMap((entry): DirectoryViewerEntry[] => {
|
|
266
530
|
const entryPath = path.join(directory, entry.name);
|
|
@@ -287,8 +551,8 @@ export class LocalFileViewerServer {
|
|
|
287
551
|
: '<div class="empty-directory">这个目录是空的。</div>';
|
|
288
552
|
const pagination = totalPages > 1
|
|
289
553
|
? renderDirectoryPagination(page, totalPages, (targetPage) => {
|
|
290
|
-
const url = this.
|
|
291
|
-
url
|
|
554
|
+
const url = new URL(this.createFileUrl(directory)!);
|
|
555
|
+
appendUrlQueryParameter(url, "page", String(targetPage));
|
|
292
556
|
return url.toString();
|
|
293
557
|
})
|
|
294
558
|
: "";
|
|
@@ -355,6 +619,16 @@ export class LocalFileViewerServer {
|
|
|
355
619
|
response.setHeader("Content-Length", body.length);
|
|
356
620
|
response.end(headOnly ? undefined : body);
|
|
357
621
|
}
|
|
622
|
+
|
|
623
|
+
private sendJavascript(response: ServerResponse, headOnly: boolean): void {
|
|
624
|
+
const body = Buffer.from(VIEWER_CLIENT_SCRIPT, "utf8");
|
|
625
|
+
setSecurityHeaders(response);
|
|
626
|
+
response.statusCode = 200;
|
|
627
|
+
response.setHeader("Cache-Control", "no-store");
|
|
628
|
+
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
629
|
+
response.setHeader("Content-Length", body.length);
|
|
630
|
+
response.end(headOnly ? undefined : body);
|
|
631
|
+
}
|
|
358
632
|
}
|
|
359
633
|
|
|
360
634
|
function renderViewerPage(input: {
|
|
@@ -363,11 +637,18 @@ function renderViewerPage(input: {
|
|
|
363
637
|
metadata: string[];
|
|
364
638
|
actions: Array<{ href: string; label: string }>;
|
|
365
639
|
content: string;
|
|
640
|
+
liveUpdates?: { eventsUrl: string; scriptPath: string };
|
|
366
641
|
}): string {
|
|
367
|
-
const metadata = input.metadata
|
|
642
|
+
const metadata = renderMetadata(input.metadata);
|
|
368
643
|
const actions = input.actions.length > 0
|
|
369
644
|
? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
|
|
370
645
|
: "";
|
|
646
|
+
const liveAttributes = input.liveUpdates
|
|
647
|
+
? ` data-events-url="${escapeAttribute(input.liveUpdates.eventsUrl)}"`
|
|
648
|
+
: "";
|
|
649
|
+
const liveScript = input.liveUpdates
|
|
650
|
+
? `<script src="${escapeAttribute(input.liveUpdates.scriptPath)}" defer></script>`
|
|
651
|
+
: "";
|
|
371
652
|
return `<!doctype html>
|
|
372
653
|
<html lang="zh-CN">
|
|
373
654
|
<head>
|
|
@@ -375,26 +656,36 @@ function renderViewerPage(input: {
|
|
|
375
656
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
376
657
|
<title>${escapeHtml(input.title)} · Agent Bot</title>
|
|
377
658
|
<style>
|
|
378
|
-
:root { color-scheme: light dark; font-family: Inter, "Segoe UI", system-ui, sans-serif; }
|
|
659
|
+
: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
660
|
* { box-sizing: border-box; }
|
|
380
661
|
body { margin: 0; background: #f5f7fa; color: #1f2329; }
|
|
381
|
-
header { position: sticky; top: 0; z-index: 2; padding:
|
|
382
|
-
h1 { margin: 0;
|
|
383
|
-
.
|
|
384
|
-
.
|
|
662
|
+
header { position: sticky; top: 0; z-index: 2; padding: 7px 12px; background: rgba(255,255,255,.96); border-bottom: 1px solid #dfe3e8; }
|
|
663
|
+
h1 { margin: 0; cursor: text; font: 600 13px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
|
|
664
|
+
.meta { display: flex; gap: 10px; align-items: center; min-height: 26px; margin-top: 3px; color: #646a73; font-size: 11px; }
|
|
665
|
+
.metadata-values { display: contents; }
|
|
385
666
|
.actions { margin-left: auto; display: flex; gap: 8px; }
|
|
386
|
-
.actions a { color: #1456f0; text-decoration: none; padding:
|
|
387
|
-
main { padding:
|
|
388
|
-
.notice, .unsupported { margin
|
|
389
|
-
.code { margin: 0; padding:
|
|
390
|
-
.line {
|
|
391
|
-
.line
|
|
667
|
+
.actions a { color: #1456f0; text-decoration: none; padding: 3px 7px; border: 1px solid #c9d0db; border-radius: 4px; background: #fff; }
|
|
668
|
+
main { padding: 0; }
|
|
669
|
+
.notice, .unsupported { margin: 8px; padding: 10px 12px; border: 1px solid #f3cf8f; background: #fff7e6; border-radius: 5px; }
|
|
670
|
+
.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; }
|
|
671
|
+
.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; }
|
|
672
|
+
.line { display: block; min-height: 1.55em; padding-right: 16px; scroll-margin-top: var(--viewer-header-offset); }
|
|
673
|
+
.line:target, .line.is-target-line { background: #fff1b8; box-shadow: inset 4px 0 #e6a700; }
|
|
392
674
|
.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; }
|
|
675
|
+
.hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
|
|
676
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #a626a4; }
|
|
677
|
+
.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; }
|
|
678
|
+
.hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #986801; }
|
|
679
|
+
.hljs-title.function_, .hljs-title.class_ { color: #005cc5; }
|
|
680
|
+
.hljs-deletion { color: #b31d28; background: #ffeef0; }
|
|
681
|
+
.hljs-addition { background: #e6ffed; }
|
|
682
|
+
.hljs-emphasis { font-style: italic; }
|
|
683
|
+
.hljs-strong { font-weight: 700; }
|
|
393
684
|
.media { display: grid; place-items: center; min-height: 220px; }
|
|
394
|
-
.media img, .media video { max-width: 100%; max-height: calc(100vh -
|
|
685
|
+
.media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
|
|
395
686
|
.media audio { width: min(720px, 100%); }
|
|
396
|
-
.document { width: 100%; height: calc(100vh -
|
|
397
|
-
.directory-list { overflow: hidden; border:
|
|
687
|
+
.document { width: 100%; height: calc(100vh - 70px); border: 0; border-radius: 0; background: #fff; }
|
|
688
|
+
.directory-list { overflow: hidden; border: 0; border-radius: 0; background: #fff; }
|
|
398
689
|
.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
690
|
.directory-entry:last-child { border-bottom: 0; }
|
|
400
691
|
.directory-entry:hover { background: #f0f4ff; }
|
|
@@ -410,29 +701,40 @@ function renderViewerPage(input: {
|
|
|
410
701
|
@media (prefers-color-scheme: dark) {
|
|
411
702
|
body { background: #17181a; color: #e5e6eb; }
|
|
412
703
|
header { background: rgba(32,33,36,.96); border-color: #3a3b3d; }
|
|
413
|
-
.
|
|
704
|
+
.meta, .pagination { color: #a6a9ad; }
|
|
414
705
|
.actions a, .pagination a { color: #8ab4ff; }
|
|
415
706
|
.actions a { border-color: #55585c; background: #292a2d; }
|
|
416
707
|
.code, .directory-list { background: #202124; color: #e8eaed; border-color: #3a3b3d; }
|
|
417
708
|
.directory-entry { border-color: #3a3b3d; }
|
|
418
709
|
.directory-entry:hover { background: #292f3d; }
|
|
419
710
|
.line-number { color: #8b9098; border-color: #3a3b3d; }
|
|
420
|
-
.line:target { background: #594c20; }
|
|
711
|
+
.line:target, .line.is-target-line { background: #594c20; box-shadow: inset 4px 0 #d29922; }
|
|
712
|
+
.hljs-comment, .hljs-quote { color: #8b949e; }
|
|
713
|
+
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #ff7b72; }
|
|
714
|
+
.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; }
|
|
715
|
+
.hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name, .hljs-params { color: #d2a8ff; }
|
|
716
|
+
.hljs-title.function_, .hljs-title.class_ { color: #d2a8ff; }
|
|
717
|
+
.hljs-deletion { color: #ffdcd7; background: #67060c; }
|
|
718
|
+
.hljs-addition { color: #aff5b4; background: #033a16; }
|
|
421
719
|
.notice, .unsupported { color: #f5d58a; background: #3b321c; border-color: #705b28; }
|
|
422
720
|
}
|
|
423
721
|
</style>
|
|
424
722
|
</head>
|
|
425
|
-
<body>
|
|
426
|
-
<header>
|
|
427
|
-
<h1>${escapeHtml(input.
|
|
428
|
-
<div class="
|
|
429
|
-
<div class="meta">${metadata}${actions}</div>
|
|
723
|
+
<body${liveAttributes}>
|
|
724
|
+
<header id="viewer-header">
|
|
725
|
+
<h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
|
|
726
|
+
<div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${actions}</div>
|
|
430
727
|
</header>
|
|
431
|
-
<main>${input.content}</main>
|
|
728
|
+
<main id="viewer-content">${input.content}</main>
|
|
729
|
+
${liveScript}
|
|
432
730
|
</body>
|
|
433
731
|
</html>`;
|
|
434
732
|
}
|
|
435
733
|
|
|
734
|
+
function renderMetadata(metadata: string[]): string {
|
|
735
|
+
return metadata.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
|
|
736
|
+
}
|
|
737
|
+
|
|
436
738
|
function directoryViewerEntryKind(filePath: string, stat: fs.Stats): DirectoryViewerEntryKind {
|
|
437
739
|
if (stat.isDirectory()) return "directory";
|
|
438
740
|
if (classifyFileContent(filePath, stat.size).kind === "text") return "text";
|
|
@@ -509,6 +811,17 @@ function normalizeAbsolutePath(filePath: string): string | undefined {
|
|
|
509
811
|
return path.resolve(normalized);
|
|
510
812
|
}
|
|
511
813
|
|
|
814
|
+
function encodeReadableQueryPath(filePath: string): string {
|
|
815
|
+
return encodeURIComponent(filePath.replaceAll("\\", "/"))
|
|
816
|
+
.replaceAll("%3A", ":")
|
|
817
|
+
.replaceAll("%2F", "/");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function appendUrlQueryParameter(url: URL, name: string, value: string): void {
|
|
821
|
+
const parameter = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
822
|
+
url.search = url.search ? `${url.search}&${parameter}` : `?${parameter}`;
|
|
823
|
+
}
|
|
824
|
+
|
|
512
825
|
function normalizePublicBaseUrl(value: string): string {
|
|
513
826
|
const url = new URL(value);
|
|
514
827
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -650,14 +963,62 @@ function hasTextLikeCharacters(value: string): boolean {
|
|
|
650
963
|
return controls <= 2 || controls / Math.max(1, characters) <= 0.02;
|
|
651
964
|
}
|
|
652
965
|
|
|
653
|
-
function renderText(value: string): string {
|
|
654
|
-
const
|
|
655
|
-
|
|
966
|
+
function renderText(value: string, filePath: string): string {
|
|
967
|
+
const normalized = value.replace(/\r\n?/gu, "\n");
|
|
968
|
+
const language = syntaxLanguageFor(filePath);
|
|
969
|
+
const lines = language && Buffer.byteLength(normalized, "utf8") <= MAX_SYNTAX_HIGHLIGHT_BYTES
|
|
970
|
+
? highlightLines(normalized, language)
|
|
971
|
+
: normalized.split("\n").map(escapeHtml);
|
|
972
|
+
const languageClass = language ? ` class="language-${escapeAttribute(language)}"` : "";
|
|
973
|
+
return `<pre class="code hljs"><code${languageClass}>${lines.map((line, index) => {
|
|
656
974
|
const lineNumber = index + 1;
|
|
657
|
-
return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${
|
|
975
|
+
return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${line}</span>`;
|
|
658
976
|
}).join("")}</code></pre>`;
|
|
659
977
|
}
|
|
660
978
|
|
|
979
|
+
function syntaxLanguageFor(filePath: string): string | undefined {
|
|
980
|
+
const fileName = path.basename(filePath).toLowerCase();
|
|
981
|
+
return HIGHLIGHT_LANGUAGE_BY_FILENAME[fileName]
|
|
982
|
+
?? HIGHLIGHT_LANGUAGE_BY_EXTENSION[path.extname(fileName)];
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function highlightLines(value: string, language: string): string[] {
|
|
986
|
+
try {
|
|
987
|
+
return splitHighlightedLines(hljs.highlight(value, { language, ignoreIllegals: true }).value);
|
|
988
|
+
} catch {
|
|
989
|
+
return value.split("\n").map(escapeHtml);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function splitHighlightedLines(value: string): string[] {
|
|
994
|
+
const lines: string[] = [];
|
|
995
|
+
const openTags: string[] = [];
|
|
996
|
+
const tokenPattern = /<span class="[^"]+">|<\/span>|\n/gu;
|
|
997
|
+
let current = "";
|
|
998
|
+
let offset = 0;
|
|
999
|
+
for (const match of value.matchAll(tokenPattern)) {
|
|
1000
|
+
const token = match[0];
|
|
1001
|
+
const index = match.index ?? offset;
|
|
1002
|
+
current += value.slice(offset, index);
|
|
1003
|
+
offset = index + token.length;
|
|
1004
|
+
if (token === "\n") {
|
|
1005
|
+
current += "</span>".repeat(openTags.length);
|
|
1006
|
+
lines.push(current);
|
|
1007
|
+
current = openTags.join("");
|
|
1008
|
+
} else if (token === "</span>") {
|
|
1009
|
+
current += token;
|
|
1010
|
+
openTags.pop();
|
|
1011
|
+
} else {
|
|
1012
|
+
current += token;
|
|
1013
|
+
openTags.push(token);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
current += value.slice(offset);
|
|
1017
|
+
current += "</span>".repeat(openTags.length);
|
|
1018
|
+
lines.push(current);
|
|
1019
|
+
return lines;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
661
1022
|
function contentTypeFor(extension: string): string {
|
|
662
1023
|
const known: Record<string, string> = {
|
|
663
1024
|
".aac": "audio/aac", ".avi": "video/x-msvideo", ".bmp": "image/bmp", ".css": "text/css; charset=utf-8",
|
|
@@ -726,8 +1087,15 @@ function formatFileSize(size: number): string {
|
|
|
726
1087
|
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
|
727
1088
|
}
|
|
728
1089
|
|
|
1090
|
+
function writeServerSentEvent(response: ServerResponse, event: string, data: string): void {
|
|
1091
|
+
if (response.destroyed || response.writableEnded) return;
|
|
1092
|
+
response.write(`event: ${event}\n`);
|
|
1093
|
+
for (const line of data.split(/\r?\n/u)) response.write(`data: ${line}\n`);
|
|
1094
|
+
response.write("\n");
|
|
1095
|
+
}
|
|
1096
|
+
|
|
729
1097
|
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'");
|
|
1098
|
+
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
1099
|
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
732
1100
|
response.setHeader("Referrer-Policy", "no-referrer");
|
|
733
1101
|
response.setHeader("X-Content-Type-Options", "nosniff");
|