Ctrl K
ComunidadGuías…

docs/plan/2026-10-22-chat-image-url-injection-technical-design.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 聊天图片 URL 注入技术方案

> 状态:已与需求方达成共识,待评审
> 适用代码库:`backend-ts/`(NestJS + Fastify + TypeScript),前端零改动
> 文档日期:2026-10-22

## 1. 需求背景

用户在输入框粘贴图片发送后,Agent 只能通过多模态视觉通道「看到」图片内容,**无法获取文件本体**。典型失败场景:用户发送建筑图纸并说「帮我转成 CAD 格式」,Agent 想找文件却报「上传目录没找到」——因为图片文件既不在会话工作区、也不在 PathSandbox 允许根内,模型上下文里更没有任何可用的文件地址。

根因是两段设计叠加的结果:

1. **URL 到不了模型手里**。消息中的图片以 `{type:'image_url', image_url:{url}}` part 存储;LlmAdapter 在发请求前会下载图片并**原地把 URL 替换为 base64 data URI**(`openai-llm-adapter.ts:539-556` + `setImageUrl` L805-812;Anthropic 适配器只认 data URI)。只有下载失败时才保留原 URL。即正常路径下模型看到的是 base64 数据块,不是可寻址的 URL。
2. **文件本体对工具链不可见**。图片落在后端扁平目录 `./uploads/{uuid}.png`(local 模式)或 OSS(OSS 模式浏览器直传、服务端不落盘),不在会话工作区、不在沙箱允许根,Agent 的 shell / 文件工具都摸不到。

对照:非图片文件走的是另一套成熟机制——`/files/upload-incoming` 落 `runtime/{userId}/{sessionId}/incoming/`,消息文本带 `@{绝对路径}@` 引用,`prompt-engine.ts:609-622` 有专门的「用户上传的文件」提示节,沙箱已放行 runtime 根。图片缺的正是「把位置告诉 Agent」这一步。

本次目标(需求方已确认):**把图片 URL 以文本形式注入用户消息**,Agent 需要文件本体时用 shell(curl/wget/python3,运行时容器镜像已内置,见 `Dockerfile.runtime:22-23`)自行下载到工作区。不加系统提示词指引——当前 LLM 足以自行理解 URL 的用途。

## 2. 需求描述(做什么 / 不做什么)

### 2.1 在范围内(本期要做)

| # | 事项 | 说明 |
|---|------|------|
| R1 | 图片 URL 注入用户消息文本 | 组装消息 content parts 时,把图片 URL 以 `[图片 N] <url>` 行追加到文本 part 尾部;序号与图片 part 顺序一一对应 |
| R2 | 单一实现收口 | 新增共享模块承载「剥离既有注入行 + 追加新注入行」,`contentParts` 与 `buildEditContent` 两处 parts 组装逻辑统一走它,消除重复实现 |
| R3 | 覆盖全部消息组装路径 | `send_message`、`edit_and_resend`、边路会话首问、队列插队消费、队列自动消费、`editMessageAndTruncate` 共 6 个组装点全部生效 |
| R4 | 幂等 | 编辑重发时若回传的正文已含注入行(前端编辑框按存储文本回填),先剥离再追加,不得重复堆积 |
| R5 | 纯图片消息也注入 | 无正文仅图片时,生成只含注入行的文本 part(当前实现会完全不建文本 part) |
| R6 | 会话标题剥离 URL 行 | 标题生成读取文本 parts 前剥离注入行,避免 URL 污染标题 |
| R7 | 回归测试 | 新增注入模块单测(幂等 / 空正文 / 序号对应 / 非匹配行不误删),扩展 contentParts、WS handler、session.service、title service 既有用例 |

### 2.2 不在范围内(本期明确不做)

| # | 事项 | 说明 |
|---|------|------|
| N1 | 不加系统提示词指引 | 不在 `prompt-engine.ts` 新增「图片 URL 可用 shell 下载」提示节。信任模型自行判断;实测发现模型经常忽略再补 |
| N2 | 不做服务端物化 | 不把图片下载/复制到 incoming 目录、不加沙箱允许根、不动 `path-sandbox.ts` 与 `create-app.ts` |
| N3 | 前端零改动 | 气泡、编辑框、历史回显照旧;URL 行作为正文自然展示,不做隐藏/美化。**2026-10-23 补充**:用户反馈「发送后消息被改掉」后,展示层改为隐藏 URL 行(气泡 / 编辑回填 / 复制 / 添加到指令统一剥离);落库与 Agent 可见内容不变 |
| N4 | 不做相对 URL 补全 | local 模式未配 `upload.baseUrl` 时消息存的是相对路径 `/uploads/xxx.png`,注入的 URL 无法 curl。本期仅文档化该依赖并给出上线前核验步骤;服务端补全(有 `file.routes.ts` 的 `getUploadBaseUrl` 先例)作为可选加固,需要时再排 |
| N5 | 不新增下载工具 | 不封装 `download_file` 内置工具。作为兜底升级路径预留,shell 方案实测不稳再做 |
| N6 | 不动非视觉模型占位符逻辑 | `replaceImagesWithPlaceholder` 维持现状 |
| N7 | 不做存量消息处理 | 改造前存储的消息没有 URL 行,继续走原视觉通道,无迁移 |

### 2.3 用户侧使用流程(目标体验)

1. 用户粘贴图片并附一句「帮我转成 CAD」发送;
2. 消息落库:文本 part = 用户正文 + `[图片 1] <url>` 行,图片 part 不变(视觉通道零回归);
3. Agent 首轮照常「看图」理解需求;当任务需要文件本体(格式转换、像素处理)时,自行用 shell 把 URL 下载到工作区(如 `curl -o drawing.png <url>`),再按本地路径用文件工具/脚本处理;
4. 历史消息重放、编辑重发、队列消费场景下 URL 行始终存在且不重复。

## 3. 现状梳理(存量实现)

### 3.1 图片上传与消息链路

- **前端**:`ChatInput.vue` 粘贴 → `useChat.ts` `uploadChatImages()` → `imageUpload.ts` 双分支——local 模式 `POST /files/upload` 落 `./uploads/{uuid}.png` 返回 `/uploads/{storedName}`(配了 `upload.baseUrl` 则为绝对地址);OSS 模式 `POST /oss/sts-token` 后浏览器直传,返回公网绝对 URL。WS `send_message` 携带 `data.images: string[]`。
- **后端**:`streaming-ws-handler.ts` 收 `images` → 视觉模型校验(`supportsVision !== 1` 拒绝、≤10 张)→ `contentParts(content, images)` 组装 parts → `sessionService.saveMessage()` 存 `message.content`(MEDIUMTEXT JSON)。
- **上下文重建**:`session-history-loader.ts` `parseContent()` 把 JSON parts 数组原样还原为多模态 content,随 `buildContext()` 进入 LLM 请求。
- **视觉通道**:适配器下载图片转 base64(成功即替换 URL,失败保留原 URL)。

### 3.2 消息 parts 组装的 6 个落点(本期全部收口)

| # | 位置 | 场景 |
|---|------|------|
| 1 | `streaming-ws-handler.ts:590` | `send_message` 手动发送 |
| 2 | `streaming-ws-handler.ts:909` | `edit_and_resend` 编辑重发 |
| 3 | `streaming-ws-handler.ts:1092` | 边路会话(Side Task)首问 |
| 4 | `streaming-ws-handler.ts:1662` | 队列插队消费 |
| 5 | `streaming-ws-handler.ts:1870` | 队列自动消费 |
| 6 | `session.service.ts:1600` `buildEditContent()` | `editMessageAndTruncate()` 落库(与 #2 同流程,独立实现,需一并收口) |

其中 #4/#5 的 `content` 来自队列行(原始用户正文,无注入行),注入发生在消费落库时;消费后调用 `handleSendMessage` 带 `autoSavedMessageId` 跳过二次落库,不会重复注入。

### 3.3 编辑回填链路(幂等需求的由来)

前端 `MessageBubble.vue:344-355` 编辑态用 `props.message.content`(API 返回的拼接文本,含注入行)回填 textarea;`useChat.ts:669` 保留原消息图片后经 `edit_and_resend` 回传。服务端若不剥离,编辑一次就多出一套 URL 行。

### 3.4 标题生成链路

`session-title.service.ts:251-259` `extractText()` 拼接文本 parts 生成标题输入。注入行带入会让标题出现 URL 片段,需剥离。

### 3.5 执行环境

CLOUD 宿主模式即后端服务器;容器模式镜像 `node:22-bookworm-slim` 明确安装 `curl` 与 `python3`/`python3-pip`(`Dockerfile.runtime:19-24`)。shell 下载的可行性无环境障碍。

## 4. 技术决策(已确认)

| 决策点 | 结论 |
|--------|------|
| 注入位置 | 消息**文本 part 尾部**(存储即注入)。历史重放、压缩摘要、导出天然继承;不选 prompt 构建期注入(需扫描全部消息 parts,改动更大且与存储态不一致) |
| 注入格式 | 每图一行 `[图片 N] <url>`,与正文之间空一行;N 从 1 起,与图片 parts 顺序对应 |
| 空正文纯图片 | 生成只含注入行的文本 part(必须,否则纯图消息没有 URL 通道) |
| 幂等方式 | strip-then-append:行首锚定的整行正则剥离既有注入行后再追加 |
| 代码组织 | 新增 `session/util/image-url-annotation.ts` 导出 `stripImageUrlLines` / `appendImageUrlLines` / `buildUserContentParts`;`contentParts` 与 `buildEditContent` 改为委托,消除 #6 的重复实现 |
| 提示词 | 不加(需求方决策,信任模型) |
| 标题 | `extractText` 剥离注入行 |
| 前端 | 零改动 |
| URL 形态 | 原样使用消息里的 URL(与视觉通道同源);local 模式依赖 `upload.baseUrl` 已配置为绝对地址,上线前核验 |

## 5. 详细设计

### 5.1 新增注入模块 `backend-ts/src/session/util/image-url-annotation.ts`

```ts
/** 注入行前缀。对用户可见(气泡/导出),保持可读;序号与图片 parts 顺序对应。 */
const IMAGE_URL_LINE_PREFIX = '[图片 ';
/** 既有注入行:行首锚定 + 整行锚定,只剥自己生成的格式,不碰用户正文。 */
const IMAGE_URL_LINE_PATTERN = /^\[图片 \d+\] \S+$/gm;

/** 剥离全部注入行,并收敛行尾多余空行(剥离后正文以单个换行结尾或为空)。 */
export function stripImageUrlLines(text: string): string;

/** 先剥离再追加:编辑重发回传已含注入行的正文时结果不变(幂等)。 */
export function appendImageUrlLines(text: string, images: readonly string[]): string;

/** 统一的用户消息 parts 组装:文本 part 带注入行 + 图片 parts。 */
export function buildUserContentParts(text: string, images: readonly string[]): ContentPart[];
```

行为规格:

- `appendImageUrlLines('帮我转成CAD', ['https://a.png'])` → `'帮我转成CAD\n\n[图片 1] https://a.png'`;
- `appendImageUrlLines('', ['https://a.png','https://b.png'])` → `'[图片 1] https://a.png\n[图片 2] https://b.png'`(无前导空行);
- `appendImageUrlLines('上文\n\n[图片 1] https://a.png', ['https://a.png'])` → 与首次注入结果完全一致(幂等);
- `images` 为空时原样返回 `text`(纯文本消息零变化);
- `stripImageUrlLines` 不匹配普通正文行(如用户手打 `[图片 1]` 后接非 URL 内容不会被误删——正则要求 `\S+` 且无空格,URL 形态才匹配;碰撞概率与格式可读性之间取平衡,风险登记入 §7)。

### 5.2 `ws-streaming-event-listener.ts` `contentParts` 改造(L383-392)

```ts
// 改造后:委托统一实现,行为 = 原 parts 组装 + URL 注入
export function contentParts(text: string, images: string[]): ContentPart[] {
  return buildUserContentParts(text, images);
}
```

函数签名与导出位置不变,`streaming-ws-handler.ts` 的 5 个调用点(§3.2 #1-#5)零改动即全部获得注入能力。

### 5.3 `session.service.ts` `buildEditContent` 改造(L1600-1613)

```ts
function buildEditContent(text: string | null, images: string[] | null): string | null {
  if (images == null || images.length === 0) return text;
  try {
    return JSON.stringify(buildUserContentParts(text ?? '', images));
  } catch {
    return text;
  }
}
```

删除本地重复的 parts 拼接;幂等由共享模块保证(编辑回传的正文含旧注入行 → 先剥离再追加)。

### 5.4 标题服务剥离(`session-title.service.ts` L251-259)

`extractText()` 对每个文本 part 先过 `stripImageUrlLines` 再拼接:

```ts
return content
  .filter((item) => item.type === 'text' && item.text != null)
  .map((item) => stripImageUrlLines(String(item.text)))
  .filter((t) => t.trim() !== '')
  .join('\n');
```

### 5.5 落库内容示例

发送「这个图片能帮我转成CAD格式的文件?」+ 1 张图(local 模式、baseUrl 已配):

```json
[
  {"type":"text","text":"这个图片能帮我转成CAD格式的文件?\n\n[图片 1] https://mao.etarch.cn/uploads/3f2a….png"},
  {"type":"image_url","image_url":{"url":"https://mao.etarch.cn/uploads/3f2a….png"}}
]
```

发给 LLM 时:文本 part 原样进入 messages(URL 可见、可 curl);图片 part 被适配器替换为 base64 data URI(视觉通道不变)。模型在同一消息里既「看到图」又「拿到地址」。

### 5.6 边界与兼容性

| 场景 | 行为 |
|------|------|
| 纯文本消息(无图) | 完全不走注入分支,`content` 与现状逐字节一致 |
| 纯图片消息(无正文) | 新增只含注入行的文本 part;`isMultimodalContentParts` 判定不受影响(text part 合法) |
| 编辑重发(图片不变) | 剥离旧行 + 追加相同 URL,结果与编辑前一致;前端回填文本与落库文本一致 |
| 编辑时用户手改正文 | 只影响非注入行;注入行按当前 images 重建 |
| 队列消息 | 队列行存原始正文;消费落库时注入一次;`autoSavedMessageId` 防二次落库 |
| 边路会话 / fork | 走同一 `contentParts`,自动覆盖 |
| `user_message_saved` 广播 | `userMessagePayloadOf` 带图时剥离注入行,广播回「用户原始正文」口径;落库 parts 不变。纯图片消息广播 content 为 `''`(前端空串兜底成立)。2026-10-23 起展示层也剥离,发送瞬间与重拉后口径一致 |
| 历史消息(改造前) | 无 URL 行,视觉通道原样工作,无回归 |
| 非视觉模型 | 维持占位符替换,不扩展 |
| OSS 模式 | URL 为公网绝对地址,curl 直接可用 |
| local 模式未配 baseUrl | 注入的是相对路径,curl 不可用——上线前核验配置(§7) |

## 6. 实现步骤(Checklist)

### 后端(backend-ts)

- [ ] 新增 `src/session/util/image-url-annotation.ts`(`stripImageUrlLines` / `appendImageUrlLines` / `buildUserContentParts`)
- [ ] `src/session/ws/ws-streaming-event-listener.ts`:`contentParts` 委托 `buildUserContentParts`
- [ ] `src/session/session.service.ts`:`buildEditContent` 委托 `buildUserContentParts`,删本地拼接
- [ ] `src/session/session-title.service.ts`:`extractText` 剥离注入行
- [ ] 新增 `image-url-annotation.spec.ts`:追加 / 剥离 / 幂等往返 / 空正文 / 多图序号 / 非匹配行保留 / 无 images 零变化
- [ ] 扩展 `ws-streaming-event-listener.spec.ts`:contentParts 注入行;纯图消息生成文本 part
- [ ] 扩展 `streaming-ws-handler.spec.ts`:send_message 落库文本含注入行;edit_and_resend 幂等(回传已含注入行的 content,落库不重复)
- [ ] 扩展 `session.service.spec.ts`:`editMessageAndTruncate` 带图 + 预注入正文不重复
- [ ] 扩展 `session-title.service.spec.ts`(如有标题文本提取用例):URL 行不进标题
- [ ] `cd backend-ts && npm run build && npm test` 全绿
- [ ] `CHANGELOG.md` 顶部版本 `### 后端` 记录(用户可见的 Agent 行为变化)
- [ ] 上线前核验 local 模式部署的 `upload.baseUrl` 为绝对地址(`GET /api/v1/upload/config`,或确认 storageMode=oss);未配置则本期仅 OSS/已配 baseUrl 环境生效,或启动 N4 可选加固

### 本期不改动(明确核对)

- `harness/` 下全部文件(适配器、prompt-engine、path-sandbox、container 挂载)
- `create-app.ts`、`Dockerfile.runtime`
- `admin/`、`android/`、`agent-cli/`
- `desktop/`:本期原定零改动;**2026-10-23 补充**——为解决「发送后消息多出一行 URL」的展示问题,新增 `utils/imageUrlAnnotation.ts` 的 `userVisibleText`,`MessageBubble.vue` / `ChatRoundList.vue` 的展示出口改为剥离口径。不含业务逻辑改动,落库与 Agent 可见内容不变

## 7. 风险与注意事项

| 风险 | 说明 | 缓解 |
|------|------|------|
| local 模式相对 URL | 未配 `upload.baseUrl` 时注入的是 `/uploads/xxx.png`,模型 curl 不到(且现状视觉通道的 adapter fetch 同样失败) | 上线前核验配置(步骤清单末项);可选加固:注入时用 settings 的 `getUploadBaseUrl`(`file.routes.ts:33` 同款依赖)补全为绝对 URL |
| 模型不下载 | 不加提示词,是否 curl 由模型自行判断,存在概率性失败 | 任务明确(要字节)时模型可靠;URL 行与图片同消息、序号对应,降低误判;兜底升级路径:`fetchBytesWithLimits`(`fetch-bytes.ts`,已带双超时与大小上限)包 `download_file` 工具,实测不稳再排 |
| 注入行格式碰撞 | 用户正文恰好出现 `[图片 N] <无空格串>` 形态的行,编辑时会被剥离 | 行首锚定 + 序号 + URL 形态三重约束,碰撞面极小;接受该残余风险 |
| URL 行露出到 UI/导出 | 气泡、编辑框、复制、添加到指令已剥离(2026-10-23 起,用户反馈「发送后消息被改掉」后补充);会话导出、分享页、admin 消息查看、搜索 snippet 仍会显示 URL 行 | 标题已剥离;气泡等主要出口已隐藏;剩余出口属信息正向,前端美化(显示态隐藏)后续按需排 |
| 发送端与远端显示不一致 | 发送端乐观消息是原始正文,`user_message_saved` 广播的 content 含注入行 | 纯展示差异,刷新/重进会话即一致;不接受客户端改动的前提下可接受。**2026-10-23 起已消除**:广播与展示层都回到剥离口径,发送瞬间与重拉后一致 |
| OSS Bucket 非公共读 | curl 得 403 | 非本期新增风险——浏览器渲染与视觉通道现状已依赖公共读 |
| LOCAL 模式 | 文件工具在桌面执行,curl 需从用户机器访问该 URL | 公网部署天然覆盖;内网部署需确认桌面可访问(登记为已知限制) |

## 8. 验收标准

1. 粘贴图片 + 正文发送后,DB `message.content` 文本 part 尾部含 `[图片 N] <url>` 行(N 与图片 parts 顺序对应),图片 parts 与现状一致。
2. 纯图片(无正文)消息同样生成含 URL 行的文本 part。
3. 编辑重发该消息:落库文本中 URL 行恰好一套(幂等),前端编辑框回填内容与落库内容一致。
4. 队列消息(入队 → 执行/插队/自动消费)落库后 URL 行存在且仅一套。
5. 边路会话首问带图时同样注入。
6. 会话标题生成结果不含 URL。
7. 纯文本消息、无图编辑、历史消息加载零变化(既有用例全绿)。
8. `cd backend-ts && npm run build && npm test` 通过。
9. 人工冒烟(CLOUD 会话):发图 + 「转成 PNG」,Agent 能自行 shell 下载并用本地路径完成转换;local 模式部署额外确认注入的 URL 为绝对地址。