feat(web): add Mermaid diagram rendering and off-thread KaTeX/Mermaid workers (#1226)

* feat(web): add Mermaid diagram rendering and off-thread KaTeX/Mermaid workers

Enable Mermaid diagram support in the web chat via markstream-vue's enableMermaid(). Set up Web Workers for both KaTeX rendering and Mermaid parsing using markstream-vue's pre-built workers (katexRenderer.worker, mermaidParser.worker), keeping heavy computation off the main thread during live streaming.

* fix(web): skip Mermaid SVG subtrees in file link and markdown link rewriters

processFileLinks() uses a TreeWalker that scans all text nodes in mdRef.
Mermaid diagrams render as inline SVG, and diagram labels containing
file-path-like strings (e.g. src/App.vue) would be replaced with HTML
<button> elements inside SVG <text> nodes, corrupting the rendered diagram.

processMarkdownLinks() similarly queries a[href] inside SVGs; while
isLocalLink() mostly filters these out, explicitly skipping SVG subtrees
is safer.

Add svg to the closest() exclusion in processFileLinks(), and skip
links inside svg in processMarkdownLinks().

* fix(web): satisfy worker import lint

* chore: align mermaid dependency versions

* chore: change mermaid workers changeset to patch

---------

Co-authored-by: qer <wbxl2000@outlook.com>
This commit is contained in:
Simon He 2026-07-01 02:17:34 +08:00 committed by GitHub
parent 8ac337a2b2
commit 7f05f589e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 70 additions and 4 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Add Mermaid diagram rendering to the web chat. Fenced `mermaid` blocks in assistant responses now render as diagrams. KaTeX math and Mermaid diagram parsing also run in Web Workers to keep the UI responsive during live streaming.

View file

@ -18,6 +18,7 @@
"@xterm/xterm": "^6.0.0",
"katex": "^0.17.0",
"markstream-vue": "^1.0.4",
"mermaid": "^11.15.0",
"shiki": "^4.3.0",
"stream-markdown": "^0.0.16",
"vue": "^3.5.35",

View file

@ -2,13 +2,23 @@
<script setup lang="ts">
import { computed, inject, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { MarkdownRender, enableKatex } from 'markstream-vue';
import {
MarkdownRender,
enableKatex,
enableMermaid,
setKaTeXWorker,
clearKaTeXWorker,
setMermaidWorker,
clearMermaidWorker,
} from 'markstream-vue';
import type { MarkdownIt } from 'markstream-vue';
import { useIsDark } from '../../composables/useIsDark';
import type { FilePreviewRequest } from '../../types';
import { collectFilePathAliases, findFilePathLinks } from '../../lib/filePathLinks';
import { markdownRenderPlan } from '../../lib/markdownPerformance';
import { copyTextToClipboard } from '../../lib/clipboard';
import * as katexWorkerModule from 'markstream-vue/workers/katexRenderer.worker?worker&type=module';
import * as mermaidWorkerModule from 'markstream-vue/workers/mermaidParser.worker?worker&type=module';
// px-based CSS build (our app is px, not rem). Imported here so the styles
// load wherever Markdown is used; scoped overrides below re-skin it to
// Terminal Pro. Importing the same file from multiple components is a no-op
@ -23,6 +33,35 @@ import 'markstream-vue/index.px.css';
import 'katex/dist/katex.min.css';
enableKatex();
// Mermaid diagram rendering. enableMermaid() registers the default
// `import('mermaid')` loader same pattern as enableKatex(). Without a worker,
// mermaid.parse() runs on the main thread; with a worker (set via
// setMermaidWorker), the MermaidBlockNode can validate partial-stream code
// off-thread so the UI stays responsive during live diagram output.
enableMermaid();
// ---------------------------------------------------------------------------
// Off-main-thread workers for KaTeX and Mermaid
//
// Both katex.renderToString and mermaid.parse are CPU-heavy. markstream-vue
// ships pre-built workers (katexRenderer.worker.js, mermaidParser.worker.js)
// that follow the exact protocol its internal worker clients expect. We import
// them via Vite's `?worker&type=module` so they're built as ES module chunks
// (supporting code-splitting, which mermaid needs for per-diagram dynamic
// imports).
//
// markstream-vue's MermaidBlockNode and MathBlockNode auto-detect the presence
// of a worker: when set, heavy parsing/rendering is dispatched off-thread; when
// absent, everything runs on the main thread.
// ---------------------------------------------------------------------------
// Tear down any previous worker (e.g. from HMR) before setting a new one.
clearKaTeXWorker();
clearMermaidWorker();
setKaTeXWorker(new katexWorkerModule.default());
setMermaidWorker(new mermaidWorkerModule.default());
// Only `$$$$` display math is rendered; single `$` inline math is disabled so
// prices, env vars, and shell paths (`$5`, `$PATH`, `$HOME/bin`) stay literal
// without any escaping or code-detection gymnastics. `math_block` (the $$ rule)
@ -172,7 +211,7 @@ function processFileLinks(): void {
const parent = text.parentElement;
if (
parent &&
!parent.closest('a, pre, .md-file-link') &&
!parent.closest('a, pre, .md-file-link, svg') &&
text.data.trim().length > 0
) {
textNodes.push(text);
@ -231,6 +270,9 @@ function processMarkdownLinks(): void {
const links = mdRef.value.querySelectorAll<HTMLAnchorElement>('a[href]');
for (const link of links) {
if (link.dataset.mdLinkHandled === 'true') continue;
// Skip links inside Mermaid SVGs their hrefs are diagram semantics, not
// workspace file paths.
if (link.closest('svg')) continue;
const href = link.getAttribute('href') ?? '';
if (!isLocalLink(href)) continue;
link.dataset.mdLinkHandled = 'true';

View file

@ -18,3 +18,12 @@ declare module '*.vue' {
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}
// Vite's `?worker&type=module` imports — not declared in `vite/client`,
// which only covers `?worker`, `?worker&inline`, and `?worker&url` for classic
// workers. ES module workers need this additional declaration so TypeScript
// can resolve the import without errors.
declare module '*?worker&type=module' {
const WorkerFactory: new () => Worker;
export default WorkerFactory;
}

View file

@ -47,4 +47,10 @@ export default defineConfig({
emptyOutDir: true,
target: 'es2022',
},
// Workers that import modules with code-splitting (e.g. mermaid's dynamic
// diagram imports) need ES format — IIFE cannot split chunks. The app
// already targets ES2022 so all supported browsers handle module workers.
worker: {
format: 'es',
},
});

View file

@ -12,7 +12,7 @@
"vitepress": "^1.5.0"
},
"dependencies": {
"mermaid": "^11.12.2",
"mermaid": "^11.15.0",
"vitepress-plugin-llms": "^1.10.0",
"vitepress-plugin-mermaid": "^2.0.17"
}

5
pnpm-lock.yaml generated
View file

@ -184,6 +184,9 @@ importers:
markstream-vue:
specifier: ^1.0.4
version: 1.0.4(katex@0.17.0)(mermaid@11.15.0)(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2))
mermaid:
specifier: ^11.15.0
version: 11.15.0
shiki:
specifier: ^4.3.0
version: 4.3.0
@ -311,7 +314,7 @@ importers:
docs:
dependencies:
mermaid:
specifier: ^11.12.2
specifier: ^11.15.0
version: 11.15.0
vitepress-plugin-llms:
specifier: ^1.10.0