| author | |
| committer | |
| log | 0ce43a4eea2f21fe404639c248fb86328c410c5d |
| tree | 61799bde2617e667d5d303458048b781732a6710 |
| parent | df4ae529eacfff8db38f2f4b9b767d3717446c8a |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 216 insertions(+), 3 deletions(-)
apps/mobile/src/features/threads/ThreadFeed.tsx+13-2| ... | ... | @@ -10,6 +10,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 10 | 10 | import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; |
| 11 | 11 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 12 | 12 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; |
| 13 | import { repairMarkdownFileLinks } from "@t3tools/client-runtime/repair-markdown-file-links"; | |
| 13 | 14 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 14 | 15 | import { |
| 15 | 16 | type OrchestrationMessageContext, |
| ... | ... | @@ -907,7 +908,17 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 907 | 908 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined; |
| 908 | 909 | }) { |
| 909 | 910 | const segments = useMemo( |
| 910 | () => splitCodexArtifactTemplateMarkdown(props.markdown), | |
| 911 | () => | |
| 912 | splitCodexArtifactTemplateMarkdown(props.markdown).map((segment) => | |
| 913 | segment.kind === "markdown" | |
| 914 | ? { | |
| 915 | ...segment, | |
| 916 | markdown: renderCodexFileCitationsAsMarkdown( | |
| 917 | repairMarkdownFileLinks(segment.markdown), | |
| 918 | ), | |
| 919 | } | |
| 920 | : segment, | |
| 921 | ), | |
| 911 | 922 | [props.markdown], |
| 912 | 923 | ); |
| 913 | 924 | |
| ... | ... | @@ -923,7 +934,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { |
| 923 | 934 | } |
| 924 | 935 | if (segment.markdown.trim().length === 0) return null; |
| 925 | 936 | |
| 926 | const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); | |
| 937 | const markdown = segment.markdown; | |
| 927 | 938 | return hasNativeSelectableMarkdownText() ? ( |
| 928 | 939 | <SelectableMarkdownText |
| 929 | 940 | key={`markdown:${segment.sourceOffset}`} |
apps/web/src/components/chat/MessagesTimeline.tsx+3-1| ... | ... | @@ -35,6 +35,7 @@ import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/en |
| 35 | 35 | import { useAtomValue } from "@effect/atom-react"; |
| 36 | 36 | import { environmentThreadDetails } from "../../state/threads"; |
| 37 | 37 | import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; |
| 38 | import { repairMarkdownFileLinks } from "@t3tools/client-runtime/repair-markdown-file-links"; | |
| 38 | 39 | import { Link } from "@tanstack/react-router"; |
| 39 | 40 | import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; |
| 40 | 41 | import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; |
| ... | ... | @@ -2494,6 +2495,7 @@ function AttemptFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "at |
| 2494 | 2495 | function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) { |
| 2495 | 2496 | const ctx = use(TimelineRowCtx); |
| 2496 | 2497 | const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); |
| 2498 | const renderedText = useMemo(() => repairMarkdownFileLinks(messageText), [messageText]); | |
| 2497 | 2499 | |
| 2498 | 2500 | return ( |
| 2499 | 2501 | <> |
| ... | ... | @@ -2507,7 +2509,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 2507 | 2509 | listRef={ctx.listRef} |
| 2508 | 2510 | > |
| 2509 | 2511 | <ChatMarkdown |
| 2510 | text={messageText} | |
| 2512 | text={renderedText} | |
| 2511 | 2513 | cwd={ctx.markdownCwd} |
| 2512 | 2514 | threadRef={ctx.threadRef ?? undefined} |
| 2513 | 2515 | isStreaming={Boolean(row.message.streaming)} |
packages/client-runtime/package.json+4| ... | ... | @@ -55,6 +55,10 @@ |
| 55 | 55 | "types": "./src/markdownLinks.ts", |
| 56 | 56 | "default": "./src/markdownLinks.ts" |
| 57 | 57 | }, |
| 58 | "./repair-markdown-file-links": { | |
| 59 | "types": "./src/repairMarkdownFileLinks.ts", | |
| 60 | "default": "./src/repairMarkdownFileLinks.ts" | |
| 61 | }, | |
| 58 | 62 | "./media-reference": { |
| 59 | 63 | "types": "./src/mediaReference.ts", |
| 60 | 64 | "default": "./src/mediaReference.ts" |
packages/client-runtime/src/markdownLinks.test.ts+112| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import remarkParse from "remark-parse"; | |
| 3 | import { unified } from "unified"; | |
| 2 | 4 | |
| 3 | 5 | import { |
| 4 | 6 | fileBasename, |
| ... | ... | @@ -9,6 +11,116 @@ import { |
| 9 | 11 | splitFilePathPosition, |
| 10 | 12 | workspaceRelativeFilePath, |
| 11 | 13 | } from "./markdownLinks.ts"; |
| 14 | import { repairMarkdownFileLinks } from "./repairMarkdownFileLinks.ts"; | |
| 15 | import { | |
| 16 | renderCodexDirectivesForCopy, | |
| 17 | renderCodexFileCitationsAsMarkdown, | |
| 18 | splitCodexArtifactTemplateMarkdown, | |
| 19 | } from "./codexMarkdownDirectives.ts"; | |
| 20 | ||
| 21 | describe("repairMarkdownFileLinks", () => { | |
| 22 | it.each([ | |
| 23 | "CONTRIBUTING.md", | |
| 24 | "local/path/file.md", | |
| 25 | "docs/My Folder/file.md", | |
| 26 | "./scripts/deploy", | |
| 27 | "~/notes/today.md", | |
| 28 | "/home/me/My Folder/file.md", | |
| 29 | "/tmp/reports/", | |
| 30 | "C:\\Users\\me\\My Folder\\file.md", | |
| 31 | "file:///home/me/file.md", | |
| 32 | "src/main.ts:12:5", | |
| 33 | ])("repairs a complete local destination: %s", (path) => { | |
| 34 | const source = `- [file](<${path})`; | |
| 35 | const rendered = repairMarkdownFileLinks(source); | |
| 36 | expect(rendered).toBe(`- [file](<${path}>)`); | |
| 37 | expect(unified().use(remarkParse).parse(rendered).children[0]).toMatchObject({ | |
| 38 | type: "list", | |
| 39 | children: [{ children: [{ children: [{ type: "link" }] }] }], | |
| 40 | }); | |
| 41 | expect(repairMarkdownFileLinks(rendered)).toBe(rendered); | |
| 42 | }); | |
| 43 | ||
| 44 | it.each([ | |
| 45 | "ordinary prose", | |
| 46 | "[file](<CONTRIBUTING.md>)", | |
| 47 | "[site](<https://example.com/docs)", | |
| 48 | "[route](</chat/settings)", | |
| 49 | "[prose](<not a file)", | |
| 50 | "[file](<src/main.ts", | |
| 51 | "[file](<src/main.ts>", | |
| 52 | "[file](<./file(one).md)", | |
| 53 | "[file](<./file.md))", | |
| 54 | "[file](<./file)name.md)", | |
| 55 | "[file](<./file\\).md)", | |
| 56 | "", | |
| 57 | "\\\\", | |
| 58 | "\\\\\\\\", | |
| 59 | "\\[file](<./file.md)", | |
| 60 | "\\\\\\[file](<./file.md)", | |
| 61 | "`[file](<./file.md)`", | |
| 62 | "``[file](<./file.md)``", | |
| 63 | "```md\n[file](<./file.md)\n```", | |
| 64 | "~~~md\n[file](<./file.md)\n~~~", | |
| 65 | " [file](<./file.md)", | |
| 66 | "<div>\n[file](<./file.md)\n</div>", | |
| 67 | "HTML <span>[file](<./file.md)</span>", | |
| 68 | '<span title="[file](<./file.md)">text</span>', | |
| 69 | "[outer [file](<./file.md)](https://example.com)", | |
| 70 | "[outer [file](<./file.md)][ref]\n\n[ref]: https://example.com", | |
| 71 | '[ref]: ./file.md "[file](<./other.md)"', | |
| 72 | ':codex-file-citation{path="./[file](<./other.md)" purpose="output"}', | |
| 73 | ':codex-file-citation{path="./[file](<./other.md)', | |
| 74 | '::artifact-template{skill_name="artifact-template-example" skill_directory="/tmp/skills/example" display_name="[file](<./file.md)" artifact_kind="document"}', | |
| 75 | '::artifact-template{display_name="[file](<./file.md)', | |
| 76 | ])("preserves protected or uncertain syntax: %s", (source) => { | |
| 77 | expect(repairMarkdownFileLinks(source)).toBe(source); | |
| 78 | }); | |
| 79 | ||
| 80 | it("uses CommonMark backslash parity for link openers", () => { | |
| 81 | expect(repairMarkdownFileLinks("\\\\[file](<./file.md)")).toBe("\\\\[file](<./file.md>)"); | |
| 82 | expect(repairMarkdownFileLinks("\\\\\\\\[file](<./file.md)")).toBe( | |
| 83 | "\\\\\\\\[file](<./file.md>)", | |
| 84 | ); | |
| 85 | expect(repairMarkdownFileLinks("\\")).toBe("\\"); | |
| 86 | }); | |
| 87 | ||
| 88 | it("repairs ordinary segments after splitting mobile artifacts and preserves copy source", () => { | |
| 89 | const link = "[file](<./file.md)"; | |
| 90 | const template = | |
| 91 | '::artifact-template{skill_name="artifact-template-example" skill_directory="/tmp/skills/example" display_name="[file](<./file.md)" artifact_kind="document"}'; | |
| 92 | const citation = ':codex-file-citation{path="./report.md"}'; | |
| 93 | const source = `${link}\n\n${template}\n\n${citation}`; | |
| 94 | const segments = splitCodexArtifactTemplateMarkdown(source); | |
| 95 | const rendered = segments.map((segment) => | |
| 96 | segment.kind === "markdown" | |
| 97 | ? { | |
| 98 | ...segment, | |
| 99 | markdown: renderCodexFileCitationsAsMarkdown(repairMarkdownFileLinks(segment.markdown)), | |
| 100 | } | |
| 101 | : segment, | |
| 102 | ); | |
| 103 | expect(rendered[0]).toEqual({ | |
| 104 | kind: "markdown", | |
| 105 | markdown: `${link.slice(0, -1)}>)\n\n`, | |
| 106 | sourceOffset: 0, | |
| 107 | }); | |
| 108 | expect(rendered[1]).toEqual(segments[1]); | |
| 109 | expect(rendered[1]).toMatchObject({ | |
| 110 | kind: "artifact-template", | |
| 111 | sourceOffset: link.length + 2, | |
| 112 | template: { displayName: link, skillName: "artifact-template-example" }, | |
| 113 | }); | |
| 114 | expect(rendered[2]).toEqual({ | |
| 115 | kind: "markdown", | |
| 116 | sourceOffset: source.indexOf("\n\n" + citation), | |
| 117 | markdown: "\n\n[report.md](<./report.md>)", | |
| 118 | }); | |
| 119 | expect(renderCodexDirectivesForCopy(source)).toBe( | |
| 120 | `${link}\n\n${link} (Document template)\n\n[report.md](<./report.md>)`, | |
| 121 | ); | |
| 122 | }); | |
| 123 | }); | |
| 12 | 124 | |
| 13 | 125 | describe("isMarkdownFileLinkLabel", () => { |
| 14 | 126 | it.each([ |
packages/client-runtime/src/repairMarkdownFileLinks.ts created+84| ... | ... | @@ -0,0 +1,84 @@ |
| 1 | import remarkParse from "remark-parse"; | |
| 2 | import { unified } from "unified"; | |
| 3 | ||
| 4 | import { remarkCodexDirectives } from "./codexMarkdownDirectives.ts"; | |
| 5 | import { parseMarkdownFileLink } from "./markdownLinks.ts"; | |
| 6 | ||
| 7 | const parser = unified().use(remarkParse).use(remarkCodexDirectives).freeze(); | |
| 8 | const CANDIDATE = /\[[^[\]\r\n]*\]\([ \t]*<([^<>()\r\n]+)\)/g; | |
| 9 | const PROTECTED = new Set([ | |
| 10 | "code", | |
| 11 | "inlineCode", | |
| 12 | "html", | |
| 13 | "link", | |
| 14 | "image", | |
| 15 | "linkReference", | |
| 16 | "imageReference", | |
| 17 | "definition", | |
| 18 | "textDirective", | |
| 19 | "leafDirective", | |
| 20 | "containerDirective", | |
| 21 | ]); | |
| 22 | ||
| 23 | function isEscaped(source: string, offset: number): boolean { | |
| 24 | let backslashes = 0; | |
| 25 | while (offset > 0 && source[--offset] === "\\") backslashes += 1; | |
| 26 | return backslashes % 2 === 1; | |
| 27 | } | |
| 28 | ||
| 29 | /** Closes only complete, unambiguous local-file destinations for assistant rendering. */ | |
| 30 | export function repairMarkdownFileLinks(markdown: string): string { | |
| 31 | if (!/\]\([ \t]*</.test(markdown)) return markdown; | |
| 32 | const candidates = Array.from(markdown.matchAll(CANDIDATE)).filter((match) => { | |
| 33 | const start = match.index; | |
| 34 | const close = start + match[0].length - 1; | |
| 35 | return ( | |
| 36 | !isEscaped(markdown, start) && | |
| 37 | !(markdown[start - 1] === "!" && !isEscaped(markdown, start - 1)) && | |
| 38 | !isEscaped(markdown, close) && | |
| 39 | (close + 1 === markdown.length || /[\s.,;:!?}\]*_~]/.test(markdown[close + 1] ?? "")) && | |
| 40 | parseMarkdownFileLink(match[1] ?? "") !== null | |
| 41 | ); | |
| 42 | }); | |
| 43 | if (candidates.length === 0) return markdown; | |
| 44 | ||
| 45 | const protectedRanges: Array<{ start: number; end: number }> = []; | |
| 46 | interface Node { | |
| 47 | type: string; | |
| 48 | position?: | |
| 49 | | { start: { offset?: number | undefined }; end: { offset?: number | undefined } } | |
| 50 | | undefined; | |
| 51 | children?: Node[] | undefined; | |
| 52 | } | |
| 53 | const visit = (node: Node, paragraph?: Node) => { | |
| 54 | const containingParagraph = node.type === "paragraph" ? node : paragraph; | |
| 55 | const sourceStart = node.position?.start.offset; | |
| 56 | const sourceEnd = node.position?.end.offset; | |
| 57 | const unfinishedDirective = | |
| 58 | (node.type === "textDirective" && markdown[sourceEnd ?? -1] === "{") || | |
| 59 | (node.type === "text" && | |
| 60 | markdown.slice(sourceStart, sourceEnd).includes("::artifact-template{")); | |
| 61 | if (PROTECTED.has(node.type) || unfinishedDirective) { | |
| 62 | // Inline HTML and unfinished directive attributes can span separate text nodes. | |
| 63 | const protectedNode = | |
| 64 | node.type === "html" || unfinishedDirective ? (containingParagraph ?? node) : node; | |
| 65 | const start = protectedNode.position?.start.offset; | |
| 66 | const end = protectedNode.position?.end.offset; | |
| 67 | if (start !== undefined && end !== undefined) protectedRanges.push({ start, end }); | |
| 68 | return; | |
| 69 | } | |
| 70 | node.children?.forEach((child) => visit(child, containingParagraph)); | |
| 71 | }; | |
| 72 | visit(parser.parse(markdown)); | |
| 73 | ||
| 74 | let rendered = ""; | |
| 75 | let cursor = 0; | |
| 76 | for (const match of candidates) { | |
| 77 | const start = match.index; | |
| 78 | const end = start + match[0].length; | |
| 79 | if (protectedRanges.some((range) => start < range.end && end > range.start)) continue; | |
| 80 | rendered += markdown.slice(cursor, end - 1) + ">"; | |
| 81 | cursor = end - 1; | |
| 82 | } | |
| 83 | return rendered + markdown.slice(cursor); | |
| 84 | } |