Skip to content

Commit eb8f4db

Browse files
committed
feat(workbench): render LaTeX math (KaTeX) and thematic-break rules in MarkdownView
Answers that contain LaTeX (\[ … \] block, \( … \) inline) and `---` rules showed the raw delimiters/dashes as literal text. Add math + horizontal-rule support to the hand-rolled MarkdownView: block/inline math render via KaTeX (bundled + self-contained, no CDN; malformed TeX falls back to a code box), and a `---`/`***`/`___` line renders an <hr>. Adds katex (pinned exact, matching the mermaid/motion convention) + @types/katex. Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG
1 parent 446338a commit eb8f4db

3 files changed

Lines changed: 99 additions & 4 deletions

File tree

frontend/package-lock.json

Lines changed: 37 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"i18next": "26.3.6",
4747
"i18next-resources-to-backend": "1.2.1",
4848
"input-otp": "^1.4.2",
49+
"katex": "0.18.1",
4950
"lucide-react": "^0.562.0",
5051
"mermaid": "11.16.0",
5152
"motion": "12.42.2",
@@ -65,6 +66,7 @@
6566
},
6667
"devDependencies": {
6768
"@eslint/js": "^9.39.1",
69+
"@types/katex": "0.16.8",
6870
"@types/node": "^24.10.1",
6971
"@types/react": "^19.2.5",
7072
"@types/react-dom": "^19.2.3",

frontend/src/components/MarkdownView.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import React, { useEffect, useId, useRef, useState } from 'react'
2+
import katex from 'katex'
3+
import 'katex/dist/katex.min.css'
24
import { useTheme } from '@/lib/theme'
35

46
/** 极简 Markdown 渲染:标题 / 列表 / 引用 / 粗体 / [[wikilink]] / 行内代码 / 代码块 / mermaid */
57
function inline(text: string, onWikiLink?: (target: string) => void): React.ReactNode[] {
68
const parts: React.ReactNode[] = []
7-
const re = /(\[\[[^\]]+\]\]|\*\*[^*]+\*\*|`[^`]+`)/g
9+
const re = /(\[\[[^\]]+\]\]|\\\([\s\S]+?\\\)|\*\*[^*]+\*\*|`[^`]+`)/g
810
let last = 0, m: RegExpExecArray | null, k = 0
911
while ((m = re.exec(text))) {
1012
if (m.index > last) parts.push(text.slice(last, m.index))
@@ -36,6 +38,22 @@ function inline(text: string, onWikiLink?: (target: string) => void): React.Reac
3638
</span>
3739
),
3840
)
41+
} else if (tok.startsWith('\\(')) {
42+
// Inline math \( … \) via KaTeX; on a KaTeX error fall back to a code chip.
43+
const tex = tok.slice(2, -2)
44+
let html = ''
45+
try {
46+
html = katex.renderToString(tex, { displayMode: false, throwOnError: false })
47+
} catch {
48+
html = ''
49+
}
50+
parts.push(
51+
html ? (
52+
<span key={k++} className="text-foreground" dangerouslySetInnerHTML={{ __html: html }} />
53+
) : (
54+
<code key={k++} className="font-mono2 text-[12px] bg-muted rounded px-1 py-px">{tex}</code>
55+
),
56+
)
3957
} else if (tok.startsWith('**')) {
4058
parts.push(<strong key={k++} className="font-semibold text-foreground">{tok.slice(2, -2)}</strong>)
4159
} else {
@@ -61,6 +79,23 @@ function CodeBox({ code, lang }: { code: string; lang?: string }) {
6179
)
6280
}
6381

82+
/** Display (block) math via KaTeX. On a KaTeX error, falls back to the raw TeX
83+
* in a code box so a malformed formula never crashes the message. */
84+
function MathBlock({ tex }: { tex: string }) {
85+
let html = ''
86+
try {
87+
html = katex.renderToString(tex, { displayMode: true, throwOnError: false })
88+
} catch {
89+
return <CodeBox code={tex} />
90+
}
91+
return (
92+
<div
93+
className="my-3 overflow-x-auto text-center text-foreground"
94+
dangerouslySetInnerHTML={{ __html: html }}
95+
/>
96+
)
97+
}
98+
6499
/**
65100
* Render a ```mermaid block as an SVG diagram. Mermaid is lazily imported (its
66101
* own bundle chunk, loaded only when a diagram appears). Theme-aware; on any
@@ -153,6 +188,30 @@ export default function MarkdownView({
153188
continue
154189
}
155190

191+
// Block math: LaTeX \[ … \] display delimiters. Handle a single-line form
192+
// and the model's usual three-line form (\[ alone, body lines, \] alone).
193+
const oneLineMath = /^\\\[([\s\S]*?)\\\]$/.exec(line.trim())
194+
if (oneLineMath) {
195+
flushList()
196+
out.push(<MathBlock key={key++} tex={oneLineMath[1].trim()} />)
197+
continue
198+
}
199+
if (line.trim() === '\\[') {
200+
flushList()
201+
const body: string[] = []
202+
i++
203+
while (i < lines.length && lines[i].trim() !== '\\]') { body.push(lines[i]); i++ }
204+
out.push(<MathBlock key={key++} tex={body.join('\n').trim()} />)
205+
continue
206+
}
207+
208+
// Horizontal rule / thematic break: ---, ***, or ___
209+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
210+
flushList()
211+
out.push(<hr key={key++} className="my-4 border-0 border-t border-[hsl(var(--glass-border))]" />)
212+
continue
213+
}
214+
156215
if (line.startsWith('- ')) { list.push(line.slice(2)); continue }
157216
flushList()
158217
if (!line.trim()) { out.push(<div key={key++} className="h-2" />); continue }

0 commit comments

Comments
 (0)