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:
co-authored by
Claude Opus 4.6
parent
c070e76515
commit
e7961c608b
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Generated
+17
@@ -27,6 +27,9 @@ importers:
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: ^3.13.19
|
||||
version: 3.13.19(vue@3.5.29(typescript@5.8.3))
|
||||
katex:
|
||||
specifier: ^0.16.33
|
||||
version: 0.16.33
|
||||
leaflet:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -1600,6 +1603,10 @@ packages:
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
commander@8.3.0:
|
||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
common-tags@1.8.2:
|
||||
resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
@@ -2171,6 +2178,10 @@ packages:
|
||||
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
katex@0.16.33:
|
||||
resolution: {integrity: sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==}
|
||||
hasBin: true
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -4654,6 +4665,8 @@ snapshots:
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commander@8.3.0: {}
|
||||
|
||||
common-tags@1.8.2: {}
|
||||
|
||||
confbox@0.1.8: {}
|
||||
@@ -5293,6 +5306,10 @@ snapshots:
|
||||
|
||||
jsonpointer@5.0.1: {}
|
||||
|
||||
katex@0.16.33:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
Reference in New Issue
Block a user