feat(ui): add LaTeX math rendering support to chat markdown
Add remark-math + rehype-katex plugins to render inline ($...$) and display ($$...$$) math expressions. Includes dark mode CSS overrides, streaming block splitting for math fences, and math range exclusion in link preprocessing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b5f63f7f7c
commit
953041efa0
6 changed files with 215 additions and 8 deletions
|
|
@ -1,10 +1,13 @@
|
|||
import * as React from 'react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
import { preprocessLinks } from './linkify'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
/**
|
||||
* Render modes for markdown content:
|
||||
|
|
@ -270,8 +273,8 @@ export function Markdown({
|
|||
return (
|
||||
<div className={cn('markdown-content break-words', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
||||
components={components}
|
||||
>
|
||||
{processedContent}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ function splitIntoBlocks(content: string): Block[] {
|
|||
const lines = content.split('\n')
|
||||
let currentBlock = ''
|
||||
let inCodeBlock = false
|
||||
let inMathBlock = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? ''
|
||||
|
|
@ -73,6 +74,26 @@ function splitIntoBlocks(content: string): Block[] {
|
|||
} else if (inCodeBlock) {
|
||||
// Inside code block - append line
|
||||
currentBlock += line + '\n'
|
||||
// Check for display math fence ($$)
|
||||
} else if (line.trim() === '$$') {
|
||||
if (!inMathBlock) {
|
||||
// Starting a math block - flush current paragraph first
|
||||
if (currentBlock.trim()) {
|
||||
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
|
||||
currentBlock = ''
|
||||
}
|
||||
inMathBlock = true
|
||||
currentBlock = line + '\n'
|
||||
} else {
|
||||
// Ending a math block
|
||||
currentBlock += line
|
||||
blocks.push({ content: currentBlock, isCodeBlock: false })
|
||||
currentBlock = ''
|
||||
inMathBlock = false
|
||||
}
|
||||
} else if (inMathBlock) {
|
||||
// Inside math block - append line (don't split on blank lines)
|
||||
currentBlock += line + '\n'
|
||||
} else if (line === '') {
|
||||
// Empty line outside code block = paragraph boundary
|
||||
if (currentBlock.trim()) {
|
||||
|
|
@ -92,8 +113,8 @@ function splitIntoBlocks(content: string): Block[] {
|
|||
// Flush remaining content
|
||||
if (currentBlock) {
|
||||
blocks.push({
|
||||
content: inCodeBlock ? currentBlock : currentBlock.trim(),
|
||||
isCodeBlock: inCodeBlock // Unclosed code block = still streaming
|
||||
content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(),
|
||||
isCodeBlock: inCodeBlock
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,14 +42,34 @@ function findCodeRanges(text: string): CodeRange[] {
|
|||
ranges.push({ start: match.index, end: match.index + match[0].length })
|
||||
}
|
||||
|
||||
// Find display math blocks ($$...$$)
|
||||
const displayMathRegex = /\$\$[\s\S]*?\$\$/g
|
||||
while ((match = displayMathRegex.exec(text)) !== null) {
|
||||
const pos = match.index
|
||||
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
|
||||
if (!insideOther) {
|
||||
ranges.push({ start: pos, end: pos + match[0].length })
|
||||
}
|
||||
}
|
||||
|
||||
// Find inline math ($...$)
|
||||
const inlineMathRegex = /(?<!\$)\$(?!\$)([^\$\n]+)\$(?!\$)/g
|
||||
while ((match = inlineMathRegex.exec(text)) !== null) {
|
||||
const pos = match.index
|
||||
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
|
||||
if (!insideOther) {
|
||||
ranges.push({ start: pos, end: pos + match[0].length })
|
||||
}
|
||||
}
|
||||
|
||||
// Find inline code (`...`)
|
||||
// But skip escaped backticks and code inside fenced blocks
|
||||
const inlineRegex = /(?<!`)`(?!`)([^`\n]+)`(?!`)/g
|
||||
while ((match = inlineRegex.exec(text)) !== null) {
|
||||
const pos = match.index
|
||||
// Check if this is inside a fenced block
|
||||
const insideFenced = ranges.some((r) => pos >= r.start && pos < r.end)
|
||||
if (!insideFenced) {
|
||||
// Check if this is inside a fenced block or math block
|
||||
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
|
||||
if (!insideOther) {
|
||||
ranges.push({ start: pos, end: pos + match[0].length })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue