Repository navigation
fix(web): render LaTeX in chat messages #9838
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,12 +67,14 @@ import React, { | |
| import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; | ||
| import ReactMarkdown from "react-markdown"; | ||
| import { defaultUrlTransform } from "react-markdown"; | ||
| import rehypeKatex from "rehype-katex"; | ||
| import rehypeRaw from "rehype-raw"; | ||
| import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; | ||
| import remarkBreaks from "remark-breaks"; | ||
| import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; | ||
| import { AssistantCitationChip } from "./chat/AssistantCitationChip"; | ||
| import remarkGfm from "remark-gfm"; | ||
| import remarkMath from "remark-math"; | ||
| import { remarkGithubAlerts } from "../markdown-github-alerts"; | ||
| import { | ||
| artifactTemplateFromHastProperties, | ||
|
|
@@ -126,6 +128,7 @@ import { | |
| serializeTableElementToMarkdown, | ||
| } from "../markdown-clipboard"; | ||
| import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; | ||
| import { normalizeLatexDelimiters } from "../markdown-latex"; | ||
| import { | ||
| extractMarkdownLinkHrefs, | ||
| isWindowsDrivePathHref, | ||
|
|
@@ -239,6 +242,53 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: { | |
| const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; | ||
| const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = []; | ||
|
|
||
| type MarkdownTreeNode = { | ||
| type?: string; | ||
| value?: string; | ||
| children?: MarkdownTreeNode[]; | ||
| properties?: Record<string, unknown>; | ||
| }; | ||
|
|
||
| function restoreProtectedSkillTokens() { | ||
| return (tree: MarkdownTreeNode) => { | ||
| const visit = (node: MarkdownTreeNode): void => { | ||
| if (node.type === "text" && node.value) node.value = node.value.replaceAll("\uE000", "$"); | ||
| node.children?.forEach(visit); | ||
| }; | ||
| visit(tree); | ||
| }; | ||
| } | ||
|
|
||
| function addMathMarkdownCopy() { | ||
| return (tree: MarkdownTreeNode) => { | ||
| const findSource = (node: MarkdownTreeNode): string | undefined => { | ||
| if (node.type === "element" && node.properties?.encoding === "application/x-tex") { | ||
| return node.children.find((child) => child.type === "text")?.value; | ||
| } | ||
| for (const child of node.children ?? []) { | ||
| const source = findSource(child); | ||
| if (source) return source; | ||
| } | ||
| return undefined; | ||
| }; | ||
| const visit = (node: MarkdownTreeNode): void => { | ||
| if (node.type === "element") { | ||
| const classes = Array.isArray(node.properties?.className) | ||
| ? node.properties.className | ||
| : typeof node.properties?.className === "string" | ||
| ? node.properties.className.split(/\s+/) | ||
| : []; | ||
| if (classes.includes("katex")) { | ||
| const source = findSource(node); | ||
| if (source) node.properties!["data-markdown-copy"] = `\\(${source}\\)`; | ||
| } | ||
| } | ||
| node.children?.forEach(visit); | ||
| }; | ||
| visit(tree); | ||
| }; | ||
| } | ||
|
|
||
| const ARTIFACT_TEMPLATE_ICON_BY_KIND = { | ||
| document: FileTextIcon, | ||
| presentation: PresentationIcon, | ||
|
|
@@ -396,6 +446,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { | |
|
|
||
| const CHAT_MARKDOWN_REMARK_PLUGINS = [ | ||
| remarkGfm, | ||
| remarkMath, | ||
| remarkGithubAlerts, | ||
| remarkNormalizeListItemIndentation, | ||
| remarkCodexDirectives, | ||
|
|
@@ -405,6 +456,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ | |
|
|
||
| const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | ||
| remarkGfm, | ||
| remarkMath, | ||
| remarkGithubAlerts, | ||
| remarkNormalizeListItemIndentation, | ||
| remarkCodexDirectives, | ||
|
|
@@ -413,10 +465,16 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | |
| remarkNormalizeLinksAndTagInlineCode, | ||
| ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; | ||
|
|
||
| const CHAT_MARKDOWN_REHYPE_PLUGINS = [ | ||
| const CHAT_MARKDOWN_REHYPE_PLUGINS = [rehypeKatex, addMathMarkdownCopy] satisfies NonNullable< | ||
| ReactMarkdownOptions["rehypePlugins"] | ||
| >; | ||
|
|
||
| const CHAT_MARKDOWN_RAW_HTML_REHYPE_PLUGINS = [ | ||
| rehypeRaw, | ||
| rehypePreserveImageSourceMeta, | ||
| [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], | ||
| rehypeKatex, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Copied math becomes garbled textMedium Severity KaTeX emits MathML plus an Reviewed by Cursor Bugbot for commit 87d0562. Configure here. |
||
| addMathMarkdownCopy, | ||
| ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; | ||
|
|
||
| /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ | ||
|
|
@@ -2903,11 +2961,30 @@ function ChatMarkdown({ | |
| } = useChatMarkdownState({ text, ...props }); | ||
| const remarkPlugins = useMemo( | ||
| () => [ | ||
| ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), | ||
| ...(lineBreaks | ||
| ? [ | ||
| remarkGfm, | ||
| remarkMath, | ||
| restoreProtectedSkillTokens, | ||
| ...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS.slice(3), | ||
| ] | ||
| : [ | ||
| remarkGfm, | ||
| remarkMath, | ||
| restoreProtectedSkillTokens, | ||
| ...CHAT_MARKDOWN_REMARK_PLUGINS.slice(3), | ||
| ]), | ||
| ...extraRemarkPlugins, | ||
| ], | ||
| [extraRemarkPlugins, lineBreaks], | ||
| [extraRemarkPlugins, lineBreaks, props.skills], | ||
| ); | ||
| const normalizedText = useMemo(() => { | ||
| const names = new Set((props.skills ?? []).map((skill) => skill.name)); | ||
| return normalizeLatexDelimiters(text).replace( | ||
| /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g, | ||
| (match, prefix: string, name: string) => (names.has(name) ? `${prefix}\uE000${name}` : match), | ||
| ); | ||
| }, [text, props.skills]); | ||
|
|
||
| // react-markdown converts unparsed HTML nodes to text when skipHtml is false. | ||
| // Keep that behavior explicit because literal mode depends on escaping the | ||
|
|
@@ -2923,12 +3000,14 @@ function ChatMarkdown({ | |
| <ChatMarkdownRendererContext value={componentState}> | ||
| <ReactMarkdown | ||
| remarkPlugins={remarkPlugins} | ||
| rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined} | ||
| rehypePlugins={ | ||
| parseRawHtml ? CHAT_MARKDOWN_RAW_HTML_REHYPE_PLUGINS : CHAT_MARKDOWN_REHYPE_PLUGINS | ||
| } | ||
| skipHtml={false} | ||
| components={CHAT_MARKDOWN_COMPONENTS} | ||
| urlTransform={markdownUrlTransform} | ||
| > | ||
| {text} | ||
| {normalizedText} | ||
| </ReactMarkdown> | ||
| </ChatMarkdownRendererContext> | ||
| {localMediaPreview ? ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
| import { normalizeLatexDelimiters } from "./markdown-latex"; | ||
|
|
||
| describe("normalizeLatexDelimiters", () => { | ||
| it("normalizes parenthesis and bracket delimiters without shifting source offsets", () => { | ||
| const markdown = "Inline \\(x\\)\n\\[\ny\n\\]\n- [ ] task"; | ||
| const normalized = normalizeLatexDelimiters(markdown); | ||
|
|
||
| expect(normalized).toBe("Inline $$x$$\n\n\n$$\ny\n$$\n\n\n- [ ] task"); | ||
| }); | ||
|
|
||
| it("leaves escaped delimiters and code unchanged", () => { | ||
| const markdown = [ | ||
| String.raw`Literal \\(x\\) and \[math\].`, | ||
| "Inline code: `\\(code\\)`.", | ||
| "", | ||
| "~~~text", | ||
| String.raw`\[fenced\]`, | ||
| "~~~", | ||
| String.raw` \(indented\)`, | ||
| ].join("\n"); | ||
|
|
||
| expect(normalizeLatexDelimiters(markdown)).toBe( | ||
| markdown.replace(String.raw`\[math\]`, () => "\n\n$$math$$\n\n"), | ||
| ); | ||
| }); | ||
|
|
||
| it("handles CR-only fences and container-prefixed code", () => { | ||
| const markdown = "```text\r\\(fenced\\)\r```\r\r> \\(quoted code\\)\r\rAfter \\(x\\)"; | ||
| expect(normalizeLatexDelimiters(markdown)).toBe( | ||
| markdown.replace(String.raw`\(x\)`, () => "$$x$$"), | ||
| ); | ||
| }); | ||
|
|
||
| it("does not normalize raw HTML code elements or malformed fences", () => { | ||
| const markdown = "<pre>\\(raw\\)</pre>\n```text `\n\\(not fenced\\)\nAfter \\(x\\)"; | ||
| expect(normalizeLatexDelimiters(markdown)).toBe( | ||
| "<pre>\\(raw\\)</pre>\n```text `\n$$not fenced$$\nAfter $$x$$", | ||
| ); | ||
| }); | ||
|
|
||
| it("does not let an unmatched inline code opener hide later math", () => { | ||
| expect(normalizeLatexDelimiters("` unmatched\nLater \\(x\\)")).toBe("` unmatched\nLater $$x$$"); | ||
| }); | ||
| }); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Skill tokens collide with math dollars
Medium Severity
remark-mathtreats paired$...$as inline math, which collides with chat$skilltokens. Two skills in one paragraph, such as$browserand$deploy, are consumed as one math span, so skill chips never render. Escaping the dollars avoids math but also preventsSkillInlineTextfrom matching the tokens.Reviewed by Cursor Bugbot for commit 87d0562. Configure here.