@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +18 -1
  3. package/README.zh.md +9 -0
  4. package/dist/cli/AgentPrerequisites.js +11 -47
  5. package/dist/cli/AgentPrerequisites.js.map +1 -1
  6. package/dist/cli/DefaultAgentSelection.js +1 -1
  7. package/dist/cli/DefaultAgentSelection.js.map +1 -1
  8. package/dist/cli.js +15 -0
  9. package/dist/cli.js.map +1 -1
  10. package/dist/codex/CodexLocalActivityDetector.js +32 -10
  11. package/dist/codex/CodexLocalActivityDetector.js.map +1 -1
  12. package/dist/codex/CodexProcessManager.js +53 -7
  13. package/dist/codex/CodexProcessManager.js.map +1 -1
  14. package/dist/codex/CodexRuntime.js +225 -164
  15. package/dist/codex/CodexRuntime.js.map +1 -1
  16. package/dist/codex/CodexVersion.js +18 -0
  17. package/dist/codex/CodexVersion.js.map +1 -0
  18. package/dist/codex/ProviderSwitch.js +32 -0
  19. package/dist/codex/ProviderSwitch.js.map +1 -0
  20. package/dist/config/AgentExecutionDefaultsStore.js +1 -1
  21. package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
  22. package/dist/feishu/CardRenderer.js +36 -11
  23. package/dist/feishu/CardRenderer.js.map +1 -1
  24. package/dist/feishu/FeishuConnector.js +17 -5
  25. package/dist/feishu/FeishuConnector.js.map +1 -1
  26. package/dist/feishu/InboundText.js +11 -0
  27. package/dist/feishu/InboundText.js.map +1 -1
  28. package/dist/feishu/MergedForwardMessage.js +17 -5
  29. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/local-files/LocalFileViewerServer.js +25 -7
  33. package/dist/local-files/LocalFileViewerServer.js.map +1 -1
  34. package/dist/local-files/MarkdownFileLinks.js +65 -0
  35. package/dist/local-files/MarkdownFileLinks.js.map +1 -0
  36. package/dist/proxy/ProxySessionController.js +400 -259
  37. package/dist/proxy/ProxySessionController.js.map +1 -1
  38. package/dist/state/StateStore.js +99 -65
  39. package/dist/state/StateStore.js.map +1 -1
  40. package/dist/utils/semanticVersion.js +39 -0
  41. package/dist/utils/semanticVersion.js.map +1 -0
  42. package/docs/technical-reference.md +5 -3
  43. package/docs/technical-reference.zh.md +5 -3
  44. package/npm-shrinkwrap.json +2 -2
  45. package/package.json +1 -1
  46. package/skills/agent-bot/SKILL.md +7 -1
  47. package/src/cli/AgentPrerequisites.ts +14 -40
  48. package/src/cli/DefaultAgentSelection.ts +1 -1
  49. package/src/cli.ts +13 -0
  50. package/src/codex/CodexLocalActivityDetector.ts +41 -9
  51. package/src/codex/CodexProcessManager.ts +44 -7
  52. package/src/codex/CodexRuntime.ts +244 -195
  53. package/src/codex/CodexVersion.ts +18 -0
  54. package/src/codex/ProviderSwitch.ts +36 -0
  55. package/src/config/AgentExecutionDefaultsStore.ts +1 -1
  56. package/src/feishu/CardRenderer.ts +37 -27
  57. package/src/feishu/FeishuConnector.ts +15 -5
  58. package/src/feishu/InboundText.ts +11 -0
  59. package/src/feishu/MergedForwardMessage.ts +15 -5
  60. package/src/index.ts +1 -1
  61. package/src/local-files/LocalFileViewerServer.ts +26 -7
  62. package/src/local-files/MarkdownFileLinks.ts +64 -0
  63. package/src/proxy/ProxySessionController.ts +370 -240
  64. package/src/runtime/types.ts +15 -2
  65. package/src/state/StateStore.ts +117 -90
  66. 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
- const action = entry.actions?.[0];
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
- graphNodeLine: string,
2829
- graphConnectorLine: string | undefined,
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 graph = [
2841
- `<font color='${current ? "green" : running || resetting ? "orange" : "blue"}'>${escapeCardHtml(graphNodeLine)}</font>`,
2842
- ...(branchConnectorLine
2843
- ? [`<font color='grey'>${escapeCardHtml(branchConnectorLine)}</font>`]
2844
- : []),
2845
- ].join("\n");
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
- ...markdown(graph),
2867
- text_align: "center",
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
- if (Array.isArray(locale.content)) {
331
- for (const row of locale.content) {
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 = normalizeFeishuPostText(rowText);
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
- if (!Array.isArray(locale.content)) return paragraphs.join("\n");
270
- for (const row of locale.content) {
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 = normalizeFeishuPostText(rowText);
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", () => requestAnimationFrame(positionHashTarget));
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-body table { display: block; max-width: 100%; overflow: auto; border-collapse: collapse; }
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
- return `<article class="markdown-body" data-view-panel="rendered">${MARKDOWN_RENDERER.render(value)}</article><section data-view-panel="code">${renderText(value, filePath)}</section>`;
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
+ }