---
url: /wave-agent/specs/ui/markdown-images.md
description: >-
  助手消息里 markdown
  图片语法（![alt](src)）的渲染与点击：页面能自己加载的地址内联显示，本地路径降级为可点击的路径链接，加载失败的图片就地降级不留破图标
---

# 功能规格说明：消息中的 markdown 图片

**创建日期**：2026-11-04

**最近更新**：2026-11-04（首版：本地路径不再产出必裂的 `<img>`，改为可点击路径链接；同日第二版：加载失败的图片就地降级，不留破图标）

> 本规格只覆盖**助手消息 markdown 正文**里的图片语法（`![alt](src)` 与 `![alt](src "title")`，含流式渲染）。粘贴图 / Read 等工具产出的 `image` 块、工具块的 `images`、以及「已发送图片标签」点开看原图各有既定行为，见 [图片粘贴](image-pasting.md)；消息里以纯文本出现的路径链接见 [文件路径链接](file-path-links.md)，本规格只复用它的点击通道。

## 背景：本地路径的图片为什么必裂

消息 markdown 由 webview 的 marked renderer 生成，图片语法此前交给 marked 默认实现，产出 `<img src="…" alt="…">` 后由 DOMPurify 按 `img`/`src`/`alt` 放行——也就是说 **src 被原样交给浏览器去加载**。这在三种宿主上都不可能成功：

* 桌面端页面自己是 `file://…/webview/index.html`：`screenshots/a.png` 会被解析成 `…/webview/screenshots/a.png` → 404；CSP 是 `img-src 'self' data: https:`，`file://` 与 `http:` 另有拦截；
* 插件端页面是 `vscode-webview://` / JB 的 JCEF origin：相对路径同样落在资源目录上 → 404；
* 远控 Web 页是 https 站点：相对路径落在站点根上 → 404。

更糟的是两种常见写法连 `src` 都留不下：DOMPurify 的 URI 白名单不认 `file:` 与盘符（`C:\…`），`<img>` 的 src 被整条剥掉。最终呈现就是用户看到的「破图标 + alt 文本」——图看不到，文件在哪也不知道。

这与 [图片粘贴](image-pasting.md) 场景 2 已经写下的口径一致：「渲染层不得把该路径直接塞进 `<img src>` 自行加载」。那条规则此前只覆盖「已发送图片标签」，漏掉了 markdown 图片语法，本规格就是把它补齐。

## 用户场景与测试 *（必填）*

### 用户故事：markdown 图片的本地路径不再裂图，而是可点开看（优先级：P1）

作为用户，我希望助手在消息里用 `![改前](路径)` 这样的语法引用本机截图或图片时，不要看到一个破图标，而是看到一个可点击的路径，点开就能看到那张图，以便我既能读到图，也能从路径知道文件到底在哪。

**为什么是这个优先级**：模型写「改前/改后」这类对照说明时的自然写法就是 markdown 图片语法，而它引用的都是本机文件——于是这条路径 100% 裂图。用户拿到的信息量为零（图看不到、路径也看不到），且这是模型侧无法自救的：换任何写法都得靠渲染层认账。

**独立测试**：渲染内容为 `![before](screenshots/before-05.png)` 的助手消息，验证 DOM 里**没有** `<img>`、有一个 class 含 `file-path-link` 的可点击元素、文案是路径原文；点击它，验证按宿主通道打开（桌面端进入文件面板并按该 pane 所在主机内联显示图片）。

**验收场景**：

1. **假设** 图片 src 是相对路径（`screenshots/a.png`、`./a.png`、`../a.png`、裸文件名 `a.png`），**当** 渲染时，**则** 不产出 `<img>`，产出一个可点击的路径链接，显示文本是 src 原文；点击时按**会话 workdir** 拼成绝对路径，再走该宿主既有的打开通道。
2. **假设** 图片 src 是绝对路径（Windows 盘符 `C:\…` / `C:/…`、POSIX `/home/u/a.png`、`file:///C:/…`），**当** 渲染时，**则** 同样降级为路径链接；点击时 `file://` 前缀先剥离，`. `/`..` 段按既有规则词法归一，再发往宿主。
3. **假设** 图片 src 是 `data:`、`blob:`、`http:` 或 `https:`，**当** 渲染时，**则** 保持现状——产出 `<img>` 内联显示（页面自己加载得了，不需要宿主参与）。
4. **假设** 图片语法带 alt（`![改前](…)`），**当** 渲染成路径链接时，**则** alt 落到该链接的 `title` 上（悬停可见），显示文本仍是路径原文。
5. **假设** 图片语法嵌在链接里（`[![alt](a.png)](https://example.com)`），**当** 渲染时，**则** 外层链接正常工作，内层不产生嵌套 `<a>`（沿用 `renderer.link` 剥掉标签内路径链接的既有规则）。
6. **假设** 会话没有 workdir 且 src 是相对路径，**当** 点击该链接，**则** 与其它相对路径点击一致地按原样交给宿主（宿主按自己的 cwd 解析），不抛错、不静默无反应。
7. **假设** src 的扩展名不是图片（`![](notes.txt)`、`![](Makefile)`），**当** 渲染时，**则** 仍按「路径」处理（语法是图片语法就降级为路径链接）——是不是图由宿主在打开/读取时判，页面不替文件下结论。
8. **假设** 宿主是远控 Web 页，**当** 用户点这条降级后的路径链接，**则** 走该页既有的按需读取通道（图片扩展名 → `getImagePreview` 弹层，其余 → `getTextPreview`），读取失败的原因原样显示，不留「看着能点、点了没反应」。
9. **假设** 宿主把路径链接档位设成 `off`（`FilePathLinkMode = "off"`），**当** 渲染时，**则** 只显示路径纯文本、不生成链接，与其它路径通道同档。
10. **假设** 消息正在流式输出（图片语法尚未闭合或刚闭合），**当** 增量重渲染时，**则** 与其它 markdown 元素一致：闭合前按普通文本显示，闭合后立刻变成路径链接，不抛错、不闪烁成破图。
11. **假设** 用户消息的正文里写了 `![x](a.png)`，**当** 渲染时，**则** 不受本规格影响——用户消息不走 markdown 渲染（走 mention 解析），保持原文。

### 用户故事：加载失败的图片不留破图标（优先级：P1）

作为用户，我希望消息里那些最终没能加载出来的图片（远端 404、桌面端被 CSP 拦下的 `http:`、坏掉的 data URL）不要以破图标留在页面上，而是就地换成一句能读的说明（`alt`，没有就用文件名），以便我至少知道「这里本来有张图、它叫什么」，而不是面对一个叉号猜它是什么。

**为什么是这个优先级**：这类失败**渲染期无法预判**——要等请求结果回来才知道，所以上一节只能处理「看 scheme 就能断定必裂」的本地路径；剩下的三类照样破图，其中 `http:` 在桌面端是 100% 失败（CSP 只有 `img-src 'self' data: https:`）。同一个坑每出现一次，用户看到的就是一条不可信的消息。

**独立测试**：渲染 `![改前](https://example.com/missing.png)`，在该 `<img>` 上派发 `error` 事件，验证 `<img>` 从 DOM 里消失、替换成文案为「改前」的文本节点、`title` 是原地址。

**验收场景**：

12. **假设** https 图片请求失败（404 / 网络错误），**当** 浏览器在该 `<img>` 上触发 `error`，**则** 该元素就地换成文本：文案取 `alt`，`alt` 为空则取地址里的文件名（`…/shots/a.png` → `a.png`），`title` 保留原地址（悬停可知它本该指向哪）。
13. **假设** 桌面端渲染 `http:` 图片（被 CSP 拦下），**当** 拦截触发 `error`，**则** 与场景 12 同一处理——不因为「是安全策略拦的」就留破图标，也不为此按宿主分档判断 `http:`。
14. **假设** `data:` 图片解不出图（坏 base64 / 不支持的格式），**当** 触发 `error`，**则** 文案取 `alt`，`alt` 为空时用「图片」；`title` **不得**写入 base64 原文（可能是几 MB 的串）。
15. **假设** 图片加载成功，**当** 渲染完成，**则** 什么都不发生——兜底只挂在 `error` 事件上，不预置占位、不改既有样式、不额外发请求。
16. **假设** 消息正在流式输出且含一张会失败的图，**当** 增量重渲染重建了这段 HTML，**则** 新的 `<img>` 重新尝试一次、失败后再降级；替换是「img → 文本」的一对一替换，不累积占位、不重复插入文本。
17. **假设** 图片出现在 markdown 表格单元格 / 列表项 / 引用块里，**当** 它加载失败，**则** 同规则——判据只看 `<img>` 元素自身，与所在块无关。
18. **假设** 粘贴图、工具块图片、文件面板预览里的图片加载失败，**当** 发生时，**则** 不受本故事影响——兜底只挂在 markdown 正文容器上，那几处各有既有处理（见 [图片粘贴](image-pasting.md)）。

***

## 行为规则

| src 形态                                               | 渲染结果                                 |
| ------------------------------------------------------ | ---------------------------------------- |
| `data:` / `blob:` / `http:` / `https:`（大小写不敏感） | `<img src alt>` 内联显示（与改造前一致） |
| 其余（相对 / 盘符 / POSIX 绝对 / `file:`）             | 可点击路径链接，无 `<img>`               |

* **降级判据**：src 不是「页面自己能加载的地址」即降级。这条判据只看 scheme，不看扩展名、不判断文件是否存在（渲染期不碰文件系统）。
* **显示文本**：src 原文，不展开 workdir、不改写分隔符——与其它路径链接一致，可点击目标的真实标识要写在脸上。
* **元素结构**：复用既有 `a.file-path-link`（追加 `md-image-link` 标记类），`href` 固定 `#`，alt 进 `title`。不新增标签、不新增属性 → DOMPurify 白名单不需要改；路径文本进 HTML 前转义。
* **点击解析**：先按既有规则识别绝对路径（`detectFilePathToken` → `resolveFilePathMatch`，覆盖盘符 / POSIX / `file://`）；不匹配时（也就是相对路径）按 `toAbsoluteFilePath(src, workdir)` 拼绝对。两条都拿不到绝对路径时原样把 src 交给宿主。
* **点击去向**：与其它路径链接完全同一条通道——桌面端 `onOpenFile`（文件面板按该分屏所在主机读，SSH 会话经 ssh 读回）、插件端 `openFile`、远控 Web 页按需读取后弹层显示。
* **破图兜底**：markdown 正文容器以**捕获阶段**监听 `error`（`error` 事件不冒泡，只能在容器上拦），命中「目标元素是 `<img>` 且解不出宽度（`naturalWidth === 0`）」时就地替换成 `span.md-image-broken`。文案阶梯是 `alt` → 地址里的文件名 → 「图片」；`title` 放原地址，`data:` 地址不放（避免把 base64 塞进悬停提示）。替换是纯 DOM 操作、不进 React 状态，所以容器下次重渲染会重新尝试加载——流式期间短暂重试，内容 settle 后稳定。

## 边界情况

* **为什么不在消息里直接把本地图片内联显示出来？** 渲染层没有字节，只能向宿主要（新 RPC），还要处理字节预算/缓存/CSP；而且「渲染层自己把路径塞进 `<img>`」正是本规格要消除的写法。`点击 → 宿主读 → 显示` 这条既有链路顺手带来一个好处：消息列表不会因为模型写了几十张图就自动读盘（远端会话尤其贵）。
* **大图 / SSH 远端会话？** 点击后的读盘与压缩各有既有责任方：桌面端文件面板按 pane 所在主机读（远端经 ssh）、远控页走压缩到单帧预算内的取图通道。渲染层只负责把路径交出去，不关心这些。
* **`http:` 图片在桌面端仍然显示不出来？** 桌面 CSP 是 `img-src 'self' data: https:`，`http:` 会被拦。本规格不改 CSP，也不让渲染层按宿主去判断 `http:`：被拦掉的图片会触发 `error`，由上面的破图兜底统一降级（场景 13）；VS Code / JetBrains 允许 `http:`，那边照旧能显示。
* **为什么不用 React 的 `onError` 兜底？** 正文是 marked + DOMPurify 产出的 HTML 字符串，走 `dangerouslySetInnerHTML`，没有可挂 `onError` 的 React 元素；而 `error` 不冒泡，所以「在容器上加捕获阶段的原生监听」是唯一不用重写整条渲染管线的位置。
* **CSP 拦截也算「加载失败」吗？** 算——被 CSP 拦下的图片与 404 一样触发 `error`。这正是本规格不需要按宿主分档、也不需要放宽 CSP 的原因：本质是「这一次请求没成」，不是「地址形态不合法」。
* **图片语法里的 title（`![a](x.png "图 1")`）？** 不保留——降级后 `title` 由 alt 占用（路径链接的悬停提示只放一句说明）。这类写法在消息里可以忽略。
* **alt 里有 HTML / 引号？** 进 `title` 前与路径文本一视同仁地转义，产物仍过 DOMPurify。
* **图片语法出现在表格单元格、列表项、引用块里？** 同规则，与所在块无关（renderer 与结构无关）。
* **文件面板 / 计划面板的 markdown 预览怎么办？** 不走本规格：那里的相对路径基准是**被预览文件所在目录**（不是会话 workdir），是另一个问题，本轮不做（`renderFileMarkdown` 现状不变）。
* **`textarea`/输入区里的 `![]()` 呢？** 那是用户正在写的纯文本，不渲染，无关。
* **会不会误伤本来就正常的消息？** 只影响「src 不是 data/blob/http(s)」的图片语法，而这类此前 100% 裂图；`data:`/`http(s):` 的既有表现逐字节不变。
