# 回复渲染完整度(Mermaid / KaTeX / 表格增强)技术方案 > 版本:v1.1(2026-10-25) > 状态:已实施(0.0.350;提案见 [../proposals/2026-10-25-message-rendering.md](../proposals/2026-10-25-message-rendering.md);第十六批另两件见 [../proposals/README.md](../proposals/README.md))。实施偏差两处:**决策 5 取备选「自研 tokenizer」**——marked-katex-extension 无法注入自定义定界校验层,价格语境防误判(A6)必须落在定界匹配处,故自研 inline/block 扩展(`utils/math-extension.ts`)+ 官方 katex 渲染;**测试基建新增 devDep `jsdom`**——node 环境跑不了 DOMPurify 全链(§9 H1),消毒锁定用例以 `@vitest-environment jsdom` 单文件开启(`markdown-sanitize.dom.test.ts`)。E2E(FileViewer 种子 md 场景)未随本批落地,按 §9 降级为手动冒烟。 ## 0. 待确认决策(评审逐条定) | # | 决策点 | 推荐 | 备选 | |---|--------|------|------| | 1 | mermaid 挂载点 | **marked 层占位 + MarkdownContent DOM 后处理**(§5.1/5.2;与现有管线纯增量组合,占位 div 天然通过 DOMPurify 默认白名单) | 渲染字符串正则替换 `
`(脆弱,依赖 marked 输出形态,否) |
| 2 | 流式期间的图渲染 | **门控不渲染**:流式中的 assistant 消息不触发 mermaid,占位保持代码块形态;流结束后渲染一次 | 每 chunk 渲染 + 缓存回填(仍有闪烁窗口与大图开销,否) |
| 3 | 「查看源码」形态 | 图下方一行折叠条「查看源码」,展开为**保留 data-code 的原代码块**(复制按钮可用) | 不提供(复制源码去别处用的场景真实存在,否) |
| 4 | mermaid 版本 | **11.x**(现行稳定,`securityLevel:'strict'` 语义完整) | 10.x(体积相近无收益) |
| 5 | KaTeX 集成 | **`marked-katex-extension` + 自定义定界符校验**(官方配套、约 2KB;校验层自研防误判) | 自研 inline tokenizer(可控但重造轮子) |
| 6 | 表格源码承载 | **`renderer.table` override 包裹层 + `token.raw` base64**(复制 Markdown 保真)| 纯 DOM 序列化(零 renderer 改动但丢内联格式);作 raw 不可得时的兜底 |
| 7 | 视口懒渲染 | **P1 不引入 IntersectionObserver**:串行队列 + 缓存已兜住;实测卡顿再加(YAGNI) | 一步到位视口懒渲染 |
| 8 | 占位内是否保留复制按钮 | 保留(占位初始内容即完整原代码块,含 `data-code`;渲染成功才替换为图 + 源码折叠) | 占位为空壳(渲染失败时无降级内容,否) |
## 1. 需求背景
提案(§1-§2)已论证:Agent 高频产出 mermaid / 公式 / 表格,而三端源码零渲染命中,用户只能复制到外部工具。本方案承接提案 §4-§5,细化到文件与函数级,并把「与现有渲染链的每一个冲突点」逐项列出(§6)。
## 2. 现状:渲染链全貌(代码事实,file:line)
### 2.1 marked 管线(`desktop/src/composables/useMarkdown.ts`)
```
renderMarkdown(text, isDark, citeMap)
1. hooks.preprocess:stripCitationDefinitions(剥 [^n^]: 定义行) :64-68
2. walkTokens:code token 整体替换为自定义
(复制按钮头 + Monaco colorize + data-code=base64) :69-76
3. renderer.text:[^n^] → 可点击上标;renderer.link:外链 _blank :91-112
4. renderer.html:原始 HTML 一律转义为纯文本 :79-81
5. DOMPurify.sanitize(默认白名单 + ADD_ATTR:['target']) :119,157
```
- marked 版本 `^18.0.4`、dompurify `^3.4.14`(desktop/package.json)。
- mermaid 围栏现状:`monacoLangFromFence('mermaid')` 原样返回(`monaco-lang.ts` 未知 lang 直通),colorize 回落,**显示为普通代码块**——即插即改的路径已存在。
- `renderInlineMarkdown`(:160-168)走 parseInline,无代码块,全案不受影响。
- codeCopy 委托是 **document 级 capture**(`codeCopy.ts:62-67`),不绑定具体节点——DOM 替换不影响既有复制按钮。
### 2.2 挂载与流式(`MarkdownContent.vue`)
- v-html(:2)+ `watch [content, isDark, citeMap]`(:25-39)+ `renderGen` 代际守卫(流式高频 + 异步渲染防旧帧覆盖,:21-23)。
- 渲染完成派发 `mao:markdown-rendered` 事件(:34-36)——外层自动滚动依赖它重测量高度。
- 流式路径:store 每 chunk 更新 content → MessageBubble segments 重算 → MarkdownContent watch 每次触发**整串重解析 + v-html 整体替换**。这是全案最重的约束(§6-C)。
### 2.3 消费方矩阵(全走 MarkdownContent 单点)
| 消费方 | 用法 | 图渲染默认 |
|---|---|---|
| MessageBubble(聊天主链) | assistant segments text 段(:95-100)、后台子代理通知(:26-31)、无 segments 回退(:110-116) | 开;流式中的段由 `streaming` prop 门控 |
| ShareView 分享页 | 经 `ChatRoundList`(:44)→ MessageBubble(`ChatRoundList.vue:4,35,62`)同链路 | 开(公开页,消毒链不放宽,§6-F4) |
| RunTraceRecordReply :15 / FileViewer :51 / FileDiffViewer :11 / SkillManager :223 | 直接 MarkdownContent | 开 |
- MessageBubble 已有 `isAssistantRunning`(ThinkingBlock 的 streaming prop 来源,:94)——流式门控数据现成。
- 主题:vueuse `useDark` 写 `data-theme`(`utils/theme.ts`),MarkdownContent 已 watch `isDark`,主题切换天然整串重渲。
- 测试环境:desktop vitest `environment: 'node'`(`vitest.config.ts:7`),DOMPurify/window 全链跑不了——marked 层可测,DOM 插入只能组件/E2E/冒烟(§9)。
- 构建:vite ^8 无 manualChunks 配置,动态 import 自动独立 chunk。
## 3. 技术选型
| 项 | 选型 | 理由 |
|----|------|------|
| mermaid | `mermaid@^11` 动态 import,本地打包 | 决策 4;内网部署禁 CDN 是硬约束 |
| 挂载点 | 三段式:**marked 占位 → DOMPurify(默认白名单不动)→ DOM 后处理渲染** | 决策 1;SVG 不进消毒前字符串(§6-B2 设计红线) |
| KaTeX | `katex` + `marked-katex-extension` + 自研定界符校验 | 决策 5;`trust:false` 固定 |
| 表格 | renderer.table override 包裹 + 工具条 document 级委托(照 codeCopy 模式) | 决策 6;与复制按钮同一交互范式 |
| 渲染调度 | 模块级单例 `mermaid-renderer.ts`:串行队列 + SVG 缓存(上限 100,写入序淘汰)+ 全局自增 id | §6-D;淘汰策略与 `MARKED_CACHE_MAX`(useMarkdown.ts:133)同范式 |
## 4. 总体架构
```
marked.parse
└─ walkTokens: lang ∈ {mermaid, mmd} 且源码 ≤ 64KB
→ token.text =
(初始内容 = 完整原代码块 HTML:复制按钮头 + 纯文本高亮) ← 天然降级基底
└─ 其余 token 走现状(colorize / citation / 链接)
DOMPurify.sanitize(不动 SANITIZE_OPTIONS;data-* 默认放行)
MarkdownContent: html.value = next → nextTick
└─ 后处理(streaming=false 时):
扫 root 内 [data-mermaid] → 入模块队列(串行)
→ 缓存命中? 直接回填 SVG
: mermaid.parse 预检 → mermaid.render(id, src)
→ SVG 二次消毒(svg profile + FORBID foreignObject/script)
→ 原位替换(保留「查看源码」折叠条 + 折叠内原代码块)
→ 补发 mao:markdown-rendered
任一步失败 → 占位原样保留(代码块),控制台 warn,无 UI 噪音
```
## 5. 详设
### 5.1 占位协议(`useMarkdown.ts` walkTokens mermaid 分支)
```ts
if (isMermaidFence(token.lang)) { // 'mermaid' | 'mmd',大小写不敏感
const source = token.text
if (source.length > MERMAID_SOURCE_CAP) return fallbackCodeBlock(...) // 64KB 上限,§6-F2
token.escaped = true
token.text = mermaidPlaceholder(source, isDark) // base64 进 data-mermaid
return
}
```
- 占位 HTML 结构(初始内容即原代码块,决策 8):
```html
…复制按钮头 + 纯文本…
```
- 关键点:**base64 在 marked 层同步完成**(`encodeCodeForCopy` 既有函数复用),不给 walkTokens 引入新异步;colorize 跳过(Mermaid 无 Monaco 语言,跳过同时省一次无效调用)。
- 原代码块的 `data-code` 保留——渲染成功后被替换进折叠条内,复制按钮(document 级委托)继续可用(§6-H2)。
### 5.2 DOM 后处理(`MarkdownContent.vue` + 新 `utils/mermaid-renderer.ts`)
- MarkdownContent 模板根加 `ref="root"`;watch 回调在 `html.value = next` 后 `nextTick(scheduleMermaidInsert)`。
- 新增可选 prop `streaming?: boolean`(默认 false):true 时跳过后处理(占位显示代码块)。MessageBubble 在 segments text 段与回退处传 `:streaming="isAssistantRunning"`;其余消费方不传(历史渲染默认渲染图)。
- `scheduleMermaidInsert(root)`:`root.querySelectorAll('[data-mermaid]')` → 逐个入队。任务闭包捕获元素,**替换前校验 `el.isConnected && dataset.mermaid 未变`**——代替 renderGen 做 DOM 级守卫(组件重渲后旧节点已断链,直接丢弃,§6-C4)。
- 渲染(模块级串行队列):
1. 缓存键 = `data-mermaid` 原串 + 主题位,命中直接回填;
2. `mermaid.initialize({ startOnLoad:false, securityLevel:'strict', flowchart:{ htmlLabels:false }, theme, themeVariables, fontFamily: 与 markdown-body 同族 })`(每次批量渲染前按当前主题初始化,fontFamily 对齐防测量偏差,§6-E2);
3. `mermaid.parse(src, { suppressErrors:true })` 失败 → 保留占位;
4. `mermaid.render(\`m${seq++}\`, src)`——id 全局自增(同屏多实例,§6-D1);
5. SVG 过 `DOMPurify.sanitize(svg, SVG_SANITIZE_OPTIONS)`(`USE_PROFILES:{svg:true,svgFilters:true}` + `ADD_TAGS:['style']` 仅此调用生效,保住 mermaid 内嵌样式;`FORBID_TAGS:['foreignObject','script']`;on* 事件默认剥除);
6. 构建「图 + 折叠条(查看源码 → 内嵌原 pre)」替换占位,随后派发 `mao:markdown-rendered`(高度变化,滚动重测量,§6-C5)。
- 缓存:`Map`,上限 100 条写入序淘汰(整体清空式,与 `MARKED_CACHE_MAX` 同范式)。
- 动态 import:`const mermaid = await import('mermaid')`——vite 自动独立 chunk,无图会话零加载(§6-G1)。
### 5.3 KaTeX(P2)
- `createMarked` 内 `marked.use(markedKatex({ throwOnError:false, trust:false, output:'htmlAndMathml', nonStandard:false }))`,**外层包一层定界符校验**:`$` 开定界符右侧非空白、左侧非 `\\`,闭定界符左侧非空白,行内公式内不得出现空行——不满足则按普通文本放行(防「价格 $5 和 $6」误判,§6-A6)。
- 代码块 / 行内 code 天然豁免(不经 text renderer,marked 分词保证)。
- CSS 与字体:`import('katex/dist/katex.min.css')` 与 mermaid 同批动态触发;字体文件 vite 本地打包(woff2 约 60KB×若干,进独立 chunk 资源)。
- 与 citation 共存:math token 是独立 token 类型,不进 text renderer 的 citation 替换;`[^n^]` 出现在公式内按原文展示(模型不会这么写,不处理)。
- DOMPurify 放行验证:KaTeX 产物含 MathML 标签与 inline style——dompurify ^3.4.14 默认 profile 理论放行,**实施时以全链测试锁定**(渲染 `$x^2$` → 断言 `