# ZACP 聊天里的图片不显示（断裂图）：根因与修复

- 日期：2026-09-24
- 机器：DSH 机 192.168.31.76 · ZACP `http://192.168.31.76:8680`（会话「敦煌飞天 Qwen Image 2.1 出图」）
- 状态：**已修复并实测通过**（20/20 图片正常加载，真实 Chromium 验证）
- 关联：`2026-09-24-ZACP-Grok-WebUI-unknown-session-id诊断与修复.md`（同一个服务的前一个问题）

---

## 0. 结论速览

图片不是"加载失败"，而是**压根没请求到图片**：agent（grok）在回复里写的是
**工作区相对路径** `![飞天 1](comfyuir任务/feitian_1_00001_.png)`，而 ZACP 的聊天渲染
**没有把这类相对路径转换成文件直链**——浏览器于是按同源相对 URL 去请求：

```
请求 http://127.0.0.1:8680/sessions/comfyuir%E4%BB%BB%E5%8A%A1/feitian_1_00001_.png
返回 200 text/html（SPA fallback = index.html，4360 字节）
```

浏览器拿到一坨 HTML 当图片解 → 经典断裂图。

**ZACP 自己是有文件直链能力的**（`GET /api/v1/workspaces/:id/files/raw?path=…&token=…`），
而且**文件浏览器的图片预览就在用它**——只是**聊天渲染这条路径没接上**。
本次补上这一环：把消息里 markdown 图片的工作区相对路径改写成直链。

> 说明：这不是上一次「unknown session id」修复引入的。官方 v0.9.0（同一 commit `9360fc6`）
> 的前端源码里同样没有任何聊天图片路径改写逻辑，属于**上游既有缺口**。

---

## 1. 证据（修复前，真实 Chromium 实测）

```
$ node check-chat-images.cjs http://127.0.0.1:8680/sessions/3
{ "total": 20, "brokenCount": 9..., "items": [
   { "src": "comfyuir任务/dunhuang_apsara_v2_00001_.png", "naturalWidth": 0 },   ← 断裂
   { "src": "comfyuir任务/feitian_1_00001_.png",          "naturalWidth": 0 },
   ...
```

网络面板里对每一张图的请求都是：

| 请求 URL | 状态 | Content-Type |
|---|---|---|
| `/sessions/comfyuir%E4%BB%BB%E5%8A%A1/feitian_1_00001_.png` | 200 | **text/html** ← 关键 |

同时确认**后端与文件都没问题**（20 个路径逐个实测）：

```
comfyuir任务/feitian_1_00001_.png   raw=200:image/png   onDisk:2223645
...（20/20 全部 200 image/png，文件都在 /home/zyw/Pictures/Wallpapers/comfyuir任务/）
```

## 2. 根因定位（源码）

- 前端 `src/components/chat/MessageItem.vue` 用 `IncremarkContent` 渲染 markdown，
  **全文没有任何 `<img src>` 处理**（`grep` 全 src 目录：聊天组件里没有 img/src 改写代码）。
- 相对路径 → 浏览器按当前页面 URL 解析。SPA 路由是 `/sessions/:id`，于是拼成
  `/sessions/comfyuir任务/xxx.png` → 命中 SPA fallback → `index.html`。
- 直链端点早就存在且工作正常：
  - `GET /api/v1/workspaces/:id/files/raw?path=…`（`<img>` 直链，支持 `?token=` 资源 token）
  - `POST /api/v1/workspaces/:id/files/preview-token`（换 12 小时有效的直链，绑定 workspace+path）
  - 文件浏览器 `components/files/FileExplorer.vue` 的图片预览**就是**用 `fetchPreviewUrl()` 拿直链的。
  - 这两个 helper 在 `api/index.ts` 里，聊天路径从未调用。

---

## 3. 修复内容（前端，2 个文件 / +139 行）

### 3.1 新增 `frontend/src/composables/useWorkspaceImageUrls.ts`

- `isWorkspaceRelativeImageSrc()`：**严格**判定才改写——带协议（`http:`/`data:`/`blob:`…）、
  以 `/` / `//` / `#` 开头的一律不动（保护外链与内联数据）。
- `resolveWorkspaceImageUrl()`：优先用 `fetchPreviewUrl()` 换 **资源 token 直链**
  （认证开启后 `<img>` 也能用，token 与登录态分离），换取失败退化为无 token 直链；
  按 `workspaceId + path` 做 Promise 缓存，同图只请求一次。
- `rewriteWorkspaceImages(root, workspaceId)`：扫描 `root` 下所有 `<img>`，
  把相对路径替换成直链。**幂等**（dataset 打标记），失败静默（宁可维持原状也不抛错）。

### 3.2 改 `frontend/src/components/chat/MessageItem.vue`

- 消息根节点加 `ref="rootRef"`（作为扫描范围）。
- `workspaceId = sessionStore.activeSession?.workspaceId`——图片路径是相对**会话工作区根**的，
  沿用它才能拼出正确直链。
- `onMounted` 先扫一次（历史消息）＋ **MutationObserver**（`childList` + `subtree` + `src`
  属性变化）跟随流式渲染新增的 `<img>`；`watch(message.content, workspaceId)` 兜底
  （工作区 id 晚于消息到达时补扫）；`onBeforeUnmount` 断开 observer。

> 副作用说明：图片元素插入的瞬间浏览器会先用**旧相对地址**发一次请求（必然是 HTML），
> 随后被改写为直链再发一次。多一次无用小请求，但换来"无需预处理 markdown"的简单实现。

---

## 4. 验证（修复后，真实 Chromium + Playwright）

```
$ node check-chat-images.cjs http://127.0.0.1:8680/sessions/3
{
 "total": 20,
 "rewritten": 20,          ← 20 张全部被改写为 /files/raw 直链
 "rewrittenLoaded": 20,    ← 20 张 naturalWidth > 0（真的解码成功）
 "rewrittenFailed": [],
 "stillRelative": [],      ← 没有漏网的相对路径
 "errors": []              ← 无 JS 报错
}
```

网络面板对应变成：

```
/api/v1/workspaces/1/files/raw?path=comfyuir%E4%BB%BB%E5%8A%A1%2Ffeitian_1_00001_.png&token=... → 200 image/png
```

**测试注意**：第一次测量只显示 11/20，是因为消息列表按需分页 + 图片 `loading=lazy`，
未被请求的图自然 `naturalWidth=0`，看起来像失败。验证脚本已改成
「上下滚动加载全部历史 + 强制 `loading=eager` + 等所有图 settle」再统计，
避免这种假阴性（脚本见附件）。

---

## 5. 维护：升级 ZACP 之后

现在一共两个补丁，都已放在本目录，重建脚本会**按文件名顺序自动全部应用**：

| 补丁 | 修的问题 |
|---|---|
| `grok-unknown-session-fix.patch` | 「离开较久再发消息」报 unknown session id（后端 Go，6 行） |
| `chat-image-workspace-url.patch` | 聊天里 markdown 图片不显示（前端 Vue，2 文件 / +139 行） |

```bash
bash rebuild-zacp-grokfix.sh                  # 默认 v0.9.0
VERSION=0.9.1 bash rebuild-zacp-grokfix.sh    # 升级后改版本号重跑
```

脚本本次已完整验证：全新克隆 → 两个补丁依次应用 → 前端 bun + 后端 go 构建 →
回归测试 → 原子替换二进制 → pm2 重启 → 健康探测（冷启动约 8s，脚本最多等 40s）。

**回滚**：

```bash
ln -sfn $HOME/.zacp/bin/zacp-0.9.0 $HOME/.local/bin/zacp && pm2 restart zacp
```

---

## 6. 附件

| 文件 | 说明 |
|---|---|
| `chat-image-workspace-url.patch` | 前端补丁（新增 composable + MessageItem 接线） |
| `check-chat-images.cjs` | 真实浏览器验证脚本（Playwright，统计改写数/加载数/漏网相对路径） |
| `rebuild-zacp-grokfix.sh` | 一键重建（自动应用本目录所有 `*.patch`） |
| `grok-unknown-session-fix.patch` | 上一次的会话失效补丁 |

> 验证脚本依赖本机已缓存的 Playwright（`~/Downloads/.tools/node_modules/playwright-core`
> + `~/.cache/ms-playwright`），无需联网安装。

---

## 7. 追加（2026-09-24 23:2x）：新生成的图又断了——这次是 grok 引用了它自己的私有目录

### 7.1 现象与判定

用户反馈「grok 生成的图片再次无法显示」。实际只有 **1 张**（会话 #3 的消息 #45），
且**不是渲染层的问题**：

| 检查 | 结果 |
|---|---|
| markdown 引用 | `![Grok 飞天](images/1.jpg)` |
| `images/1.jpg` 在哪 | `~/.grok/sessions/%2F…Wallpapers/01a0d2df-…/images/1.jpg` ← **grok 的会话私有目录，webui 访问不到** |
| 工作区里的真实文件 | `comfyuir任务/feitian_grok_00001.jpg`（447848 B，与私有目录里那份**字节数相同**，是 `cp` 出来的副本） |
| raw 端点 | `images/1.jpg` → **404**；`comfyuir任务/feitian_grok_00001.jpg` → **200 image/jpeg** |

**根因**：grok 的图片工具把成品写到**自己的会话目录** `$GROK_HOME/sessions/<cwd>/<session-id>/images/1.jpg`
（**同名文件、每张覆盖上一张**），然后 grok 在 markdown 里直接引用了这个内部路径。
它虽然已经 `cp` 了一份到工作区，但**引用的是内部路径**。grok 自己的思考里写得很直白：

```
{"type":"agent_thought","text":"Present the image using images/1.jpg as the markdown path.
 Also mention the copy in comfyuir任务. Don't describe appearance."}
```

渲染层按「工作区根」解析 `images/1.jpg` → 文件不存在 → 404 → 断裂图。
**这一层无法在渲染端补救**：私有目录里的文件同名覆盖，把它映射出来只会显示"最新那张"，
与消息当时的图对不上。

### 7.2 处置

1. **持久化规则**（治本）：新增 `~/.grok/rules/image-paths.md`（grok 的 **global 规则**目录，
   不依赖目录信任），要求内嵌图片必须引用**工作目录下的真实路径**，禁止引用 `$GROK_HOME/sessions/.../images/`。
   验证：`cd /home/zyw/Pictures/Wallpapers && grok inspect`
   → `Project Instructions (1) └ /home/zyw/.grok/rules/image-paths.md (global, ~209 tokens)`。
2. **修复已存在的这条消息**（治标）：
   - 关键点：历史消息的正文渲染走的是 **`events` 里的 `agent_message` 文本**（见
     `composables/useMessageBlocks.ts` 的 `deriveBlocks`），**不是** `messages.content` 列 ——
     所以只改 `content` 页面依旧断图，必须同时改 `events`。
   - 改动：两处 `![Grok 飞天](images/1.jpg)` → `![Grok 飞天](comfyuir任务/feitian_grok_00001.jpg)`；
     **只替换 agent_message 文本**，`cp "…/images/1.jpg" …` 的命令原文保持不动。
     出现次数校验为 1 才执行（防误改）；原文备份 `message45-backup.json`。
3. **让运行中的 agent 生效**：`pm2 restart zacp`（规则在 agent 启动时加载；会话经
   `session/load` 自动恢复，用户无感）。

### 7.3 验证

真实 Chromium 复查会话 #3：**21/21 图片改写且加载成功，0 断裂，0 JS 报错**。

### 7.4 经验

- 渲染层只认「**工作区相对路径**」；对「agent 私有目录路径」无能为力 → **只能在源头纠正 agent 的引用习惯**。
- grok 的项目/全局规则（`AGENTS.md` / `$GROK_HOME/rules/*.md`）是这类"行为纠正"的正规落点，
  且 global 规则不需要目录信任；`grok inspect` 可验证是否真的加载。
- 排查时踩了一个坑：**只搜 `messages.content` 会漏掉渲染真正用的 `events`**（本次就是先改了 content、
  页面照旧断裂，才发现渲染源是 events）。
