-
Notifications
You must be signed in to change notification settings - Fork 5.3k
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
base: main
Are you sure you want to change the base?
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, | ||
|
|
@@ -396,6 +399,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { | |
|
|
||
| const CHAT_MARKDOWN_REMARK_PLUGINS = [ | ||
| remarkGfm, | ||
| remarkMath, | ||
| remarkGithubAlerts, | ||
| remarkNormalizeListItemIndentation, | ||
| remarkCodexDirectives, | ||
|
|
@@ -405,6 +409,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ | |
|
|
||
| const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | ||
| remarkGfm, | ||
| remarkMath, | ||
| remarkGithubAlerts, | ||
| remarkNormalizeListItemIndentation, | ||
| remarkCodexDirectives, | ||
|
|
@@ -413,10 +418,15 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | |
| remarkNormalizeLinksAndTagInlineCode, | ||
| ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; | ||
|
|
||
| const CHAT_MARKDOWN_REHYPE_PLUGINS = [ | ||
| const CHAT_MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] 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. |
||
| ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; | ||
|
|
||
| /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ | ||
|
|
@@ -2908,6 +2918,7 @@ function ChatMarkdown({ | |
| ], | ||
| [extraRemarkPlugins, lineBreaks], | ||
| ); | ||
| const normalizedText = useMemo(() => normalizeLatexDelimiters(text), [text]); | ||
|
|
||
| // 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 +2934,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,28 @@ | ||
| 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$$\ny\n$$\n- [ ] task"); | ||
| expect(normalized).toHaveLength(markdown.length); | ||
| }); | ||
|
|
||
| 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\]`, () => "$$math$$"), | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,78 @@ | ||||||||||||||||||
| const LATEX_DELIMITERS = ["\\(", "\\)", "\\[", "\\]"] as const; | ||||||||||||||||||
|
|
||||||||||||||||||
| function isEscaped(value: string, index: number): boolean { | ||||||||||||||||||
| let slashCount = 0; | ||||||||||||||||||
| for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) { | ||||||||||||||||||
| slashCount += 1; | ||||||||||||||||||
| } | ||||||||||||||||||
| return slashCount % 2 === 1; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| function startingFence(line: string): { marker: "`" | "~"; length: number } | null { | ||||||||||||||||||
| const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); | ||||||||||||||||||
| const run = match?.[1]; | ||||||||||||||||||
| if (!run) return null; | ||||||||||||||||||
| return { marker: run[0] as "`" | "~", length: run.length }; | ||||||||||||||||||
|
Comment on lines
+12
to
+15
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. 🟡 Medium
Suggested change
🤖 Copy this AI Prompt to have your agent fix this: |
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| function closesFence(line: string, fence: { marker: "`" | "~"; length: number }): boolean { | ||||||||||||||||||
| const match = /^ {0,3}(`+|~+)[ \t]*$/.exec(line); | ||||||||||||||||||
| const run = match?.[1]; | ||||||||||||||||||
| return Boolean(run && run[0] === fence.marker && run.length >= fence.length); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** Converts TeX delimiters before Markdown consumes their leading backslashes. */ | ||||||||||||||||||
| export function normalizeLatexDelimiters(markdown: string): string { | ||||||||||||||||||
| if (!LATEX_DELIMITERS.some((delimiter) => markdown.includes(delimiter))) return markdown; | ||||||||||||||||||
|
|
||||||||||||||||||
| let fence: { marker: "`" | "~"; length: number } | null = null; | ||||||||||||||||||
| let inlineCodeTicks = 0; | ||||||||||||||||||
|
|
||||||||||||||||||
| return markdown | ||||||||||||||||||
| .split(/(\r?\n)/) | ||||||||||||||||||
|
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. 🟡 Medium CR-only input is treated as a single line, so fenced code is never recognized and - .split(/(\r?\n)/)
+ .split(/(\r\n|\n|\r)/)🤖 Copy this AI Prompt to have your agent fix this: |
||||||||||||||||||
| .map((line) => { | ||||||||||||||||||
| if (line === "\n" || line === "\r\n") return line; | ||||||||||||||||||
|
|
||||||||||||||||||
| if (fence) { | ||||||||||||||||||
| if (closesFence(line, fence)) fence = null; | ||||||||||||||||||
| return line; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (inlineCodeTicks === 0) { | ||||||||||||||||||
| const openingFence = startingFence(line); | ||||||||||||||||||
| if (openingFence) { | ||||||||||||||||||
| fence = openingFence; | ||||||||||||||||||
| return line; | ||||||||||||||||||
| } | ||||||||||||||||||
| if (/^(?: {4}|\t)/.test(line)) return line; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| let normalized = ""; | ||||||||||||||||||
| for (let index = 0; index < line.length; index += 1) { | ||||||||||||||||||
| const character = line[index]; | ||||||||||||||||||
|
|
||||||||||||||||||
| if (character === "`" && !isEscaped(line, index)) { | ||||||||||||||||||
| let runLength = 1; | ||||||||||||||||||
| while (line[index + runLength] === "`") runLength += 1; | ||||||||||||||||||
| if (inlineCodeTicks === 0) inlineCodeTicks = runLength; | ||||||||||||||||||
|
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. 🟡 Medium An unmatched backtick sets 🤖 Copy this AI Prompt to have your agent fix this: |
||||||||||||||||||
| else if (inlineCodeTicks === runLength) inlineCodeTicks = 0; | ||||||||||||||||||
| normalized += "`".repeat(runLength); | ||||||||||||||||||
| index += runLength - 1; | ||||||||||||||||||
| continue; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (inlineCodeTicks === 0 && character === "\\" && !isEscaped(line, index)) { | ||||||||||||||||||
|
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. 🟡 Medium
🤖 Copy this AI Prompt to have your agent fix this:
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. 🟡 Medium The normalizer rewrites 🤖 Copy this AI Prompt to have your agent fix this: |
||||||||||||||||||
| const delimiter = line[index + 1]; | ||||||||||||||||||
| if (delimiter === "(" || delimiter === ")" || delimiter === "[" || delimiter === "]") { | ||||||||||||||||||
| normalized += "$$"; | ||||||||||||||||||
| index += 1; | ||||||||||||||||||
|
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. 🟡 Medium A same-line - normalized += "$$";
+ normalized += delimiter === "[" ? "\n\n$$" : delimiter === "]" ? "$$\n\n" : "$$";🤖 Copy this AI Prompt to have your agent fix this: |
||||||||||||||||||
| continue; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| normalized += character; | ||||||||||||||||||
| } | ||||||||||||||||||
| return normalized; | ||||||||||||||||||
| }) | ||||||||||||||||||
| .join(""); | ||||||||||||||||||
| } | ||||||||||||||||||


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.