From f65d70dc1fdc4ccfec7a928def47e7018795f57e Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Fri, 15 May 2026 11:49:20 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=B0=86=20.sisyphus=20=E4=BB=8E=20gi?= =?UTF-8?q?t=20=E8=BF=BD=E8=B8=AA=E4=B8=AD=E7=A7=BB=E9=99=A4=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=E5=85=A5=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .sisyphus/boulder.json | 12 - .sisyphus/plans/svg-streaming-rendering.md | 636 --------------------- 3 files changed, 1 insertion(+), 648 deletions(-) delete mode 100644 .sisyphus/boulder.json delete mode 100644 .sisyphus/plans/svg-streaming-rendering.md diff --git a/.gitignore b/.gitignore index 518c5499..ec56efef 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ cache .cursor .trae .codex +.sisyphus .pytest_cache *.secret* diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json deleted file mode 100644 index 1d4588bf..00000000 --- a/.sisyphus/boulder.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "active_plan": "D:\\Code\\Yuxi\\.sisyphus\\plans\\svg-streaming-rendering.md", - "started_at": "2026-05-14T11:41:19.064Z", - "session_ids": [ - "ses_1da8ed4c2ffei0u3qB6QzD1uy4" - ], - "session_origins": { - "ses_1da8ed4c2ffei0u3qB6QzD1uy4": "direct" - }, - "plan_name": "svg-streaming-rendering", - "agent": "atlas" -} \ No newline at end of file diff --git a/.sisyphus/plans/svg-streaming-rendering.md b/.sisyphus/plans/svg-streaming-rendering.md deleted file mode 100644 index 762d4cf0..00000000 --- a/.sisyphus/plans/svg-streaming-rendering.md +++ /dev/null @@ -1,636 +0,0 @@ -# SVG 流式渲染支持计划(更新版) - -## TL;DR -> **概要**:支持将 LLM 在流式聊天消息和知识库内容中输出的 SVG 图像渲染出来。SVG 以 ` ```svg` 围栏代码块的形式出现;目前会被 Shiki 渲染为语法高亮的代码。需要在 markdown-it 处理前预处理 Markdown,将 SVG 代码块转换为内联 SVG HTML(DOMPurify 默认已安全支持 SVG,无需额外配置)。 -> -> **交付物**:1 个工具模块、修改渲染入口(`markdown_preview.js` + `MarkdownPreview.vue`)、CSS 样式、单元测试 -> -> **工作量**:短期(3-5 个任务) -> -> **并行度**:是 — 2 个波次 -> -> **关键路径**:svgRenderer.js → markdown_preview.js(renderMarkdown)→ MarkdownPreview.vue(CSS)→ 测试 - -## 背景 - -### 原始需求 -调研 Yuxi 的流式渲染对 SVG 显示的支持能力,确认问题,并制定一个添加 SVG 渲染能力的计划。 - -### 问题确认:所有 Markdown 渲染路径均不具备 SVG 渲染能力 -经过全面调研,**无论是流式内容还是非流式(静态)内容,只要经过 Markdown 渲染的路径,目前都不支持 SVG 渲染**。SVG 代码块 ` ```svg ... ``` ` 在所有路径中都被 Shiki 渲染为语法高亮的代码块,而非内联 SVG 图像。 - -### 渲染架构总览 -项目使用自建的 `MarkdownPreview.vue` 组件作为**唯一的 Markdown 渲染入口**,渲染链路统一: - -``` -Markdown 内容 → MarkdownPreview.vue - → renderMarkdown() [markdown_preview.js] - → 预处理:renderSvgBlocks() 将 ```svg → ← 新增步骤 - → markdown-it(html:true) + Shiki(代码高亮) - → DOMPurify.sanitize() ← ✅ 默认已安全支持 SVG - → v-html -``` - -### 覆盖范围:所有 Markdown 渲染路径(6 个组件) -`MarkdownPreview` 被以下 6 个组件使用,覆盖 AI 对话的全生命周期: - -| # | 组件 | 用途 | 涉及 AI 对话? | -|---|------|------|:---:| -| 1 | `AgentMessageComponent.vue` | **AI 回复消息渲染**(流式+历史) | ✅ 核心 | -| 2 | `ToolCallingResult/tools/TaskTool.vue` | **子智能体任务结果渲染** | ✅ 是 | -| 3 | `MarkdownContentViewer.vue` | 知识库文件内容预览 | ❌ 知识库页 | -| 4 | `AgentFilePreview.vue` | 工作区文件 Markdown 预览 | ❌ 工作区 | -| 5 | `FileDetailModal.vue` | 文件详情弹窗 | ❌ 通用 | -| 6 | `KbChunkDetailModal.vue` | 知识块详情弹窗 | ❌ 知识库 | - -**所有路径最终都汇聚到同一个渲染入口** `renderMarkdown()`,修改这一处即可全量覆盖。 - -### 访谈总结(再调研更新) -- LLM 在 ` ```svg` 围栏代码块中输出 SVG — 所有 `type === 'ai'` 的消息内容都经过 `renderMarkdown()` 渲染 -- 项目**已不再使用 `md-editor-v3`**,而是使用自建的 `MarkdownPreview.vue` 组件 -- 渲染链路:markdown-it(html: true)+ Shiki(代码高亮)+ **DOMPurify(XSS 过滤)** → v-html -- **关键发现**:经查阅 DOMPurify v3.4.2 源码确认,DOMPurify **默认已安全支持 SVG 标签和属性**,无需修改其配置 -- 真正的瓶颈:` ```svg ` 代码块在 markdown-it 阶段被 Shiki 拦截并渲染为高亮代码,SVG 内容未到达 DOMPurify -- 方案:在 `renderMarkdown()` 函数内部、`md.render()` 之前预处理 SVG 代码块,将 ` ```svg → ` 标签 -- 流式安全:只转换完整的 ` ```svg ... ``` ` 代码块 -- 测试:单元测试(Vitest)+ 手动 E2E 验证 - -### Metis 审查 -不可用。已进行自我审查。 - -## 工作目标 - -### 核心目标 -将 ` ```svg ... ``` ` 代码块渲染为内联 SVG 图像,覆盖 AI 对话全生命周期中所有可能出现 SVG 的渲染路径,包括: -- **流式渲染中**:AI 正在生成的过程中,完整出现的 SVG 块 -- **流式完成后**:完整消息展示时 -- **历史消息加载**:从后端加载的历史 AI 回复 -- **子智能体任务结果**:`TaskTool.vue` 中渲染的子任务输出 -- **知识库内容**:`MarkdownContentViewer.vue` 中渲染的引用内容 -- **文件预览**:工作区中 Markdown 文件的 SVG 渲染 - -### 交付物 -1. `web/src/utils/svgRenderer.js` — SVG 代码块预处理工具函数 -2. 修改 `web/src/utils/markdown_preview.js` — 在 `renderMarkdown()` 中集成 SVG 预处理(DOMPurify 默认已支持,无需配置) -3. CSS 样式 — 在 `MarkdownPreview.vue` 中添加响应式 SVG 容器样式(含深色模式) -4. 单元测试 — `web/src/utils/__tests__/svgRenderer.test.js` - -### 完成标准(可验证的条件与命令) -1. AI 回复消息(流式+完成态)中 ` ```svg ... ``` ` 渲染为内联 SVG 图像 -2. 流式传输过程中,不完整/未完成的 SVG 块保持文本形式,完成后正确渲染 -3. 子智能体任务结果(TaskTool)中 SVG 正确渲染 -4. 所有使用 MarkdownPreview 的组件(6 处)均支持 SVG 渲染 -5. SVG 响应式缩放并适配深色模式 -6. 无 XSS 风险 — SVG 内容经 DOMPurify 默认安全白名单过滤(无需额外配置) -7. 所有测试通过:`pnpm --filter web exec vitest run web/src/utils/__tests__/svgRenderer.test.js` -8. 现有 Markdown 渲染(代码块、表格、图片、Katex、frontmatter 等)无回归 - -### 必须包含 -- ` ```svg` 代码块 → 内联 SVG 渲染(覆盖所有 MarkdownPreview 使用场景) -- 流式安全性(部分代码块不破坏 UI) -- 响应式 SVG(max-width: 100%,height: auto) -- 深色模式支持 -- 保持现有 DOMPurify 配置不变(默认已安全支持 SVG) - -### 禁止包含(护栏、AI 套话模式、范围边界) -- 不修改后端流式逻辑 -- 不修改各个业务组件(AgentMessageComponent.vue、MarkdownContentViewer.vue 等) -- 不破坏现有 Shiki 代码高亮功能 -- 不添加额外外部依赖 -- **不修改 DOMPurify 配置**(默认已安全支持 SVG,无需改动) -- 不通过 `v-html` 直接插入未过滤的 SVG — 必须经过 DOMPurify -- 不修改其他语言的代码块渲染方式 -- 不包含 SVG 编辑/创作功能 -- 不修改其他图像格式(PNG/JPG/GIF)的处理方式 -- 不包括 `MdSidepanel.vue`(看板侧面板,使用 `marked` 库独立渲染,属于另一功能域) - -## 验证策略 -> **零人工干预** — 所有验证均由代理自动执行。 -- **测试决策**:svgRenderer.js 的单元测试(Vitest)+ 手动 E2E 验证 -- **QA 策略**:每个任务都有代理执行的验证场景 -- **证据**:.sisyphus/evidence/task-{N}-{slug}.{ext} - -## 执行策略 - -### 核心策略:单点修改,全量覆盖 - -本方案的核心优势在于:项目使用统一的 Markdown 渲染入口 `MarkdownPreview.vue` → `renderMarkdown()`,所以**只需修改 `markdown_preview.js` 一个文件**,即可让所有 6 个组件(包括流式聊天、历史消息、子任务结果、知识库内容等)同时获得 SVG 渲染能力。 - -### 并行执行波次 - -**波次 1**:[svgRenderer.js 工具 + 单元测试] — 基础,可并行 -**波次 2**:[markdown_preview.js 集成 SVG 预处理 + MarkdownPreview.vue CSS] — 依赖波次 1 - -### 依赖矩阵(完整,所有任务) - -| 任务 | 依赖 | 阻塞 | 覆盖的渲染路径 | -|------|------|------|--------------| -| 1. svgRenderer.js 工具函数 | — | 2, 4 | 工具函数,被 renderMarkdown 调用 | -| 2. markdown_preview.js 集成 SVG 预处理 | 1 | — | **所有 6 个组件的 Markdown 渲染** | -| 3. MarkdownPreview.vue CSS 样式 | — | — | 全局 SVG 容器样式 | -| 4. 单元测试 | 1 | — | 工具函数测试 | -| F1-F4. 验证 | 1,2,3,4 | — | 全量验证 | - -### 代理调度摘要 -- **波次 1**:2 个任务(svgRenderer.js 工具 + 单元测试)— 可并行执行 -- **波次 2**:2 个任务(markdown_preview.js 集成 + CSS 样式)— 波次 1 完成后可并行 -- **最终波次**:4 个并行审查代理 -- **影响范围**:修改 2 个核心文件 + 1 个 CSS 文件,覆盖所有 6 个 markdown 渲染组件 - -## 任务列表 - -- [ ] 1. 创建 SVG 预处理工具函数(`web/src/utils/svgRenderer.js`) - - **任务内容**:创建一个工具函数 `renderSvgBlocks(markdown)`,负责将 Markdown 字符串中的 ` ```svg` 围栏代码块转换为内联 SVG HTML。 - - **正则需要覆盖的所有围栏变体**: - - | 围栏样式 | 示例 | - |----------|------| - | 反引号围栏 | \`\`\`svg ... \`\`\` | - | 波浪线围栏 | ~~~svg ... ~~~ | - | 带缩进 | ` \`\`\`svg ...` | - | 后缀空格 | \`\`\`svg\n\`\`\` | - | 大写标签 | \`\`\`SVG ... \`\`\`(不区分大小写匹配) | - | 围栏内含属性 | \`\`\`svg id="mySvg"\`\`\`(仅第一个词作为语言标识) | - - **稳健的正则策略**(分步处理,避免单一大正则的边界问题): - - 1. **第一步 — 检测并提取**:使用行级扫描匹配围栏代码块,而非单一大正则 - ```js - // 匹配围栏开头的正则 - const FENCE_OPEN_RE = /^( {0,3})(`{3,}|~{3,})\s*(\S*)/ - // 检测 SVG 语言标识:第一个非空单词为 svg(不区分大小写) - // 排除 backtick-fenced 内部的 ``` 误匹配 - ``` - - 2. **第二步 — 逐行解析**:从围栏开始行向后扫描,寻找匹配的关闭围栏 - - 关闭围栏规则:缩进 ≤ 开头缩进,同种围栏字符(\` 或 ~),长度 ≥ 开头 - - 内容行原样累积 - - 3. **第三步 — 条件替换**:仅当找到匹配的关闭围栏时才执行替换(流式安全的核心保证) - - 4. **第四步 — 组装 HTML**:`
` 包裹原始 SVG 内容 - - **流式安全核心设计**: - - 如果到字符串末尾仍未找到闭合围栏 → **原样保留**(不破坏不完整块) - - 缩进嵌套的围栏代码块内层不触发转换(markdown-it 自身处理嵌套) - - **边缘情况清单**: - - [ ] SVG 内容中包含反引号(如 `\`code\``)— 关闭围栏在单独一行,不会误匹配 - - [ ] SVG 内容中包含空行 — 压缩为单行后不会触发 markdown-it HTML 块截断 - - [ ] 波浪线围栏 `~~~svg ... ~~~` - - [ ] 带缩进的围栏 ` \`\`\`svg` - - [ ] 围栏前有其他内容:`text\n\`\`\`svg\n...\n\`\`\`\nrest` - - [ ] 多个不连续的 SVG 块 - - [ ] 连续多个 SVG 块 - - [ ] 空的 SVG 代码块 `\`\`\`svg\n\`\`\`` — 转换为空容器或保留原样 - - [ ] 非 SVG 代码块保持完全不变 - - [ ] 大小写变体:svg / SVG / Svg / Svg - - [ ] 围栏行后有多余空行 - - **⚠️ 关键设计细节:SVG 内容必须压缩为单行** - - **为什么需要压缩**:markdown-it 的 HTML 块解析规则中,`
` 属于 **Type 1 HTML 块**。CommonMark 规定:Type 1 块的起始标签 `
` 开启一个 HTML 块,**遇到空行即终止**。如果 SVG 中有空行,空行后的 SVG 内容会被当作普通 Markdown 解析,导致渲染结果被破坏。 - - **修复方案**:将 SVG 内容合并为单行再输出,避免触发空行终止规则: - ```js - // 压缩行间空白为单行,防止 markdown-it 因空行截断 HTML 块 - const singleLine = svgLines - .join('') - .replace(/>\s+<') // 间留一个空格 - .replace(/\s{2,}/g, ' ') // 多余空白合并 - .trim() - output.push(`
${singleLine}
`) - ``` - - **伪代码实现**(完整): - ```js - export function renderSvgBlocks(markdown) { - const lines = markdown.split('\n') - const output = [] - let i = 0 - - while (i < lines.length) { - const openMatch = lines[i].match(/^( {0,3})(`{3,}|~{3,})\s*(\S*)/) - - if (openMatch && openMatch[3].toLowerCase() === 'svg') { - const indent = openMatch[1] - const fenceChar = openMatch[2] - const openLine = lines[i] - const svgLines = [] - i++ - - // 扫描闭合围栏 - let closed = false - while (i < lines.length) { - const closeMatch = lines[i].match(/^( {0,3})(`{3,}|~{3,})\s*$/) - if (closeMatch - && closeMatch[1].length <= indent.length // 缩进 ≤ 开头 - && closeMatch[2][0] === fenceChar[0] // 同种字符(` 或 ~) - && closeMatch[2].length >= fenceChar.length) { - closed = true - // ⚠️ 压缩为单行,防止 markdown-it HTML 块因空行截断 - const singleLine = svgLines - .join('') - .replace(/>\s+<') - .replace(/\s{2,}/g, ' ') - .trim() - output.push(`
${singleLine}
`) - i++ - break - } - svgLines.push(lines[i]) - i++ - } - - if (!closed) { - // 不完整块 — 原样保留(流式安全) - output.push(openLine) - output.push(...svgLines) - } - } else { - output.push(lines[i]) - i++ - } - } - - return output.join('\n') - } - ``` - - **注意**:本函数只是字符串替换,不负责 XSS 过滤(XSS 由 DOMPurify 在 `renderMarkdown()` 中统一处理) - - **禁止**: - - 不要修改非 SVG 代码块内的内容 - - 不要添加任何外部依赖 - - 不要在此函数内进行 DOMPurify 处理 - - 不要使用单一大正则 `[\s\S]*?` 直接匹配整个文件(会错误匹配嵌套围栏和内容中的反引号) - - **推荐代理画像**: - - 类别:`quick` — 单个工具函数,规格清晰 - - 技能:`[]` — 标准 JavaScript 操作(正则 + 字符串处理) - - 已评估但省略:无 - - **并行化**:可并行:是 | 波次 1 | 阻塞:[2] | 依赖:[] - - **参考文件**: - - `web/src/utils/messageProcessor.js` — 现有工具模块,参考代码风格 - - `web/src/utils/markdown_preview.js` — 本函数将在此文件中被调用 - - **验收标准**: - - [ ] 基本场景:`\`\`\`svg ... \`\`\`` → `
...
` - - [ ] 反引号围栏:` \`\`\`svg \`\`\` ` → 转换 ✓ - - [ ] 波浪线围栏:` ~~~svg ~~~ ` → 转换 ✓ - - [ ] 带缩进围栏:` \`\`\`svg \`\`\`` → 转换 ✓ - - [ ] 不区分大小写:` \`\`\`SVG \`\`\`` → 转换 ✓ - - [ ] 不完整块(流式):`\`\`\`svg ...`(无闭合)→ 保持原样 - - [ ] 非 SVG 代码块:`\`\`\`python print(1) \`\`\`` → 保持原样 - - [ ] 多个连续 SVG 块 → 全部完整转换 - - [ ] SVG 内容包含 HTML 注释等 → 保留内容原样 - - [ ] SVG 内容包含空行 → 压缩为单行,渲染结果正确 - - [ ] 空的 SVG 代码块:`\`\`\`svg\`\`\`` → 安全处理 - - [ ] 导出的函数名为 `renderSvgBlocks` - - **QA 场景**: - ``` - 场景:完整 SVG 块 — 反引号围栏 - 工具:Bash - 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('before\n\`\`\`svg\n\n\`\`\`\nafter'));" - 预期:输出包含 'svg-inline-render' 和 '',不包含 '\`\`\`svg' - 证据:.sisyphus/evidence/task-1-complete-backtick.txt - - 场景:完整 SVG 块 — 波浪线围栏 - 工具:Bash - 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('~~~svg\n\n~~~'));" - 预期:输出包含 'svg-inline-render' 和 '' - 证据:.sisyphus/evidence/task-1-complete-tilde.txt - - 场景:SVG 含空行 — 压缩为单行 - 工具:Bash - 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('\`\`\`svg\n\n\n\n\n\n\n\n\n\n\`\`\`'));" - 预期:输出仅包含 1 行 '
...' - 失败指标:输出包含多个 '<' 开头的行(表明 HTML 块被截断) - 证据:.sisyphus/evidence/task-1-blank-lines.txt - - 场景:带缩进的 SVG 块 - 工具:Bash - 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks(' \`\`\`svg\n\n \`\`\`'));" - 预期:输出包含 'svg-inline-render' - 证据:.sisyphus/evidence/task-1-indented.txt - - 场景:大小写变体 SVG 块 - 工具:Bash - 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('\`\`\`SVG\n\n\`\`\`'));" - 预期:输出包含 'svg-inline-render' - 证据:.sisyphus/evidence/task-1-case-insensitive.txt - - 场景:不完整 SVG 块(流式)不被转换 - 工具:Bash - 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('before\n\`\`\`svg\n'));" - 预期:输出包含 '\`\`\`svg'(不变),不包含 'svg-inline-render' - 证据:.sisyphus/evidence/task-1-incomplete.txt - - 场景:非 SVG 代码块不受影响 - 工具:Bash - 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('\`\`\`python\\nprint(1)\\n\`\`\`'));" - 预期:输出包含 '\`\`\`python' 和 '\`\`\`' - 证据:.sisyphus/evidence/task-1-python-block.txt - - 场景:多个连续 SVG 块 - 工具:Bash - 步骤:node -e "const { renderSvgBlocks } = require('./web/src/utils/svgRenderer.js'); console.log(renderSvgBlocks('\`\`\`svg\\n\\n\`\`\`\ntext\n\`\`\`svg\\n\\n\`\`\`'));" - 预期:输出包含 2 个 'svg-inline-render' - 证据:.sisyphus/evidence/task-1-multiple-blocks.txt - ``` - - **提交**:是 | 信息:`feat(web): 添加 SVG 代码块预处理工具函数(行级解析,支持围栏变体)` | 文件:[`web/src/utils/svgRenderer.js`] - - 提交前命令:`pnpm --filter web exec vitest run web/src/utils/__tests__/svgRenderer.test.js` - ---- - -- [ ] 2. 在 `markdown_preview.js` 中集成 SVG 渲染(无需修改 DOMPurify) - - **任务内容**:修改 `web/src/utils/markdown_preview.js` 中的 `renderMarkdown()` 函数,使其支持 SVG 渲染。 - - **关键发现**:经过调研确认,**DOMPurify 的默认配置已经支持 SVG 标签和属性**(见 DOMPurify 源码 `tags.ts` / `attrs.ts`),无需修改其配置。真正的瓶颈在于 Shiki 在 markdown-it 阶段将 ` ```svg ` 代码块渲染为语法高亮的 HTML,导致 SVG 内容以代码形式呈现。因此只需做以下工作: - -1. **导入 SVG 预处理函数**:在文件顶部添加 `import { renderSvgBlocks } from './svgRenderer'` - -2. **关键:在 `hasCodeFence()` 之前调用 `renderSvgBlocks()`** - 修改后的 `renderMarkdown()` 执行顺序: - ``` - ① normalizeHtmlTagQuotes() → 标准化 HTML 引号 - ② renderSvgBlocks() → 将 ```svg 代码块转为 inline SVG(NEW) - ③ 生成 cacheKey 基于步骤②产出 → 缓存 key 使用转换后的内容 - ④ 检查缓存 → 命中则直接返回缓存的 HTML - ⑤ hasCodeFence() → 检测是否需要语法高亮 - ⑥ collectCodeFenceLanguages() → 收集需要加载的 Shiki 语言 - ⑦ getRenderer() → 获取 markdown-it 实例 - ⑧ md.render() → Markdown 解析(SVG 已是 raw HTML,pass-through) - ⑨ DOMPurify.sanitize() → XSS 过滤(SVG 在默认白名单中) - ⑩ 缓存结果,返回 - ``` - **为什么转换必须在缓存之前**: - - 如果先查缓存(基于原始内容),原始内容包含 ` ```svg `,缓存不命中后才转换 → 正确但性能有损 - - 如果先转换再查缓存(基于转换后内容),相同输入直接命中缓存 → 性能更优 - - 且 `hasCodeFence()` 基于转换后内容,**不会把 'svg' 当作代码语言加载 Shiki**,避免了不必要的开销 - -3. **缓存策略**:cacheKey 应基于 `renderSvgBlocks()` 转换后的内容(`svgContent`),而非原始 `normalizedContent`。因为: - - 转换前后内容不同,对应的渲染结果不同 - - 用转换后内容做 key 能保证缓存一致性 - - 且避免了先查缓存再转换的"双路径"逻辑 - -4. **不需要修改 DOMPurify 配置** — 现有 `ADD_TAGS: ['input']` 和 `ADD_ATTR: [...]` 已足够,DOMPurify 默认的 SVG 白名单包含所有常用 SVG 标签和属性,并自动阻止 `