diff --git a/apps/desktop/src/lib/__tests__/ai/aiMessageRender.spec.ts b/apps/desktop/src/lib/__tests__/ai/aiMessageRender.spec.ts index d30ea50d4a..159f995067 100644 --- a/apps/desktop/src/lib/__tests__/ai/aiMessageRender.spec.ts +++ b/apps/desktop/src/lib/__tests__/ai/aiMessageRender.spec.ts @@ -79,6 +79,122 @@ describe("createAiMessageRenderer", () => { expect(code).toMatchObject({ type: "code", pending: false, html: "SELECT 1" }); }); + it("recovers a language fence appended to the preceding prose line", () => { + const markdown = (text: string) => `
${text}
`; + const highlightCode = (content: string) => `${content}`; + const renderer = createAiMessageRenderer({ markdown, highlightCode }); + + const segments = renderer.render("SQL 如下:```sql\nALTER TABLE users ADD COLUMN name TEXT;\n```\n\n字段允许为空。"); + + expect(segments).toEqual([ + { type: "text", content: "SQL 如下:", html: "SQL 如下:
" }, + { + type: "code", + content: "ALTER TABLE users ADD COLUMN name TEXT;", + html: "ALTER TABLE users ADD COLUMN name TEXT;", + lang: "SQL", + isSql: true, + pending: false, + }, + { type: "text", content: "\n字段允许为空。", html: "\n字段允许为空。
" }, + ]); + }); + + it("recovers an appended fence after inline code in the prose", () => { + const renderer = createAiMessageRenderer({ + markdown: (text) => `${text}
`, + highlightCode: (content) => `${content}`, + }); + + const segments = renderer.render("Run `EXPLAIN`:```sql\nSELECT 1;\n```\n\nDone."); + + expect(segments.map((segment) => segment.type)).toEqual(["text", "code", "text"]); + expect(segments[1]).toMatchObject({ type: "code", content: "SELECT 1;", lang: "SQL", pending: false }); + expect(segments[2]).toMatchObject({ type: "text", content: "\nDone." }); + }); + + it("does not recover inline-looking fences inside an outer code fence", () => { + const markdown = vi.fn((text: string) => `${text}
`); + const renderer = createAiMessageRenderer({ markdown }); + const content = "````markdown\nPreview:```html\nsafe example
\n````"; + + expect(renderer.render(content)).toEqual([{ type: "text", content, html: `${content}
` }]); + expect(markdown).toHaveBeenCalledWith(content); + }); + + it.each(["> ```sql\n> SELECT 1;\n> ```", "- ```sql\n SELECT 1;\n ```"])("leaves container code fences to the Markdown renderer", (content) => { + const markdown = vi.fn((text: string) => `${text}
`); + const renderer = createAiMessageRenderer({ markdown }); + + expect(renderer.render(content)).toEqual([{ type: "text", content, html: `${content}
` }]); + expect(markdown).toHaveBeenCalledWith(content); + }); + + it("leaves multiline code spans to the Markdown renderer", () => { + const markdown = vi.fn((text: string) => `${text}
`); + const renderer = createAiMessageRenderer({ markdown }); + const content = "Label: ```sql\nSELECT 1;\n``` remains literal."; + + expect(renderer.render(content)).toEqual([{ type: "text", content, html: `${content}
` }]); + expect(markdown).toHaveBeenCalledWith(content); + }); + + it("does not borrow a closing fence from a later tilde code block", () => { + const markdown = vi.fn((text: string) => `${text}
`); + const renderer = createAiMessageRenderer({ markdown }); + const content = "Label: ```sql\nSELECT 1;\n``` remains literal.\n\n~~~markdown\n```\n~~~"; + + const segments = renderer.render(content); + expect(segments).toHaveLength(2); + expect(segments[0]).toMatchObject({ type: "text", content: "Label: ```sql\nSELECT 1;\n``` remains literal.\n\n~~~markdown" }); + expect(segments[1]).toMatchObject({ type: "code", content: "~~~", pending: true }); + }); + + it("does not recover inline-looking fences inside raw HTML blocks", () => { + const renderer = createAiMessageRenderer({ markdown: (text) => `${text}
` }); + const content = ""; + + const segments = renderer.render(content); + expect(segments.some((segment) => segment.type === "code" && !segment.pending)).toBe(false); + }); + + it("does not recover inline-looking fences inside ordinary HTML blocks", () => { + const renderer = createAiMessageRenderer({ markdown: (text) => `${text}
` }); + const content = "${text}
` }); + + const segments = renderer.render("SQL:```sql\r\nSELECT 1;\r\n```\r\n\r\nDone."); + + expect(segments.map((segment) => segment.type)).toEqual(["text", "code", "text"]); + expect(segments[1]).toMatchObject({ type: "code", content: "SELECT 1;", lang: "SQL", pending: false }); + expect(segments[2]).toMatchObject({ type: "text", content: "\r\nDone." }); + }); + + it("does not recover an appended fence without a parser-compatible close", () => { + const markdown = vi.fn((text: string) => `${text}
`); + const renderer = createAiMessageRenderer({ markdown }); + const content = "SQL:```sql\nSELECT 1;\n ```\nDone."; + + expect(renderer.render(content)).toEqual([{ type: "text", content, html: `${content}
` }]); + expect(markdown).toHaveBeenCalledWith(content); + }); + + it("recovers multiple appended language fences in one message", () => { + const renderer = createAiMessageRenderer({ markdown: (text) => `${text}
` }); + + const segments = renderer.render("First:```sql\nSELECT 1;\n```\n\nSecond:```bash\necho done\n```"); + + expect(segments.map((segment) => segment.type)).toEqual(["text", "code", "text", "code"]); + expect(segments[1]).toMatchObject({ type: "code", content: "SELECT 1;", lang: "SQL", pending: false }); + expect(segments[3]).toMatchObject({ type: "code", content: "echo done", lang: "BASH", pending: false }); + }); + it("falls back to escaped code when highlighting is not supported", () => { const markdown = (text: string) => `${text}
`; const highlightCode = vi.fn(() => { diff --git a/apps/desktop/src/lib/ai/aiMessageRender.ts b/apps/desktop/src/lib/ai/aiMessageRender.ts index 56d7ca76b8..0d905dcc6c 100644 --- a/apps/desktop/src/lib/ai/aiMessageRender.ts +++ b/apps/desktop/src/lib/ai/aiMessageRender.ts @@ -50,6 +50,9 @@ const DEFAULT_MAX_CACHE_CHARS = 400_000; const STREAM_BLOCK_MIN_CHARS = 240; const BLANK_LINE_RE = /\n{2,}/g; const FENCE_LINE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/; +const INLINE_LANGUAGE_FENCE_RE = /^(.*?)(```[a-zA-Z0-9_+.-]+)[ \t]*(\r?)$/; +const MARKDOWN_CONTAINER_PREFIX_RE = /^[>*+\d.)-]+$/; +const STANDALONE_CLOSING_FENCE_RE = /^```[ \t]*\r?$/; // Definitions inside block containers still apply to the whole document. Container indentation // may exceed three columns, so this prefix is intentionally conservative: a false positive only // disables streaming splits, while a false negative changes reference-link rendering. @@ -57,6 +60,7 @@ const LINK_REFERENCE_RE = /^(?:[ \t]*(?:>[ \t]?|(?:[*+-]|\d{1,9}[.)])[ \t]+))*[ // Raw HTML blocks stay open across blank lines, which no block boundary may cut: // comments, processing instructions, declarations, CDATA and the raw-text elements. const RAW_HTML_BLOCK_RE = /