# 回复渲染完整度(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$` → 断言 ``/`.katex` 存活);若被剥,仅对本次调用补 `ADD_TAGS` 最小集,不动主链 SANITIZE_OPTIONS。 ### 5.4 表格增强(P3) - `renderer.table` override:`
` + 默认表格 HTML。`token.raw` 不可得或超 64KB → 回落 `data-table-dom`(仅包裹层,复制走 DOM 序列化)。 - 工具条:`.md-table-wrap` hover 显示右上角「复制 Markdown / 复制 CSV」;新 `utils/tableCopy.ts` 走 **document 级委托**(与 codeCopy 同范式,零 onclick 注入面)。 - CSV 序列化:单元格取 `textContent`,含 `, " \n` 时整格加引号并把 `"` 翻倍;Markdown 复制优先解码 `data-table-md`(保真内联格式),缺失时 DOM 序列化管道表。 ### 5.5 主题映射 - mermaid:light → `theme:'default'`,dark → `theme:'dark'`;`themeVariables` 两套常量(背景透明化 `background:'#0000'` 适配气泡底色)。 - 主题切换:MarkdownContent 既有 `watch isDark` 整串重渲 → 占位重建 → 后处理按新主题重新插入(缓存键含主题,两套 SVG 各自命中)。 ## 6. 冲突与风险清单(逐项) > 每项给出代码依据与应对;标 ★ 的为设计红线,违反即产生真冲突。 ### A. 与 marked 管线 | # | 冲突 / 风险 | 依据 | 应对 | |---|---|---|---| | A1 | code 渲染已被 walkTokens 整体接管,mermaid 若走「渲染后正则替换 `
`」会与自定义 `
` 结构耦合 | useMarkdown.ts:69-76 | ★占位在 walkTokens 分支内生成(决策 1),与自定义结构零耦合;分支同步返回,不引入新异步竞争 |
| A2 | 模型直接输出 `
…` 原始 HTML 不会被渲染 | renderer.html 一律转义(:79-81) | 接受:只支持 fenced 形态。这是既有安全语义非缺陷;prompt 侧无需变更(模型本就惯用围栏) | | A3 | `stripCitationDefinitions` 会误删 mermaid 源码中形如 `[^1^]: xxx` 的行 | :64-68 已知取舍注释 | 沿用既有取舍(概率极低);新增一条用例锁定现状,不在本案扩修 | | A4 | marked v18 的 renderer 签名与旧版差异 | ^18.0.4 已锁 | 本案只动 walkTokens 分支与 renderer.table override,实施时以 v18 签名为准;createMarked 层测试兜底 | | A5 | `renderInlineMarkdown` 误染 | :160-168 parseInline 无代码块 | 无影响,零改动;加一条「inline 渲染不产占位」断言防回归 | | A6 | KaTeX 误判正文价格/金额(`$5 和 $6` 成对出现) | §5.3 | 定界符校验层(开闭空白规则 + 禁跨空行);`throwOnError:false` 时误判回落原文无阅读损失;用例集覆盖典型误判句 | ### B. 与 DOMPurify | # | 冲突 / 风险 | 依据 | 应对 | |---|---|---|---| | B1 | `data-mermaid` 属性被消毒剥除则全案失效 | 依赖 ALLOW_DATA_ATTR 默认 true(dompurify ^3.4.14) | 实施时加全链测试锁定(占位过 sanitize 后 data 属性存活);上游默认若变,显式 `ADD_ATTR` 一行即可,主链其余不动 | | B2 | ★SVG 混进消毒前的 HTML 字符串:默认 profile 会剥掉大半 SVG 节点,图残缺且诱导后续给主链开白名单 | §4 | 设计红线:SVG 只在 DOM 插入时生成;主链 SANITIZE_OPTIONS 永不改(code review 检查项) | | B3 | mermaid SVG 内嵌 `