@keyou007/agent-bot 0.1.20-alpha.1 → 0.1.20
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 +11 -0
- package/README.md +13 -0
- package/README.zh.md +9 -0
- 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/CodexLocalActivityDetector.js +32 -10
- package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
- package/dist/codex/CodexProcessManager.js +53 -7
- package/dist/codex/CodexProcessManager.js.map +1 -1
- package/dist/codex/CodexRuntime.js +225 -164
- 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/ProviderSwitch.js +32 -0
- package/dist/codex/ProviderSwitch.js.map +1 -0
- package/dist/feishu/CardRenderer.js +36 -11
- 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/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 +1 -1
- package/dist/index.js.map +1 -1
- package/dist/local-files/LocalFileViewerServer.js +14 -4
- 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/proxy/ProxySessionController.js +399 -257
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/state/StateStore.js +58 -65
- package/dist/state/StateStore.js.map +1 -1
- package/dist/utils/semanticVersion.js +39 -0
- package/dist/utils/semanticVersion.js.map +1 -0
- package/docs/technical-reference.md +5 -3
- package/docs/technical-reference.zh.md +5 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +7 -1
- package/src/cli/AgentPrerequisites.ts +14 -40
- package/src/cli/DefaultAgentSelection.ts +1 -1
- package/src/cli.ts +13 -0
- package/src/codex/CodexLocalActivityDetector.ts +41 -9
- package/src/codex/CodexProcessManager.ts +44 -7
- package/src/codex/CodexRuntime.ts +244 -195
- package/src/codex/CodexVersion.ts +18 -0
- package/src/codex/ProviderSwitch.ts +36 -0
- package/src/feishu/CardRenderer.ts +37 -27
- package/src/feishu/FeishuConnector.ts +15 -5
- package/src/feishu/InboundText.ts +11 -0
- package/src/feishu/MergedForwardMessage.ts +15 -5
- package/src/index.ts +1 -1
- package/src/local-files/LocalFileViewerServer.ts +14 -4
- package/src/local-files/MarkdownFileLinks.ts +64 -0
- package/src/proxy/ProxySessionController.ts +369 -238
- package/src/runtime/types.ts +15 -2
- package/src/state/StateStore.ts +77 -90
- package/src/utils/semanticVersion.ts +28 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { compareSemanticVersions } from "../utils/semanticVersion.js";
|
|
2
|
+
|
|
3
|
+
// Verified protocol baseline: paginated Turn summaries and excludeTurns.
|
|
4
|
+
export const MINIMUM_CODEX_VERSION = "0.153.4";
|
|
5
|
+
|
|
6
|
+
export class CodexVersionError extends Error {}
|
|
7
|
+
|
|
8
|
+
export function codexVersionIssue(version: string | undefined): string | undefined {
|
|
9
|
+
if (version && /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/u.test(version)
|
|
10
|
+
&& compareSemanticVersions(version, MINIMUM_CODEX_VERSION) >= 0) return undefined;
|
|
11
|
+
return `Codex ${version ?? "版本未知"} 不受支持,AgentBot 需要 Codex >= ${MINIMUM_CODEX_VERSION}。`
|
|
12
|
+
+ "请运行 codex update(或 npm install -g @openai/codex@latest)升级,然后安全重启 AgentBot。";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function assertSupportedCodexVersion(version: string | undefined): void {
|
|
16
|
+
const issue = codexVersionIssue(version);
|
|
17
|
+
if (issue) throw new CodexVersionError(issue);
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AppServerRequestError } from "./AppServerConnection.js";
|
|
2
|
+
import type { RuntimeExecutionSettings } from "../runtime/types.js";
|
|
3
|
+
|
|
4
|
+
export const PROVIDER_SWITCH_BUSY = "当前任务正在执行,暂时不能切换 Provider。请等待任务完成,或停止任务后重试。";
|
|
5
|
+
|
|
6
|
+
export function isMissingRolloutError(error: unknown): boolean {
|
|
7
|
+
if (error instanceof AppServerRequestError && error.method !== "thread/resume") return false;
|
|
8
|
+
const message = error instanceof AppServerRequestError ? error.serverMessage
|
|
9
|
+
: error instanceof Error ? error.message : String(error);
|
|
10
|
+
if (/paginated history lineage|missing source rollout/iu.test(message)) return false;
|
|
11
|
+
return /no rollout found|rollout[^\n]*(?:not found|missing)/iu.test(message);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function assertProviderSettingsApplied(
|
|
15
|
+
actual: { modelProvider?: string; model?: string },
|
|
16
|
+
requested: Pick<RuntimeExecutionSettings, "modelProvider" | "model">,
|
|
17
|
+
): void {
|
|
18
|
+
if (actual.modelProvider !== requested.modelProvider) {
|
|
19
|
+
throw new Error(`Provider 未生效:请求 ${requested.modelProvider},实际返回 ${actual.modelProvider ?? "未知"}。`);
|
|
20
|
+
}
|
|
21
|
+
if (actual.model !== requested.model) {
|
|
22
|
+
throw new Error(`模型未生效:请求 ${requested.model},实际返回 ${actual.model ?? "未知"}。请确认目标 Provider 支持该模型。`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function providerSwitchFailure(error: unknown): string {
|
|
27
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
28
|
+
if (/active writer/iu.test(message)) return "任务被另一个 App Server 占用。请在原客户端释放任务后重试。";
|
|
29
|
+
if (/model[^\n]*(?:unsupported|not supported|not found|unavailable|does not exist)|unknown model/iu.test(message)) {
|
|
30
|
+
return `目标 Provider 不支持当前模型,请选择兼容的模型后重试。${message}`;
|
|
31
|
+
}
|
|
32
|
+
if (/unauthorized|forbidden|auth(?:entication|orization)|api.?key|provider[^\n]*(?:not found|unknown|not configured)/iu.test(message)) {
|
|
33
|
+
return `请检查目标 Provider 的配置和认证信息。${message}`;
|
|
34
|
+
}
|
|
35
|
+
return message;
|
|
36
|
+
}
|
|
@@ -1531,18 +1531,9 @@ export class CardRenderer {
|
|
|
1531
1531
|
if (view.entries.length === 0) {
|
|
1532
1532
|
elements.push(markdown("当前任务还没有成功完成的 turn。"));
|
|
1533
1533
|
} else {
|
|
1534
|
+
const sequenceWidth = `${Math.max(16, ...view.entries.map((entry) => String(entry.sequence).length * 10))}px`;
|
|
1534
1535
|
view.entries.forEach((entry) => {
|
|
1535
|
-
|
|
1536
|
-
elements.push(resetHistoryEntryRow(
|
|
1537
|
-
entry.graphNodeLine,
|
|
1538
|
-
entry.graphConnectorLine,
|
|
1539
|
-
entry.lines,
|
|
1540
|
-
entry.timestamp,
|
|
1541
|
-
action,
|
|
1542
|
-
entry.current === true,
|
|
1543
|
-
entry.running === true,
|
|
1544
|
-
entry.resetting === true,
|
|
1545
|
-
));
|
|
1536
|
+
elements.push(resetHistoryEntryRow(entry, sequenceWidth));
|
|
1546
1537
|
});
|
|
1547
1538
|
}
|
|
1548
1539
|
if (view.footerLines.length > 0) {
|
|
@@ -2825,24 +2816,31 @@ function settingsOptionRow(input: {
|
|
|
2825
2816
|
}
|
|
2826
2817
|
|
|
2827
2818
|
function resetHistoryEntryRow(
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
lines: string[],
|
|
2831
|
-
timestamp: string | undefined,
|
|
2832
|
-
action: TaskListCardAction | undefined,
|
|
2833
|
-
current: boolean,
|
|
2834
|
-
running: boolean,
|
|
2835
|
-
resetting: boolean,
|
|
2819
|
+
entry: ResetHistoryCardEntry,
|
|
2820
|
+
sequenceWidth: string,
|
|
2836
2821
|
): Record<string, unknown> {
|
|
2822
|
+
const { graphNodeLine, graphConnectorLine, lines, timestamp, current, running, resetting } = entry;
|
|
2823
|
+
const action = entry.actions?.[0];
|
|
2837
2824
|
const branchConnectorLine = graphConnectorLine && /[╱╲]/u.test(graphConnectorLine)
|
|
2838
2825
|
? graphConnectorLine
|
|
2839
2826
|
: undefined;
|
|
2840
|
-
const
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2827
|
+
const color = current ? "green" : running || resetting ? "orange" : "blue";
|
|
2828
|
+
const nodeLanes = graphNodeLine.match(/[●│]/gu) ?? [];
|
|
2829
|
+
const connectorLanes = branchConnectorLine?.split(" ");
|
|
2830
|
+
// Each lane has its own center; shorter connectors and wider turn numbers cannot shift it.
|
|
2831
|
+
const graphColumns = nodeLanes.map((node, lane) => ({
|
|
2832
|
+
tag: "column",
|
|
2833
|
+
width: "16px",
|
|
2834
|
+
vertical_align: "top",
|
|
2835
|
+
padding: "0px",
|
|
2836
|
+
elements: [{
|
|
2837
|
+
...markdown([
|
|
2838
|
+
`<font color='${color}'>${node}</font>`,
|
|
2839
|
+
...(connectorLanes?.[lane] ? [`<font color='grey'>${escapeCardHtml(connectorLanes[lane])}</font>`] : []),
|
|
2840
|
+
].join("\n")),
|
|
2841
|
+
text_align: "center",
|
|
2842
|
+
}],
|
|
2843
|
+
}));
|
|
2846
2844
|
const trailingElements = current
|
|
2847
2845
|
? [markdown("✅ 当前")]
|
|
2848
2846
|
: resetting
|
|
@@ -2863,8 +2861,20 @@ function resetHistoryEntryRow(
|
|
|
2863
2861
|
width: "auto",
|
|
2864
2862
|
vertical_align: "top",
|
|
2865
2863
|
elements: [{
|
|
2866
|
-
|
|
2867
|
-
|
|
2864
|
+
tag: "column_set",
|
|
2865
|
+
flex_mode: "none",
|
|
2866
|
+
horizontal_spacing: "0px",
|
|
2867
|
+
horizontal_align: "left",
|
|
2868
|
+
columns: [
|
|
2869
|
+
...graphColumns,
|
|
2870
|
+
{
|
|
2871
|
+
tag: "column",
|
|
2872
|
+
width: sequenceWidth,
|
|
2873
|
+
vertical_align: "top",
|
|
2874
|
+
padding: "0px",
|
|
2875
|
+
elements: [{ ...markdown(`<font color='${color}'>${entry.sequence}</font>`), text_align: "left" }],
|
|
2876
|
+
},
|
|
2877
|
+
],
|
|
2868
2878
|
}],
|
|
2869
2879
|
},
|
|
2870
2880
|
{
|
|
@@ -3,7 +3,7 @@ import type { WSClient as LarkWsClient, WSConnectionStatus } from "@larksuiteoap
|
|
|
3
3
|
import type { AppConfig } from "../config/schema.js";
|
|
4
4
|
import { threadContextKey } from "./contextKey.js";
|
|
5
5
|
import { resolveFeishuBotOpenId } from "./FeishuBotIdentity.js";
|
|
6
|
-
import { normalizeFeishuPostText } from "./InboundText.js";
|
|
6
|
+
import { normalizeFeishuPostText, renderFeishuCodeBlock } from "./InboundText.js";
|
|
7
7
|
import { allowsFeishuUser } from "./ownerAccess.js";
|
|
8
8
|
import type { CardAction, ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
|
|
9
9
|
|
|
@@ -327,19 +327,29 @@ function parseMessageContent(
|
|
|
327
327
|
const title = typeof locale.title === "string" ? normalizeFeishuPostText(locale.title) : "";
|
|
328
328
|
if (title) paragraphs.push(title);
|
|
329
329
|
const images: string[] = [];
|
|
330
|
-
|
|
331
|
-
|
|
330
|
+
const rows = Array.isArray(locale.content) ? locale.content : locale.content_v2;
|
|
331
|
+
if (Array.isArray(rows)) {
|
|
332
|
+
for (const row of rows) {
|
|
332
333
|
if (!Array.isArray(row)) continue;
|
|
333
334
|
let rowText = "";
|
|
335
|
+
let preserveFormatting = false;
|
|
334
336
|
for (const element of row) {
|
|
335
337
|
if (!isRecord(element)) continue;
|
|
336
338
|
if ((element.tag === "text" || element.tag === "a") && typeof element.text === "string") {
|
|
337
339
|
rowText += element.text;
|
|
340
|
+
} else if (element.tag === "md" && typeof element.text === "string") {
|
|
341
|
+
rowText += element.text;
|
|
342
|
+
preserveFormatting = true;
|
|
343
|
+
} else if (element.tag === "code_block" && typeof element.text === "string") {
|
|
344
|
+
rowText += renderFeishuCodeBlock(element.text, element.language);
|
|
345
|
+
preserveFormatting = true;
|
|
338
346
|
} else if (element.tag === "img" && typeof element.image_key === "string") {
|
|
339
347
|
images.push(element.image_key);
|
|
340
348
|
}
|
|
341
349
|
}
|
|
342
|
-
const normalizedRow =
|
|
350
|
+
const normalizedRow = preserveFormatting
|
|
351
|
+
? rowText.replace(/\r\n?/gu, "\n").trim()
|
|
352
|
+
: normalizeFeishuPostText(rowText);
|
|
343
353
|
if (normalizedRow) paragraphs.push(normalizedRow);
|
|
344
354
|
}
|
|
345
355
|
}
|
|
@@ -347,7 +357,7 @@ function parseMessageContent(
|
|
|
347
357
|
}
|
|
348
358
|
|
|
349
359
|
function selectPostLocale(content: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
350
|
-
if (Array.isArray(content.content)) return content;
|
|
360
|
+
if (Array.isArray(content.content) || Array.isArray(content.content_v2)) return content;
|
|
351
361
|
for (const locale of ["zh_cn", "en_us", "ja_jp"]) {
|
|
352
362
|
if (isRecord(content[locale])) return content[locale];
|
|
353
363
|
}
|
|
@@ -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
|
@@ -483,7 +483,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
|
|
|
483
483
|
),
|
|
484
484
|
};
|
|
485
485
|
case "task_turns":
|
|
486
|
-
return { ok: true, data: controller.controlListTaskTurns(request.localSessionId) };
|
|
486
|
+
return { ok: true, data: await controller.controlListTaskTurns(request.localSessionId) };
|
|
487
487
|
case "task_reset":
|
|
488
488
|
return { ok: true, data: await controller.controlResetTaskToTurn(request.localSessionId, request.turnId) };
|
|
489
489
|
case "task_mute":
|
|
@@ -7,6 +7,7 @@ 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,
|
|
@@ -43,6 +44,8 @@ const MARKDOWN_RENDERER = new MarkdownIt({
|
|
|
43
44
|
MARKDOWN_RENDERER.renderer.rules.table_open = () =>
|
|
44
45
|
'<div class="markdown-table-scroll" role="region" aria-label="Markdown 表格" tabindex="0"><table>\n';
|
|
45
46
|
MARKDOWN_RENDERER.renderer.rules.table_close = () => '</table></div>\n';
|
|
47
|
+
const validateMarkdownLink = MARKDOWN_RENDERER.validateLink.bind(MARKDOWN_RENDERER);
|
|
48
|
+
MARKDOWN_RENDERER.validateLink = (url) => isFileUrl(url) || validateMarkdownLink(url);
|
|
46
49
|
|
|
47
50
|
const HIGHLIGHT_LANGUAGE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
48
51
|
".bash": "bash", ".bat": "dos", ".c": "c", ".cc": "cpp", ".cmd": "dos", ".cpp": "cpp",
|
|
@@ -132,7 +135,10 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
|
|
|
132
135
|
if (typeof ResizeObserver === "function") {
|
|
133
136
|
new ResizeObserver(updateHeaderOffset).observe(header);
|
|
134
137
|
}
|
|
135
|
-
window.addEventListener("hashchange", () =>
|
|
138
|
+
window.addEventListener("hashchange", () => {
|
|
139
|
+
if (/^#L\\d+$/u.test(window.location.hash)) setViewMode("code");
|
|
140
|
+
else requestAnimationFrame(positionHashTarget);
|
|
141
|
+
});
|
|
136
142
|
requestAnimationFrame(positionHashTarget);
|
|
137
143
|
if (!eventsUrl || typeof EventSource !== "function") return;
|
|
138
144
|
|
|
@@ -496,7 +502,7 @@ export class LocalFileViewerServer {
|
|
|
496
502
|
if (classification.kind === "text") {
|
|
497
503
|
const preview = readTextPreview(filePath, stat.size, classification.encoding);
|
|
498
504
|
const previewContent = isMarkdownFile(filePath)
|
|
499
|
-
? renderMarkdownDocument(preview.text, filePath)
|
|
505
|
+
? renderMarkdownDocument(preview.text, filePath, (target) => this.createPreviewUrl(target))
|
|
500
506
|
: renderText(preview.text, filePath);
|
|
501
507
|
content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${previewContent}`;
|
|
502
508
|
} else if (contentType.startsWith("image/")) {
|
|
@@ -1084,8 +1090,12 @@ function renderText(value: string, filePath: string): string {
|
|
|
1084
1090
|
}).join("")}</code></pre>`;
|
|
1085
1091
|
}
|
|
1086
1092
|
|
|
1087
|
-
function renderMarkdownDocument(value: string, filePath: string): string {
|
|
1088
|
-
|
|
1093
|
+
function renderMarkdownDocument(value: string, filePath: string, createPreviewUrl: (filePath: string) => URL): string {
|
|
1094
|
+
const environment = {};
|
|
1095
|
+
const tokens = MARKDOWN_RENDERER.parse(value, environment);
|
|
1096
|
+
rewriteMarkdownFileLinks(tokens, filePath, createPreviewUrl);
|
|
1097
|
+
const rendered = MARKDOWN_RENDERER.renderer.render(tokens, MARKDOWN_RENDERER.options, environment);
|
|
1098
|
+
return `<article class="markdown-body" data-view-panel="rendered">${rendered}</article><section data-view-panel="code">${renderText(value, filePath)}</section>`;
|
|
1089
1099
|
}
|
|
1090
1100
|
|
|
1091
1101
|
function isMarkdownFile(filePath: string): boolean {
|
|
@@ -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
|
+
}
|