diff --git a/docs/images/transcript-math-after.png b/docs/images/transcript-math-after.png new file mode 100644 index 000000000..234d5c81b Binary files /dev/null and b/docs/images/transcript-math-after.png differ diff --git a/docs/images/transcript-math-before.png b/docs/images/transcript-math-before.png new file mode 100644 index 000000000..779095516 Binary files /dev/null and b/docs/images/transcript-math-before.png differ diff --git a/package.json b/package.json index 7e8204d0f..1da0ebeb6 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "i18next": "^26.4.0", "i18next-resources-to-backend": "^1.2.3", "input-otp": "^1.5.0", + "katex": "^0.16.47", "lucide-react": "^1.34.0", "matter-js": "^0.20.0", "media-chrome": "^4.19.2", @@ -134,6 +135,7 @@ "recharts": "^3.10.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", "shiki": "^3.23.0", "sonner": "^2.0.8", "streamdown": "^2.6.0", @@ -141,6 +143,7 @@ "three": "^0.185.1", "tokenlens": "^1.3.1", "tw-animate-css": "^1.4.0", + "unified": "^11.0.5", "use-stick-to-bottom": "^1.1.6", "vaul": "^1.1.2", "yaml": "2.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9ff7a030..4cf88c6e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,6 +248,9 @@ importers: input-otp: specifier: ^1.5.0 version: 1.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + katex: + specifier: ^0.16.47 + version: 0.16.47 lucide-react: specifier: ^1.34.0 version: 1.34.0(react@19.2.8) @@ -293,6 +296,9 @@ importers: shiki: specifier: ^3.23.0 version: 3.23.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 sonner: specifier: ^2.0.8 version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -311,6 +317,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 use-stick-to-bottom: specifier: ^1.1.6 version: 1.1.6(react@19.2.8) diff --git a/src/shared/ui/ai-elements/message-math.render.test.tsx b/src/shared/ui/ai-elements/message-math.render.test.tsx new file mode 100644 index 000000000..bf040a916 --- /dev/null +++ b/src/shared/ui/ai-elements/message-math.render.test.tsx @@ -0,0 +1,411 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { MessageResponse } from "./message"; + +describe("MessageResponse math with the real renderer", () => { + it("renders fractions and multiple bracket equations as KaTeX", () => { + const { container } = render( + + {String.raw`Before \[\frac{x^2}{100} \le y\] then \[a+b=c\] after`} + , + ); + expect(container.querySelectorAll(".katex")).toHaveLength(2); + expect(container.querySelectorAll(".katex-display")).toHaveLength(2); + expect(container.querySelector(".katex-error")).toBeNull(); + expect(container.textContent).not.toContain(String.raw`\[`); + expect(container.textContent).toContain("Before"); + }); + + it.each([ + String.raw`\[x^2\]`, + "Before \\[a\n+ b\\] after", + "> \\[a\n> + b\\]", + "- Formula \\[a\n + b\\] afterwards", + "- Formula \\[a\n - b\\] afterwards", + "- Formula \\[a\n + \\beta\\] afterwards", + "- Formula \\[a\n + b^2\\] afterwards", + "> - Formula \\[a\n> + b\\] afterwards", + "- Outer\n - Inner \\[a\n + b\\] afterwards", + "1. Formula \\[a\n + b\\] afterwards\n2. Unrelated prose", + "- Formula \\[a\n + b\\] afterwards\n- Unrelated prose", + ])("renders a display equation in its Markdown container: %s", (content) => { + const { container } = render( + {content}, + ); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + expect(container.querySelector(".katex-error")).toBeNull(); + if (content.startsWith(">")) + expect( + container.querySelector("blockquote .katex-display"), + ).not.toBeNull(); + if (content.includes("- ")) + expect(container.querySelector("li .katex-display")).not.toBeNull(); + }); + + it.each([ + "- First \\[x\n- Second y\\]", + "- Parent \\[x\n - Child y\\]", + "1. Parent \\[x\n 1. Child y\\]", + "> - Parent \\[x\n> - Child y\\]", + "- Parent \\[x\n + Child y\\]", + "- Parent \\[x\n * Child b\\]", + "- Parent \\[x\n - Child y\n z\\]", + "1. First \\[x\n2. Second y\\]", + "> - First \\[x\n> - Second y\\]", + "- First \\[x\n+ Second y\\]", + ])("preserves both list items when a delimiter pair spans them: %s", (content) => { + const { container } = render( + {content}, + ); + const items = container.querySelectorAll("li"); + expect(items).toHaveLength(2); + expect(items[0].textContent).toContain( + content.includes("Parent") ? "Parent" : "First", + ); + expect(items[1].textContent).toContain( + content.includes("Child") ? "Child" : "Second", + ); + expect(container.querySelector(".katex-display")).toBeNull(); + expect(container.querySelector(".katex-error")).toBeNull(); + }); + + it("preserves sibling list items as a cross-item delimiter pair streams in", () => { + const content = "- First \\[x\n- Second y"; + const { container, rerender } = render( + {content}, + ); + expect(container.querySelectorAll("li")).toHaveLength(2); + rerender({content + "\\]"}); + expect(container.querySelectorAll("li")).toHaveLength(2); + expect(container.querySelector(".katex-display")).toBeNull(); + expect(container.querySelectorAll("li")[1].textContent).toContain("Second"); + rerender( + {"- First \\[x\\]\n- Second y"}, + ); + expect(container.querySelectorAll("li")).toHaveLength(2); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + }); + + it("preserves parent and child as the closing delimiter streams in", () => { + const source = "- Parent \\[x\n - Child y"; + const { container, rerender } = render( + {source}, + ); + for (const suffix of ["", "\\", "\\]"]) { + rerender({source + suffix}); + expect(container.querySelectorAll("li")).toHaveLength(2); + expect(container.querySelector("li li")?.textContent).toContain("Child"); + expect(container.querySelector(".katex-display")).toBeNull(); + } + }); + + it.each([ + [ + "\\[unfinished\n\n## Next section\n\nHere is the closing marker \\]", + "h2", + ], + [ + "- \\[unfinished\n\n ## Next section\n\n Here is the closing marker \\]", + "li h2", + ], + [ + "> \\[unfinished\n>\n> ## Next section\n>\n> Here is the closing marker \\]", + "blockquote h2", + ], + ["\\[unfinished\n\nNext section\n============\n\nClosing \\]", "h1"], + ["\\[unfinished\n\n***\n\nClosing \\]", "hr"], + ["\\[unfinished\n\n| Value |\n| --- |\n| prose |\n\nClosing \\]", "table"], + ["\\[unfinished\n\n>\n\nClosing \\]", "blockquote"], + ["\\[unfinished\n\n-\n\nClosing \\]", "li"], + ])("preserves intervening blocks through the actual renderer: %s", (source, selector) => { + const { container, rerender } = render( + {source}, + ); + expect(container.querySelector(selector)).not.toBeNull(); + expect(container.querySelector(".katex-display")).toBeNull(); + rerender( + + {source + "\n\n\\[live\\]"} + , + ); + expect(container.querySelector(selector)).not.toBeNull(); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + }); + + it("preserves a heading as the closing delimiter streams in", () => { + const source = "\\[unfinished\n\n## Next section\n\nClosing "; + const { container, rerender } = render( + {source}, + ); + for (const suffix of ["", "\\", "\\]"]) { + rerender({source + suffix}); + expect(container.querySelector("h2")?.textContent).toBe("Next section"); + expect(container.querySelector(".katex-display")).toBeNull(); + } + }); + + it.each([ + "-\tFormula \\[x\\] afterwards\n- Next item", + "1.\tFormula \\[x\\] afterwards\n2. Next item", + "12.\tFormula \\[x\\] afterwards\n13. Next item", + "123.\tFormula \\[x\\] afterwards\n124. Next item", + "> -\tFormula \\[x\\] afterwards\n> - Next item", + "- Outer\n -\tFormula \\[x\\] afterwards\n - Next item", + "-\tFormula \\[a\n\t+ b\\] afterwards\n- Next item", + ])("keeps tab-indented math and tail inside their list item: %s", (source) => { + const { container, rerender } = render( + {source}, + ); + const items = container.querySelectorAll("li"); + const formula = container.querySelector(".katex-display")?.closest("li"); + expect(items).toHaveLength(source.includes("Outer") ? 3 : 2); + expect(formula).toBeTruthy(); + expect(formula?.textContent).toContain("Formula"); + expect(formula?.textContent).toContain("afterwards"); + expect(items[items.length - 1].textContent?.trim()).toBe("Next item"); + expect(container.querySelector("pre")).toBeNull(); + expect(container.querySelector(".katex-error")).toBeNull(); + const cutoff = source.indexOf("afterwards"); + rerender( + + {source} + , + ); + expect( + container.querySelector('[data-voice-unspoken="true"]')?.textContent, + ).toContain("afterwards"); + expect(container.querySelector("li .katex-display")).not.toBeNull(); + }); + + it.each([ + " - \\[x\\] after\n - Next", + " - \\[x\\] after\n - Next", + "- \\[x\\] after\n- Next", + "> > - \\[x\\] after\n> > - Next", + "-\n \\[x\\] after\n- Next", + "- \\[x\\] after\n- Next", + "+ \\[x\\] after\n+ Next", + "* \\[x\\] after\n* Next", + "1. \\[x\\] after\n2. Next", + "12. \\[x\\] after\n13. Next", + "123) \\[x\\] after\n124) Next", + "-\t\\[x\\] after\n- Next", + "1.\t\\[x\\] after\n2. Next", + "123.\t\\[x\\] after\n124. Next", + "> - \\[x\\] after\n> - Next", + "> 1. \\[x\\] after\n> 2. Next", + "- Outer\n - \\[x\\] after\n - Next", + "- Outer\n -\t\\[x\\] after\n - Next", + "> - Outer\n> - \\[x\\] after\n> - Next", + "- > \\[x\\] after\n- Next", + "- \\[a\n + b\\] after\n- Next", + "- \\[x\\] after\r\n- Next", + "- \\[x\\] after and \\[y\\] also\n- Next", + "- Intro\n\n \\[x\\] after\n- Next", + ])("keeps item-start equations, tail and following item in the same list: %s", (source) => { + const { container, rerender } = render( + {source}, + ); + const verify = () => { + const items = container.querySelectorAll("li"); + const next = items[items.length - 1]; + const owner = items[items.length - 2]; + expect(items).toHaveLength(source.includes("Outer") ? 3 : 2); + const displays = container.querySelectorAll(".katex-display"); + expect(displays).toHaveLength(source.includes("also") ? 2 : 1); + for (const display of displays) expect(display.closest("li")).toBe(owner); + expect(owner.textContent).toContain("after"); + expect(next.textContent?.trim()).toBe("Next"); + expect(owner.parentElement).toBe(next.parentElement); + expect(container.querySelector("pre")).toBeNull(); + expect(container.querySelector(".katex-error")).toBeNull(); + if (source.includes("[ ]") || source.includes("[x]")) + expect( + container.querySelectorAll('input[type="checkbox"]'), + ).toHaveLength(2); + }; + verify(); + rerender( + + {source} + , + ); + verify(); + expect( + container.querySelector('[data-voice-unspoken="true"]')?.textContent, + ).toContain("after"); + }); + + it.each([ + ["- ", "\n- Next"], + ["1. ", "\n2. Next"], + ["-\t", "\n- Next"], + ["> - ", "\n> - Next"], + ["- Outer\n - ", "\n - Next"], + ])("retains item-start equation ownership when the close streams in: %s", (prefix, following) => { + const { container, rerender } = render( + {prefix}, + ); + for (const equation of ["\\[x", "\\[x\\", "\\[x\\]"]) { + rerender( + + {prefix + equation + " after" + following} + , + ); + const items = container.querySelectorAll("li"); + const owner = items[items.length - 2]; + const next = items[items.length - 1]; + expect(items).toHaveLength(prefix.includes("Outer") ? 3 : 2); + expect(owner.textContent).toContain("after"); + expect(owner.parentElement).toBe(next.parentElement); + if (equation.endsWith("\\]")) + expect(next.textContent?.trim()).toBe("Next"); + else expect(next.textContent).toContain("Next"); + if (equation.endsWith("\\]")) + expect(container.querySelector(".katex-display")?.closest("li")).toBe( + owner, + ); + else expect(container.querySelector(".katex-display")).toBeNull(); + } + }); + + it.each([ + " ", + "x", + "X", + ])("preserves checkbox semantics and source for task math: %s", (state) => { + const source = `- [${state}] \\[x\\] after\n- [ ] Next`; + const { container } = render( + {source}, + ); + const items = container.querySelectorAll("li"); + expect(items).toHaveLength(2); + expect(items[0].textContent).toContain("[x] after"); + expect(items[1].textContent?.trim()).toBe("Next"); + const checkboxes = container.querySelectorAll( + 'input[type="checkbox"]', + ); + expect(checkboxes).toHaveLength(2); + expect(checkboxes[0].checked).toBe(state !== " "); + expect(checkboxes[1].checked).toBe(false); + expect(container.querySelector(".katex-display")).toBeNull(); + expect(container.querySelector("pre")).toBeNull(); + }); + + it("retains a later child under an equation-first parent", () => { + const { container } = render( + + {"- \\[x\\] after\n - Child\n- Next"} + , + ); + const rootList = container.querySelector("ul"); + const items = rootList?.children; + expect(items).toHaveLength(2); + expect(container.querySelector(".katex-display")?.closest("li")).toBe( + items?.[0], + ); + expect(items?.[0].querySelector("li")?.textContent?.trim()).toBe("Child"); + expect(items?.[1].textContent?.trim()).toBe("Next"); + }); + + it("renders an equation-only item at end of source", () => { + const { container } = render( + {"- \\[x\\]"}, + ); + const item = container.querySelector("li"); + expect(container.querySelectorAll("ul")).toHaveLength(1); + expect(container.querySelectorAll("li")).toHaveLength(1); + expect(container.querySelector(".katex-display")?.closest("li")).toBe(item); + expect(container.querySelector(".katex-error")).toBeNull(); + }); + + it("allows a later task-item paragraph and a plain child to render math", () => { + const source = + "- [ ] Task\n\n \\[x\\] after\n\n - \\[y\\] child\n- [ ] Next"; + const { container } = render( + {source}, + ); + const items = container.querySelectorAll("li"); + const math = container.querySelectorAll(".katex-display"); + expect(items).toHaveLength(3); + expect(math).toHaveLength(2); + expect(math[0].closest("li")).toBe(items[0]); + expect(math[1].closest("li")).toBe(items[1]); + expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength( + 2, + ); + }); + + it("preserves raw HTML code and GFM autolink destinations", () => { + const { container } = render( + + {String.raw`$\rightarrow$ https://example.com/$\alpha$ and \[x^2\]`} + , + ); + expect(container.querySelector("code")?.textContent).toBe( + String.raw`$\rightarrow$`, + ); + expect(container.querySelector("a")?.getAttribute("href")).toBe( + "https://example.com/$%5Calpha$", + ); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + }); + + it("updates a partially streamed formula once its closing delimiter arrives", () => { + const { container, rerender } = render( + {String.raw`\[x^2`}, + ); + expect(container.querySelector(".katex")).toBeNull(); + rerender({String.raw`\[x^2\]`}); + expect(container.querySelectorAll(".katex")).toHaveLength(1); + }); + + it("preserves unfinished inline code through the streaming renderer", () => { + const { container, rerender } = render( + {"`\\[literal\\]"}, + ); + expect(container.querySelector(".katex")).toBeNull(); + expect(container.querySelector("code")?.textContent).toBe( + String.raw`\[literal\]`, + ); + rerender( + {"`\\[literal\\]` then \\[x^2\\]"}, + ); + expect(container.querySelector("code")?.textContent).toBe( + String.raw`\[literal\]`, + ); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + }); + + it("keeps currency, shell text and actual code literal while showing symbols", () => { + const { container } = render( + + {"Revenue $20 and $10; $PATH; $\\rightarrow$. Code `\\[x^2\\]`."} + , + ); + expect(container.querySelector(".katex")).toBeNull(); + expect(container.textContent).toContain("Revenue $20 and $10; $PATH; →."); + expect(container.querySelector("code")?.textContent).toBe( + String.raw`\[x^2\]`, + ); + }); + + it("keeps the voice cutoff aligned after symbol shortening", () => { + const content = String.raw`$\alpha$ heard $\rightarrow$ unheard`; + const { container } = render( + + {content} + , + ); + expect( + container.querySelector('[data-voice-unspoken="true"]')?.textContent, + ).toBe("unheard"); + expect(screen.getByText("Not spoken:")).toBeTruthy(); + }); +}); diff --git a/src/shared/ui/ai-elements/message-math.test.ts b/src/shared/ui/ai-elements/message-math.test.ts new file mode 100644 index 000000000..489656f38 --- /dev/null +++ b/src/shared/ui/ai-elements/message-math.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMessageMath, prepareMessageMath } from "./message-math"; + +const normalize = (source: string) => normalizeMessageMath(source).content; +const display = (body: string) => `\n\n$$\n${body}\n$$\n\n`; + +describe("message math normalization", () => { + it("renders bracket display equations with fractions and inequalities", () => { + const equation = String.raw`\frac{a^2}{b} \le \text{limit}_{n}`; + expect(normalize(`Before \\[${equation}\\] after`)).toBe( + `Before ${display(equation)} after`, + ); + expect(normalize(`\\[\n${equation}\n\\]`)).toBe(display(equation)); + }); + + it("handles separate equations and symbols without changing formula commands", () => { + expect( + normalize( + String.raw`$\alpha$ then \[x\] $\rightarrow$ \[\text{value}\] $\Omega$`, + ), + ).toBe(`α then ${display("x")} → ${display(String.raw`\text{value}`)} Ω`); + }); + + it.each([ + String.raw`\(a^2\)`, + "$a^2$", + "$\\unknownCommand$", + "Revenue $20; margin $10.", + "Run $PATH and $HOME; use snake_case.", + "[ordinary brackets]", + String.raw`\[unfinished`, + String.raw`\\[literal\\]`, + String.raw`\$\rightarrow$`, + String.raw`$$\rightarrow$$`, + "$$\n\\[literal\\]\n$$", + ])("leaves unsupported, escaped, ordinary and existing math unchanged: %s", (source) => { + expect(normalize(source)).toBe(source); + }); + + it.each([ + "`\\[literal\\]`", + "``\\[literal ` text\\]``", + "`first```\\[literal\\]`", + "```tex\n\\[literal\\]\n```", + "````tex\n```\n\\[literal\\]\n````", + "~~~tex\n$\\rightarrow$\n~~~", + "> ```tex\n> \\[literal\\]\n> ```", + "- example\n ~~~tex\n \\[literal\\]\n ~~~", + "```tex\n> ```\n\\[literal\\]\n```", + "```tex\r\n\\[literal\\]\r\n```", + "```tex\n ```\n\\[literal\\]\n```", + "
\\[literal\\]
", + "[link](https://example.com/\\[literal\\])", + ])("preserves parser-recognized code and markup: %s", (source) => { + expect(normalize(source)).toBe(source); + expect(normalize(`${source}\n\n\\[live\\]`)).toBe( + `${source}\n\n${display("live")}`, + ); + }); + + it("protects indented code and unclosed streaming fences", () => { + const indented = " \\[literal\\]\n"; + expect(normalize(indented)).toBe(indented); + const unclosed = "```tex\n\\[unfinished\n\n\\[live\\]"; + expect(normalize(unclosed)).toBe(unclosed); + }); + + it.each([ + String.raw`before \[live\] then ` + "`" + String.raw`\[literal\]`, + "`" + String.raw`$\alpha$`, + "``" + String.raw`\[literal\]`, + ])("preserves the remainder of an incomplete inline code span: %s", (source) => { + const expected = source.replace(String.raw`\[live\]`, () => + display("live"), + ); + expect(normalize(source)).toBe(expected); + }); + + it("does not let an escaped backtick hide live math", () => { + expect(normalize("\\` " + String.raw`\[live\]`)).toBe( + "\\` " + display("live"), + ); + }); + + it.each([ + "- item\n ~~~text\n text\n", + "```text `invalid`\n", + " ```\n literal\n\n", + ])("does not let invalid pseudo-fences hide following equations: %s", (prefix) => { + const rendered = normalize(`${prefix}\\[live\\]`); + expect(rendered).toContain("$$\n"); + expect(rendered).toMatch(/\n[ ]*live\n[ ]*\$\$/); + expect(rendered).not.toContain(String.raw`\[live\]`); + }); + + it("distinguishes even and odd backslash runs at both delimiters", () => { + expect(normalize(String.raw`\\\[x\\\]`)).toBe( + String.raw`\\` + display(String.raw`x\\`), + ); + expect(normalize(String.raw`\[x\\] remains incomplete`)).toBe( + String.raw`\[x\\] remains incomplete`, + ); + }); + + it("leaves a split streaming delimiter unchanged until complete", () => { + const chunks = ["\\", "\\[", "\\[x^2", "\\[x^2\\", "\\[x^2\\]"]; + expect(chunks.map(normalize)).toEqual([ + ...chunks.slice(0, -1), + display("x^2"), + ]); + }); + + it("is idempotent for normalized equations and symbols", () => { + const source = String.raw`$\leq$ \[\frac{a}{b}\] $\rightarrow$`; + expect(normalize(normalize(source))).toBe(normalize(source)); + }); + + it("maps the voice boundary after multiple shortened symbols", () => { + const source = String.raw`$\alpha$ heard $\rightarrow$ unheard`; + const cutoff = source.indexOf("unheard"); + const result = normalizeMessageMath(source, cutoff); + expect(result).toEqual({ + content: "α heard → unheard", + cutoff: "α heard → ".length, + }); + expect(normalizeMessageMath(source, 4).cutoff).toBe(0); + expect(normalizeMessageMath("Nothing changed", 4)).toEqual({ + content: "Nothing changed", + cutoff: 4, + }); + }); + + it.each([ + String.raw`$\rightarrow$ \[literal\]`, + String.raw`
\[literal\]
`, + String.raw`$\alpha$`, + String.raw`$\alpha$`, + String.raw`\[unfinished`, + String.raw`ordinary`, + String.raw`https://example.com/$\alpha$`, + String.raw`www.example.com/$\alpha$`, + String.raw``, + ])("preserves HTML code bodies, attributes, and GFM destinations: %s", (source) => { + expect(normalize(source)).toBe(source); + }); + + it("keeps inline HTML code unchanged while converting following prose", () => { + const source = String.raw`\[literal\] $\alpha$`; + expect(normalize(source + String.raw` then $\rightarrow$`)).toBe( + source + " then →", + ); + }); + + it.each([ + [String.raw`\[x\]`, display("x")], + ["\\[a\n\nb\\]", display("a\n\nb")], + [String.raw`> \[x\]`, "> $$\n> x\n> $$\n>\n> "], + ["> \\[a\n> + b\\]", "> $$\n> a\n> + b\n> $$\n>\n> "], + ["> \\[a\n>+ b\\]", "> $$\n> a\n> + b\n> $$\n>\n> "], + [String.raw`- \[x\]`, "- $$\n x\n $$\n\n "], + ["- \\[a\n + b\\]", "- $$\n a\n + b\n $$\n\n "], + [String.raw`> - \[x\]`, "> - $$\n> x\n> $$\n>\n> "], + ["- outer\n - \\[x\\]", "- outer\n - $$\n x\n $$\n\n "], + [String.raw`1. \[x\]`, "1. $$\n x\n $$\n\n "], + ])("emits display blocks with the source container: %s", (source, expected) => { + expect(normalize(source)).toBe(expected); + expect(normalize(expected)).toBe(expected); + }); + + it.each([ + "- ", + "1.\t", + "> - ", + "- Outer\n - ", + ])("maps voice positions for an item-start equation: %s", (prefix) => { + const source = prefix + "\\[x\\] after\n"; + const prepared = prepareMessageMath(source); + const start = source.indexOf("\\["); + const end = source.indexOf("\\]") + 2; + expect(prepared.content.slice(0, start)).toBe(source.slice(0, start)); + for (let cutoff = start; cutoff < end; cutoff += 1) + expect(prepared.remapCutoff(cutoff)).toBe(start); + expect(prepared.remapCutoff(end)).toBe(prepared.content.indexOf(" after")); + expect(prepared.remapCutoff(source.indexOf("after"))).toBe( + prepared.content.indexOf("after"), + ); + expect(prepared.remapCutoff(source.length)).toBe(prepared.content.length); + expect(normalize(prepared.content)).toBe(prepared.content); + }); + + it.each([ + " ", + "x", + "X", + ])("preserves source and all offsets in a task paragraph: %s", (state) => { + const source = `- [${state}] \\[x\\] after\n- [ ] Next`; + const prepared = prepareMessageMath(source); + expect(prepared.content).toBe(source); + for (let cutoff = 0; cutoff <= source.length; cutoff += 1) + expect(prepared.remapCutoff(cutoff)).toBe(cutoff); + }); + + it.each([ + String.raw`**before \[x\] after**`, + String.raw`*before \[x\] after*`, + String.raw`~~before \[x\] after~~`, + String.raw`# Heading \[x\] after`, + "| Value |\n| -- |\n| \\[x\\] |", + String.raw`before \[x\] after`, + "\\[root\n\n> quote\\]", + ])("preserves bracket source where display insertion would change markup: %s", (source) => { + expect(normalize(source)).toBe(source); + }); + + it("still converts known symbols within inline formatting and table cells", () => { + expect(normalize(String.raw`**$\alpha$**`)).toBe("**α**"); + expect(normalize("| Value |\n| -- |\n| $\\alpha$ |")).toBe( + "| Value |\n| -- |\n| α |", + ); + }); + + it.each([ + "\\[unfinished\n\n## Next section\n\nHere is the closing marker \\]", + "- \\[unfinished\n\n ## Next section\n\n Here is the closing marker \\]", + "> \\[unfinished\n>\n> ## Next section\n>\n> Here is the closing marker \\]", + "\\[unfinished\n\nNext section\n============\n\nClosing \\]", + "\\[unfinished\n\n***\n\nClosing \\]", + "\\[unfinished\n\n| Value |\n| --- |\n| prose |\n\nClosing \\]", + "\\[unfinished\n\n>\n\nClosing \\]", + "\\[unfinished\n\n-\n\nClosing \\]", + ])("preserves intervening block structure: %s", (source) => { + const prepared = prepareMessageMath(source); + expect(prepared.content).toBe(source); + for (let cutoff = 0; cutoff <= source.length; cutoff += 1) + expect(prepared.remapCutoff(cutoff)).toBe(cutoff); + expect(normalize(`${source}\n\n\\[live\\]`)).toContain(display("live")); + }); + + it.each([ + ["-\t", " "], + ["1.\t", " "], + ["12.\t", " "], + ["123.\t", " "], + ["> -\t", "> "], + ])("uses tab-expanded source columns for a list prefix: %s", (marker, prefix) => { + const source = `${marker}Formula \\[x\\] afterwards`; + const prepared = prepareMessageMath(source); + expect(prepared.content).toContain( + `\n${prefix}$$\n${prefix}x\n${prefix}$$\n`, + ); + expect(prepared.content.endsWith(`${prefix} afterwards`)).toBe(true); + expect(prepared.remapCutoff(source.indexOf("afterwards"))).toBe( + prepared.content.indexOf("afterwards"), + ); + }); + + it("renders a multiline equation with tabbed list continuation", () => { + expect(normalize("-\tFormula \\[a\n\t+ b\\] afterwards")).toContain( + "\n $$\n a\n + b\n $$\n", + ); + }); + + it.each([ + "- First \\[x\n- Second y\\]", + "1. First \\[x\n2. Second y\\]", + "> - First \\[x\n> - Second y\\]", + "- Parent\n - First \\[x\n - Second y\\]", + "- First \\[x\n+ Second y\\]", + "\\[root\n> quote\\]", + ])("preserves delimiters crossing Markdown containers: %s", (source) => { + const prepared = prepareMessageMath(source); + expect(prepared.content).toBe(source); + expect(prepared.remapCutoff(source.indexOf("\\]"))).toBe( + source.indexOf("\\]"), + ); + expect(prepared.remapCutoff(source.length)).toBe(source.length); + }); + + it.each([ + "- Parent \\[x\n - Child y\\]", + "1. Parent \\[x\n 1. Child y\\]", + "> - Parent \\[x\n> - Child y\\]", + "- Parent \\[x\n + Child y\\]", + "- Parent \\[x\n * b\\]", + "- Parent \\[x\n - Child y\n z\\]", + ])("preserves equations crossing into a child item: %s", (source) => { + const prepared = prepareMessageMath(source); + expect(prepared.content).toBe(source); + for (let cutoff = 0; cutoff <= source.length; cutoff += 1) + expect(prepared.remapCutoff(cutoff)).toBe(cutoff); + }); + + it("maps boundaries across inserted display lines and shortened symbols", () => { + const source = String.raw`$\alpha$ before \[x^2\] after $\rightarrow$ tail`; + const prepared = prepareMessageMath(source); + const formulaStart = source.indexOf(String.raw`\[`); + const formulaEnd = source.indexOf(String.raw`\]`) + 2; + const outputStart = prepared.content.indexOf(display("x^2")); + expect(prepared.remapCutoff(formulaStart)).toBe(outputStart); + expect(prepared.remapCutoff(formulaStart + 4)).toBe(outputStart); + expect(prepared.remapCutoff(formulaEnd)).toBe( + outputStart + display("x^2").length, + ); + expect(prepared.remapCutoff(source.indexOf("tail"))).toBe( + prepared.content.indexOf("tail"), + ); + expect(prepared.remapCutoff(source.length)).toBe(prepared.content.length); + expect(prepared.remapCutoff()).toBeUndefined(); + }); + + it.each([ + "+ b", + "- b", + "+ 2.5", + "+ \\beta", + "+ b^2", + "- x_{12}", + ])("keeps a simple algebra continuation: %s", (term) => { + const source = `- Formula \\[a\n ${term}\\] afterwards`; + expect(normalize(source)).toContain(` a\n ${term}\n $$`); + expect(normalize(source)).not.toContain("\\["); + }); + it("normalizes equations through the source-size boundary", () => { + const source = "a".repeat(127_993) + String.raw`\[x^2\]`; + expect(source).toHaveLength(128_000); + expect(normalize(source)).toBe("a".repeat(127_993) + display("x^2")); + }); + + it.each([ + "a".repeat(128_000) + String.raw`\[x^2\]`, + "a".repeat(128_000) + "\n```tex\n" + String.raw`\[literal\]` + "\n```", + ])("preserves oversized source and voice offsets without parsing", (source) => { + const prepared = prepareMessageMath(source); + expect(prepared.content).toBe(source); + expect(prepared.remapCutoff(128_002)).toBe(128_002); + expect(prepared.remapCutoff()).toBeUndefined(); + }); +}); diff --git a/src/shared/ui/ai-elements/message-math.ts b/src/shared/ui/ai-elements/message-math.ts new file mode 100644 index 000000000..bcf5caad3 --- /dev/null +++ b/src/shared/ui/ai-elements/message-math.ts @@ -0,0 +1,522 @@ +import { math } from "@streamdown/math"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; +import { unified } from "unified"; + +// Use the renderer's math configuration and CommonMark's code boundaries. +const parser = unified() + .use(remarkParse) + .use(remarkGfm) + .use([math.remarkPlugin]); +// Bound the extra CommonMark pass on pathological model output. Larger +// messages retain native rendering and source offsets without partial edits. +const maxNormalizationLength = 128_000; +const protectedTypes = new Set([ + "code", + "inlineCode", + "html", + "math", + "inlineMath", + "link", + "linkReference", + "image", + "imageReference", + "definition", +]); + +const symbols: Readonly> = { + to: "→", + rightarrow: "→", + leftarrow: "←", + leftrightarrow: "↔", + Rightarrow: "⇒", + Leftarrow: "⇐", + Leftrightarrow: "⇔", + mapsto: "↦", + uparrow: "↑", + downarrow: "↓", + Uparrow: "⇑", + Downarrow: "⇓", + updownarrow: "↕", + Updownarrow: "⇕", + implies: "⇒", + iff: "⇔", + le: "≤", + leq: "≤", + ge: "≥", + geq: "≥", + ne: "≠", + neq: "≠", + approx: "≈", + sim: "∼", + simeq: "≃", + cong: "≅", + equiv: "≡", + propto: "∝", + pm: "±", + mp: "∓", + times: "×", + div: "÷", + cdot: "·", + infty: "∞", + degree: "°", + checkmark: "✓", + in: "∈", + notin: "∉", + ni: "∋", + subset: "⊂", + subseteq: "⊆", + supset: "⊃", + supseteq: "⊇", + cup: "∪", + cap: "∩", + emptyset: "∅", + forall: "∀", + exists: "∃", + neg: "¬", + land: "∧", + lor: "∨", + therefore: "∴", + because: "∵", + alpha: "α", + beta: "β", + gamma: "γ", + delta: "δ", + epsilon: "ε", + theta: "θ", + lambda: "λ", + mu: "μ", + pi: "π", + rho: "ρ", + sigma: "σ", + tau: "τ", + phi: "φ", + omega: "ω", + Delta: "Δ", + Gamma: "Γ", + Lambda: "Λ", + Pi: "Π", + Sigma: "Σ", + Phi: "Φ", + Omega: "Ω", +}; + +type SourceNode = { + type: string; + position?: { start: { offset?: number }; end: { offset?: number } }; + children?: SourceNode[]; + checked?: boolean | null; +}; +type Edit = { start: number; end: number; replacement: string }; +type Range = { start: number; end: number }; +type Paragraph = Range & { + index: number; + prefix: string; + container: number; + containerEnd: number; +}; + +function paragraphAt(paragraphs: Paragraph[], offset: number) { + let low = 0; + let high = paragraphs.length - 1; + while (low <= high) { + const middle = (low + high) >>> 1; + const paragraph = paragraphs[middle]; + if (offset < paragraph.start) high = middle - 1; + else if (offset >= paragraph.end) low = middle + 1; + else return paragraph; + } + return undefined; +} + +function overlapsRange(ranges: Range[], start: number, end: number) { + let low = 0; + let high = ranges.length - 1; + while (low <= high) { + const middle = (low + high) >>> 1; + const range = ranges[middle]; + if (range.end <= start) low = middle + 1; + else if (range.start >= end) high = middle - 1; + else return true; + } + return false; +} + +function mergeRanges(ranges: Range[]): Range[] { + ranges.sort((a, b) => a.start - b.start); + const merged: Range[] = []; + for (const range of ranges) { + const previous = merged[merged.length - 1]; + if (previous && range.start <= previous.end) + previous.end = Math.max(previous.end, range.end); + else merged.push({ ...range }); + } + return merged; +} + +function isEscaped(source: string, index: number): boolean { + let backslashes = 0; + while (index > 0 && source[--index] === "\\") backslashes += 1; + return backslashes % 2 === 1; +} + +function continuationPrefix(source: string): string { + // CommonMark indentation uses four-column tab stops, not character counts. + // Expand before replacing markers so nested list/quote columns stay aligned. + let column = 0; + let expanded = ""; + for (const character of source) { + const width = character === "\t" ? 4 - (column % 4) : 1; + expanded += character === "\t" ? " ".repeat(width) : character; + column += width; + } + return expanded.replace(/(?:[-+*]|\d+[.)]) +/g, (marker) => + " ".repeat(marker.length), + ); +} + +function displayReplacement( + body: string, + prefix: string, + atContainerParagraphStart: boolean, +): string { + const lines = body.split(/\r?\n/); + const continuation = new RegExp( + "^" + prefix.replace(/[ \t]+/g, (spaces) => `[ \\t]{0,${spaces.length}}`), + ); + // The first line starts after \\[. Subsequent lines still carry their + // Markdown quote/list continuation prefix, which is not part of TeX. + for (let index = 1; index < lines.length; index += 1) { + if (prefix) lines[index] = lines[index].replace(continuation, ""); + } + while (lines.length && !lines[0].trim()) lines.shift(); + while (lines.length && !lines[lines.length - 1].trim()) lines.pop(); + const blank = prefix.trimEnd(); + // A list marker with no preceding prose cannot survive a blank line before + // its first block. Start the display on that retained marker/quote line. + const opening = atContainerParagraphStart ? "$$" : `\n${blank}\n${prefix}$$`; + return `${opening}\n${lines.map((line) => prefix + line).join("\n")}\n${prefix}$$\n${blank}\n${prefix}`; +} + +function isOperatorContinuation(body: string, prefix: string): boolean { + // CommonMark can mistake a simple algebra continuation for a child bullet. + // Admit only an operator followed by one explicit math atom. Ambiguous prose + // and more complex cross-item source retain their original Markdown instead. + const continuation = new RegExp( + "^" + prefix.replace(/[ \t]+/g, (spaces) => `[ \\t]{0,${spaces.length}}`), + ); + return body + .split(/\r?\n/) + .slice(1) + .every((line) => { + const source = (prefix ? line.replace(continuation, "") : line).trim(); + return /^[+-][ \t]+(?:[A-Za-z]|\d+(?:\.\d+)?|\\[A-Za-z]+)(?:[_^](?:[A-Za-z0-9]|\{[A-Za-z0-9]+\}))?$/.test( + source, + ); + }); +} + +function segmentEdits( + source: string, + offset: number, + paragraphs: Paragraph[], + nonDisplayRanges: Range[], +): Edit[] { + const edits: Edit[] = []; + let opening: number | undefined; + for (const match of source.matchAll(/\\[[\]]/g)) { + const index = match.index; + if (isEscaped(source, index)) continue; + if (match[0] === "\\[") { + opening ??= index; + } else if (opening !== undefined) { + const start = offset + opening; + const end = offset + index + 2; + const paragraph = paragraphAt(paragraphs, start); + const closingParagraph = paragraphAt(paragraphs, end - 1); + const body = source.slice(opening + 2, index); + // Block displays cannot be inserted into inline formatting, table cells, + // or across unrelated list/quote containers without changing their markup. + // A math continuation such as "+ b" can parse as a nested list. Only + // admit that narrow algebra case across containers; a parent's source + // span also contains real child items and cannot alone protect them. + // Preserve unrelated source rather than manufacture an invalid block. + if ( + !paragraph || + !closingParagraph || + end > paragraph.containerEnd || + (paragraph.prefix.match(/>/g)?.length ?? 0) !== + (closingParagraph.prefix.match(/>/g)?.length ?? 0) || + (paragraphs + .slice(paragraph.index, closingParagraph.index + 1) + .some( + (candidate) => + candidate.start < end && + candidate.end > start && + candidate.container !== paragraph.container, + ) && + !isOperatorContinuation(body, paragraph.prefix)) || + overlapsRange(nonDisplayRanges, start, end) + ) { + opening = undefined; + continue; + } + edits.push({ + start, + end, + replacement: displayReplacement( + body, + paragraph.prefix, + start === paragraph.start && paragraph.prefix.length > 0, + ), + }); + opening = undefined; + } + } + // Symbols inside bracket equations belong to KaTeX, not this prose pass. + const equations = [...edits]; + let equationIndex = 0; + for (const match of source.matchAll(/\$\\([A-Za-z]+)\$/g)) { + const start = offset + match.index; + while (equations[equationIndex] && equations[equationIndex].end <= start) + equationIndex += 1; + const equation = equations[equationIndex]; + const symbol = symbols[match[1]]; + if ( + !symbol || + isEscaped(source, match.index) || + (equation && start >= equation.start) + ) + continue; + edits.push({ start, end: start + match[0].length, replacement: symbol }); + } + return edits.sort((a, b) => a.start - b.start); +} + +export type PreparedMessageMath = { + content: string; + remapCutoff: (cutoff?: number) => number | undefined; +}; + +/** Prepare supported math once per source change, independent of voice updates. */ +export function prepareMessageMath(content: string): PreparedMessageMath { + if ( + content.length > maxNormalizationLength || + (!content.includes("\\[") && !/\$\\[A-Za-z]+\$/.test(content)) + ) { + return { content, remapCutoff: (cutoff) => cutoff }; + } + const protectedRanges: Range[] = []; + const paragraphs: Paragraph[] = []; + const htmlRanges: Range[] = []; + const nonDisplayRanges: Range[] = []; + // Leaf blocks and empty containers are barriers even when the equation's + // opening and closing paragraphs share the same parent. + const nonDisplayTypes = new Set([ + "emphasis", + "strong", + "delete", + "heading", + "thematicBreak", + "table", + ]); + let nextContainer = 0; + const visit = ( + node: SourceNode, + container = 0, + containerEnd = content.length, + ) => { + if (node.type === "blockquote" || node.type === "listItem") + container = ++nextContainer; + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if ( + (node.type === "blockquote" || node.type === "listItem") && + end !== undefined + ) + containerEnd = end; + if (node.type === "paragraph" && start !== undefined && end !== undefined) { + const lineStart = content.lastIndexOf("\n", start - 1) + 1; + // A paragraph's source position begins after its container markers. + // Keep quote markers and turn list markers into continuation indentation. + const prefix = continuationPrefix(content.slice(lineStart, start)); + paragraphs.push({ + index: paragraphs.length, + start, + end, + prefix, + container, + containerEnd, + }); + } + if ( + (nonDisplayTypes.has(node.type) || + ((node.type === "blockquote" || node.type === "listItem") && + !node.children?.length)) && + start !== undefined && + end !== undefined + ) + nonDisplayRanges.push({ start, end }); + // GFM task markers belong to their first paragraph. Replacing that + // paragraph with a display block loses checkbox semantics, so preserve + // bracket source there just as in other non-display Markdown contexts. + if (node.type === "listItem" && typeof node.checked === "boolean") { + const first = node.children?.[0]; + const taskStart = first?.position?.start.offset; + const taskEnd = first?.position?.end.offset; + if ( + first?.type === "paragraph" && + taskStart !== undefined && + taskEnd !== undefined + ) + nonDisplayRanges.push({ start: taskStart, end: taskEnd }); + } + if (node.type === "html" && start !== undefined && end !== undefined) + htmlRanges.push({ start, end }); + if (protectedTypes.has(node.type)) { + if (start !== undefined && end !== undefined) + protectedRanges.push({ start, end }); + return; + } + for (const child of node.children ?? []) + visit(child, container, containerEnd); + }; + visit(parser.parse(content)); + // Inline HTML tags are separate AST nodes from their bodies. Preserve code + // and pre contents through the matching close, or the end of a partial stream. + const rawCode: { tag: string; start: number }[] = []; + const rawHtml: { tag: string; start: number }[] = []; + const voidTags = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + ]); + for (const range of htmlRanges) { + const html = content.slice(range.start, range.end); + for (const match of html.matchAll( + /<\s*(\/?)\s*([A-Za-z][\w-]*)\b[^>]*>/g, + )) { + const tag = match[2].toLowerCase(); + if (voidTags.has(tag) || /\/\s*>$/.test(match[0])) continue; + if (!match[1]) { + rawHtml.push({ tag, start: range.start + match.index }); + } else { + let opening = rawHtml.length - 1; + while (opening >= 0 && rawHtml[opening].tag !== tag) opening -= 1; + if (opening !== -1) { + nonDisplayRanges.push({ + start: rawHtml[opening].start, + end: range.start + match.index + match[0].length, + }); + rawHtml.splice(opening); + } + } + } + for (const match of html.matchAll(/<\s*(\/?)\s*(code|pre)\b[^>]*>/gi)) { + const tag = match[2].toLowerCase(); + if (!match[1]) { + rawCode.push({ tag, start: range.start + match.index }); + } else { + let opening = rawCode.length - 1; + while (opening >= 0 && rawCode[opening].tag !== tag) opening -= 1; + if (opening !== -1) { + protectedRanges.push({ + start: rawCode[opening].start, + end: range.start + match.index + match[0].length, + }); + rawCode.splice(opening); + } + } + } + } + for (const opening of rawCode) + protectedRanges.push({ start: opening.start, end: content.length }); + for (const opening of rawHtml) + nonDisplayRanges.push({ start: opening.start, end: content.length }); + const existingProtection = mergeRanges(protectedRanges); + // Streamdown can complete an unfinished inline code span while streaming. + // CommonMark has no code node yet, so conservatively preserve its remainder. + for (const paragraph of paragraphs) { + const source = content.slice(paragraph.start, paragraph.end); + for (const match of source.matchAll(/`+/g)) { + const start = paragraph.start + match.index; + const lineEnd = source.indexOf("\n", match.index); + if ( + match.index === 0 && + match[0].length >= 3 && + source + .slice(match[0].length, lineEnd === -1 ? undefined : lineEnd) + .includes("`") + ) + continue; + if ( + !isEscaped(content, start) && + !overlapsRange(existingProtection, start, start + match[0].length) + ) { + protectedRanges.push({ start, end: paragraph.end }); + break; + } + } + } + const mergedRanges = mergeRanges(protectedRanges); + const mergedNonDisplayRanges = mergeRanges(nonDisplayRanges); + const edits: Edit[] = []; + let position = 0; + for (const range of mergedRanges) { + edits.push( + ...segmentEdits( + content.slice(position, range.start), + position, + paragraphs, + mergedNonDisplayRanges, + ), + ); + position = range.end; + } + edits.push( + ...segmentEdits( + content.slice(position), + position, + paragraphs, + mergedNonDisplayRanges, + ), + ); + const parts: string[] = []; + position = 0; + for (const edit of edits) { + parts.push(content.slice(position, edit.start), edit.replacement); + position = edit.end; + } + parts.push(content.slice(position)); + return { + content: parts.join(""), + remapCutoff: (cutoff) => { + if (cutoff === undefined) return undefined; + let delta = 0; + for (const edit of edits) { + if (cutoff < edit.start) break; + // A partially spoken symbol or formula stays wholly unspoken. + if (cutoff < edit.end) return edit.start + delta; + delta += edit.replacement.length - (edit.end - edit.start); + } + return cutoff + delta; + }, + }; +} + +/** Compatibility wrapper for callers that already supply a voice boundary. */ +export function normalizeMessageMath(content: string, cutoff?: number) { + const prepared = prepareMessageMath(content); + return { content: prepared.content, cutoff: prepared.remapCutoff(cutoff) }; +} diff --git a/src/shared/ui/ai-elements/message.tsx b/src/shared/ui/ai-elements/message.tsx index f30a8f280..87cfbf5cb 100644 --- a/src/shared/ui/ai-elements/message.tsx +++ b/src/shared/ui/ai-elements/message.tsx @@ -13,9 +13,11 @@ import { LinkSafetyModal } from "@/shared/ui/ai-elements/link-safety-modal"; import { cn } from "@/shared/lib/cn"; import { useVirtualLayoutPendingForStreamdown } from "@/features/chat/transcript/measurement"; import { useStreamdownTableScrollbarSizing } from "@/shared/ui/ai-elements/streamdown-table-scrollbar"; +import { prepareMessageMath } from "@/shared/ui/ai-elements/message-math"; import { cjk } from "@streamdown/cjk"; import { code } from "@streamdown/code"; import { math } from "@streamdown/math"; +import "katex/dist/katex.min.css"; import { mermaid } from "@streamdown/mermaid"; import type { UIMessage } from "ai"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; @@ -974,6 +976,10 @@ export const MessageResponse = memo( }: MessageResponseProps) => { const { t } = useTranslation("common"); const [modalUrl, setModalUrl] = useState(null); + const normalized = useMemo( + () => prepareMessageMath(children ?? ""), + [children], + ); const streamdownComponents = useMemo( () => buildStreamdownComponents(imageRenderer, strikethroughLabel), [imageRenderer, strikethroughLabel], @@ -988,12 +994,12 @@ export const MessageResponse = memo( ...berdRehypePlugins, [ strikethroughFromPlugin, - strikethroughFrom, + normalized.remapCutoff(strikethroughFrom), strikethroughLabel, - children, + normalized.content, ], ], - [children, strikethroughFrom, strikethroughLabel], + [normalized, strikethroughFrom, strikethroughLabel], ); const streamdownRootRef = useRef(null); const streamdownLayoutPending = useVirtualLayoutPendingForStreamdown({ @@ -1075,7 +1081,7 @@ export const MessageResponse = memo( } {...props} > - {children} + {normalized.content}