feat(renderer): KaTeX math rendering for $inline$ and $$block$$ (M10.6)

- Lazy-loads KaTeX (~70KB) on first math detected
- Inline $...$ and block $$...$$ LaTeX support
- Falls back to <code> for invalid LaTeX
- Batch render after markdown, not during streaming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:56:12 +00:00
co-authored by Claude Opus 4.6
parent c070e76515
commit e7961c608b
4 changed files with 98 additions and 4 deletions
+1
View File
@@ -21,6 +21,7 @@
"dependencies": {
"@aiui/core": "workspace:*",
"@tanstack/vue-virtual": "^3.13.19",
"katex": "^0.16.33",
"leaflet": "^1.9.4",
"markdown-it": "^14.1.1",
"pdfjs-dist": "^5.5.207",
@@ -323,8 +323,9 @@
</template>
<script setup lang="ts">
import { computed, ref, nextTick } from 'vue'
import { computed, ref, nextTick, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { hasMath, renderMathInHtml } from '@/composables/useMathRenderer'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenuItem from '@/components/ui/ContextMenuItem.vue'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
@@ -493,9 +494,18 @@ const displayText = computed(() => {
return text
})
const renderedMarkdown = computed(() => {
return md.render(displayText.value)
})
const baseMarkdown = computed(() => md.render(displayText.value))
const renderedMarkdown = ref('')
// Render math after markdown, batch on content change
watch(baseMarkdown, async (html) => {
if (hasMath(displayText.value)) {
renderedMarkdown.value = html // Show immediately
renderedMarkdown.value = await renderMathInHtml(html)
} else {
renderedMarkdown.value = html
}
}, { immediate: true })
const formattedTime = computed(() => {
const d = new Date(props.message.timestamp)
@@ -0,0 +1,66 @@
/**
* Lazy KaTeX math rendering for markdown content.
* Detects $...$ (inline) and $$...$$ (block) LaTeX.
* Falls back to raw <code> on invalid LaTeX.
*/
let katexModule: typeof import('katex') | null = null
let katexLoading: Promise<typeof import('katex')> | null = null
async function loadKaTeX() {
if (katexModule) return katexModule
if (katexLoading) return katexLoading
katexLoading = import('katex').then((m) => {
katexModule = m
// Inject KaTeX CSS
if (!document.getElementById('katex-css')) {
const link = document.createElement('link')
link.id = 'katex-css'
link.rel = 'stylesheet'
link.href = new URL('katex/dist/katex.min.css', import.meta.url).toString()
document.head.appendChild(link)
}
return m
})
return katexLoading
}
const BLOCK_RE = /\$\$([\s\S]+?)\$\$/g
const INLINE_RE = /\$([^\n$]+?)\$/g
function renderKaTeX(latex: string, displayMode: boolean): string {
if (!katexModule) return `<code>${escapeForHtml(latex)}</code>`
try {
return katexModule.default.renderToString(latex, {
displayMode,
throwOnError: false,
output: 'html',
})
} catch {
return `<code>${escapeForHtml(latex)}</code>`
}
}
function escapeForHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
export function hasMath(text: string): boolean {
return /\$\$[\s\S]+?\$\$/.test(text) || /\$[^\n$]+?\$/.test(text)
}
export async function renderMathInHtml(html: string): Promise<string> {
await loadKaTeX()
// Block math first ($$...$$)
let result = html.replace(BLOCK_RE, (_, latex) => {
return `<div class="katex-block my-4 text-center overflow-x-auto">${renderKaTeX(latex.trim(), true)}</div>`
})
// Inline math ($...$)
result = result.replace(INLINE_RE, (_, latex) => {
return renderKaTeX(latex.trim(), false)
})
return result
}