@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,12 +2,137 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import http 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 { selectPreferredNetworkAddress, } from "./NetworkAddressSelector.js";
|
|
6
10
|
const SECRET_FILE = "secret";
|
|
7
11
|
const PORT_FILE = "port";
|
|
12
|
+
const SHORT_TOKEN_BYTES = 12;
|
|
13
|
+
const SHORT_TOKEN_LENGTH = 16;
|
|
8
14
|
const MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024;
|
|
9
15
|
const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
|
|
16
|
+
const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
|
|
10
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
|
+
`;
|
|
11
136
|
export class LocalFileViewerServer {
|
|
12
137
|
options;
|
|
13
138
|
server;
|
|
@@ -105,8 +230,7 @@ export class LocalFileViewerServer {
|
|
|
105
230
|
catch {
|
|
106
231
|
return undefined;
|
|
107
232
|
}
|
|
108
|
-
const
|
|
109
|
-
const url = this.createSignedUrl("view", token);
|
|
233
|
+
const url = this.createPreviewUrl(absolutePath);
|
|
110
234
|
const line = parseLineReference(reference);
|
|
111
235
|
if (line !== undefined)
|
|
112
236
|
url.hash = `L${line}`;
|
|
@@ -120,14 +244,27 @@ export class LocalFileViewerServer {
|
|
|
120
244
|
}
|
|
121
245
|
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
122
246
|
const route = routeWithinBasePath(requestUrl.pathname, this.basePath);
|
|
123
|
-
|
|
124
|
-
|
|
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) {
|
|
125
265
|
this.sendHtml(response, 404, errorPage("文件链接无效", "请从 Agent Bot 的回答中重新打开文件链接。"), request.method === "HEAD");
|
|
126
266
|
return;
|
|
127
267
|
}
|
|
128
|
-
const token = match[2];
|
|
129
|
-
const signature = requestUrl.searchParams.get("sig") ?? "";
|
|
130
|
-
const filePath = this.verifyToken(token, signature);
|
|
131
268
|
if (!filePath) {
|
|
132
269
|
this.sendHtml(response, 403, errorPage("文件链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
|
|
133
270
|
return;
|
|
@@ -142,7 +279,16 @@ export class LocalFileViewerServer {
|
|
|
142
279
|
this.sendHtml(response, 404, errorPage("文件不存在", "文件可能已被移动、重命名或删除。"), request.method === "HEAD");
|
|
143
280
|
return;
|
|
144
281
|
}
|
|
145
|
-
if (
|
|
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) {
|
|
146
292
|
if (stat.isDirectory()) {
|
|
147
293
|
this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
|
|
148
294
|
return;
|
|
@@ -151,11 +297,11 @@ export class LocalFileViewerServer {
|
|
|
151
297
|
return;
|
|
152
298
|
}
|
|
153
299
|
const html = stat.isDirectory()
|
|
154
|
-
? this.renderDirectoryViewer(filePath, stat,
|
|
155
|
-
: this.renderViewer(filePath, stat
|
|
300
|
+
? this.renderDirectoryViewer(filePath, stat, parseDirectoryPage(requestUrl.searchParams.get("page")))
|
|
301
|
+
: this.renderViewer(filePath, stat);
|
|
156
302
|
this.sendHtml(response, 200, html, request.method === "HEAD");
|
|
157
303
|
}
|
|
158
|
-
|
|
304
|
+
verifyLegacyToken(token, signature) {
|
|
159
305
|
if (!this.secret || !/^[a-f0-9]{64}$/u.test(signature))
|
|
160
306
|
return undefined;
|
|
161
307
|
const expected = this.sign(token);
|
|
@@ -174,10 +320,21 @@ export class LocalFileViewerServer {
|
|
|
174
320
|
return undefined;
|
|
175
321
|
return normalizeAbsolutePath(decoded);
|
|
176
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
|
+
}
|
|
177
334
|
sign(token) {
|
|
178
335
|
return createHmac("sha256", this.secret).update(token).digest("hex");
|
|
179
336
|
}
|
|
180
|
-
|
|
337
|
+
createLegacySignedUrl(route, token) {
|
|
181
338
|
const url = new URL(this.address.publicBaseUrl);
|
|
182
339
|
url.pathname = `${this.basePath}/${route}/${token}`.replace(/\/{2,}/gu, "/");
|
|
183
340
|
url.search = "";
|
|
@@ -185,19 +342,59 @@ export class LocalFileViewerServer {
|
|
|
185
342
|
url.searchParams.set("sig", this.sign(token));
|
|
186
343
|
return url;
|
|
187
344
|
}
|
|
188
|
-
|
|
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) {
|
|
189
387
|
const fileName = path.basename(filePath);
|
|
190
388
|
const extension = path.extname(filePath).toLowerCase();
|
|
191
389
|
const classification = classifyFileContent(filePath, stat.size);
|
|
192
390
|
const contentType = binaryContentTypeFor(extension);
|
|
193
|
-
const rawUrl = this.
|
|
194
|
-
rawUrl.
|
|
195
|
-
|
|
196
|
-
downloadUrl.searchParams.set("download", "1");
|
|
391
|
+
const rawUrl = new URL(this.createFileUrl(filePath));
|
|
392
|
+
rawUrl.hash = "";
|
|
393
|
+
appendUrlQueryParameter(rawUrl, "raw", "1");
|
|
197
394
|
let content;
|
|
198
395
|
if (classification.kind === "text") {
|
|
199
396
|
const preview = readTextPreview(filePath, stat.size, classification.encoding);
|
|
200
|
-
content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${renderText(preview.text)}`;
|
|
397
|
+
content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${renderText(preview.text, filePath)}`;
|
|
201
398
|
}
|
|
202
399
|
else if (contentType.startsWith("image/")) {
|
|
203
400
|
content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
|
|
@@ -214,18 +411,79 @@ export class LocalFileViewerServer {
|
|
|
214
411
|
else {
|
|
215
412
|
content = '<div class="unsupported">该文件是二进制格式,无法直接预览。可以打开原始文件或下载到本地。</div>';
|
|
216
413
|
}
|
|
217
|
-
return
|
|
218
|
-
title: fileName,
|
|
219
|
-
filePath,
|
|
220
|
-
metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
|
|
221
|
-
actions: [
|
|
222
|
-
{ href: rawUrl.toString(), label: "打开原始文件" },
|
|
223
|
-
{ href: downloadUrl.toString(), label: "下载" },
|
|
224
|
-
],
|
|
414
|
+
return {
|
|
225
415
|
content,
|
|
226
|
-
|
|
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);
|
|
227
485
|
}
|
|
228
|
-
renderDirectoryViewer(directory, stat,
|
|
486
|
+
renderDirectoryViewer(directory, stat, requestedPage) {
|
|
229
487
|
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
|
230
488
|
.flatMap((entry) => {
|
|
231
489
|
const entryPath = path.join(directory, entry.name);
|
|
@@ -254,8 +512,8 @@ export class LocalFileViewerServer {
|
|
|
254
512
|
: '<div class="empty-directory">这个目录是空的。</div>';
|
|
255
513
|
const pagination = totalPages > 1
|
|
256
514
|
? renderDirectoryPagination(page, totalPages, (targetPage) => {
|
|
257
|
-
const url = this.
|
|
258
|
-
url
|
|
515
|
+
const url = new URL(this.createFileUrl(directory));
|
|
516
|
+
appendUrlQueryParameter(url, "page", String(targetPage));
|
|
259
517
|
return url.toString();
|
|
260
518
|
})
|
|
261
519
|
: "";
|
|
@@ -312,12 +570,27 @@ export class LocalFileViewerServer {
|
|
|
312
570
|
response.setHeader("Content-Length", body.length);
|
|
313
571
|
response.end(headOnly ? undefined : body);
|
|
314
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
|
+
}
|
|
315
582
|
}
|
|
316
583
|
function renderViewerPage(input) {
|
|
317
|
-
const metadata = input.metadata
|
|
584
|
+
const metadata = renderMetadata(input.metadata);
|
|
318
585
|
const actions = input.actions.length > 0
|
|
319
586
|
? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
|
|
320
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
|
+
: "";
|
|
321
594
|
return `<!doctype html>
|
|
322
595
|
<html lang="zh-CN">
|
|
323
596
|
<head>
|
|
@@ -325,26 +598,36 @@ function renderViewerPage(input) {
|
|
|
325
598
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
326
599
|
<title>${escapeHtml(input.title)} · Agent Bot</title>
|
|
327
600
|
<style>
|
|
328
|
-
:root { color-scheme: light dark; font-family: Inter, "Segoe UI", system-ui, sans-serif; }
|
|
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; }
|
|
329
602
|
* { box-sizing: border-box; }
|
|
330
603
|
body { margin: 0; background: #f5f7fa; color: #1f2329; }
|
|
331
|
-
header { position: sticky; top: 0; z-index: 2; padding:
|
|
332
|
-
h1 { margin: 0;
|
|
333
|
-
.
|
|
334
|
-
.
|
|
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; }
|
|
335
608
|
.actions { margin-left: auto; display: flex; gap: 8px; }
|
|
336
|
-
.actions a { color: #1456f0; text-decoration: none; padding:
|
|
337
|
-
main { padding:
|
|
338
|
-
.notice, .unsupported { margin
|
|
339
|
-
.code { margin: 0; padding:
|
|
340
|
-
.line {
|
|
341
|
-
.line
|
|
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; }
|
|
342
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; }
|
|
343
626
|
.media { display: grid; place-items: center; min-height: 220px; }
|
|
344
|
-
.media img, .media video { max-width: 100%; max-height: calc(100vh -
|
|
627
|
+
.media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
|
|
345
628
|
.media audio { width: min(720px, 100%); }
|
|
346
|
-
.document { width: 100%; height: calc(100vh -
|
|
347
|
-
.directory-list { overflow: hidden; border:
|
|
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; }
|
|
348
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; }
|
|
349
632
|
.directory-entry:last-child { border-bottom: 0; }
|
|
350
633
|
.directory-entry:hover { background: #f0f4ff; }
|
|
@@ -360,28 +643,38 @@ function renderViewerPage(input) {
|
|
|
360
643
|
@media (prefers-color-scheme: dark) {
|
|
361
644
|
body { background: #17181a; color: #e5e6eb; }
|
|
362
645
|
header { background: rgba(32,33,36,.96); border-color: #3a3b3d; }
|
|
363
|
-
.
|
|
646
|
+
.meta, .pagination { color: #a6a9ad; }
|
|
364
647
|
.actions a, .pagination a { color: #8ab4ff; }
|
|
365
648
|
.actions a { border-color: #55585c; background: #292a2d; }
|
|
366
649
|
.code, .directory-list { background: #202124; color: #e8eaed; border-color: #3a3b3d; }
|
|
367
650
|
.directory-entry { border-color: #3a3b3d; }
|
|
368
651
|
.directory-entry:hover { background: #292f3d; }
|
|
369
652
|
.line-number { color: #8b9098; border-color: #3a3b3d; }
|
|
370
|
-
.line:target { background: #594c20; }
|
|
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; }
|
|
371
661
|
.notice, .unsupported { color: #f5d58a; background: #3b321c; border-color: #705b28; }
|
|
372
662
|
}
|
|
373
663
|
</style>
|
|
374
664
|
</head>
|
|
375
|
-
<body>
|
|
376
|
-
<header>
|
|
377
|
-
<h1>${escapeHtml(input.
|
|
378
|
-
<div class="
|
|
379
|
-
<div class="meta">${metadata}${actions}</div>
|
|
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>
|
|
380
669
|
</header>
|
|
381
|
-
<main>${input.content}</main>
|
|
670
|
+
<main id="viewer-content">${input.content}</main>
|
|
671
|
+
${liveScript}
|
|
382
672
|
</body>
|
|
383
673
|
</html>`;
|
|
384
674
|
}
|
|
675
|
+
function renderMetadata(metadata) {
|
|
676
|
+
return metadata.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
|
|
677
|
+
}
|
|
385
678
|
function directoryViewerEntryKind(filePath, stat) {
|
|
386
679
|
if (stat.isDirectory())
|
|
387
680
|
return "directory";
|
|
@@ -460,6 +753,15 @@ function normalizeAbsolutePath(filePath) {
|
|
|
460
753
|
return undefined;
|
|
461
754
|
return path.resolve(normalized);
|
|
462
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
|
+
}
|
|
463
765
|
function normalizePublicBaseUrl(value) {
|
|
464
766
|
const url = new URL(value);
|
|
465
767
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -586,13 +888,61 @@ function hasTextLikeCharacters(value) {
|
|
|
586
888
|
}
|
|
587
889
|
return controls <= 2 || controls / Math.max(1, characters) <= 0.02;
|
|
588
890
|
}
|
|
589
|
-
function renderText(value) {
|
|
590
|
-
const
|
|
591
|
-
|
|
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) => {
|
|
592
899
|
const lineNumber = index + 1;
|
|
593
|
-
return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${
|
|
900
|
+
return `<span class="line" id="L${lineNumber}"><a class="line-number" href="#L${lineNumber}">${lineNumber}</a>${line}</span>`;
|
|
594
901
|
}).join("")}</code></pre>`;
|
|
595
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
|
+
}
|
|
596
946
|
function contentTypeFor(extension) {
|
|
597
947
|
const known = {
|
|
598
948
|
".aac": "audio/aac", ".avi": "video/x-msvideo", ".bmp": "image/bmp", ".css": "text/css; charset=utf-8",
|
|
@@ -663,8 +1013,16 @@ function formatFileSize(size) {
|
|
|
663
1013
|
return `${(size / 1024 / 1024).toFixed(1)} MiB`;
|
|
664
1014
|
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
|
665
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
|
+
}
|
|
666
1024
|
function setSecurityHeaders(response) {
|
|
667
|
-
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'");
|
|
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'");
|
|
668
1026
|
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
669
1027
|
response.setHeader("Referrer-Policy", "no-referrer");
|
|
670
1028
|
response.setHeader("X-Content-Type-Options", "nosniff");
|