@keyou007/agent-bot 0.1.20-alpha.0 → 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 +17 -0
- package/README.md +18 -1
- 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/config/AgentExecutionDefaultsStore.js +1 -1
- package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
- 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 +25 -7
- 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 +400 -259
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/state/StateStore.js +99 -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/config/AgentExecutionDefaultsStore.ts +1 -1
- 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 +26 -7
- package/src/local-files/MarkdownFileLinks.ts +64 -0
- package/src/proxy/ProxySessionController.ts +370 -240
- package/src/runtime/types.ts +15 -2
- package/src/state/StateStore.ts +117 -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
|
+
}
|
|
@@ -26,7 +26,7 @@ export function writeAgentExecutionDefaults(
|
|
|
26
26
|
if (existingDefaults !== undefined && !isMap(existingDefaults)) {
|
|
27
27
|
throw new Error(`Configuration agents.${agentName}.defaults must contain a YAML mapping: ${configPath}`);
|
|
28
28
|
}
|
|
29
|
-
if (!isMap(existingDefaults)) agent.set("defaults", {});
|
|
29
|
+
if (!isMap(existingDefaults)) agent.set("defaults", document.createNode({}));
|
|
30
30
|
const target = agent.get("defaults", true);
|
|
31
31
|
if (!isMap(target)) throw new Error(`Could not create defaults for Agent: ${agentName}`);
|
|
32
32
|
|
|
@@ -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,
|
|
@@ -40,6 +41,11 @@ const MARKDOWN_RENDERER = new MarkdownIt({
|
|
|
40
41
|
return `<pre class="markdown-code-block hljs"><code${languageAttribute}>${highlighted}</code></pre>`;
|
|
41
42
|
},
|
|
42
43
|
});
|
|
44
|
+
MARKDOWN_RENDERER.renderer.rules.table_open = () =>
|
|
45
|
+
'<div class="markdown-table-scroll" role="region" aria-label="Markdown 表格" tabindex="0"><table>\n';
|
|
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);
|
|
43
49
|
|
|
44
50
|
const HIGHLIGHT_LANGUAGE_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
45
51
|
".bash": "bash", ".bat": "dos", ".c": "c", ".cc": "cpp", ".cmd": "dos", ".cpp": "cpp",
|
|
@@ -129,16 +135,22 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
|
|
|
129
135
|
if (typeof ResizeObserver === "function") {
|
|
130
136
|
new ResizeObserver(updateHeaderOffset).observe(header);
|
|
131
137
|
}
|
|
132
|
-
window.addEventListener("hashchange", () =>
|
|
138
|
+
window.addEventListener("hashchange", () => {
|
|
139
|
+
if (/^#L\\d+$/u.test(window.location.hash)) setViewMode("code");
|
|
140
|
+
else requestAnimationFrame(positionHashTarget);
|
|
141
|
+
});
|
|
133
142
|
requestAnimationFrame(positionHashTarget);
|
|
134
143
|
if (!eventsUrl || typeof EventSource !== "function") return;
|
|
135
144
|
|
|
136
|
-
const restoreScroll = (top, left, atBottom, codeScrollLeft) => {
|
|
145
|
+
const restoreScroll = (top, left, atBottom, codeScrollLeft, tableScrollLeft) => {
|
|
137
146
|
requestAnimationFrame(() => {
|
|
138
147
|
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
139
148
|
window.scrollTo(left, atBottom ? maxTop : Math.min(top, maxTop));
|
|
140
149
|
const code = content.querySelector(".code");
|
|
141
150
|
if (code) code.scrollLeft = codeScrollLeft;
|
|
151
|
+
content.querySelectorAll(".markdown-table-scroll").forEach((table, index) => {
|
|
152
|
+
table.scrollLeft = tableScrollLeft[index] ?? 0;
|
|
153
|
+
});
|
|
142
154
|
});
|
|
143
155
|
};
|
|
144
156
|
const replaceContent = (nextContent, nextMetadata) => {
|
|
@@ -147,11 +159,12 @@ const VIEWER_CLIENT_SCRIPT = `(() => {
|
|
|
147
159
|
const maxTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
|
148
160
|
const atBottom = maxTop - top <= 24;
|
|
149
161
|
const codeScrollLeft = content.querySelector(".code")?.scrollLeft ?? 0;
|
|
162
|
+
const tableScrollLeft = Array.from(content.querySelectorAll(".markdown-table-scroll"), (table) => table.scrollLeft);
|
|
150
163
|
content.innerHTML = nextContent;
|
|
151
164
|
metadata.innerHTML = nextMetadata;
|
|
152
165
|
syncViewMode();
|
|
153
166
|
highlightHashTarget();
|
|
154
|
-
restoreScroll(top, left, atBottom, codeScrollLeft);
|
|
167
|
+
restoreScroll(top, left, atBottom, codeScrollLeft, tableScrollLeft);
|
|
155
168
|
};
|
|
156
169
|
|
|
157
170
|
const source = new EventSource(eventsUrl);
|
|
@@ -489,7 +502,7 @@ export class LocalFileViewerServer {
|
|
|
489
502
|
if (classification.kind === "text") {
|
|
490
503
|
const preview = readTextPreview(filePath, stat.size, classification.encoding);
|
|
491
504
|
const previewContent = isMarkdownFile(filePath)
|
|
492
|
-
? renderMarkdownDocument(preview.text, filePath)
|
|
505
|
+
? renderMarkdownDocument(preview.text, filePath, (target) => this.createPreviewUrl(target))
|
|
493
506
|
: renderText(preview.text, filePath);
|
|
494
507
|
content = `${preview.truncated ? '<div class="notice">文件较大,仅显示开头 2 MiB。可使用下方按钮查看或下载完整文件。</div>' : ""}${previewContent}`;
|
|
495
508
|
} else if (contentType.startsWith("image/")) {
|
|
@@ -763,7 +776,9 @@ function renderViewerPage(input: {
|
|
|
763
776
|
.markdown-body code { padding: .14em .35em; border-radius: 4px; background: #f2f3f5; font-family: var(--viewer-code-font); font-size: .9em; }
|
|
764
777
|
.markdown-body .markdown-code-block { margin: 0 0 1em; padding: 14px 16px; overflow: auto; border-radius: 6px; background: #f6f8fa; line-height: 1.55; }
|
|
765
778
|
.markdown-body .markdown-code-block code { padding: 0; background: transparent; font-size: 13px; }
|
|
766
|
-
.markdown-
|
|
779
|
+
.markdown-table-scroll { max-width: 100%; margin: 0 0 1em; overflow-x: auto; }
|
|
780
|
+
.markdown-table-scroll:focus-visible { outline: 2px solid #1456f0; outline-offset: 2px; }
|
|
781
|
+
.markdown-body table { width: max-content; margin: 0; border-collapse: collapse; white-space: nowrap; overflow-wrap: normal; word-break: normal; }
|
|
767
782
|
.markdown-body th, .markdown-body td { padding: 7px 12px; border: 1px solid #dfe3e8; text-align: left; }
|
|
768
783
|
.markdown-body th { background: #f5f7fa; }
|
|
769
784
|
.markdown-body img { max-width: 100%; height: auto; }
|
|
@@ -1075,8 +1090,12 @@ function renderText(value: string, filePath: string): string {
|
|
|
1075
1090
|
}).join("")}</code></pre>`;
|
|
1076
1091
|
}
|
|
1077
1092
|
|
|
1078
|
-
function renderMarkdownDocument(value: string, filePath: string): string {
|
|
1079
|
-
|
|
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>`;
|
|
1080
1099
|
}
|
|
1081
1100
|
|
|
1082
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
|
+
}
|