feat(chat): render fenced latex math blocks
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add reliable LaTeX/math rendering to Hermes Web UI chat Markdown messages so formulas render visually instead of appearing as raw TeX.
|
||||
**Goal:** Add reliable LaTeX/math rendering to Hermes Web UI chat Markdown messages so formulas render visually instead of appearing as raw TeX, including explicit fenced math blocks like ```latex.
|
||||
|
||||
**Architecture:** Keep the existing `markdown-it` renderer in `MarkdownRenderer.vue` and add a KaTeX-backed math plugin there. Load KaTeX styles globally once, cover inline and block math in component tests, then verify with a production build and a local Web UI smoke test.
|
||||
**Architecture:** Keep the existing `markdown-it` renderer in `MarkdownRenderer.vue` and add a KaTeX-backed math plugin there, plus a small fence override for explicit LaTeX code fences. Load KaTeX styles globally once, cover inline, display, and fenced math in component tests, then verify with a production build and a local Web UI smoke test.
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, Vite, markdown-it, KaTeX, Vitest, @vue/test-utils.
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
|
||||
- Chat messages render inline math like `$x^2 + y^2 = z^2$` as KaTeX HTML.
|
||||
- Chat messages render block math like `$$\n\\int_0^1 x^2 dx = \\frac{1}{3}\n$$` as display KaTeX HTML.
|
||||
- Chat messages render explicit fenced math blocks like <code>```latex</code>, <code>```tex</code>, or <code>```math</code> as display KaTeX HTML.
|
||||
- Existing Markdown features still work: code fences, Mermaid fences, headings, local file links, mentions.
|
||||
- Math inside fenced code blocks stays literal and is not rendered.
|
||||
- Ordinary fenced code blocks stay literal and are not rendered as math.
|
||||
- Invalid/unsupported math syntax does not crash the chat renderer.
|
||||
- `npm run test -- tests/client/markdown-rendering.test.ts` passes.
|
||||
- `npm run build` passes.
|
||||
|
||||
@@ -19,8 +19,45 @@ import {
|
||||
} from './mermaidRenderer'
|
||||
import { downloadFile, getDownloadUrl, fetchFileText } from '@/api/hermes/download'
|
||||
|
||||
const LATEX_FENCE_LANGS = new Set(['latex', 'tex', 'math', 'katex'])
|
||||
const PREVIEW_AREA_WIDTH = 'min(800px, 100vw)'
|
||||
|
||||
function getFenceLanguage(info: string): string {
|
||||
return info.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
function isLatexFence(info: string): boolean {
|
||||
return LATEX_FENCE_LANGS.has(getFenceLanguage(info))
|
||||
}
|
||||
|
||||
function normalizeLatexFenceContent(content: string): string {
|
||||
const trimmed = content.trim()
|
||||
|
||||
if (trimmed.startsWith('\\[') && trimmed.endsWith('\\]')) {
|
||||
return trimmed.slice(2, -2).trim()
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('$$') && trimmed.endsWith('$$')) {
|
||||
return trimmed.slice(2, -2).trim()
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('\\(') && trimmed.endsWith('\\)')) {
|
||||
return trimmed.slice(2, -2).trim()
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function renderLatexFence(content: string): string {
|
||||
const latex = normalizeLatexFenceContent(content)
|
||||
return `<div class="latex-block">${katex.renderToString(latex, {
|
||||
displayMode: true,
|
||||
output: 'htmlAndMathml',
|
||||
throwOnError: false,
|
||||
strict: 'ignore',
|
||||
})}</div>`
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
content: string
|
||||
mentionNames?: string[]
|
||||
@@ -46,12 +83,17 @@ const md: MarkdownIt = new MarkdownItConstructor({
|
||||
md.use(markdownItKatex, {
|
||||
katex,
|
||||
throwOnError: false,
|
||||
strict: 'ignore',
|
||||
})
|
||||
|
||||
const defaultFenceRenderer = md.renderer.rules.fence?.bind(md.renderer.rules)
|
||||
|
||||
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx]
|
||||
if (isLatexFence(token.info)) {
|
||||
return renderLatexFence(token.content)
|
||||
}
|
||||
|
||||
if (isMermaidFence(token.info)) {
|
||||
return renderMermaidPlaceholder(token.content)
|
||||
}
|
||||
|
||||
@@ -611,7 +611,21 @@ describe('MarkdownRenderer', () => {
|
||||
expect(body.text()).not.toContain('$$')
|
||||
})
|
||||
|
||||
it('does not render latex inside fenced code blocks', () => {
|
||||
it('renders explicit latex fenced blocks with katex', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```latex\n\\[\\text{Итог} = \\operatorname{Округление}\\!\\left(0.5\\,O_1 + 0.5\\,O_2\\right)\\]\n```',
|
||||
},
|
||||
})
|
||||
|
||||
const body = wrapper.find('.markdown-body')
|
||||
expect(body.find('.katex-display').exists()).toBe(true)
|
||||
expect(body.find('.katex').exists()).toBe(true)
|
||||
expect(body.text()).not.toContain('```latex')
|
||||
expect(body.text()).toContain('Округление')
|
||||
})
|
||||
|
||||
it('does not render latex inside ordinary fenced code blocks', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```ts\nconst formula = "$x^2 + y^2 = z^2$"\n```',
|
||||
|
||||
Reference in New Issue
Block a user