From 73041775d485e34e133aa7df8725b602954f710e Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Fri, 15 May 2026 05:15:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=B7=BB=E5=8A=A0=20SVG=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=9D=97=E9=A2=84=E5=A4=84=E7=90=86=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=87=BD=E6=95=B0=EF=BC=88=E8=A1=8C=E7=BA=A7=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=EF=BC=8C=E6=94=AF=E6=8C=81=E5=9B=B4=E6=A0=8F=E5=8F=98?= =?UTF-8?q?=E4=BD=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 svgRenderer.js,实现 renderSvgBlocks() 函数 - 支持反引号/波浪线围栏、大小写不敏感、围栏属性等变体 - 流式安全:不完整 SVG 块保持原样 - SVG 内容压缩为单行,防止 markdown-it HTML 块截断 - 新增 15 个单元测试覆盖所有场景 --- .sisyphus/boulder.json | 12 + .sisyphus/plans/svg-streaming-rendering.md | 636 ++++++++++++++++++++ web/src/utils/__tests__/svgRenderer.test.js | 139 +++++ web/src/utils/svgRenderer.js | 67 +++ 4 files changed, 854 insertions(+) create mode 100644 .sisyphus/boulder.json create mode 100644 .sisyphus/plans/svg-streaming-rendering.md create mode 100644 web/src/utils/__tests__/svgRenderer.test.js create mode 100644 web/src/utils/svgRenderer.js diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json new file mode 100644 index 00000000..1d4588bf --- /dev/null +++ b/.sisyphus/boulder.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 00000000..762d4cf0 --- /dev/null +++ b/.sisyphus/plans/svg-streaming-rendering.md @@ -0,0 +1,636 @@ +# 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 标签和属性,并自动阻止 `