diff --git a/apps/web/package.json b/apps/web/package.json index 4d10f89743b6..36b49cc28176 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -60,6 +60,7 @@ "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", + "katex": "^0.16.22", "lucide": "^0.564.0", "lucide-react": "^0.564.0", "mermaid": "^11.17.2", @@ -67,6 +68,7 @@ "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", "remark-gfm": "catalog:", "tailwind-merge": "^3.4.0", "unbash": "^5.0.0", diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index d55986a9589e..860659313480 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -10,12 +10,14 @@ import { MarkdownFindContext } from "./chat/markdownFindContext"; import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { GitHubIcon } from "./Icons"; import { Button } from "./ui/button"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; +import { serializeRenderedMarkdownFragment } from "../markdown-clipboard"; +import katex from "katex"; vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); @@ -24,13 +26,18 @@ vi.mock("./chat/MermaidDiagram", () => ({ MermaidDiagram: () => , })); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +const mathPreference = vi.hoisted(() => ({ + latexRenderingMode: "on" as "off" | "readable" | "on", +})); vi.mock("../hooks/useSettings", async (importOriginal) => { const actual = await importOriginal(); const settings = actual.getClientSettings(); return { ...actual, - useClientSettings: (select?: (value: typeof settings) => unknown) => - select ? select(settings) : settings, + useClientSettings: (select?: (value: typeof settings) => unknown) => { + const current = { ...settings, ...mathPreference }; + return select ? select(current) : current; + }, }; }); vi.mock("./ui/tooltip", async () => { @@ -81,6 +88,7 @@ vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn(), })); +import { loadChatMathPlugins } from "./chat/useChatMath"; import ChatMarkdown, { canUseMarkdownFileShellActions, hasMarkdownFilePrimaryAction, @@ -95,6 +103,327 @@ function codeButton(renderer: ReactTestRenderer, label: string) { return button.props as ComponentProps; } +describe("ChatMarkdown math", () => { + beforeAll(() => Promise.all([loadChatMathPlugins(), loadChatMathPlugins("readable")])); + it.each([ + { mode: "on", parseRawHtml: true }, + { mode: "on", parseRawHtml: false }, + { mode: "readable", parseRawHtml: true }, + { mode: "readable", parseRawHtml: false }, + ] as const)( + "keeps heading links and HTML handling with $mode math (parseRawHtml=$parseRawHtml)", + async ({ mode, parseRawHtml }) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const authoredHtml = 'Formatted'; + const text = `[Equation](#equation)\n\n## Equation\n\n\\(\\frac{17}{29}\\)\n\n${authoredHtml}`; + const url = window.location.href; + try { + mathPreference.latexRenderingMode = mode; + await act(async () => + root.render(), + ); + if (mode === "on") { + expect(container.querySelector("mfrac")?.textContent).toBe("1729"); + } else { + expect(container.querySelector(".math-readable-inline")?.textContent).toBe("17 / 29"); + } + expect(container.querySelector("script, [onclick]")).toBeNull(); + if (parseRawHtml) { + expect(container.querySelector("b")?.textContent).toBe("Formatted"); + } else { + expect(container.querySelector("b")).toBeNull(); + expect(container.textContent).toContain(authoredHtml); + } + + const heading = container.querySelector("h2")!; + expect(heading.id).toBe("user-content-equation"); + const scrollIntoView = vi.fn(); + heading.scrollIntoView = scrollIntoView; + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + container.querySelector("a")!.dispatchEvent(click); + expect(click.defaultPrevented).toBe(true); + expect(scrollIntoView.mock.contexts).toEqual([heading]); + expect(window.location.href).toBe(url); + } finally { + mathPreference.latexRenderingMode = "on"; + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } + }, + ); + it.each(["on", "readable"] as const)( + "keeps math fences as code in %s mode when an unrelated equation is added", + async (mode) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + const root = createRoot(container); + const fence = "```math\nx^2\n```"; + try { + mathPreference.latexRenderingMode = mode; + await act(async () => root.render()); + expect(container.querySelector("pre code")?.textContent).toContain("x^2"); + await act(async () => + root.render(), + ); + expect(container.querySelector("pre code")?.textContent).toContain("x^2"); + const selector = mode === "on" ? ".katex" : ".math-readable-inline"; + expect(container.querySelectorAll(selector)).toHaveLength(1); + expect(container.querySelector(".katex-display, .math-readable-display")).toBeNull(); + } finally { + mathPreference.latexRenderingMode = "on"; + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } + }, + ); + it("skips visual HTML in readable mode without reusing it for typeset mode", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const renderMath = vi.spyOn(katex, "renderToString"); + const container = document.createElement("div"); + const root = createRoot(container); + const text = String.raw`\(\frac{139}{251}\)`; + try { + mathPreference.latexRenderingMode = "readable"; + await act(async () => root.render()); + expect(container.querySelector(".math-readable-inline")?.textContent).toBe("139 / 251"); + expect(renderMath).toHaveBeenCalledTimes(1); + expect(renderMath.mock.calls[0]?.[1]).toMatchObject({ output: "mathml", trust: false }); + + mathPreference.latexRenderingMode = "on"; + await act(async () => + root.render(), + ); + expect(container.querySelector(".katex-html")).not.toBeNull(); + expect(renderMath).toHaveBeenCalledTimes(2); + expect(renderMath.mock.calls[1]?.[1]).toMatchObject({ + output: "htmlAndMathml", + trust: false, + }); + } finally { + mathPreference.latexRenderingMode = "on"; + renderMath.mockRestore(); + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } + }); + it.each([ + [String.raw`\frac{1}{2}`, "1 / 2"], + [String.raw`x^2+b_{12}`, "x^2 + b[12]"], + [String.raw`x_a^b`, "x[a]^b"], + [String.raw`\int_0^\pi \sin x\,dx=2`, "integral[0 to π]"], + [String.raw`\sum_{n=1}^{\infty}\frac{1}{n^2}`, "sum[n = 1 to ∞]"], + [String.raw`\lim_{x\to0}\frac{\sin x}{x}=1`, "lim(x → 0)"], + [String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, "sqrt(b^2 − 4 a c)"], + [String.raw`\begin{pmatrix}3&8\\-2&6\end{pmatrix}`, "3"], + [String.raw`f(x)=\begin{cases}x^2&x\ge0\\-x&x<0\end{cases}`, "cases:\nx^2 if x ≥ 0"], + ])("shows readable text for %s and preserves its source when copying", async (tex, expected) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + const root = createRoot(container); + try { + mathPreference.latexRenderingMode = "readable"; + await act(async () => root.render()); + const readable = container.querySelector(".math-readable-display"); + expect(readable?.textContent).toContain(expected); + expect(container.querySelector(".katex")).toBeNull(); + expect(container.querySelector("math")).toBeNull(); + expect(serializeRenderedMarkdownFragment(container)).toContain(tex); + } finally { + mathPreference.latexRenderingMode = "on"; + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } + }); + it("reuses an unchanged equation while surrounding text streams", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const renderMath = vi.spyOn(katex, "renderToString"); + const container = document.createElement("div"); + const root = createRoot(container); + const tex = String.raw`\frac{137}{241}`; + try { + for (let index = 0; index < 10; index++) { + await act(async () => + root.render( + , + ), + ); + expect(container.querySelector("mfrac")?.textContent).toBe("137241"); + expect(container.textContent).toContain(`next ${index}`); + } + expect(renderMath).toHaveBeenCalledTimes(1); + } finally { + renderMath.mockRestore(); + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } + }); + it("shows source when disabled and restores equations when enabled", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + const root = createRoot(container); + const text = String.raw`Before \(x^2\) after.`; + try { + mathPreference.latexRenderingMode = "off"; + await act(async () => root.render()); + expect(container.querySelector(".katex")).toBeNull(); + expect(container.textContent).toBe("Before (x^2) after."); + mathPreference.latexRenderingMode = "on"; + await act(async () => + root.render(), + ); + expect(container.querySelector("msup")?.textContent).toBe("x2"); + } finally { + mathPreference.latexRenderingMode = "on"; + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } + }); + it.each([ + { + name: "definite integrals", + tex: String.raw`\int_0^\pi \sin x\,dx=2`, + selector: "msubsup", + rendered: "∫0π", + }, + { + name: "improper integrals with square roots", + tex: String.raw`\int_{-\infty}^{\infty}e^{-x^2}\,dx=\sqrt{\pi}`, + selector: "msqrt", + rendered: "π", + }, + { + name: "double integrals", + tex: String.raw`\int_0^1\int_0^1(x+y)\,dx\,dy=1`, + selector: "msubsup", + rendered: "∫01", + }, + { + name: "infinite series", + tex: String.raw`\sum_{n=1}^{\infty}\frac{1}{n^2}=\frac{\pi^2}{6}`, + selector: "munderover", + rendered: "∑n=1∞", + }, + { + name: "limits with fractions", + tex: String.raw`\lim_{x\to0}\frac{\sin x}{x}=1`, + selector: "munder", + rendered: "lim\u2061x→0", + }, + { + name: "the quadratic formula", + tex: String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, + selector: "msqrt", + rendered: "b2−4ac", + }, + { + name: "aligned integration steps", + tex: String.raw`\begin{aligned}\int xe^x\,dx&=xe^x-\int e^x\,dx\\&=e^x(x-1)+C\end{aligned}`, + selector: "mtable mtr:last-child", + rendered: "=ex(x−1)+C", + }, + { + name: "piecewise functions", + tex: String.raw`f(x)=\begin{cases}x^2&x\ge0\\-x&x<0\end{cases}`, + selector: "mtable mtr:last-child", + rendered: "−xx<0", + }, + ])("typesets $name into readable MathML", async ({ tex, selector, rendered }) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => { + root.render(); + }); + expect(container.querySelector(".katex-error")).toBeNull(); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + expect(container.querySelector("annotation")?.textContent?.trim()).toBe(tex); + expect(container.querySelector(selector)?.textContent).toBe(rendered); + } finally { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } + }); + + it.each([true, false])( + "renders the diagnostic matrix with parseRawHtml=%s", + async (parseRawHtml) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => { + root.render( + , + ); + }); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + expect(container.querySelectorAll(".katex")).toHaveLength(3); + const rows = container.querySelectorAll("mtable mtr"); + expect( + [...rows].map((row) => [...row.querySelectorAll("mtd")].map((cell) => cell.textContent)), + ).toEqual([ + ["3", "8", "1"], + ["−2", "6", "4"], + ]); + expect(container.querySelector("msub")?.textContent).toBe("b12"); + expect(container.querySelectorAll("pre")).toHaveLength(0); + const copied = serializeRenderedMarkdownFragment(container); + expect(copied).toContain(String.raw`B=\begin{pmatrix}3&8&1\\-2&6&4\end{pmatrix}`); + expect(copied).toContain(String.raw`\(b_{12}\)`); + } finally { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } + }, + ); + + it("renders dollar math and keeps malformed LaTeX from breaking the message", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + const root = createRoot(container); + try { + await act(async () => { + root.render( + , + ); + }); + expect(container.querySelectorAll(".katex")).toHaveLength(2); + expect(container.querySelector("mfrac")?.textContent).toBe("12"); + expect(container.querySelector(".katex-error")?.textContent).toBe("\\frac{"); + expect(container.textContent).toContain("and still readable."); + } finally { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } + }); +}); + describe("ChatMarkdown bare anchor placeholders", () => { it.each(["", "", "", "", "", ""])( "preserves unmatched %s without linking later blocks", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a7a2b0c4eba2..38558e2bfe87 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -98,6 +98,7 @@ import type { Options as ReactMarkdownOptions, } from "react-markdown"; import ReactMarkdown from "react-markdown"; +import { useChatMathPlugins } from "./chat/useChatMath"; import { toHtml } from "hast-util-to-html"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; @@ -3404,6 +3405,8 @@ function ChatMarkdown({ extraRemarkPlugins = EMPTY_REMARK_PLUGINS, ...props }: ChatMarkdownProps) { + const mathMode = useClientSettings((settings) => settings.latexRenderingMode); + const mathPlugins = useChatMathPlugins(mathMode, text); const { componentState, handleCopy, @@ -3419,11 +3422,20 @@ function ChatMarkdown({ const remarkPlugins = useMemo( () => [ ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), + ...(mathPlugins?.remark ?? EMPTY_REMARK_PLUGINS), ...extraRemarkPlugins, ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), ], - [extraRemarkPlugins, incrementalParsing, lineBreaks], + [extraRemarkPlugins, incrementalParsing, lineBreaks, mathPlugins], ); + const rehypePlugins = useMemo(() => { + if (!mathPlugins) { + return parseRawHtml + ? CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS + : CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS; + } + return [...(parseRawHtml ? mathPlugins.rehype : mathPlugins.literalRehype), rehypeHeadingIds]; + }, [mathPlugins, parseRawHtml]); // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the @@ -3442,11 +3454,7 @@ function ChatMarkdown({ >[0]; +type RootContent = Root["children"][number]; +type Element = Extract; + +const MAX_CACHED_EXPRESSIONS = 64; +const expressionCache = new Map(); +type MathOutput = "htmlAndMathml" | "mathml"; + +function rehypeCachedKatex({ output }: { output: MathOutput }): ReturnType { + const render = rehypeKatex({ trust: false, output }); + return (tree, file) => { + visit(tree); + + function visit(parent: Root | Element) { + for (let index = 0; index < parent.children.length; index++) { + const child = parent.children[index]; + if (child?.type !== "element") continue; + const code = child.tagName === "pre" ? child.children[0] : child; + const classes = code?.type === "element" ? code.properties.className : undefined; + if ( + code?.type !== "element" || + code.tagName !== "code" || + !Array.isArray(classes) || + !classes.some((name) => ["math-inline", "math-display"].includes(String(name))) + ) { + visit(child); + continue; + } + const value = code.children + .map((node) => (node.type === "text" ? node.value : "")) + .join(""); + const display = child.tagName === "pre" || classes.includes("math-display"); + const key = `${output}\0${display}\0${value}`; + let result = expressionCache.get(key); + if (!result) { + const fragment: Root = { type: "root", children: [child] }; + render(fragment, file); + result = fragment.children; + expressionCache.set(key, structuredClone(result)); + if (expressionCache.size > MAX_CACHED_EXPRESSIONS) { + expressionCache.delete(expressionCache.keys().next().value!); + } + } else { + // Each processor owns mutable nodes; never hand it the cached tree. + result = structuredClone(result); + } + parent.children.splice(index, 1, ...(result as Element["children"])); + index += result.length - 1; + } + } + }; +} + +export function createChatMathPlugins(output: MathOutput = "htmlAndMathml") { + const katexPlugins: PluggableList = [[rehypeCachedKatex, { output }]]; + return { + remark: [remarkChatMath] satisfies PluggableList, + // Sanitize authored HTML before KaTeX generates its own MathML and styles. + rehype: [...CHAT_MARKDOWN_REHYPE_PLUGINS, ...katexPlugins], + literalRehype: katexPlugins, + }; +} + +export const CHAT_MATH_PLUGINS = createChatMathPlugins(); diff --git a/apps/web/src/components/chat/markdownMathReadable.ts b/apps/web/src/components/chat/markdownMathReadable.ts new file mode 100644 index 000000000000..ece7782d128a --- /dev/null +++ b/apps/web/src/components/chat/markdownMathReadable.ts @@ -0,0 +1,153 @@ +import type rehypeKatex from "rehype-katex"; +import { createChatMathPlugins } from "./markdownMath"; + +type Root = Parameters>[0]; +type Node = Root["children"][number]; +type Element = Extract; + +function childrenOf(node: Node): Node[] { + return "children" in node ? node.children : []; +} + +function findElement(node: Node, tagName: string): Element | undefined { + if (node.type === "element" && node.tagName === tagName) return node; + for (const child of childrenOf(node)) { + const found = findElement(child, tagName); + if (found) return found; + } +} + +function grouped(value: string): string { + return /^[\p{L}\p{N}]+$/u.test(value) ? value : `(${value})`; +} + +/** Linearize semantic MathML rather than guessing at TeX command boundaries. */ +function readableMath(node: Node): string { + if (node.type === "text") return node.value.replace(/[\u2061-\u2064]/g, ""); + if (node.type !== "element") return ""; + const children = node.children; + const values = children.map(readableMath); + const base = values[0] ?? ""; + switch (node.tagName) { + case "annotation": + case "annotation-xml": + return ""; + case "semantics": + return base; + case "mfrac": + return `${grouped(base)} / ${grouped(values[1] ?? "")}`; + case "msqrt": + return `sqrt(${values.join(" ")})`; + case "mroot": + return `root(${values[1]}, ${base})`; + case "msup": + return `${grouped(base)}^${grouped(values[1] ?? "")}`; + case "msub": + return `${grouped(base)}[${values[1]}]`; + case "msubsup": + case "munderover": { + const operator = + base === "∫" ? "integral" : base === "∑" ? "sum" : base === "∏" ? "product" : base; + if (base === "∫" || base === "∑" || base === "∏") { + return `${operator}[${values[1]} to ${values[2]}]`; + } + return node.tagName === "msubsup" + ? `${grouped(base)}[${values[1]}]^${grouped(values[2] ?? "")}` + : `${base} (with ${values[1]} below and ${values[2]} above)`; + } + case "munder": + return `${base}(${values[1]})`; + case "mover": + return `${base} (with ${values[1]} above)`; + case "mrow": { + const table = children.find( + (child) => child.type === "element" && child.tagName === "mtable", + ); + if (base === "{" && table) { + return `cases:\n${childrenOf(table) + .map((row) => { + const cells = childrenOf(row).map(readableMath); + return `${cells[0]} if ${cells.slice(1).join(" ")}`; + }) + .join("\n")}`; + } + return values + .join(" ") + .replace(/[ \t]+/g, " ") + .trim(); + } + case "mtable": { + const rows = children.map((row) => childrenOf(row).map(readableMath)); + const widths = rows.reduce((result, row) => { + row.forEach((cell, index) => { + result[index] = Math.max(result[index] ?? 0, cell.length); + }); + return result; + }, []); + return `\n${rows + .map((row) => + row + .map((cell, index) => cell.padEnd(widths[index] ?? 0)) + .join(" ") + .trimEnd(), + ) + .join("\n")}\n`; + } + case "mspace": + case "mphantom": + return " "; + default: + return values + .join(" ") + .replace(/[ \t]+/g, " ") + .trim(); + } +} + +function rehypeReadableMath(): ReturnType { + return (tree) => { + visit(tree); + function visit(parent: Root | Element) { + for (let index = 0; index < parent.children.length; index++) { + const node = parent.children[index]; + if (node?.type !== "element") continue; + const classes = node.properties.className; + const display = Array.isArray(classes) && classes.includes("katex-display"); + const inline = Array.isArray(classes) && classes.includes("katex"); + if (Array.isArray(classes) && classes.includes("katex-error")) { + node.properties.className = ["math-readable-inline"]; + delete node.properties.style; + continue; + } + if (!display && !inline) { + visit(node); + continue; + } + const math = findElement(node, "math"); + const annotation = findElement(node, "annotation"); + if (!math || !annotation) continue; + const displayMode = display || math.properties.display === "block"; + const source = annotation.children + .map((child) => (child.type === "text" ? child.value : "")) + .join(""); + parent.children[index] = { + type: "element", + tagName: "span", + properties: { + className: [displayMode ? "math-readable-display" : "math-readable-inline"], + dataMarkdownCopy: displayMode ? `\\[\n${source}\n\\]` : `\\(${source}\\)`, + }, + children: [{ type: "text", value: readableMath(math).trim() }], + }; + } + } + }; +} + +const mathmlPlugins = createChatMathPlugins("mathml"); + +export const CHAT_MATH_PLUGINS = { + ...mathmlPlugins, + rehype: [...mathmlPlugins.rehype, rehypeReadableMath], + literalRehype: [...mathmlPlugins.literalRehype, rehypeReadableMath], +}; diff --git a/apps/web/src/components/chat/markdownMathRendered.ts b/apps/web/src/components/chat/markdownMathRendered.ts new file mode 100644 index 000000000000..c6542801c3b2 --- /dev/null +++ b/apps/web/src/components/chat/markdownMathRendered.ts @@ -0,0 +1,3 @@ +import "katex/dist/katex.min.css"; + +export { CHAT_MATH_PLUGINS } from "./markdownMath"; diff --git a/apps/web/src/components/chat/useChatMath.test.tsx b/apps/web/src/components/chat/useChatMath.test.tsx new file mode 100644 index 000000000000..d9f8583663cf --- /dev/null +++ b/apps/web/src/components/chat/useChatMath.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import ReactMarkdown from "react-markdown"; +import { + CHAT_MARKDOWN_REMARK_PLUGINS, + CHAT_MARKDOWN_REHYPE_PLUGINS, +} from "@t3tools/shared/markdownPipeline"; +import { expect, it, vi } from "vite-plus/test"; +import { useChatMathPlugins } from "./useChatMath"; + +const imports = vi.hoisted(() => { + function ready() { + let resolve!: () => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; + } + return { loaded: vi.fn(), on: ready(), readable: ready() }; +}); + +vi.mock("./markdownMathRendered", () => { + imports.loaded("on"); + imports.on.resolve(); + return { CHAT_MATH_PLUGINS: { remark: [], rehype: [], literalRehype: [] } }; +}); +vi.mock("./markdownMathReadable", () => { + imports.loaded("readable"); + imports.readable.resolve(); + return { CHAT_MATH_PLUGINS: { remark: [], rehype: [], literalRehype: [] } }; +}); + +function Markdown({ mode, text }: { mode: "off" | "on" | "readable"; text: string }) { + const plugins = useChatMathPlugins(mode, text); + return ( + + {text} + + ); +} + +it("loads math only for delimiters outside code in both active modes", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + const root = createRoot(container); + const codeExamples = [ + "Run `echo $$` to print the process ID.", + "Regex: `\\(x\\)` and `\\[x\\]`.", + "``code with ` and \\(x\\)``", + "`multiline\n\\(x\\)\ncode`", + "```sh\necho $$\n```", + "~~~regex\n\\(x\\)\n~~~", + "```math\n\\[x^2\\]\n```", + "````sh\n```\necho $$\n````", + "```sh\necho $$", + " echo $$\n \\(x\\)", + "> ```sh\n> echo $$\n> ```", + "- example:\n\n ```sh\n echo $$\n ```", + String.raw`Escaped \\(x\\), \\[x\\], and \$$.`, + "[example]: /regex/\\(x\\)", + ]; + try { + for (const mode of ["off", "on", "readable"] as const) { + for (const text of codeExamples) { + await act(async () => root.render()); + expect(imports.loaded, `${mode}: ${text}`).not.toHaveBeenCalled(); + } + await act(async () => + root.render( + Formatted with `echo $$` "} + />, + ), + ); + expect(container.querySelector("b")?.textContent).toBe("Formatted"); + expect(container.querySelector("script")).toBeNull(); + expect(imports.loaded).not.toHaveBeenCalled(); + } + await act(async () => + root.render(), + ); + expect(imports.loaded).not.toHaveBeenCalled(); + for (const mode of ["on", "readable"] as const) { + const equation = mode === "on" ? String.raw`\(x^2\)` : "$$\nx^2\n$$"; + const text = codeExamples[0] + "\n\n" + equation; + await act(async () => root.render()); + await act(async () => imports[mode].promise); + expect(imports.loaded).toHaveBeenCalledWith(mode); + expect(container.querySelector("code")?.textContent).toBe("echo $$"); + } + expect(imports.loaded).toHaveBeenCalledTimes(2); + } finally { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/components/chat/useChatMath.ts b/apps/web/src/components/chat/useChatMath.ts new file mode 100644 index 000000000000..e16c722ee874 --- /dev/null +++ b/apps/web/src/components/chat/useChatMath.ts @@ -0,0 +1,82 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { LatexRenderingMode } from "@t3tools/contracts/settings"; +import type { Root, RootContent } from "mdast"; +import type { Plugin } from "unified"; +import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline"; + +type MathPlugins = typeof import("./markdownMath").CHAT_MATH_PLUGINS; +type ActiveMathMode = Exclude; + +const loadedPlugins: Partial> = {}; +const loadingPlugins: Partial>> = {}; + +function hasMathOpening(text: string): boolean { + if (!text.includes("\\(") && !text.includes("\\[") && !text.includes("$$")) return false; + for (const match of text.matchAll(/\\[([]|\$\$/g)) { + let preceding = match.index - 1; + while (text[preceding] === "\\") preceding--; + if ((match.index - preceding - 1) % 2 === 0) return true; + } + return false; +} + +function hasMathOutsideCode(node: Root | RootContent, source: string): boolean { + if (node.type === "text") { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + return start !== undefined && end !== undefined && hasMathOpening(source.slice(start, end)); + } + return "children" in node && node.children.some((child) => hasMathOutsideCode(child, source)); +} + +export function loadChatMathPlugins(mode: ActiveMathMode = "on"): Promise { + loadingPlugins[mode] ??= ( + mode === "on" ? import("./markdownMathRendered") : import("./markdownMathReadable") + ) + .then(({ CHAT_MATH_PLUGINS }) => { + loadedPlugins[mode] = CHAT_MATH_PLUGINS; + return CHAT_MATH_PLUGINS; + }) + .catch((error: unknown) => { + delete loadingPlugins[mode]; + throw error; + }); + return loadingPlugins[mode]; +} + +/** Ordinary messages keep their pipeline. Readable mode does not load typesetting styles or fonts. */ +export function useChatMathPlugins(mode: LatexRenderingMode, text: string) { + const needed = mode !== "off" && hasMathOpening(text); + const parsedMath = useRef({ source: "", needed: false }); + const probePlugins = useMemo(() => { + // Reuse ReactMarkdown's parse to exclude code without parsing the message a second time. + const probe: Plugin<[], Root> = () => (tree, file) => { + const source = String(file.value); + parsedMath.current = { source, needed: hasMathOutsideCode(tree, source) }; + }; + return { remark: [probe], rehype: CHAT_MARKDOWN_REHYPE_PLUGINS, literalRehype: [] }; + }, []); + const [loaded, setLoaded] = useState<{ mode: ActiveMathMode; plugins: MathPlugins }>(); + const plugins = + mode !== "off" + ? (loadedPlugins[mode] ?? (loaded?.mode === mode ? loaded.plugins : undefined)) + : undefined; + useEffect(() => { + if (!needed || plugins || parsedMath.current.source !== text || !parsedMath.current.needed) { + return; + } + let cancelled = false; + void loadChatMathPlugins(mode).then( + (result) => { + if (!cancelled) setLoaded({ mode, plugins: result }); + }, + () => { + // A failed chunk download leaves the original Markdown readable. + }, + ); + return () => { + cancelled = true; + }; + }, [mode, needed, plugins, text]); + return needed ? (plugins ?? probePlugins) : undefined; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 131e8539282c..09775c4d3350 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -138,6 +138,7 @@ import { } from "../ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; +import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"; import { ScopedSwitch } from "./ScopedSwitch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -548,6 +549,9 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Diff colors"] : []), ...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ["Chat width"] : []), + ...(settings.latexRenderingMode !== DEFAULT_UNIFIED_SETTINGS.latexRenderingMode + ? ["LaTeX rendering"] + : []), ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? ["Panel animations"] : []), @@ -675,6 +679,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.appearanceContrast, settings.diffColorScheme, settings.chatWidth, + settings.latexRenderingMode, settings.enableAgentBrowserAccess, settings.confirmQuit, settings.confirmThreadArchive, @@ -792,6 +797,7 @@ export function useSettingsRestore(onRestored?: () => void) { appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth, + latexRenderingMode: DEFAULT_UNIFIED_SETTINGS.latexRenderingMode, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled, @@ -1457,6 +1463,44 @@ export function AppearanceSettingsPanel() { } /> + + updateSettings({ + latexRenderingMode: DEFAULT_UNIFIED_SETTINGS.latexRenderingMode, + }) + } + /> + ) : null + } + control={ + { + const mode = values[0]; + if (mode === "off" || mode === "readable" || mode === "on") { + updateSettings({ latexRenderingMode: mode }); + } + }} + > + + Off + + + Readable + + + On + + + } + /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0ee55ef3785b..fd9f766c0cee 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -276,6 +276,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["wide full width column layout messages composer monitor"], }, + { + id: "latex-rendering", + title: "LaTeX rendering", + to: "/settings/appearance", + searchTerms: ["math equations formulas matrices integrals TeX readable plain text"], + }, { id: "panel-animations", title: "Panel animations", diff --git a/apps/web/src/index.css b/apps/web/src/index.css index dfdf09af249d..964fc4e0d906 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1767,6 +1767,12 @@ code { /* Chat markdown rendering */ +.chat-markdown .katex-display { + overflow-x: auto; + overflow-y: hidden; + padding: 0.15rem 0; +} + .chat-markdown > :first-child { margin-top: 0; } @@ -2367,3 +2373,15 @@ code { } } } + +.chat-markdown .math-readable-inline, +.chat-markdown .math-readable-display { + font-family: var(--font-mono); + white-space: pre-wrap; +} + +.chat-markdown .math-readable-display { + display: block; + margin-block: 1rem; + overflow-x: auto; +} diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 39b941801638..815aa5c40cba 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -203,6 +203,12 @@ function serializeNode(node: Node): string { } const markdownCopy = element.getAttribute("data-markdown-copy"); if (markdownCopy !== null) return markdownCopy; + if (element.classList.contains("katex-display") || element.classList.contains("katex")) { + const tex = element.querySelector('annotation[encoding="application/x-tex"]')?.textContent; + if (tex !== undefined && tex !== null) { + return element.classList.contains("katex-display") ? `\\[\n${tex}\n\\]\n\n` : `\\(${tex}\\)`; + } + } if (isSkippedElement(element)) return ""; const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1]; diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx index 3682deb281fa..0c3d19876e40 100644 --- a/apps/web/src/markdown-incremental.test.tsx +++ b/apps/web/src/markdown-incremental.test.tsx @@ -1,15 +1,15 @@ -import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline"; +import { + CHAT_MARKDOWN_REHYPE_PLUGINS, + CHAT_MARKDOWN_REMARK_PLUGINS, +} from "@t3tools/shared/markdownPipeline"; +import { remarkChatMath } from "@t3tools/shared/markdownMath"; import type { Root } from "mdast"; import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import type { Plugin } from "unified"; import { describe, expect, it } from "vite-plus/test"; -import { remarkCodexDirectives } from "@t3tools/shared/codexMarkdownDirectives"; -import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts"; import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; -import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation"; function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) { let tree: Root | undefined; @@ -30,10 +30,8 @@ function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: remarkPlugins={[ observeParsing, capture, - remarkGfm, - remarkGithubAlerts, - remarkNormalizeListItemIndentation, - remarkCodexDirectives, + ...CHAT_MARKDOWN_REMARK_PLUGINS, + remarkChatMath, ...(incremental ? [incremental] : []), ]} rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS} @@ -73,6 +71,8 @@ describe("incremental Markdown parsing", () => { "\n\n\tcode\n\nmore", "text *bold*", "> [!NOTE]\n> alert\n\n- [ ] task", + String.raw`Inline \(b_{12}\), then \[\begin{pmatrix}3&8\\-2&6\end{pmatrix}\]`, + "\\[\nB=\\begin{pmatrix}3&8\\\\-2&6\\end{pmatrix}\n\\]", "\uFEFFtext after a byte-order mark", ])("preserves the parse tree, positions, and HTML while streaming %j", (tail) => { const source = prefix + tail; diff --git a/docs/user/appearance.md b/docs/user/appearance.md index ec02a28ed787..012549ded22e 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -21,6 +21,13 @@ Android uses **Material You Layout** by default unless you have turned it off in It changes shapes, spacing, and controls independently of the selected theme. +## Math display + +On web and desktop, use **Settings → Appearance → LaTeX rendering** to choose how equations +appear in chat. It is **Off** by default, which keeps the original source. Choose **On** to typeset +equations, or **Readable** for plain text with readable fractions, powers, and integral limits. This +preference is saved separately on each device or browser. Copying an equation preserves its LaTeX source. + ## Composer context Git-backed projects show branch and worktree controls below the composer while you create a thread. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index deafcf507165..b3df4bacb3ab 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -23,6 +23,25 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); +describe("ClientSettings LaTeX rendering", () => { + it("leaves math rendering off until it is turned on, in new and existing settings", () => { + expect(decodeClientSettings({}).latexRenderingMode).toBe("off"); + expect(decodeClientSettings({ chatWidth: "wide" }).latexRenderingMode).toBe("off"); + }); + + it.each(["off", "readable", "on"])( + "preserves the %s mode when saving and loading preferences", + (mode) => { + const preference = { latexRenderingMode: mode }; + expect(decodeClientSettingsPatch(preference)).toEqual(preference); + expect(encodeClientSettings(decodeClientSettings(preference))).toMatchObject(preference); + }, + ); + it("rejects unsupported rendering modes", () => { + expect(() => decodeClientSettingsPatch({ latexRenderingMode: "other" })).toThrow(); + }); +}); + describe("ServerSettings response streaming", () => { it("defaults to paragraph buffering", () => { expect(decodeServerSettings({}).responseStreamingMode).toBe("paragraph"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d3fd53e31f1..90e7e8290b8a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -299,6 +299,9 @@ export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); export type ChatWidth = typeof ChatWidth.Type; +export const LatexRenderingMode = Schema.Literals(["off", "readable", "on"]); +export type LatexRenderingMode = typeof LatexRenderingMode.Type; + /** Preferences shared by clients connected to the same environment. */ export const ModelFavorite = Schema.Struct({ // The historical field name is retained; default instance ids also decode old provider-kind favorites. @@ -327,6 +330,10 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("red-green" as const)), ), chatWidth: ChatWidth.pipe(Schema.withDecodingDefault(Effect.succeed("comfortable" as const))), + // Web and desktop render explicit LaTeX; native mobile keeps its existing renderer. + latexRenderingMode: LatexRenderingMode.pipe( + Schema.withDecodingDefault(Effect.succeed("off" as const)), + ), loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), appearanceContrast: AppearanceContrast.pipe( @@ -1668,6 +1675,7 @@ export const ClientSettingsPatch = Schema.Struct({ inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), diffColorScheme: Schema.optionalKey(DiffColorScheme), chatWidth: Schema.optionalKey(ChatWidth), + latexRenderingMode: Schema.optionalKey(LatexRenderingMode), loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), appearanceContrast: Schema.optionalKey(AppearanceContrast), diff --git a/packages/shared/package.json b/packages/shared/package.json index 5e290d502e6f..3dd4d5705a10 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -443,6 +443,10 @@ "types": "./src/proposedPlanText.ts", "import": "./src/proposedPlanText.ts" }, + "./markdownMath": { + "types": "./src/markdownMath.ts", + "import": "./src/markdownMath.ts" + }, "./markdownPipeline": { "types": "./src/markdownPipeline.ts", "import": "./src/markdownPipeline.ts" @@ -495,7 +499,9 @@ "effect": "catalog:", "jose": "catalog:", "mdast-util-directive": "catalog:", + "mdast-util-math": "^3.0.0", "micromark-extension-directive": "catalog:", + "micromark-extension-math": "^3.1.0", "micromark-util-character": "catalog:", "rehype-raw": "catalog:", "rehype-sanitize": "catalog:", @@ -509,6 +515,7 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@types/mdast": "^4.0.4", "@types/node": "catalog:", "micromark-util-types": "catalog:", "vite-plus": "catalog:" diff --git a/packages/shared/src/markdownMath.test.ts b/packages/shared/src/markdownMath.test.ts new file mode 100644 index 000000000000..e32b3679c8b8 --- /dev/null +++ b/packages/shared/src/markdownMath.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vite-plus/test"; +import { unified } from "unified"; +import type { Root } from "mdast"; +import remarkParse from "remark-parse"; +import { + CHAT_MARKDOWN_REMARK_PLUGINS, + CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, +} from "./markdownPipeline.ts"; + +import { remarkChatMath } from "./markdownMath.ts"; + +const mathPlugins = [...CHAT_MARKDOWN_REMARK_PLUGINS, remarkChatMath]; +const mathPluginsWithBreaks = [...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, remarkChatMath]; + +describe("chat math parsing", () => { + it.each(["$$\nx^2", "> $$\n> x^2\n\noutside", "- $$\n x^2\n\noutside"])( + "keeps an unfinished dollar block literal: %s", + (source) => { + const processor = unified().use(remarkParse).use(mathPlugins); + const tree = processor.runSync(processor.parse(source)) as Root; + expect(JSON.stringify(tree)).not.toContain('"type":"math"'); + expect(JSON.stringify(tree)).not.toContain('"type":"inlineMath"'); + }, + ); + + it("keeps single-dollar text and links intact", () => { + const processor = unified().use(remarkParse).use(mathPlugins); + const tree = processor.runSync( + processor.parse("Use $TOKEN, then [docs](/users?$select=id)."), + ) as Root; + expect(tree.children[0]).toMatchObject({ + type: "paragraph", + children: [ + { type: "text", value: "Use $TOKEN, then " }, + { type: "link", url: "/users?$select=id" }, + { type: "text", value: "." }, + ], + }); + }); + it.each([[mathPlugins], [mathPluginsWithBreaks]])( + "preserves matrix row separators and source positions", + (plugins) => { + const processor = unified().use(remarkParse).use(plugins); + const source = String.raw`Before \( +B=\begin{pmatrix}3&8&1\\-2&6&4\end{pmatrix} +\) after`; + const tree = processor.runSync(processor.parse(source)) as Root; + const paragraph = tree.children[0]; + expect(paragraph?.type).toBe("paragraph"); + if (paragraph?.type !== "paragraph") throw new Error("Missing paragraph"); + const math = paragraph.children[1]; + expect(math).toMatchObject({ + type: "inlineMath", + value: "\nB=\\begin{pmatrix}3&8&1\\\\-2&6&4\\end{pmatrix}\n", + }); + expect(source.slice(math?.position?.start.offset, math?.position?.end.offset)).toBe( + source.slice(7, -6), + ); + }, + ); + + it("marks backslash display math without changing ordinary Markdown escapes", () => { + const processor = unified().use(remarkParse).use(mathPlugins); + const source = String.raw`\[x_{12}\] and \[literal bracket and \(x\)`; + const tree = processor.runSync(processor.parse(source)) as Root; + expect(tree.children[0]).toMatchObject({ + type: "paragraph", + children: [ + { + type: "inlineMath", + value: "x_{12}", + data: { hProperties: { className: ["language-math", "math-display"] } }, + }, + { type: "text", value: " and [literal bracket and " }, + { type: "inlineMath", value: "x" }, + ], + }); + }); + + it("keeps code examples, escaped delimiters, and unfinished math literal", () => { + const processor = unified().use(remarkParse).use(mathPlugins); + const source = + "Inline: `\\(x\\) $x$`" + + String.raw` + +~~~tex +\[x\] +~~~ + +Escaped \\(x\\), price \$5, unfinished \(x`; + const tree = processor.runSync(processor.parse(source)) as Root; + expect(JSON.stringify(tree)).not.toContain('"type":"inlineMath"'); + expect(JSON.stringify(tree)).not.toContain('"type":"math"'); + expect(tree.children[1]).toMatchObject({ type: "code", value: "\\[x\\]" }); + }); + + it("preserves skill mentions and prices that use single dollars", () => { + const processor = unified().use(remarkParse).use(mathPlugins); + const tree = processor.runSync( + processor.parse("Use $2spec with a $20k budget; $5 or $10."), + ) as Root; + expect(tree.children[0]).toMatchObject({ + type: "paragraph", + children: [{ type: "text", value: "Use $2spec with a $20k budget; $5 or $10." }], + }); + }); +}); diff --git a/packages/shared/src/markdownMath.ts b/packages/shared/src/markdownMath.ts new file mode 100644 index 000000000000..f7f94d9a8be1 --- /dev/null +++ b/packages/shared/src/markdownMath.ts @@ -0,0 +1,143 @@ +import { mathFromMarkdown } from "mdast-util-math"; +import { math } from "micromark-extension-math"; +import { markdownLineEnding } from "micromark-util-character"; +import type { Construct, Extension, Tokenizer } from "micromark-util-types"; +import type { Processor } from "unified"; + +// Use the math extension's tokens so backslash delimiters keep the same AST and source +// positions as dollar math. Recognize them before CommonMark consumes escapes. +const tokenizeLatexMath: Tokenizer = (effects, ok, nok) => { + let closing = 0; + let dataOpen = false; + const end: Construct = { + partial: true, + tokenize(effects, ok, nok) { + return (code) => { + effects.enter("mathTextSequence"); + effects.consume(code!); + return (code) => { + if (code !== closing) return nok(code); + effects.consume(code); + effects.exit("mathTextSequence"); + return ok; + }; + }; + }, + }; + + return (code) => { + effects.enter("mathText"); + effects.enter("mathTextSequence"); + effects.consume(code!); + return (code) => { + if (code !== 40 && code !== 91) return nok(code); + closing = code === 40 ? 41 : 93; + effects.consume(code); + effects.exit("mathTextSequence"); + return body; + }; + }; + + function body(code: number | null) { + if (code === null) return nok(code); + if (code === 92) return effects.check(end, close, escape)(code); + if (markdownLineEnding(code)) { + if (dataOpen) effects.exit("mathTextData"); + dataOpen = false; + effects.enter("lineEnding"); + effects.consume(code); + effects.exit("lineEnding"); + return body; + } + openData(); + effects.consume(code); + return body; + } + + function escape(code: number | null) { + openData(); + effects.consume(code!); + return (code: number | null) => { + if (code === null || markdownLineEnding(code)) return body(code); + effects.consume(code); + return body; + }; + } + + function close(code: number | null) { + if (dataOpen) effects.exit("mathTextData"); + effects.enter("mathTextSequence"); + effects.consume(code!); + return (code: number | null) => { + effects.consume(code!); + effects.exit("mathTextSequence"); + effects.exit("mathText"); + return ok; + }; + } + + function openData() { + if (!dataOpen) effects.enter("mathTextData"); + dataOpen = true; + } +}; + +const LATEX_MATH_SYNTAX: Extension = { text: { 92: { tokenize: tokenizeLatexMath } } }; + +/** Parse dollar math and the LaTeX delimiters commonly emitted by providers. */ +function remarkChatMath(this: Processor) { + // oxlint-disable-next-line oxc/no-this-in-exported-function -- Unified binds plugins to their processor. + const data = this.data(); + // Single dollars are skill mentions and prices in chat. Use \(...\) for + // inline math and \[...\] or $$ fences for display math. + const syntax = math({ singleDollarTextMath: false }); + const flow = syntax.flow![36] as Construct; + syntax.flow = { + 36: { + ...flow, + tokenize(effects, ok, nok) { + const start = this.events.length; + return flow.tokenize.call( + this, + effects, + (code) => { + if (this.interrupt) return ok(code); + let fences = 0; + for (let index = start; index < this.events.length; index++) { + const event = this.events[index]!; + if (event[0] === "exit" && event[1].type === "mathFlowFence") fences++; + } + // Unlike code fences, unfinished math stays literal while streaming. + return fences > 1 ? ok(code) : nok(code); + }, + nok, + ); + }, + }, + }; + (data.micromarkExtensions ??= []).push(syntax, LATEX_MATH_SYNTAX); + const fromMarkdown = mathFromMarkdown(); + fromMarkdown.enter!.mathText = function (token) { + this.enter( + { + type: "inlineMath", + value: "", + data: { + hName: "code", + hProperties: { + className: [ + "language-math", + this.sliceSerialize(token).startsWith("\\[") ? "math-display" : "math-inline", + ], + }, + hChildren: [], + }, + }, + token, + ); + this.buffer(); + }; + (data.fromMarkdownExtensions ??= []).push(fromMarkdown); +} + +export { remarkChatMath }; diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 7317dcc5b9ff..069511911acd 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -145,7 +145,14 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), - code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], + code: [ + ...(defaultSchema.attributes?.code ?? []).filter( + (attribute) => !(Array.isArray(attribute) && attribute[0] === "className"), + ), + ["className", /^language-./, "math-inline", "math-display"], + "dataCodeMeta", + "dataInlineCode", + ], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3b0094e6e3b..4447a6af6eb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -890,6 +890,9 @@ importers: jszip: specifier: 3.10.1 version: 3.10.1 + katex: + specifier: ^0.16.22 + version: 0.16.47 lucide: specifier: ^0.564.0 version: 0.564.0 @@ -911,6 +914,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.3.0)(react@19.2.6) + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 remark-gfm: specifier: 'catalog:' version: 4.0.1 @@ -1509,9 +1515,15 @@ importers: mdast-util-directive: specifier: 'catalog:' version: 3.1.0 + mdast-util-math: + specifier: ^3.0.0 + version: 3.0.0 micromark-extension-directive: specifier: 'catalog:' version: 4.0.0 + micromark-extension-math: + specifier: ^3.1.0 + version: 3.1.0 micromark-util-character: specifier: 'catalog:' version: 2.1.1 @@ -1546,6 +1558,9 @@ importers: '@effect/vitest': specifier: 4.0.2 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -6370,6 +6385,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -9126,9 +9144,21 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -9147,6 +9177,9 @@ packages: hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -9752,6 +9785,9 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -9893,6 +9929,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -10988,6 +11027,9 @@ packages: resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} hasBin: true + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} @@ -11743,12 +11785,18 @@ packages: unifont@0.7.5: resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -16991,6 +17039,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 24.12.4 @@ -19830,6 +19880,28 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.5 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.5 @@ -19841,6 +19913,10 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.5 @@ -19911,6 +19987,13 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -20534,6 +20617,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -20912,6 +21007,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.47 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -22295,6 +22400,16 @@ snapshots: dependencies: jsesc: 3.1.0 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.5 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.47 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.5 @@ -23168,6 +23283,11 @@ snapshots: ohash: 2.0.12 undici: 8.11.2 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -23176,6 +23296,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index a2eb54574ffe..da215badcdc4 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -189,6 +189,16 @@ "copyrights": ["Copyright (c) 2025 Aiden Bai"] } }, + { + "license": "MIT", + "name": "rehype-katex", + "version": "7.0.1", + "sourceUrl": "https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) Junyoung Choi "] + } + }, { "name": "glob-to-regexp", "generatedNotice": {