feat(renderer): Mermaid diagram rendering with dark theme (M10.7)

- Lazy-loads Mermaid.js on first ```mermaid block detected
- Dark theme with Bitcoin orange accent colors
- Cached renders to avoid re-rendering on scroll
- Error display inline without crashing app

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:57:41 +00:00
co-authored by Claude Opus 4.6
parent e7961c608b
commit 7c6a0565d5
4 changed files with 959 additions and 5 deletions
+1
View File
@@ -24,6 +24,7 @@
"katex": "^0.16.33",
"leaflet": "^1.9.4",
"markdown-it": "^14.1.1",
"mermaid": "^11.12.3",
"pdfjs-dist": "^5.5.207",
"pinia": "^3.0.4",
"plyr": "^3.8.4",
@@ -326,6 +326,7 @@
import { computed, ref, nextTick, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { hasMath, renderMathInHtml } from '@/composables/useMathRenderer'
import { hasMermaid, renderMermaidBlocks } from '@/composables/useMermaidRenderer'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenuItem from '@/components/ui/ContextMenuItem.vue'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
@@ -497,14 +498,17 @@ const displayText = computed(() => {
const baseMarkdown = computed(() => md.render(displayText.value))
const renderedMarkdown = ref('')
// Render math after markdown, batch on content change
// Render math/mermaid after markdown, batch on content change
watch(baseMarkdown, async (html) => {
let result = html
renderedMarkdown.value = html // Show immediately
if (hasMath(displayText.value)) {
renderedMarkdown.value = html // Show immediately
renderedMarkdown.value = await renderMathInHtml(html)
} else {
renderedMarkdown.value = html
result = await renderMathInHtml(result)
}
if (hasMermaid(displayText.value)) {
result = await renderMermaidBlocks(result)
}
renderedMarkdown.value = result
}, { immediate: true })
const formattedTime = computed(() => {
@@ -0,0 +1,96 @@
/**
* Lazy Mermaid diagram rendering for ```mermaid code blocks.
* Dark theme matching glass design, cached renders.
*/
let mermaidModule: typeof import('mermaid') | null = null
let mermaidLoading: Promise<typeof import('mermaid')> | null = null
let mermaidInitialized = false
async function loadMermaid() {
if (mermaidModule) return mermaidModule
if (mermaidLoading) return mermaidLoading
mermaidLoading = import('mermaid').then((m) => {
mermaidModule = m
return m
})
return mermaidLoading
}
function initMermaid() {
if (mermaidInitialized || !mermaidModule) return
mermaidModule.default.initialize({
startOnLoad: false,
theme: 'dark',
themeVariables: {
primaryColor: '#F7931A',
primaryTextColor: '#fff',
primaryBorderColor: '#F7931A',
lineColor: '#666',
secondaryColor: '#1a1a1a',
tertiaryColor: '#111',
background: '#0a0a0a',
mainBkg: '#1a1a1a',
nodeBorder: '#444',
clusterBkg: '#111',
clusterBorder: '#333',
titleColor: '#ddd',
edgeLabelBackground: '#1a1a1a',
},
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: 13,
})
mermaidInitialized = true
}
// Cache rendered diagrams
const renderCache = new Map<string, string>()
export function hasMermaid(text: string): boolean {
return /```mermaid/i.test(text)
}
let renderCounter = 0
export async function renderMermaidBlocks(html: string): Promise<string> {
await loadMermaid()
initMermaid()
const mermaid = mermaidModule!.default
// Find <pre><code class="language-mermaid">...</code></pre> blocks
const PRE_RE = /<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/gi
const matches = [...html.matchAll(PRE_RE)]
if (matches.length === 0) return html
let result = html
for (const match of matches) {
const raw = match[1]
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim()
const cacheKey = raw
if (renderCache.has(cacheKey)) {
result = result.replace(match[0], renderCache.get(cacheKey)!)
continue
}
try {
const id = `mermaid-${++renderCounter}`
const { svg } = await mermaid.render(id, raw)
const wrapped = `<div class="mermaid-diagram my-4 overflow-x-auto rounded-lg bg-white/5 border border-white/5 p-4">${svg}</div>`
renderCache.set(cacheKey, wrapped)
result = result.replace(match[0], wrapped)
} catch {
// Show error inline without crashing
const errHtml = `<div class="my-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400/70">Mermaid render error</div><pre><code>${match[1]}</code></pre>`
result = result.replace(match[0], errHtml)
}
}
return result
}
+853
View File
File diff suppressed because it is too large Load Diff