@keyou007/agent-bot 0.1.20-alpha.1 → 0.1.21
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 +21 -0
- package/README.md +27 -4
- package/README.zh.md +23 -4
- package/dist/cli/AgentPrerequisites.js +11 -47
- package/dist/cli/AgentPrerequisites.js.map +1 -1
- package/dist/cli/DefaultAgentSelection.js +1 -1
- package/dist/cli/DefaultAgentSelection.js.map +1 -1
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -1
- package/dist/codex/CodexEventMapper.js +21 -1
- package/dist/codex/CodexEventMapper.js.map +1 -1
- package/dist/codex/CodexLocalActivityDetector.js +32 -10
- package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
- package/dist/codex/CodexProcessManager.js +56 -7
- package/dist/codex/CodexProcessManager.js.map +1 -1
- package/dist/codex/CodexRuntime.js +361 -168
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/codex/CodexVersion.js +18 -0
- package/dist/codex/CodexVersion.js.map +1 -0
- package/dist/codex/ProviderModels.js +159 -0
- package/dist/codex/ProviderModels.js.map +1 -0
- package/dist/codex/ProviderSwitch.js +32 -0
- package/dist/codex/ProviderSwitch.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 +82 -27
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +17 -5
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +34 -8
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/feishu/InboundText.js +11 -0
- package/dist/feishu/InboundText.js.map +1 -1
- package/dist/feishu/MergedForwardMessage.js +17 -5
- package/dist/feishu/MergedForwardMessage.js.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/local-files/LocalFileViewerServer.js +164 -19
- package/dist/local-files/LocalFileViewerServer.js.map +1 -1
- package/dist/local-files/MarkdownFileLinks.js +65 -0
- package/dist/local-files/MarkdownFileLinks.js.map +1 -0
- package/dist/local-files/TurnPreviewPage.js +725 -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 +573 -325
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/state/StateStore.js +65 -65
- package/dist/state/StateStore.js.map +1 -1
- package/dist/utils/formatStorageSize.js +15 -0
- package/dist/utils/formatStorageSize.js.map +1 -0
- package/dist/utils/semanticVersion.js +39 -0
- package/dist/utils/semanticVersion.js.map +1 -0
- package/docs/technical-reference.md +11 -3
- package/docs/technical-reference.zh.md +11 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +11 -1
- package/src/cli/AgentPrerequisites.ts +14 -40
- package/src/cli/DefaultAgentSelection.ts +1 -1
- package/src/cli.ts +13 -0
- package/src/codex/CodexEventMapper.ts +31 -1
- package/src/codex/CodexLocalActivityDetector.ts +41 -9
- package/src/codex/CodexProcessManager.ts +48 -7
- package/src/codex/CodexRuntime.ts +397 -204
- package/src/codex/CodexVersion.ts +18 -0
- package/src/codex/ProviderModels.ts +185 -0
- package/src/codex/ProviderSwitch.ts +36 -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 +84 -43
- package/src/feishu/FeishuConnector.ts +15 -5
- package/src/feishu/FeishuTurnPresenter.ts +46 -4
- package/src/feishu/InboundText.ts +11 -0
- package/src/feishu/MergedForwardMessage.ts +15 -5
- package/src/index.ts +3 -1
- package/src/local-files/LocalFileViewerServer.ts +179 -20
- package/src/local-files/MarkdownFileLinks.ts +64 -0
- package/src/local-files/TurnPreviewPage.ts +792 -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 +547 -297
- package/src/runtime/types.ts +33 -3
- package/src/state/StateStore.ts +86 -90
- package/src/utils/formatStorageSize.ts +13 -0
- package/src/utils/semanticVersion.ts +28 -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";
|
|
@@ -10,3 +10,14 @@ export function normalizeFeishuPostText(value: string): string {
|
|
|
10
10
|
.replace(/\n{3,}/gu, "\n\n")
|
|
11
11
|
.trim();
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
export function renderFeishuCodeBlock(text: string, language: unknown): string {
|
|
15
|
+
const code = text.replace(/\r\n?/gu, "\n").replace(/\n+$/gu, "");
|
|
16
|
+
if (!code) return "";
|
|
17
|
+
const longestBacktickRun = Math.max(0, ...[...code.matchAll(/`+/gu)].map((match) => match[0].length));
|
|
18
|
+
const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
|
|
19
|
+
const languageHint = typeof language === "string" && /^[A-Za-z0-9_+#.-]+$/u.test(language.trim())
|
|
20
|
+
? language.trim().toLowerCase()
|
|
21
|
+
: "";
|
|
22
|
+
return `${fence}${languageHint}\n${code}\n${fence}`;
|
|
23
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeFeishuPostText } from "./InboundText.js";
|
|
1
|
+
import { normalizeFeishuPostText, renderFeishuCodeBlock } from "./InboundText.js";
|
|
2
2
|
|
|
3
3
|
export const MAX_MERGED_FORWARD_PROMPT_CHARS = 48_000;
|
|
4
4
|
|
|
@@ -266,10 +266,12 @@ function renderPost(
|
|
|
266
266
|
const paragraphs: string[] = [];
|
|
267
267
|
const title = normalizeFeishuPostText(textValue(locale.title));
|
|
268
268
|
if (title) paragraphs.push(title);
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
const rows = Array.isArray(locale.content) ? locale.content : locale.content_v2;
|
|
270
|
+
if (!Array.isArray(rows)) return paragraphs.join("\n");
|
|
271
|
+
for (const row of rows) {
|
|
271
272
|
if (!Array.isArray(row)) continue;
|
|
272
273
|
let rowText = "";
|
|
274
|
+
let preserveFormatting = false;
|
|
273
275
|
for (const rawElement of row) {
|
|
274
276
|
if (!isRecord(rawElement)) continue;
|
|
275
277
|
const tag = textValue(rawElement.tag);
|
|
@@ -279,6 +281,12 @@ function renderPost(
|
|
|
279
281
|
const text = textValue(rawElement.text);
|
|
280
282
|
const href = textValue(rawElement.href);
|
|
281
283
|
rowText += href && href !== text ? `${text || href} (${href})` : text || href;
|
|
284
|
+
} else if (tag === "md") {
|
|
285
|
+
rowText += textValue(rawElement.text);
|
|
286
|
+
preserveFormatting = true;
|
|
287
|
+
} else if (tag === "code_block") {
|
|
288
|
+
rowText += renderFeishuCodeBlock(textValue(rawElement.text), rawElement.language);
|
|
289
|
+
preserveFormatting = true;
|
|
282
290
|
} else if (tag === "at") {
|
|
283
291
|
rowText += renderMention(rawElement, mentions);
|
|
284
292
|
} else if (tag === "img") {
|
|
@@ -287,7 +295,9 @@ function renderPost(
|
|
|
287
295
|
rowText += "[媒体]";
|
|
288
296
|
}
|
|
289
297
|
}
|
|
290
|
-
const normalized =
|
|
298
|
+
const normalized = preserveFormatting
|
|
299
|
+
? rowText.replace(/\r\n?/gu, "\n").trim()
|
|
300
|
+
: normalizeFeishuPostText(rowText);
|
|
291
301
|
if (normalized) paragraphs.push(normalized);
|
|
292
302
|
}
|
|
293
303
|
return paragraphs.join("\n");
|
|
@@ -314,7 +324,7 @@ function replaceMentions(text: string, mentions: MergedForwardMessageItem["menti
|
|
|
314
324
|
}
|
|
315
325
|
|
|
316
326
|
function selectPostLocale(content: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
317
|
-
if (Array.isArray(content.content)) return content;
|
|
327
|
+
if (Array.isArray(content.content) || Array.isArray(content.content_v2)) return content;
|
|
318
328
|
for (const locale of ["zh_cn", "en_us", "ja_jp"]) {
|
|
319
329
|
if (isRecord(content[locale])) return content[locale];
|
|
320
330
|
}
|
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!;
|
|
@@ -483,7 +485,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
|
|
|
483
485
|
),
|
|
484
486
|
};
|
|
485
487
|
case "task_turns":
|
|
486
|
-
return { ok: true, data: controller.controlListTaskTurns(request.localSessionId) };
|
|
488
|
+
return { ok: true, data: await controller.controlListTaskTurns(request.localSessionId) };
|
|
487
489
|
case "task_reset":
|
|
488
490
|
return { ok: true, data: await controller.controlResetTaskToTurn(request.localSessionId, request.turnId) };
|
|
489
491
|
case "task_mute":
|
|
@@ -7,10 +7,18 @@ import dos from "highlight.js/lib/languages/dos";
|
|
|
7
7
|
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
|
8
8
|
import powershell from "highlight.js/lib/languages/powershell";
|
|
9
9
|
import MarkdownIt from "markdown-it";
|
|
10
|
+
import { isFileUrl, rewriteMarkdownFileLinks } from "./MarkdownFileLinks.js";
|
|
10
11
|
import {
|
|
11
12
|
selectPreferredNetworkAddress,
|
|
12
13
|
type NetworkConnectionKind,
|
|
13
14
|
} from "./NetworkAddressSelector.js";
|
|
15
|
+
import {
|
|
16
|
+
isTurnPreviewState,
|
|
17
|
+
detectTurnPreviewLanguage,
|
|
18
|
+
renderTurnPreviewPage,
|
|
19
|
+
renderTurnPreviewSnapshot,
|
|
20
|
+
TURN_PREVIEW_CLIENT_SCRIPT,
|
|
21
|
+
} from "./TurnPreviewPage.js";
|
|
14
22
|
|
|
15
23
|
const SECRET_FILE = "secret";
|
|
16
24
|
const PORT_FILE = "port";
|
|
@@ -21,6 +29,7 @@ const MAX_CONTENT_SAMPLE_BYTES = 64 * 1024;
|
|
|
21
29
|
const MAX_SYNTAX_HIGHLIGHT_BYTES = 512 * 1024;
|
|
22
30
|
const DIRECTORY_PAGE_SIZE = 100;
|
|
23
31
|
const MARKDOWN_EXTENSIONS = new Set([".markdown", ".md", ".mdown", ".mkd", ".mkdn"]);
|
|
32
|
+
const HTML_EXTENSIONS = new Set([".html", ".htm"]);
|
|
24
33
|
hljs.registerLanguage("dos", dos);
|
|
25
34
|
hljs.registerLanguage("dockerfile", dockerfile);
|
|
26
35
|
hljs.registerLanguage("powershell", powershell);
|
|
@@ -43,6 +52,8 @@ const MARKDOWN_RENDERER = new MarkdownIt({
|
|
|
43
52
|
MARKDOWN_RENDERER.renderer.rules.table_open = () =>
|
|
44
53
|
'<div class="markdown-table-scroll" role="region" aria-label="Markdown 表格" tabindex="0"><table>\n';
|
|
45
54
|
MARKDOWN_RENDERER.renderer.rules.table_close = () => '</table></div>\n';
|
|
55
|
+
const validateMarkdownLink = MARKDOWN_RENDERER.validateLink.bind(MARKDOWN_RENDERER);
|
|
56
|
+
MARKDOWN_RENDERER.validateLink = (url) => isFileUrl(url) || validateMarkdownLink(url);
|
|
46
57
|
|
|
47
58
|
const HIGHLIGHT_LANGUAGE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
48
59
|
".bash": "bash", ".bat": "dos", ".c": "c", ".cc": "cpp", ".cmd": "dos", ".cpp": "cpp",
|
|
@@ -132,7 +143,10 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
|
|
|
132
143
|
if (typeof ResizeObserver === "function") {
|
|
133
144
|
new ResizeObserver(updateHeaderOffset).observe(header);
|
|
134
145
|
}
|
|
135
|
-
window.addEventListener("hashchange", () =>
|
|
146
|
+
window.addEventListener("hashchange", () => {
|
|
147
|
+
if (/^#L\\d+$/u.test(window.location.hash)) setViewMode("code");
|
|
148
|
+
else requestAnimationFrame(positionHashTarget);
|
|
149
|
+
});
|
|
136
150
|
requestAnimationFrame(positionHashTarget);
|
|
137
151
|
if (!eventsUrl || typeof EventSource !== "function") return;
|
|
138
152
|
|
|
@@ -166,7 +180,7 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
|
|
|
166
180
|
try {
|
|
167
181
|
const update = JSON.parse(event.data);
|
|
168
182
|
if (typeof update.content === "string" && typeof update.metadata === "string") {
|
|
169
|
-
if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown";
|
|
183
|
+
if (viewSwitch) viewSwitch.hidden = update.viewMode !== "markdown" && update.viewMode !== "html";
|
|
170
184
|
replaceContent(update.content, update.metadata);
|
|
171
185
|
}
|
|
172
186
|
} catch {
|
|
@@ -208,7 +222,7 @@ interface DirectoryViewerEntry {
|
|
|
208
222
|
interface RenderedFileSnapshot {
|
|
209
223
|
content: string;
|
|
210
224
|
metadata: string[];
|
|
211
|
-
viewMode?: "markdown";
|
|
225
|
+
viewMode?: "markdown" | "html";
|
|
212
226
|
}
|
|
213
227
|
|
|
214
228
|
export interface LocalFileViewerServerOptions {
|
|
@@ -216,6 +230,8 @@ export interface LocalFileViewerServerOptions {
|
|
|
216
230
|
port: number;
|
|
217
231
|
publicBaseUrl?: string;
|
|
218
232
|
stateDirectory: string;
|
|
233
|
+
getTurnSnapshot?: (turnId: string) => unknown;
|
|
234
|
+
turnPreviewPollIntervalMs?: number;
|
|
219
235
|
}
|
|
220
236
|
|
|
221
237
|
export interface LocalFileViewerAddress {
|
|
@@ -327,6 +343,18 @@ export class LocalFileViewerServer {
|
|
|
327
343
|
return url.toString();
|
|
328
344
|
}
|
|
329
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
|
+
|
|
330
358
|
private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
331
359
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
332
360
|
response.setHeader("Allow", "GET, HEAD");
|
|
@@ -344,6 +372,42 @@ export class LocalFileViewerServer {
|
|
|
344
372
|
this.sendJavascript(response, request.method === "HEAD");
|
|
345
373
|
return;
|
|
346
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
|
+
}
|
|
347
411
|
const previewMatch = new RegExp(`^/preview/([A-Za-z0-9_-]{${SHORT_TOKEN_LENGTH}})$`, "u").exec(route);
|
|
348
412
|
const legacyMatch = /^\/(view|raw)\/([A-Za-z0-9_-]+)$/u.exec(route);
|
|
349
413
|
const previewPath = previewMatch?.[1]
|
|
@@ -381,12 +445,13 @@ export class LocalFileViewerServer {
|
|
|
381
445
|
}
|
|
382
446
|
|
|
383
447
|
const raw = legacyMatch?.[1] === "raw" || requestUrl.searchParams.get("raw") === "1";
|
|
384
|
-
|
|
448
|
+
const renderHtml = !raw && requestUrl.searchParams.get("render") === "html";
|
|
449
|
+
if (raw || renderHtml) {
|
|
385
450
|
if (stat.isDirectory()) {
|
|
386
451
|
this.sendHtml(response, 400, errorPage("无法打开目录", "目录只能通过文件列表页面浏览。"), request.method === "HEAD");
|
|
387
452
|
return;
|
|
388
453
|
}
|
|
389
|
-
this.serveRawFile(request, response, filePath, stat, requestUrl.searchParams.get("download") === "1");
|
|
454
|
+
this.serveRawFile(request, response, filePath, stat, requestUrl.searchParams.get("download") === "1", renderHtml);
|
|
390
455
|
return;
|
|
391
456
|
}
|
|
392
457
|
|
|
@@ -424,6 +489,17 @@ export class LocalFileViewerServer {
|
|
|
424
489
|
: undefined;
|
|
425
490
|
}
|
|
426
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
|
+
|
|
427
503
|
private sign(token: string): string {
|
|
428
504
|
return createHmac("sha256", this.secret!).update(token).digest("hex");
|
|
429
505
|
}
|
|
@@ -493,12 +569,25 @@ export class LocalFileViewerServer {
|
|
|
493
569
|
appendUrlQueryParameter(rawUrl, "raw", "1");
|
|
494
570
|
|
|
495
571
|
let content: string;
|
|
572
|
+
let viewMode: RenderedFileSnapshot["viewMode"];
|
|
496
573
|
if (classification.kind === "text") {
|
|
497
574
|
const preview = readTextPreview(filePath, stat.size, classification.encoding);
|
|
498
|
-
const
|
|
499
|
-
?
|
|
500
|
-
:
|
|
501
|
-
|
|
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
|
+
}
|
|
502
591
|
} else if (contentType.startsWith("image/")) {
|
|
503
592
|
content = `<div class="media"><img src="${escapeAttribute(rawUrl.toString())}" alt="${escapeAttribute(fileName)}"></div>`;
|
|
504
593
|
} else if (contentType === "application/pdf") {
|
|
@@ -513,9 +602,7 @@ export class LocalFileViewerServer {
|
|
|
513
602
|
return {
|
|
514
603
|
content,
|
|
515
604
|
metadata: [formatFileSize(stat.size), stat.mtime.toLocaleString()],
|
|
516
|
-
...(
|
|
517
|
-
? { viewMode: "markdown" as const }
|
|
518
|
-
: {}),
|
|
605
|
+
...(viewMode ? { viewMode } : {}),
|
|
519
606
|
};
|
|
520
607
|
}
|
|
521
608
|
|
|
@@ -584,6 +671,57 @@ export class LocalFileViewerServer {
|
|
|
584
671
|
response.once("close", cleanup);
|
|
585
672
|
}
|
|
586
673
|
|
|
674
|
+
private serveTurnEvents(request: IncomingMessage, response: ServerResponse, turnId: string): void {
|
|
675
|
+
setSecurityHeaders(response);
|
|
676
|
+
response.statusCode = 200;
|
|
677
|
+
response.setHeader("Cache-Control", "no-cache, no-store");
|
|
678
|
+
response.setHeader("Connection", "keep-alive");
|
|
679
|
+
response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
680
|
+
response.setHeader("X-Accel-Buffering", "no");
|
|
681
|
+
if (request.method === "HEAD") {
|
|
682
|
+
response.end();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
response.flushHeaders();
|
|
686
|
+
response.write("retry: 2000\n\n");
|
|
687
|
+
const language = detectTurnPreviewLanguage(request.headers["accept-language"]);
|
|
688
|
+
|
|
689
|
+
let closed = false;
|
|
690
|
+
let previousSnapshot = "";
|
|
691
|
+
const sendSnapshot = () => {
|
|
692
|
+
if (closed || response.destroyed) return;
|
|
693
|
+
const state = this.options.getTurnSnapshot?.(turnId);
|
|
694
|
+
if (!isTurnPreviewState(state)) {
|
|
695
|
+
writeServerSentEvent(response, "unavailable", JSON.stringify({ message: "Turn 暂时无法读取。" }));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const serialized = JSON.stringify(state);
|
|
699
|
+
if (serialized === previousSnapshot) return;
|
|
700
|
+
previousSnapshot = serialized;
|
|
701
|
+
const snapshot = renderTurnPreviewSnapshot(state, (filePath) => this.createFileUrl(filePath), language);
|
|
702
|
+
writeServerSentEvent(response, "update", JSON.stringify(snapshot));
|
|
703
|
+
};
|
|
704
|
+
sendSnapshot();
|
|
705
|
+
const poller = setInterval(
|
|
706
|
+
sendSnapshot,
|
|
707
|
+
Math.max(50, this.options.turnPreviewPollIntervalMs ?? 1_000),
|
|
708
|
+
);
|
|
709
|
+
poller.unref();
|
|
710
|
+
const keepAlive = setInterval(() => {
|
|
711
|
+
if (!closed && !response.destroyed) response.write(": keep-alive\n\n");
|
|
712
|
+
}, 15_000);
|
|
713
|
+
keepAlive.unref();
|
|
714
|
+
|
|
715
|
+
const cleanup = () => {
|
|
716
|
+
if (closed) return;
|
|
717
|
+
closed = true;
|
|
718
|
+
clearInterval(poller);
|
|
719
|
+
clearInterval(keepAlive);
|
|
720
|
+
};
|
|
721
|
+
request.once("aborted", cleanup);
|
|
722
|
+
response.once("close", cleanup);
|
|
723
|
+
}
|
|
724
|
+
|
|
587
725
|
private renderDirectoryViewer(directory: string, stat: fs.Stats, requestedPage: number): string {
|
|
588
726
|
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
|
589
727
|
.flatMap((entry): DirectoryViewerEntry[] => {
|
|
@@ -632,9 +770,14 @@ export class LocalFileViewerServer {
|
|
|
632
770
|
filePath: string,
|
|
633
771
|
stat: fs.Stats,
|
|
634
772
|
download: boolean,
|
|
773
|
+
renderHtml = false,
|
|
635
774
|
): void {
|
|
636
775
|
const extension = path.extname(filePath).toLowerCase();
|
|
637
776
|
const classification = classifyFileContent(filePath, stat.size);
|
|
777
|
+
if (renderHtml && (!HTML_EXTENSIONS.has(extension) || classification.kind !== "text")) {
|
|
778
|
+
this.sendHtml(response, 400, errorPage("无法预览 HTML", "只有 HTML 文本文件支持此预览模式。"), request.method === "HEAD");
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
638
781
|
const contentType = classification.kind === "text"
|
|
639
782
|
? textContentTypeFor(extension, classification.encoding)
|
|
640
783
|
: binaryContentTypeFor(extension);
|
|
@@ -642,6 +785,10 @@ export class LocalFileViewerServer {
|
|
|
642
785
|
const fileName = path.basename(filePath);
|
|
643
786
|
const disposition = download ? "attachment" : "inline";
|
|
644
787
|
setSecurityHeaders(response);
|
|
788
|
+
if (renderHtml) {
|
|
789
|
+
// A response-level sandbox also isolates this document when opened outside the viewer iframe.
|
|
790
|
+
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'");
|
|
791
|
+
}
|
|
645
792
|
response.setHeader("Accept-Ranges", "bytes");
|
|
646
793
|
response.setHeader("Cache-Control", "no-store");
|
|
647
794
|
response.setHeader("Content-Type", contentType);
|
|
@@ -680,8 +827,8 @@ export class LocalFileViewerServer {
|
|
|
680
827
|
response.end(headOnly ? undefined : body);
|
|
681
828
|
}
|
|
682
829
|
|
|
683
|
-
private sendJavascript(response: ServerResponse, headOnly: boolean): void {
|
|
684
|
-
const body = Buffer.from(
|
|
830
|
+
private sendJavascript(response: ServerResponse, headOnly: boolean, source = VIEWER_CLIENT_SCRIPT): void {
|
|
831
|
+
const body = Buffer.from(source, "utf8");
|
|
685
832
|
setSecurityHeaders(response);
|
|
686
833
|
response.statusCode = 200;
|
|
687
834
|
response.setHeader("Cache-Control", "no-store");
|
|
@@ -697,14 +844,14 @@ function renderViewerPage(input: {
|
|
|
697
844
|
metadata: string[];
|
|
698
845
|
actions: Array<{ href: string; label: string }>;
|
|
699
846
|
content: string;
|
|
700
|
-
viewMode?: "
|
|
847
|
+
viewMode?: RenderedFileSnapshot["viewMode"];
|
|
701
848
|
liveUpdates?: { eventsUrl: string; scriptPath: string };
|
|
702
849
|
}): string {
|
|
703
850
|
const metadata = renderMetadata(input.metadata);
|
|
704
851
|
const actions = input.actions.length > 0
|
|
705
852
|
? `<nav class="actions">${input.actions.map((action) => `<a href="${escapeAttribute(action.href)}">${escapeHtml(action.label)}</a>`).join("")}</nav>`
|
|
706
853
|
: "";
|
|
707
|
-
const viewSwitch = input.viewMode
|
|
854
|
+
const viewSwitch = input.viewMode
|
|
708
855
|
? '<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>'
|
|
709
856
|
: "";
|
|
710
857
|
const toolbar = viewSwitch || actions
|
|
@@ -781,6 +928,7 @@ function renderViewerPage(input: {
|
|
|
781
928
|
.media img, .media video { max-width: 100%; max-height: calc(100vh - 70px); }
|
|
782
929
|
.media audio { width: min(720px, 100%); }
|
|
783
930
|
.document { width: 100%; height: calc(100vh - 70px); border: 0; border-radius: 0; background: #fff; }
|
|
931
|
+
.html-preview { display: block; width: 100%; height: calc(100dvh - var(--viewer-header-offset)); min-height: 240px; border: 0; background: #fff; color-scheme: normal; }
|
|
784
932
|
.directory-list { overflow: hidden; border: 0; border-radius: 0; background: #fff; }
|
|
785
933
|
.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; }
|
|
786
934
|
.directory-entry:last-child { border-bottom: 0; }
|
|
@@ -828,7 +976,7 @@ function renderViewerPage(input: {
|
|
|
828
976
|
}
|
|
829
977
|
</style>
|
|
830
978
|
</head>
|
|
831
|
-
<body${input.viewMode
|
|
979
|
+
<body${input.viewMode ? ' data-view-mode="rendered"' : ""}${liveAttributes}>
|
|
832
980
|
<header id="viewer-header">
|
|
833
981
|
<h1 id="viewer-title" data-file-path="${escapeAttribute(input.filePath)}">${escapeHtml(input.filePath)}</h1>
|
|
834
982
|
<div class="meta"><span class="metadata-values" id="viewer-metadata">${metadata}</span>${toolbar}</div>
|
|
@@ -1084,8 +1232,12 @@ function renderText(value: string, filePath: string): string {
|
|
|
1084
1232
|
}).join("")}</code></pre>`;
|
|
1085
1233
|
}
|
|
1086
1234
|
|
|
1087
|
-
function renderMarkdownDocument(value: string, filePath: string): string {
|
|
1088
|
-
|
|
1235
|
+
function renderMarkdownDocument(value: string, filePath: string, createPreviewUrl: (filePath: string) => URL): string {
|
|
1236
|
+
const environment = {};
|
|
1237
|
+
const tokens = MARKDOWN_RENDERER.parse(value, environment);
|
|
1238
|
+
rewriteMarkdownFileLinks(tokens, filePath, createPreviewUrl);
|
|
1239
|
+
const rendered = MARKDOWN_RENDERER.renderer.render(tokens, MARKDOWN_RENDERER.options, environment);
|
|
1240
|
+
return `<article class="markdown-body" data-view-panel="rendered">${rendered}</article><section data-view-panel="code">${renderText(value, filePath)}</section>`;
|
|
1089
1241
|
}
|
|
1090
1242
|
|
|
1091
1243
|
function isMarkdownFile(filePath: string): boolean {
|
|
@@ -1211,7 +1363,7 @@ function writeServerSentEvent(response: ServerResponse, event: string, data: str
|
|
|
1211
1363
|
}
|
|
1212
1364
|
|
|
1213
1365
|
function setSecurityHeaders(response: ServerResponse): void {
|
|
1214
|
-
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'");
|
|
1366
|
+
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'");
|
|
1215
1367
|
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
1216
1368
|
response.setHeader("Referrer-Policy", "no-referrer");
|
|
1217
1369
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
@@ -1226,6 +1378,13 @@ function escapeAttribute(value: string): string {
|
|
|
1226
1378
|
return escapeHtml(value);
|
|
1227
1379
|
}
|
|
1228
1380
|
|
|
1381
|
+
function normalizeTurnId(value: string): string | undefined {
|
|
1382
|
+
const normalized = value.trim();
|
|
1383
|
+
return normalized && normalized.length <= 256 && !normalized.includes("\0")
|
|
1384
|
+
? normalized
|
|
1385
|
+
: undefined;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1229
1388
|
function errorPage(title: string, message: string): string {
|
|
1230
1389
|
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>`;
|
|
1231
1390
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
+
import type { Token } from "markdown-it";
|
|
4
|
+
|
|
5
|
+
export function isFileUrl(value: string): boolean {
|
|
6
|
+
return /^file:/iu.test(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function rewriteMarkdownFileLinks(
|
|
10
|
+
tokens: Token[],
|
|
11
|
+
markdownPath: string,
|
|
12
|
+
createPreviewUrl: (filePath: string) => URL,
|
|
13
|
+
): void {
|
|
14
|
+
for (const token of tokens) {
|
|
15
|
+
const attribute = token.type === "link_open" ? "href" : token.type === "image" ? "src" : undefined;
|
|
16
|
+
const target = attribute ? token.attrGet(attribute) : null;
|
|
17
|
+
if (attribute && typeof target === "string" && target) {
|
|
18
|
+
const local = resolveLocalTarget(target, markdownPath);
|
|
19
|
+
if (local) {
|
|
20
|
+
const url = createPreviewUrl(local.filePath);
|
|
21
|
+
url.hash = local.hash;
|
|
22
|
+
if (token.type === "image") url.searchParams.set("raw", "1");
|
|
23
|
+
token.attrSet(attribute, url.toString());
|
|
24
|
+
} else if (isFileUrl(target)) {
|
|
25
|
+
// File URLs are accepted by the parser only to convert them to signed HTTP URLs.
|
|
26
|
+
token.attrs = token.attrs?.filter(([name]) => name !== attribute) ?? null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (token.children) rewriteMarkdownFileLinks(token.children, markdownPath, createPreviewUrl);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveLocalTarget(target: string, markdownPath: string): { filePath: string; hash: string } | undefined {
|
|
34
|
+
if (/^(?:#|\?|\/\/)/u.test(target)) return undefined;
|
|
35
|
+
const windows = process.platform === "win32";
|
|
36
|
+
const normalized = windows ? target.replace(/%5c/giu, "/").replaceAll("\\", "/") : target;
|
|
37
|
+
const drivePath = /^\/?[a-z]:\//iu.test(normalized);
|
|
38
|
+
const relativeFileWithLine = /^[^:/]+\.[^:/]+:\d+(?::\d+)?(?:#.*)?$/u.test(normalized);
|
|
39
|
+
if (!drivePath && !isFileUrl(normalized) && !relativeFileWithLine && /^[a-z][a-z\d+.-]*:/iu.test(normalized)) return undefined;
|
|
40
|
+
const pathEnd = normalized.search(/[?#]/u);
|
|
41
|
+
const pathTarget = pathEnd < 0 ? normalized : normalized.slice(0, pathEnd);
|
|
42
|
+
const reference = /:(\d+)(?::\d+)?$/u.exec(pathTarget);
|
|
43
|
+
const withoutReference = reference
|
|
44
|
+
? normalized.slice(0, reference.index) + normalized.slice(reference.index + reference[0].length)
|
|
45
|
+
: normalized;
|
|
46
|
+
if (drivePath && !windows) return undefined;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const url = drivePath
|
|
50
|
+
? new URL(`file:///${withoutReference.replace(/^\//u, "")}`)
|
|
51
|
+
: new URL(withoutReference, pathToFileURL(markdownPath));
|
|
52
|
+
if (url.protocol !== "file:") return undefined;
|
|
53
|
+
const filePath = fileURLToPath(url);
|
|
54
|
+
if (!path.isAbsolute(filePath) || /[\x00-\x1f\x7f]/u.test(filePath)) return undefined;
|
|
55
|
+
const line = /^#L(\d+)(?:C\d+)?(?:-L?\d+(?:C\d+)?)?$/u.exec(url.hash)?.[1]
|
|
56
|
+
?? (url.hash ? undefined : reference?.[1]);
|
|
57
|
+
const hash = line && Number.isSafeInteger(Number(line)) && Number(line) > 0
|
|
58
|
+
? `#L${Number(line)}`
|
|
59
|
+
: url.hash;
|
|
60
|
+
return { filePath: path.resolve(filePath), hash };
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|