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}