@keyou007/agent-bot 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +19 -5
- package/README.zh.md +19 -5
- package/dist/codex/CodexEventMapper.js +21 -1
- package/dist/codex/CodexEventMapper.js.map +1 -1
- package/dist/codex/CodexProcessManager.js +3 -0
- package/dist/codex/CodexProcessManager.js.map +1 -1
- package/dist/codex/CodexRuntime.js +136 -4
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/codex/ProviderModels.js +159 -0
- package/dist/codex/ProviderModels.js.map +1 -0
- package/dist/commands/CommandRouter.js +8 -3
- package/dist/commands/CommandRouter.js.map +1 -1
- package/dist/console/ConsoleTurnPresenter.js +2 -1
- package/dist/console/ConsoleTurnPresenter.js.map +1 -1
- package/dist/feishu/CardRenderer.js +47 -17
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +34 -8
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/local-files/LocalFileViewerServer.js +175 -26
- package/dist/local-files/LocalFileViewerServer.js.map +1 -1
- package/dist/local-files/MarkdownFileLinks.js +12 -5
- package/dist/local-files/MarkdownFileLinks.js.map +1 -1
- package/dist/local-files/TurnPreviewPage.js +752 -0
- package/dist/local-files/TurnPreviewPage.js.map +1 -0
- package/dist/presentation/OutboundRouter.js +6 -1
- package/dist/presentation/OutboundRouter.js.map +1 -1
- package/dist/presentation/TurnStateReducer.js +142 -3
- package/dist/presentation/TurnStateReducer.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +186 -80
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/state/StateStore.js +7 -0
- package/dist/state/StateStore.js.map +1 -1
- package/dist/utils/formatStorageSize.js +15 -0
- package/dist/utils/formatStorageSize.js.map +1 -0
- package/docs/technical-reference.md +7 -1
- package/docs/technical-reference.zh.md +7 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +5 -1
- package/src/codex/CodexEventMapper.ts +31 -1
- package/src/codex/CodexProcessManager.ts +4 -0
- package/src/codex/CodexRuntime.ts +153 -9
- package/src/codex/ProviderModels.ts +185 -0
- package/src/commands/CommandRouter.ts +7 -2
- package/src/commands/commandTypes.ts +1 -1
- package/src/console/ConsoleTurnPresenter.ts +3 -0
- package/src/feishu/CardRenderer.ts +48 -17
- package/src/feishu/FeishuTurnPresenter.ts +46 -4
- package/src/index.ts +2 -0
- package/src/local-files/LocalFileViewerServer.ts +189 -28
- package/src/local-files/MarkdownFileLinks.ts +12 -7
- package/src/local-files/TurnPreviewPage.ts +821 -0
- package/src/presentation/OutboundRouter.ts +8 -0
- package/src/presentation/TurnStateReducer.ts +175 -2
- package/src/presentation/turnViewTypes.ts +13 -0
- package/src/proxy/ProxySessionController.ts +186 -67
- package/src/runtime/types.ts +18 -1
- package/src/state/StateStore.ts +9 -0
- package/src/utils/formatStorageSize.ts +13 -0
|
@@ -51,6 +51,7 @@ export interface FeishuTurnPresenterOptions {
|
|
|
51
51
|
onError?: (error: unknown) => void;
|
|
52
52
|
finalRetryBackoffMs?: number[];
|
|
53
53
|
localFileUrl?: LocalFileUrlResolver;
|
|
54
|
+
turnPreviewUrl?: (turnId: string) => string | undefined;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
const MAX_FINAL_TABLES_PER_CARD = 5;
|
|
@@ -76,6 +77,7 @@ export class FeishuTurnPresenter {
|
|
|
76
77
|
private readonly sessionTitles = new Map<string, string>();
|
|
77
78
|
private readonly sessionCwds = new Map<string, string>();
|
|
78
79
|
private readonly sessionAgentLabels = new Map<string, string>();
|
|
80
|
+
private readonly sessionModels = new Map<string, string>();
|
|
79
81
|
private readonly entries = new Map<string, TurnEntry>();
|
|
80
82
|
private readonly pendingEntries = new Map<string, TurnEntry>();
|
|
81
83
|
private readonly renderer: CardRenderer;
|
|
@@ -95,11 +97,25 @@ export class FeishuTurnPresenter {
|
|
|
95
97
|
taskTitle?: string,
|
|
96
98
|
projectCwd?: string,
|
|
97
99
|
agentLabel?: string,
|
|
100
|
+
model?: string,
|
|
98
101
|
): void {
|
|
99
102
|
this.sessionContexts.set(sessionId, contextKey);
|
|
100
103
|
if (taskTitle) this.sessionTitles.set(sessionId, taskTitle);
|
|
101
104
|
if (projectCwd) this.sessionCwds.set(sessionId, projectCwd);
|
|
102
105
|
if (agentLabel) this.sessionAgentLabels.set(sessionId, agentLabel);
|
|
106
|
+
if (model) {
|
|
107
|
+
this.updateSessionModel(sessionId, model);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
updateSessionModel(sessionId: string, model?: string): void {
|
|
112
|
+
if (!model) return;
|
|
113
|
+
this.sessionModels.set(sessionId, model);
|
|
114
|
+
const pending = this.pendingEntries.get(sessionId);
|
|
115
|
+
if (!pending || pending.state.model === model) return;
|
|
116
|
+
pending.state = { ...pending.state, model };
|
|
117
|
+
this.store.saveTurnSnapshot(pending.state.turnId, sessionId, pending.state, pending.contextKey);
|
|
118
|
+
if (!pending.historySnapshot) pending.scheduler?.update(pending.state, "critical");
|
|
103
119
|
}
|
|
104
120
|
|
|
105
121
|
updateSessionTitle(sessionId: string, taskTitle: string): void {
|
|
@@ -117,6 +133,7 @@ export class FeishuTurnPresenter {
|
|
|
117
133
|
this.sessionTitles.delete(sessionId);
|
|
118
134
|
this.sessionCwds.delete(sessionId);
|
|
119
135
|
this.sessionAgentLabels.delete(sessionId);
|
|
136
|
+
this.sessionModels.delete(sessionId);
|
|
120
137
|
}
|
|
121
138
|
|
|
122
139
|
async startPendingTurn(
|
|
@@ -147,6 +164,7 @@ export class FeishuTurnPresenter {
|
|
|
147
164
|
this.sessionCwds.get(sessionId),
|
|
148
165
|
prompt,
|
|
149
166
|
this.sessionAgentLabels.get(sessionId),
|
|
167
|
+
this.sessionModels.get(sessionId),
|
|
150
168
|
);
|
|
151
169
|
const entry = { contextKey, state, initializing: Promise.resolve() } as TurnEntry;
|
|
152
170
|
this.entries.set(state.turnId, entry);
|
|
@@ -257,6 +275,7 @@ export class FeishuTurnPresenter {
|
|
|
257
275
|
this.sessionCwds.get(sessionId),
|
|
258
276
|
undefined,
|
|
259
277
|
this.sessionAgentLabels.get(sessionId),
|
|
278
|
+
this.sessionModels.get(sessionId),
|
|
260
279
|
),
|
|
261
280
|
status: "running" as const,
|
|
262
281
|
};
|
|
@@ -292,6 +311,7 @@ export class FeishuTurnPresenter {
|
|
|
292
311
|
pending.state.projectCwd,
|
|
293
312
|
pending.state.prompt,
|
|
294
313
|
pending.state.agentLabel,
|
|
314
|
+
pending.state.model,
|
|
295
315
|
),
|
|
296
316
|
event,
|
|
297
317
|
);
|
|
@@ -428,6 +448,7 @@ export class FeishuTurnPresenter {
|
|
|
428
448
|
this.sessionCwds.get(event.sessionId),
|
|
429
449
|
undefined,
|
|
430
450
|
this.sessionAgentLabels.get(event.sessionId),
|
|
451
|
+
this.sessionModels.get(event.sessionId),
|
|
431
452
|
);
|
|
432
453
|
const state = reduceTurnEvent(
|
|
433
454
|
initial,
|
|
@@ -476,15 +497,30 @@ export class FeishuTurnPresenter {
|
|
|
476
497
|
}
|
|
477
498
|
|
|
478
499
|
private renderTurn(state: TurnViewState): Record<string, unknown> {
|
|
479
|
-
return this.renderer.renderTurn(
|
|
500
|
+
return this.renderer.renderTurn(
|
|
501
|
+
normalizeTurnCardMarkdown(state, this.options.localFileUrl),
|
|
502
|
+
this.turnPreviewUrl(state),
|
|
503
|
+
);
|
|
480
504
|
}
|
|
481
505
|
|
|
482
506
|
private renderTurnDetails(state: TurnViewState): Record<string, unknown> {
|
|
483
|
-
return this.renderer.renderTurnDetails(
|
|
507
|
+
return this.renderer.renderTurnDetails(
|
|
508
|
+
normalizeTurnCardMarkdown(state, this.options.localFileUrl),
|
|
509
|
+
this.turnPreviewUrl(state),
|
|
510
|
+
);
|
|
484
511
|
}
|
|
485
512
|
|
|
486
513
|
private renderActivityHistory(state: TurnViewState, page: number): Record<string, unknown> {
|
|
487
|
-
return this.renderer.renderActivityHistory(
|
|
514
|
+
return this.renderer.renderActivityHistory(
|
|
515
|
+
normalizeTurnCardMarkdown(state, this.options.localFileUrl),
|
|
516
|
+
page,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private turnPreviewUrl(state: TurnViewState): string | undefined {
|
|
521
|
+
return state.turnId.startsWith("pending_")
|
|
522
|
+
? undefined
|
|
523
|
+
: this.options.turnPreviewUrl?.(state.turnId);
|
|
488
524
|
}
|
|
489
525
|
|
|
490
526
|
private startElapsedUpdates(entry: TurnEntry): void {
|
|
@@ -642,7 +678,13 @@ function brandedFinalChunks(response: string, maxLength: number): string[] {
|
|
|
642
678
|
}
|
|
643
679
|
|
|
644
680
|
function eventPriority(event: AgentEvent): "normal" | "critical" {
|
|
645
|
-
if (
|
|
681
|
+
if (
|
|
682
|
+
event.type === "turn_started"
|
|
683
|
+
|| event.type === "approval_requested"
|
|
684
|
+
|| event.type === "approval_resolved"
|
|
685
|
+
|| event.type === "tool_started"
|
|
686
|
+
|| event.type === "context_compaction"
|
|
687
|
+
) {
|
|
646
688
|
return "critical";
|
|
647
689
|
}
|
|
648
690
|
return "normal";
|
package/src/index.ts
CHANGED
|
@@ -80,6 +80,7 @@ if (config.fileViewer.enabled) {
|
|
|
80
80
|
port: config.fileViewer.port,
|
|
81
81
|
publicBaseUrl: config.fileViewer.publicBaseUrl,
|
|
82
82
|
stateDirectory: path.join(path.dirname(config.storage.sqlitePath), "file-viewer"),
|
|
83
|
+
getTurnSnapshot: (turnId) => store.getTurnSnapshot(turnId),
|
|
83
84
|
});
|
|
84
85
|
try {
|
|
85
86
|
const address = await viewer.start();
|
|
@@ -116,6 +117,7 @@ if (feishuOutbound) {
|
|
|
116
117
|
criticalGapMs: 500,
|
|
117
118
|
onError: (error) => logger.warn({ error }, "Failed to update Agent progress card."),
|
|
118
119
|
localFileUrl: (filePath, reference) => localFileViewer?.createFileUrl(filePath, reference),
|
|
120
|
+
turnPreviewUrl: (turnId) => localFileViewer?.createTurnPreviewUrl(turnId),
|
|
119
121
|
});
|
|
120
122
|
routes.push({ matches: (contextKey) => !contextKey.startsWith("console:"), outbound: feishuOutbound, presenter });
|
|
121
123
|
const defaultAgentName = config.defaults.agent!;
|
|
@@ -12,6 +12,13 @@ import {
|
|
|
12
12
|
selectPreferredNetworkAddress,
|
|
13
13
|
type NetworkConnectionKind,
|
|
14
14
|
} from "./NetworkAddressSelector.js";
|
|
15
|
+
import {
|
|
16
|
+
isTurnPreviewState,
|
|
17
|
+
detectTurnPreviewLanguage,
|
|
18
|
+
renderTurnPreviewPage,
|
|
19
|
+
renderTurnPreviewSnapshot,
|
|
20
|
+
TURN_PREVIEW_CLIENT_SCRIPT,
|
|
21
|
+
} from "./TurnPreviewPage.js";
|
|
15
22
|
|
|
16
23
|
const SECRET_FILE = "secret";
|
|
17
24
|
const PORT_FILE = "port";
|
|
@@ -22,6 +29,7 @@ const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
|
|
|
22
29
|
const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
|
|
23
30
|
const DIRECTORY_PAGE_SIZE = 100;
|
|
24
31
|
const MARKDOWN_EXTENSIONS = new Set([".markdown", ".md", ".mdown", ".mkd", ".mkdn"]);
|
|
32
|
+
const HTML_EXTENSIONS = new Set([".html", ".htm"]);
|
|
25
33
|
hljs.registerLanguage("dos", dos);
|
|
26
34
|
hljs.registerLanguage("dockerfile", dockerfile);
|
|
27
35
|
hljs.registerLanguage("powershell", powershell);
|
|
@@ -172,7 +180,7 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
|
|
|
172
180
|
try {
|
|
173
181
|
const update = JSON.parse(event.data);
|
|
174
182
|
if (typeof update.content === "string" && typeof update.metadata === "string") {
|
|
175
|
-
if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown";
|
|
183
|
+
if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown" && update.viewMode !== "html";
|
|
176
184
|
replaceContent(update.content, update.metadata);
|
|
177
185
|
}
|
|
178
186
|
} catch {
|
|
@@ -214,7 +222,7 @@ interface DirectoryViewerEntry {
|
|
|
214
222
|
interface RenderedFileSnapshot {
|
|
215
223
|
content: string;
|
|
216
224
|
metadata: string[];
|
|
217
|
-
viewMode?: "markdown";
|
|
225
|
+
viewMode?: "markdown" | "html";
|
|
218
226
|
}
|
|
219
227
|
|
|
220
228
|
export interface LocalFileViewerServerOptions {
|
|
@@ -222,6 +230,8 @@ export interface LocalFileViewerServerOptions {
|
|
|
222
230
|
port: number;
|
|
223
231
|
publicBaseUrl?: string;
|
|
224
232
|
stateDirectory: string;
|
|
233
|
+
getTurnSnapshot?: (turnId: string) => unknown;
|
|
234
|
+
turnPreviewPollIntervalMs?: number;
|
|
225
235
|
}
|
|
226
236
|
|
|
227
237
|
export interface LocalFileViewerAddress {
|
|
@@ -333,6 +343,18 @@ export class LocalFileViewerServer {
|
|
|
333
343
|
return url.toString();
|
|
334
344
|
}
|
|
335
345
|
|
|
346
|
+
createTurnPreviewUrl(turnId: string): string | undefined {
|
|
347
|
+
const normalizedTurnId = normalizeTurnId(turnId);
|
|
348
|
+
if (!this.address || !this.secret || !this.options.getTurnSnapshot || !normalizedTurnId) return undefined;
|
|
349
|
+
const url = new URL(this.address.publicBaseUrl);
|
|
350
|
+
url.pathname = `${this.basePath}/turn-preview/${this.createShortToken(`turn:${normalizedTurnId}`)}`
|
|
351
|
+
.replace(/\/{2,}/gu, "/");
|
|
352
|
+
url.search = "";
|
|
353
|
+
url.hash = "";
|
|
354
|
+
url.searchParams.set("turn", normalizedTurnId);
|
|
355
|
+
return url.toString();
|
|
356
|
+
}
|
|
357
|
+
|
|
336
358
|
private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
337
359
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
338
360
|
response.setHeader("Allow", "GET, HEAD");
|
|
@@ -350,6 +372,42 @@ export class LocalFileViewerServer {
|
|
|
350
372
|
this.sendJavascript(response, request.method === "HEAD");
|
|
351
373
|
return;
|
|
352
374
|
}
|
|
375
|
+
if (route === "/assets/turn-preview.js") {
|
|
376
|
+
this.sendJavascript(response, request.method === "HEAD", TURN_PREVIEW_CLIENT_SCRIPT);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const turnPreviewMatch = new RegExp(`^/turn-preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
|
|
380
|
+
if (turnPreviewMatch) {
|
|
381
|
+
const turnId = this.verifyTurnPreviewToken(
|
|
382
|
+
turnPreviewMatch[1] ?? "",
|
|
383
|
+
requestUrl.searchParams.get("turn") ?? "",
|
|
384
|
+
);
|
|
385
|
+
if (!turnId) {
|
|
386
|
+
this.sendHtml(response, 403, errorPage("Turn 链接无效", "签名校验失败,Agent Bot 已拒绝该请求。"), request.method === "HEAD");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const snapshot = this.options.getTurnSnapshot?.(turnId);
|
|
390
|
+
if (!isTurnPreviewState(snapshot)) {
|
|
391
|
+
this.sendHtml(response, 404, errorPage("Turn 不存在", "这次执行可能尚未保存或已被清理。"), request.method === "HEAD");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (requestUrl.searchParams.get("events") === "1") {
|
|
395
|
+
this.serveTurnEvents(request, response, turnId);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const eventsUrl = new URL(this.createTurnPreviewUrl(turnId)!);
|
|
399
|
+
eventsUrl.searchParams.set("events", "1");
|
|
400
|
+
const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
|
|
401
|
+
const html = renderTurnPreviewPage({
|
|
402
|
+
state: snapshot,
|
|
403
|
+
eventsUrl: eventsUrl.toString(),
|
|
404
|
+
scriptPath: `${this.basePath}/assets/turn-preview.js`.replace(/\/{2,}/gu, "/"),
|
|
405
|
+
localFileUrl: (filePath) => this.createFileUrl(filePath),
|
|
406
|
+
language,
|
|
407
|
+
});
|
|
408
|
+
this.sendHtml(response, 200, html, request.method === "HEAD");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
353
411
|
const previewMatch = new RegExp(`^/preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
|
|
354
412
|
const legacyMatch = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
|
|
355
413
|
const previewPath = previewMatch?.[1]
|
|
@@ -387,12 +445,13 @@ export class LocalFileViewerServer {
|
|
|
387
445
|
}
|
|
388
446
|
|
|
389
447
|
const raw = legacyMatch?.[1] === "raw" || requestUrl.searchParams.get("raw") === "1";
|
|
390
|
-
|
|
448
|
+
const renderHtml = !raw && requestUrl.searchParams.get("render") === "html";
|
|
449
|
+
if (raw || renderHtml) {
|
|
391
450
|
if (stat.isDirectory()) {
|
|
392
451
|
this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
|
|
393
452
|
return;
|
|
394
453
|
}
|
|
395
|
-
this.serveRawFile(request, response, filePath, stat, requestUrl.searchParams.get("download") === "1");
|
|
454
|
+
this.serveRawFile(request, response, filePath, stat, requestUrl.searchParams.get("download") === "1", renderHtml);
|
|
396
455
|
return;
|
|
397
456
|
}
|
|
398
457
|
|
|
@@ -430,6 +489,17 @@ export class LocalFileViewerServer {
|
|
|
430
489
|
: undefined;
|
|
431
490
|
}
|
|
432
491
|
|
|
492
|
+
private verifyTurnPreviewToken(token: string, rawTurnId: string): string | undefined {
|
|
493
|
+
const turnId = normalizeTurnId(rawTurnId);
|
|
494
|
+
if (!turnId || !this.secret) return undefined;
|
|
495
|
+
const expected = this.createShortToken(`turn:${turnId}`);
|
|
496
|
+
const actualBuffer = Buffer.from(token, "utf8");
|
|
497
|
+
const expectedBuffer = Buffer.from(expected, "utf8");
|
|
498
|
+
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
|
|
499
|
+
? turnId
|
|
500
|
+
: undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
433
503
|
private sign(token: string): string {
|
|
434
504
|
return createHmac("sha256", this.secret!).update(token).digest("hex");
|
|
435
505
|
}
|
|
@@ -499,12 +569,25 @@ export class LocalFileViewerServer {
|
|
|
499
569
|
appendUrlQueryParameter(rawUrl, "raw", "1");
|
|
500
570
|
|
|
501
571
|
let content: string;
|
|
572
|
+
let viewMode: RenderedFileSnapshot["viewMode"];
|
|
502
573
|
if (classification.kind === "text") {
|
|
503
574
|
const preview = readTextPreview(filePath, stat.size, classification.encoding);
|
|
504
|
-
const
|
|
505
|
-
?
|
|
506
|
-
:
|
|
507
|
-
|
|
575
|
+
const notice = preview.truncated
|
|
576
|
+
? '<div class="notice">文件较大,源码仅显示开头 2 MiB。可打开原始文件或下载完整文件。</div>'
|
|
577
|
+
: "";
|
|
578
|
+
if (HTML_EXTENSIONS.has(extension)) {
|
|
579
|
+
viewMode = "html";
|
|
580
|
+
const renderUrl = this.createPreviewUrl(filePath);
|
|
581
|
+
appendUrlQueryParameter(renderUrl, "render", "html");
|
|
582
|
+
appendUrlQueryParameter(renderUrl, "version", `${stat.mtimeMs}-${stat.ctimeMs}-${stat.size}`);
|
|
583
|
+
content = `<iframe class="html-preview" data-view-panel="rendered" title="HTML 预览" sandbox="allow-scripts" referrerpolicy="no-referrer" src="${escapeAttribute(renderUrl.toString())}"></iframe><section data-view-panel="code">${notice}${renderText(preview.text, filePath)}</section>`;
|
|
584
|
+
} else {
|
|
585
|
+
viewMode = isMarkdownFile(filePath) ? "markdown" : undefined;
|
|
586
|
+
const previewContent = viewMode === "markdown"
|
|
587
|
+
? renderMarkdownDocument(preview.text, filePath, (target) => this.createPreviewUrl(target))
|
|
588
|
+
: renderText(preview.text, filePath);
|
|
589
|
+
content = `${notice}${previewContent}`;
|
|
590
|
+
}
|
|
508
591
|
} else if (contentType.startsWith("image/")) {
|
|
509
592
|
content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
|
|
510
593
|
} else if (contentType === "application/pdf") {
|
|
@@ -519,9 +602,7 @@ export class LocalFileViewerServer {
|
|
|
519
602
|
return {
|
|
520
603
|
content,
|
|
521
604
|
metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
|
|
522
|
-
...(
|
|
523
|
-
? { viewMode: "markdown" as const }
|
|
524
|
-
: {}),
|
|
605
|
+
...(viewMode ? { viewMode } : {}),
|
|
525
606
|
};
|
|
526
607
|
}
|
|
527
608
|
|
|
@@ -541,10 +622,12 @@ export class LocalFileViewerServer {
|
|
|
541
622
|
|
|
542
623
|
let closed = false;
|
|
543
624
|
let pendingUpdate: NodeJS.Timeout | undefined;
|
|
625
|
+
let previousStat: fs.Stats | undefined;
|
|
544
626
|
const sendSnapshot = () => {
|
|
545
627
|
if (closed || response.destroyed) return;
|
|
546
628
|
try {
|
|
547
629
|
const stat = fs.statSync(filePath);
|
|
630
|
+
previousStat = stat;
|
|
548
631
|
if (!stat.isFile()) throw new Error("Path is no longer a file");
|
|
549
632
|
const snapshot = this.renderFileSnapshot(filePath, stat);
|
|
550
633
|
writeServerSentEvent(response, "update", JSON.stringify({
|
|
@@ -563,17 +646,27 @@ export class LocalFileViewerServer {
|
|
|
563
646
|
pendingUpdate = setTimeout(sendSnapshot, 100);
|
|
564
647
|
pendingUpdate.unref();
|
|
565
648
|
};
|
|
566
|
-
const watcher = (current: fs.Stats, previous: fs.Stats) => {
|
|
567
|
-
if (
|
|
568
|
-
current.mtimeMs === previous.mtimeMs
|
|
569
|
-
&& current.ctimeMs === previous.ctimeMs
|
|
570
|
-
&& current.size === previous.size
|
|
571
|
-
&& current.nlink === previous.nlink
|
|
572
|
-
) return;
|
|
573
|
-
scheduleSnapshot();
|
|
574
|
-
};
|
|
575
|
-
fs.watchFile(filePath, { interval: 1_000, persistent: false }, watcher);
|
|
576
649
|
sendSnapshot();
|
|
650
|
+
// Compare against the sent snapshot so edits during watcher startup cannot be missed.
|
|
651
|
+
let checking = false;
|
|
652
|
+
const poller = setInterval(() => {
|
|
653
|
+
if (closed || checking) return;
|
|
654
|
+
checking = true;
|
|
655
|
+
fs.stat(filePath, (error, stat) => {
|
|
656
|
+
checking = false;
|
|
657
|
+
if (closed) return;
|
|
658
|
+
const current = error ? undefined : stat;
|
|
659
|
+
if (
|
|
660
|
+
current?.mtimeMs === previousStat?.mtimeMs
|
|
661
|
+
&& current?.ctimeMs === previousStat?.ctimeMs
|
|
662
|
+
&& current?.size === previousStat?.size
|
|
663
|
+
&& current?.nlink === previousStat?.nlink
|
|
664
|
+
) return;
|
|
665
|
+
previousStat = current;
|
|
666
|
+
scheduleSnapshot();
|
|
667
|
+
});
|
|
668
|
+
}, 1_000);
|
|
669
|
+
poller.unref();
|
|
577
670
|
const keepAlive = setInterval(() => {
|
|
578
671
|
if (!closed && !response.destroyed) response.write(": keep-alive\n\n");
|
|
579
672
|
}, 15_000);
|
|
@@ -584,7 +677,58 @@ export class LocalFileViewerServer {
|
|
|
584
677
|
closed = true;
|
|
585
678
|
if (pendingUpdate) clearTimeout(pendingUpdate);
|
|
586
679
|
clearInterval(keepAlive);
|
|
587
|
-
|
|
680
|
+
clearInterval(poller);
|
|
681
|
+
};
|
|
682
|
+
request.once("aborted", cleanup);
|
|
683
|
+
response.once("close", cleanup);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private serveTurnEvents(request: IncomingMessage, response: ServerResponse, turnId: string): void {
|
|
687
|
+
setSecurityHeaders(response);
|
|
688
|
+
response.statusCode = 200;
|
|
689
|
+
response.setHeader("Cache-Control", "no-cache, no-store");
|
|
690
|
+
response.setHeader("Connection", "keep-alive");
|
|
691
|
+
response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
692
|
+
response.setHeader("X-Accel-Buffering", "no");
|
|
693
|
+
if (request.method === "HEAD") {
|
|
694
|
+
response.end();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
response.flushHeaders();
|
|
698
|
+
response.write("retry: 2000\n\n");
|
|
699
|
+
const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
|
|
700
|
+
|
|
701
|
+
let closed = false;
|
|
702
|
+
let previousSnapshot = "";
|
|
703
|
+
const sendSnapshot = () => {
|
|
704
|
+
if (closed || response.destroyed) return;
|
|
705
|
+
const state = this.options.getTurnSnapshot?.(turnId);
|
|
706
|
+
if (!isTurnPreviewState(state)) {
|
|
707
|
+
writeServerSentEvent(response, "unavailable", JSON.stringify({ message: "Turn 暂时无法读取。" }));
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const serialized = JSON.stringify(state);
|
|
711
|
+
if (serialized === previousSnapshot) return;
|
|
712
|
+
previousSnapshot = serialized;
|
|
713
|
+
const snapshot = renderTurnPreviewSnapshot(state, (filePath) => this.createFileUrl(filePath), language);
|
|
714
|
+
writeServerSentEvent(response, "update", JSON.stringify(snapshot));
|
|
715
|
+
};
|
|
716
|
+
sendSnapshot();
|
|
717
|
+
const poller = setInterval(
|
|
718
|
+
sendSnapshot,
|
|
719
|
+
Math.max(50, this.options.turnPreviewPollIntervalMs ?? 1_000),
|
|
720
|
+
);
|
|
721
|
+
poller.unref();
|
|
722
|
+
const keepAlive = setInterval(() => {
|
|
723
|
+
if (!closed && !response.destroyed) response.write(": keep-alive\n\n");
|
|
724
|
+
}, 15_000);
|
|
725
|
+
keepAlive.unref();
|
|
726
|
+
|
|
727
|
+
const cleanup = () => {
|
|
728
|
+
if (closed) return;
|
|
729
|
+
closed = true;
|
|
730
|
+
clearInterval(poller);
|
|
731
|
+
clearInterval(keepAlive);
|
|
588
732
|
};
|
|
589
733
|
request.once("aborted", cleanup);
|
|
590
734
|
response.once("close", cleanup);
|
|
@@ -638,9 +782,14 @@ export class LocalFileViewerServer {
|
|
|
638
782
|
filePath: string,
|
|
639
783
|
stat: fs.Stats,
|
|
640
784
|
download: boolean,
|
|
785
|
+
renderHtml = false,
|
|
641
786
|
): void {
|
|
642
787
|
const extension = path.extname(filePath).toLowerCase();
|
|
643
788
|
const classification = classifyFileContent(filePath, stat.size);
|
|
789
|
+
if (renderHtml && (!HTML_EXTENSIONS.has(extension) || classification.kind !== "text")) {
|
|
790
|
+
this.sendHtml(response, 400, errorPage("无法预览 HTML", "只有 HTML 文本文件支持此预览模式。"), request.method === "HEAD");
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
644
793
|
const contentType = classification.kind === "text"
|
|
645
794
|
? textContentTypeFor(extension, classification.encoding)
|
|
646
795
|
: binaryContentTypeFor(extension);
|
|
@@ -648,6 +797,10 @@ export class LocalFileViewerServer {
|
|
|
648
797
|
const fileName = path.basename(filePath);
|
|
649
798
|
const disposition = download ? "attachment" : "inline";
|
|
650
799
|
setSecurityHeaders(response);
|
|
800
|
+
if (renderHtml) {
|
|
801
|
+
// A response-level sandbox also isolates this document when opened outside the viewer iframe.
|
|
802
|
+
response.setHeader("Content-Security-Policy", "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
|
|
803
|
+
}
|
|
651
804
|
response.setHeader("Accept-Ranges", "bytes");
|
|
652
805
|
response.setHeader("Cache-Control", "no-store");
|
|
653
806
|
response.setHeader("Content-Type", contentType);
|
|
@@ -686,8 +839,8 @@ export class LocalFileViewerServer {
|
|
|
686
839
|
response.end(headOnly ? undefined : body);
|
|
687
840
|
}
|
|
688
841
|
|
|
689
|
-
private sendJavascript(response: ServerResponse, headOnly: boolean): void {
|
|
690
|
-
const body = Buffer.from(
|
|
842
|
+
private sendJavascript(response: ServerResponse, headOnly: boolean, source = VIEWER_CLIENT_SCRIPT): void {
|
|
843
|
+
const body = Buffer.from(source, "utf8");
|
|
691
844
|
setSecurityHeaders(response);
|
|
692
845
|
response.statusCode = 200;
|
|
693
846
|
response.setHeader("Cache-Control", "no-store");
|
|
@@ -703,14 +856,14 @@ function renderViewerPage(input: {
|
|
|
703
856
|
metadata: string[];
|
|
704
857
|
actions: Array<{ href: string; label: string }>;
|
|
705
858
|
content: string;
|
|
706
|
-
viewMode?: "
|
|
859
|
+
viewMode?: RenderedFileSnapshot["viewMode"];
|
|
707
860
|
liveUpdates?: { eventsUrl: string; scriptPath: string };
|
|
708
861
|
}): string {
|
|
709
862
|
const metadata = renderMetadata(input.metadata);
|
|
710
863
|
const actions = input.actions.length > 0
|
|
711
864
|
? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
|
|
712
865
|
: "";
|
|
713
|
-
const viewSwitch = input.viewMode
|
|
866
|
+
const viewSwitch = input.viewMode
|
|
714
867
|
? '<div class="view-switch" id="viewer-view-switch" role="tablist" aria-label="预览模式"><button class="is-active" type="button" role="tab" aria-selected="true" data-view-mode-button="rendered">预览</button><button type="button" role="tab" aria-selected="false" data-view-mode-button="code">代码</button></div>'
|
|
715
868
|
: "";
|
|
716
869
|
const toolbar = viewSwitch || actions
|
|
@@ -787,6 +940,7 @@ function renderViewerPage(input: {
|
|
|
787
940
|
.media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
|
|
788
941
|
.media audio { width: min(720px, 100%); }
|
|
789
942
|
.document { width: 100%; height: calc(100vh - 70px); border: 0; border-radius: 0; background: #fff; }
|
|
943
|
+
.html-preview { display: block; width: 100%; height: calc(100dvh - var(--viewer-header-offset)); min-height: 240px; border: 0; background: #fff; color-scheme: normal; }
|
|
790
944
|
.directory-list { overflow: hidden; border: 0; border-radius: 0; background: #fff; }
|
|
791
945
|
.directory-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 18px; align-items: center; min-height: 44px; padding: 8px 14px; color: inherit; text-decoration: none; border-bottom: 1px solid #eceff3; }
|
|
792
946
|
.directory-entry:last-child { border-bottom: 0; }
|
|
@@ -834,7 +988,7 @@ function renderViewerPage(input: {
|
|
|
834
988
|
}
|
|
835
989
|
</style>
|
|
836
990
|
</head>
|
|
837
|
-
<body${input.viewMode
|
|
991
|
+
<body${input.viewMode ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
|
|
838
992
|
<header id="viewer-header">
|
|
839
993
|
<h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
|
|
840
994
|
<div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${toolbar}</div>
|
|
@@ -1221,7 +1375,7 @@ function writeServerSentEvent(response: ServerResponse, event: string, data: str
|
|
|
1221
1375
|
}
|
|
1222
1376
|
|
|
1223
1377
|
function setSecurityHeaders(response: ServerResponse): void {
|
|
1224
|
-
response.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; object-src 'self'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
|
|
1378
|
+
response.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; object-src 'self'; frame-src 'self'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'");
|
|
1225
1379
|
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
1226
1380
|
response.setHeader("Referrer-Policy", "no-referrer");
|
|
1227
1381
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
@@ -1236,6 +1390,13 @@ function escapeAttribute(value: string): string {
|
|
|
1236
1390
|
return escapeHtml(value);
|
|
1237
1391
|
}
|
|
1238
1392
|
|
|
1393
|
+
function normalizeTurnId(value: string): string | undefined {
|
|
1394
|
+
const normalized = value.trim();
|
|
1395
|
+
return normalized && normalized.length <= 256 && !normalized.includes("\0")
|
|
1396
|
+
? normalized
|
|
1397
|
+
: undefined;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1239
1400
|
function errorPage(title: string, message: string): string {
|
|
1240
1401
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} · Agent Bot</title><style>body{font-family:system-ui,sans-serif;max-width:720px;margin:12vh auto;padding:0 24px;color:#1f2329}h1{font-size:24px}p{color:#646a73}</style></head><body><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p></body></html>`;
|
|
1241
1402
|
}
|
|
@@ -8,8 +8,8 @@ export function isFileUrl(value: string): boolean {
|
|
|
8
8
|
|
|
9
9
|
export function rewriteMarkdownFileLinks(
|
|
10
10
|
tokens: Token[],
|
|
11
|
-
markdownPath: string,
|
|
12
|
-
createPreviewUrl: (filePath: string) => URL,
|
|
11
|
+
markdownPath: string | undefined,
|
|
12
|
+
createPreviewUrl: (filePath: string) => URL | undefined,
|
|
13
13
|
): void {
|
|
14
14
|
for (const token of tokens) {
|
|
15
15
|
const attribute = token.type === "link_open" ? "href" : token.type === "image" ? "src" : undefined;
|
|
@@ -18,9 +18,13 @@ export function rewriteMarkdownFileLinks(
|
|
|
18
18
|
const local = resolveLocalTarget(target, markdownPath);
|
|
19
19
|
if (local) {
|
|
20
20
|
const url = createPreviewUrl(local.filePath);
|
|
21
|
-
url
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
if (url) {
|
|
22
|
+
url.hash = local.hash;
|
|
23
|
+
if (token.type === "image") url.searchParams.set("raw", "1");
|
|
24
|
+
token.attrSet(attribute, url.toString());
|
|
25
|
+
} else {
|
|
26
|
+
token.attrs = token.attrs?.filter(([name]) => name !== attribute) ?? null;
|
|
27
|
+
}
|
|
24
28
|
} else if (isFileUrl(target)) {
|
|
25
29
|
// File URLs are accepted by the parser only to convert them to signed HTTP URLs.
|
|
26
30
|
token.attrs = token.attrs?.filter(([name]) => name !== attribute) ?? null;
|
|
@@ -30,7 +34,7 @@ export function rewriteMarkdownFileLinks(
|
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
function resolveLocalTarget(target: string, markdownPath: string): { filePath: string; hash: string } | undefined {
|
|
37
|
+
function resolveLocalTarget(target: string, markdownPath: string | undefined): { filePath: string; hash: string } | undefined {
|
|
34
38
|
if (/^(?:#|\?|\/\/)/u.test(target)) return undefined;
|
|
35
39
|
const windows = process.platform === "win32";
|
|
36
40
|
const normalized = windows ? target.replace(/%5c/giu, "/").replaceAll("\\", "/") : target;
|
|
@@ -44,11 +48,12 @@ function resolveLocalTarget(target: string, markdownPath: string): { filePath: s
|
|
|
44
48
|
? normalized.slice(0, reference.index) + normalized.slice(reference.index + reference[0].length)
|
|
45
49
|
: normalized;
|
|
46
50
|
if (drivePath && !windows) return undefined;
|
|
51
|
+
if (!markdownPath && !drivePath && !isFileUrl(normalized) && !path.isAbsolute(normalized)) return undefined;
|
|
47
52
|
|
|
48
53
|
try {
|
|
49
54
|
const url = drivePath
|
|
50
55
|
? new URL(`file:///${withoutReference.replace(/^\//u, "")}`)
|
|
51
|
-
: new URL(withoutReference, pathToFileURL(markdownPath));
|
|
56
|
+
: new URL(withoutReference, markdownPath ? pathToFileURL(markdownPath) : "file:///");
|
|
52
57
|
if (url.protocol !== "file:") return undefined;
|
|
53
58
|
const filePath = fileURLToPath(url);
|
|
54
59
|
if (!path.isAbsolute(filePath) || /[\x00-\x1f\x7f]/u.test(filePath)) return undefined;
|