Преглед изворни кода

feat(chat): 多模态输入 —— 请求体新增 pictures / files 两个数组字段

用户要求:增加 pictures 与 files 两个字段,内容都是数组,支持多个图片和文件。
(这正是技术债「文件上传待定」那条:此前请求体只收 {thread_id, question},多一个字段 422,
附件只能把 OSS 地址拼进 question 文本。)

先实测契约(打真实后端,四组对照):
- 只有 thread_id+question → 200
- pictures/files 传空数组 → 200 接受
- 传字符串数组 ['url'] → 200 接受
- 传对象数组 [{url,name}] → 422 invalid_request
再验「是否真生效」:同一句话带 pictures:[图URL] 时模型描述了图的内容
("企业微信添加页…二维码下方标注'上海CIPA海外服务平台'"),不带则答不出 ——
说明后端自己去取图喂视觉模型,URL 必须公网可达。

实现:
- shared.ts 新增 toChatAttachmentUrls():按 attachments[].isImage 拆成 pictures / files,
  过滤空 URL;只有 files(无 attachments)时全落 files(地址不丢)
- 旧函数 buildQuestionWithAttachments(方案②)按约定注释保留
- 协调器 generateAnswer(text, attachments?) + buildRequestBody():**空列表不发字段** ——
  没有附件时请求体与从前逐字节一致,后端若回滚只影响带附件的轮次
- hook 改传 attachmentsForBackend,question 就是用户原文(地址不再拼进去)

验证:verify-attachment-question.mjs 重写后 19/19(拆分纯逻辑 + 真正发出的请求体
+ 旧函数无活跃调用点的源码级断言);npm run build 通过;vue-tsc 本次文件 0 新增类型错误;
全量回归 65/65 + 165/165 + 19/19 + 84/84 + 30/30 + 33/33 + 24/24 + 19/19;validate-harness 通过。

文档:api-chat.md 请求体表补两字段 + 新增「多模态字段的实测契约」节;
tech-debt 条目 3 移到「已还」;README 加 20260923 节。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao пре 1 месец
родитељ
комит
b161750603

+ 11 - 0
README.md

@@ -340,3 +340,14 @@
 - 排查中撞到一个 DMS 协议坑并记录:payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照写,
   而 `updateContent` 会**静默丢弃整个更新**(返回仍是 `{"code":200,"message":"成功"}`)——
   新增列时必须先确认 DMS 侧已加
+
+## 20260923
+
+- **多模态输入:新增 `pictures` / `files` 两个请求字段**(后端扩展了 `/api/chat` 的请求体)
+  - `pictures`:**图片 URL 列表**;`files`:非图片文件 URL 列表。两者都是**字符串数组**
+  - 截图、文档上传后不再把 OSS 地址拼进问题文本,而是随请求体发出 ——
+    地址不再被模型当正文读,本地消息、会话标题、DMS 记录也不再被 URL 污染
+  - 实测(打真实后端):元素必须是 URL 字符串,传对象数组会被 `422 invalid_request` 拒掉;
+    带上 `pictures` 问「这张图片里是什么」,模型确实描述了图的内容(不是收下就忽略);
+    空数组与不传都接受
+  - 没有附件时**不发**这两个字段 —— 请求体与从前逐字节一致

+ 19 - 10
harness/docs/exec-plans/tech-debt-tracker.md

@@ -26,16 +26,6 @@
 - **该还的条件**:后端修(推荐,最干净),或前端加去重(权宜:比对首句并剥离,
   但属启发式判断,可能误删)。**需用户拍板走哪条。**
 
-### 3. 文件上传功能待定
-
-- **是什么**:旧协议的文件/图片上传(按钮、拖拽、粘贴三条入口)当前**入口已隐藏**,
-  发送逻辑被注释保留。
-- **为什么先欠着**:接口请求体只接受 `{thread_id, question}` 两个字段,多一个返回 422,
-  旧协议的 `transmission.files / file_pos` **发不出去**。
-- **该还的条件**:先定后端方案 —— ①后端扩展请求体接受 files/file_pos;
-  ②前端把 OSS 地址拼进 question 文本;③后端提供文件登记接口返回 file_id。
-- **参考**:原实现的完整参数见 `harness/docs/reference/legacy/API.md`(旧协议快照,仅供理解原实现)
-
 ### 4. 部门筛选在新数据下筛不出结果
 
 - **是什么**:政策「更多」面板的部门下拉是**固定的最初 15 个部门**,
@@ -136,6 +126,25 @@
 - **验证**:`verify-dms-chat-storage.mjs` 打真实 DMS,两个方向都断言(访客行查不到 /
   登录行查得到),且实测「拿掉过滤该断言立刻失败」——是真守卫。
 
+### 文件上传 / 多模态输入(2026-09-23 已还)
+
+- **当时的问题**:接口请求体只接受 `{thread_id, question}`,多一个字段 422,
+  旧协议的 `transmission.files / file_pos` 发不出去 —— 上传入口虽然活着,
+  但附件只能**把 OSS 地址拼进 question 文本**(方案②),模型会把 URL 当正文读,
+  本地消息、会话标题、DMS 记录也都跟着被 URL 污染。
+- **怎么还的**:后端扩展了请求体(方案①),新增两个多模态字段:
+  - `pictures: string[]` —— 图片 URL 列表(**后端自己去取图**,须公网可达)
+  - `files: string[]` —— 非图片文件 URL 列表
+  - 实测:元素必须是 URL **字符串**(对象数组 `[{url,name}]` 会被 `422 invalid_request` 拒掉);
+    带上 `pictures` 问「这张图片里是什么」,模型确实描述出了图的内容(**不是收下就忽略**);
+    空数组与不传都接受。
+- **前端改动**:`toChatAttachmentUrls()` 按 `isImage` 拆成两个列表随请求体发出;
+  地址**不再拼进 question**。旧函数 `buildQuestionWithAttachments` 按约定注释保留。
+- **不变量(有断言守着)**:没有附件时请求体**只有** `thread_id` + `question` ——
+  既不多传无意义字段,也让「后端若回滚」只影响带附件的那几轮。
+- **验证**:`harness/tools/verify-attachment-question.mjs` **19/19**(拆分纯逻辑 + 真正发出的
+  请求体 + 「旧函数无活跃调用点」的源码级断言);字段生效是打真实后端探针实测的。
+
 ---
 
 ## 已知且**已接受**的行为(不是债,勿当 bug 修)

+ 17 - 1
harness/docs/reference/api-chat.md

@@ -28,8 +28,24 @@
 | --- | --- | --- | --- |
 | thread_id | string | 是 | 窗口标识,1–128字符,仅ASCII字母、数字、`_`、`-` |
 | question | string | 是 | 本轮输入,不能为空或全空白,最多8000字符 |
+| pictures | string[] | 否 | **图片 URL 列表**(多模态输入)。2026-09-23 新增 |
+| files | string[] | 否 | **非图片文件 URL 列表**。2026-09-23 新增 |
 
-只接受这两个字段,多余字段也返回422。请求体最多65536字节,读取请求体超时15秒。新窗口可生成 `crypto.randomUUID()`;同一窗口复用ID。未存在或Redis已过期的ID开始新会话。当前ID不是用户鉴权凭证。
+除上述字段外,**多一个字段返回 422**。请求体最多65536字节,读取请求体超时15秒。新窗口可生成 `crypto.randomUUID()`;同一窗口复用ID。未存在或Redis已过期的ID开始新会话。当前ID不是用户鉴权凭证。
+
+### 2.0 多模态字段的实测契约(2026-09-23)
+
+> 以下是在**跑着的后端**上实测出来的(文档此前只写了"只接受这两个字段"):
+
+- **元素必须是 URL 字符串**:`pictures: ["https://…/a.jpg"]` → 200 ✅;
+  传对象数组 `[{url, name}]` → **`422 invalid_request`** ❌
+- **后端自己去取图**,所以 URL 必须**可公网访问**(OSS 直链可以)
+- **实测生效**:带上 `pictures:[CIPA 二维码图]` 问「这张图片里是什么」,
+  模型确实描述了图的内容(文字、配色、二维码下的标注都对);
+  不带 `pictures` 问同一句则答不出 —— 说明不是"收下但忽略"
+- 空数组(`pictures: []`)与完全不传都接受 ✅
+- 前端约定:**没有附件时不发这两个字段**(请求体与从前逐字节一致),
+  这样即便后端回滚,也只有"带附件的那几轮"会受影响
 
 ## 2. 返回的两层结构
 

+ 39 - 0
harness/progress.md

@@ -3655,3 +3655,42 @@ DMS 代理**注入全权限 token 且不区分调用者** → 任何能打开站
 
 **复制出来的实际样子**(节选真实回答):标题只剩「一、市级通用查询与申兑」,
 列表各项单独成行、有序列表保留 `1. 2.`,链接变成 `查看产品详情(https://…)`。
+
+### 续 22:多模态输入 —— 请求体新增 pictures / files(用户要求)
+
+**用户要求**:「针对多模态输入,增加两个字段:pictures 以及 files,两个参数的内容都是数组,
+支持多个图片和文件」。这正是技术债里「文件上传待定」那条(此前请求体只收 `{thread_id, question}`,
+多一个字段 422,附件只能把 OSS 地址拼进 question)。
+
+**先实测契约(不猜)**,打真实后端四组对照:
+| 请求体 | 结果 |
+|---|---|
+| 只有 thread_id+question | 200 |
+| `pictures: []` / `files: []` | **200 接受** |
+| `pictures: ['url']` / `files: ['url']`(字符串数组) | **200 接受** |
+| `[{url, name}]`(对象数组) | **422 invalid_request** |
+
+再验「是不是真的生效」:同一句话,带 `pictures:[CIPA 二维码图]` 时模型**描述了图的内容**
+("企业微信添加页…二维码下方标注'上海CIPA海外服务平台'"),不带则答不出 —— 说明后端**自己去取图**
+喂给视觉模型,所以 URL 必须公网可达 ✓
+
+**实现**:
+- `shared.ts` 新增 `toChatAttachmentUrls(transmission)`:按 `attachments[].isImage` 拆成
+  `pictures` / `files`,过滤空 URL;只有 `files`(没 attachments)时全落 `files`(地址不丢)
+- 旧函数 `buildQuestionWithAttachments`(方案②)**按约定注释保留** ✓
+- 协调器:`generateAnswer(text, attachments?)` + 私有 `buildRequestBody()` ——
+  **空列表不发字段**(没有附件时请求体与从前逐字节一致 ✓,后端若回滚只影响带附件的轮次)
+- hook:`sendMessage` 改传 `attachmentsForBackend`,`question` 就是用户原文 ✓
+
+**运行过的验证**:
+- 重写 `verify-attachment-question.mjs` → **19/19**:拆分纯逻辑 7 项、
+  「没附件时只有两个字段」3 项、「带附件时」7 项(含 question 是原文、元素是字符串、
+  多图多文件、空 URL 被清)、「旧函数无活跃调用点」源码级断言 1 项
+- `npm run build` 通过;`vue-tsc` 对本次改动文件 **0 个新增类型错误**
+- 全量回归:DMS 65/65、答案流式 165/165、载荷字段 19/19、企业分类 84/84、判空 30/30、
+  聊天任务 33/33、政策卡 24/24、复制纯文本 19/19;`validate-harness` 通过
+
+**文档**:`reference/api-chat.md` 的请求体表补上两个字段 + 新增「多模态字段的实测契约」一节;
+`tech-debt-tracker` 条目 3(文件上传待定)**移到已还**;README 加 20260923 节。
+
+**注**:上一轮「删掉 CIPA 图片 + 正文图片链接渲染」的活儿被这条需求打断,尚未做。

+ 8 - 2
harness/tools/_entry-attachments.ts

@@ -1,2 +1,8 @@
-/** esbuild 入口:附件地址拼进 question 的纯逻辑 */
-export { buildQuestionWithAttachments } from "../../src/components/business-assistant/shared";
+/**
+ * esbuild 入口:多模态附件链路。
+ *
+ * - `toChatAttachmentUrls` —— 把上传模块的 transmission 拆成 `pictures` / `files`(纯逻辑)
+ * - `ApiChatCoordinator` —— 验证真正发出的请求体里带没带这两个字段
+ */
+export { toChatAttachmentUrls } from "../../src/components/business-assistant/shared";
+export { ApiChatCoordinator } from "../../src/components/api-chat-coordinator";

+ 140 - 27
harness/tools/verify-attachment-question.mjs

@@ -1,21 +1,33 @@
 /**
- * 验证「把附件地址拼进 question 文本」这条链路(方案②)。
+ * 验证「多模态附件」这条链路(`/api/chat` 的 `pictures` / `files` 两个字段)。
  *
- * 背景(2026-09-18):新接口 /api/chat 只收 {thread_id, question},多字段 422,
- * 旧协议的 transmission.files 发不出去;改为把 OSS 地址拼进 question。
+ * 历史:2026-09-18 时新接口只收 `{thread_id, question}`,多一个字段 422,
+ * 附件只能**把 OSS 地址拼进 question 文本**(方案②)。
+ * 2026-09-23 后端支持了 `pictures` / `files`(方案①),改为走请求体字段。
+ *
+ * **实测到的契约**(2026-09-23,打真实后端):
+ *   - 两者都是 **URL 字符串数组**;传对象数组(`[{url,name}]`)会被 `422 invalid_request` 拒掉
+ *   - `pictures` 里的图**由后端自己去取**(必须公网可达)——
+ *     实测带上 `pictures:[图URL]` 问「这张图片里是什么」,模型确实描述了图的内容
+ *   - 空数组与不传都接受
  *
  * 不变量:
- *   ① 没有附件时**必须原样返回**问题(不能凭空多个换行 —— 那会改变所有普通提问)
- *   ② 有附件时:原文在前、每条 URL 各占一行、**不加**任何自定义措辞
- *   ③ 空/无效 URL 被过滤(上传失败的文件不该混进问题里)
+ *   ① **没有附件时请求体逐字节与从前一致**(只有 thread_id + question)——
+ *      既不多传无意义字段,也让「后端若回滚」只影响带附件的轮次
+ *   ② 有附件时:`question` 仍是**用户原文**(地址不拼进去),附件在 `pictures` / `files` 里
+ *   ③ 图片按 `isImage` 进 `pictures`,其余进 `files`;空 URL 被过滤掉
  *
  * 怎么跑(在项目根目录):
  *   npx esbuild harness/tools/_entry-attachments.ts --bundle --format=esm \
- *     --outfile=harness/tools/_attachments.mjs
+ *     --outfile=harness/tools/_attachments.mjs --alias:@=./src \
+ *     --define:import.meta.env='{}' --loader:.png=dataurl
+ *     # ⚠️ 必须带 --loader:.png=dataurl:协调器 import 了企业微信二维码
  *   node harness/tools/verify-attachment-question.mjs
  */
 
-import { buildQuestionWithAttachments } from './_attachments.mjs';
+import { readFileSync, readdirSync } from 'node:fs';
+
+const { toChatAttachmentUrls, ApiChatCoordinator } = await import('./_attachments.mjs');
 
 let pass = 0;
 let fail = 0;
@@ -29,25 +41,126 @@ const check = (name, cond, extra = '') => {
   }
 };
 
-const Q = '帮我看看这份材料符合条件吗';
-const U1 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/a.png';
-const U2 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/b.pdf';
-
-console.log('【1】没有附件时:必须原样返回');
-check('undefined → 原文', buildQuestionWithAttachments(Q, undefined) === Q);
-check('null → 原文', buildQuestionWithAttachments(Q, null) === Q);
-check('空数组 → 原文', buildQuestionWithAttachments(Q, []) === Q);
-check('全是空串 → 原文(不要多个换行)', buildQuestionWithAttachments(Q, ['', '   ', null]) === Q, JSON.stringify(buildQuestionWithAttachments(Q, ['', '   ', null])));
-
-console.log('\n【2】有附件时:原文在前、URL 各一行');
-check('单个附件', buildQuestionWithAttachments(Q, [U1]) === `${Q}\n${U1}`, JSON.stringify(buildQuestionWithAttachments(Q, [U1])));
-check('多个附件按顺序', buildQuestionWithAttachments(Q, [U1, U2]) === `${Q}\n${U1}\n${U2}`);
-check('**原文完整保留在前面**', buildQuestionWithAttachments(Q, [U1]).startsWith(Q + '\n'));
-check('**不添加自定义措辞**(只有原文与 URL)', !/附件|文件|attachment/i.test(buildQuestionWithAttachments(Q, [U1]).slice(Q.length)));
-
-console.log('\n【3】脏数据过滤');
-check('混入空值只保留有效 URL', buildQuestionWithAttachments(Q, [U1, '', null, U2]) === `${Q}\n${U1}\n${U2}`);
-check('URL 前后空格被去掉', buildQuestionWithAttachments(Q, [`  ${U1}  `]) === `${Q}\n${U1}`);
+const IMG1 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/a.png';
+const IMG2 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/b.jpg';
+const PDF = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/c.pdf';
+const DOC = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/d.docx';
+
+const att = (url, isImage) => ({ id: url, name: url.split('/').pop(), size: 1, isImage, url });
+const tx = (attachments, files) => ({
+  files: files ?? attachments.map((a) => a.url),
+  file_pos: attachments.map(() => 0),
+  attachments,
+});
+
+console.log('\n【1】按类型拆分(纯逻辑)');
+{
+  const mixed = toChatAttachmentUrls(tx([att(IMG1, true), att(PDF, false), att(IMG2, true)]));
+  check('图片进 pictures', JSON.stringify(mixed.pictures) === JSON.stringify([IMG1, IMG2]), JSON.stringify(mixed.pictures));
+  check('非图片进 files', JSON.stringify(mixed.files) === JSON.stringify([PDF]), JSON.stringify(mixed.files));
+
+  const onlyImages = toChatAttachmentUrls(tx([att(IMG1, true)]));
+  check('只有图片时 files 为空数组', onlyImages.files.length === 0 && onlyImages.pictures.length === 1);
+
+  const none = toChatAttachmentUrls(undefined);
+  check('没有附件 → 两个空数组', none.pictures.length === 0 && none.files.length === 0);
+  check('null 也安全', JSON.stringify(toChatAttachmentUrls(null)) === JSON.stringify({ pictures: [], files: [] }));
+  check('空 attachments 数组 → 两个空数组', JSON.stringify(toChatAttachmentUrls(tx([]))) === JSON.stringify({ pictures: [], files: [] }));
+
+  const dirty = toChatAttachmentUrls(tx([att('  ' + IMG1 + '  ', true), att('', false)]));
+  check('URL 前后空格去掉、空 URL 过滤掉', JSON.stringify(dirty.pictures) === JSON.stringify([IMG1]) && dirty.files.length === 0, JSON.stringify(dirty));
+
+  const noAttachments = toChatAttachmentUrls({ files: [PDF, DOC], file_pos: [0, 0] });
+  check('只有 files(没 attachments)→ 全落 files,地址不丢', JSON.stringify(noAttachments.files) === JSON.stringify([PDF, DOC]), JSON.stringify(noAttachments));
+}
+
+/* ------------------------------------------------------------------ *
+ * 协调器:真正发出的请求体
+ * ------------------------------------------------------------------ */
+const sse = (events) =>
+  events
+    .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`)
+    .join('');
+
+let captured = [];
+const stubFetch = () => {
+  globalThis.fetch = async (url, init = {}) => {
+    captured.push(init.body ? JSON.parse(String(init.body)) : null);
+    return new Response(
+      new ReadableStream({
+        start(controller) {
+          controller.enqueue(new TextEncoder().encode(sse([['done', { status: 'completed' }]])));
+          controller.close();
+        },
+      }),
+      { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
+    );
+  };
+};
+
+const runTurn = async (question, attachments) => {
+  captured = [];
+  stubFetch();
+  const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
+  await coordinator.generateAnswer(question, attachments);
+  await new Promise((r) => setTimeout(r, 60));
+  return captured[0];
+};
+
+console.log('\n【2】请求体:没有附件时与从前逐字节一致');
+{
+  const body = await runTurn('帮我看看这份材料符合条件吗');
+  check(
+    '只有 thread_id + question',
+    JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['question', 'thread_id']),
+    JSON.stringify(body)
+  );
+  check('不该出现空数组字段', !('pictures' in body) && !('files' in body), JSON.stringify(body));
+
+  const empty = await runTurn('没有附件', { pictures: [], files: [] });
+  check('显式传空数组也不发字段', !('pictures' in empty) && !('files' in empty), JSON.stringify(empty));
+}
+
+console.log('\n【3】请求体:带附件时');
+{
+  const Q = '帮我看看这份材料符合条件吗';
+  const body = await runTurn(Q, { pictures: [IMG1], files: [PDF] });
+  check('**question 是用户原文**(地址不拼进去)', body.question === Q, JSON.stringify(body.question));
+  check('pictures 是 URL 字符串数组', JSON.stringify(body.pictures) === JSON.stringify([IMG1]), JSON.stringify(body.pictures));
+  check('files 是 URL 字符串数组', JSON.stringify(body.files) === JSON.stringify([PDF]), JSON.stringify(body.files));
+  check('没有多余字段', JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['files', 'pictures', 'question', 'thread_id']), JSON.stringify(Object.keys(body)));
+  check(
+    'pictures 里**不带**对象结构(后端只认字符串数组,传对象 422)',
+    body.pictures.every((item) => typeof item === 'string'),
+    JSON.stringify(body.pictures)
+  );
+
+  const multi = await runTurn(Q, { pictures: [IMG1, IMG2], files: [PDF, DOC] });
+  check('支持多个图片与文件', multi.pictures.length === 2 && multi.files.length === 2, JSON.stringify(multi));
+
+  const dirty = await runTurn(Q, { pictures: ['', '  '], files: [PDF] });
+  check('空 URL 被清掉(不会发出 [""])', !('pictures' in dirty) && JSON.stringify(dirty.files) === JSON.stringify([PDF]), JSON.stringify(dirty));
+}
+
+console.log('\n【4】旧方案已停用(源码级)');
+{
+  const files = [];
+  const walk = (dir) => {
+    for (const entry of readdirSync(dir, { withFileTypes: true })) {
+      const full = `${dir}/${entry.name}`;
+      if (entry.isDirectory()) walk(full);
+      else if (/\.(ts|vue)$/.test(entry.name)) files.push(full);
+    }
+  };
+  walk('src');
+
+  const callers = files.filter((file) => {
+    const text = readFileSync(file, 'utf8');
+    // 跳过它自己的定义(已在 shared.ts 里注释掉)
+    return text.split('\n').some((line) => !line.trim().startsWith('//') && line.includes('buildQuestionWithAttachments'));
+  });
+  check('没有活跃调用点(地址不再拼进 question)', callers.length === 0, callers.join(', '));
+}
 
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 2 - 1
src/components/BusinessAssistantMobileInputShell.vue

@@ -33,7 +33,8 @@
 
       <div class="input-actions-bar">
         <div class="actions-left">
-          <!-- 上传入口:附件地址会拼进 question 文本发给后端(见 useBusinessAssistantChat 的 sendMessage) -->
+          <!-- 上传入口:附件上传到 OSS 后随请求体的 `pictures` / `files` 字段发给后端
+               (2026-09-23 起;此前是拼进 question 文本,见 useBusinessAssistantChat 的 sendMessage) -->
           <button type="button" class="action-btn" aria-label="上传图片" @click="emit('imageUpload')">
             <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
               <path

+ 27 - 3
src/components/api-chat-coordinator.ts

@@ -47,6 +47,11 @@ import {
   type AnswerStreamTarget,
   type IncrementalMarkdownNormalizer,
 } from './answer-stream';
+import type { ChatAttachmentUrls } from './business-assistant/shared';
+
+/** 去掉空值/空白后的 URL 列表(请求体里不要塞空串) */
+const cleanUrlList = (urls?: readonly string[]): string[] =>
+  (urls || []).map((url) => String(url ?? '').trim()).filter(Boolean);
 
 /* ------------------------------------------------------------------ *
  * 事件负载类型(全部字段可选,保证与后端降级/缺字段时兼容)
@@ -922,7 +927,26 @@ export class ApiChatCoordinator {
     this._eventbus.all.clear();
   }
 
-  async generateAnswer(text: string): Promise<void> {
+  /**
+   * 组装 `/api/chat` 的请求体。
+   *
+   * - 基础字段**只有** `thread_id` 与 `question`(历史包袱:早期多一个字段就 422)
+   * - 多模态时追加 `pictures` / `files`(**字符串 URL 数组**,2026-09-23 实测契约):
+   *   传对象数组会被 `422 invalid_request` 拒掉;`pictures` 里的图由后端自己去取,
+   *   所以必须是可公网访问的地址
+   * - **空列表不发**:没有附件时请求体与从前**逐字节一致** ——
+   *   既少传无意义字段,也让「后端若回滚到旧版本」只影响带附件的那几轮
+   */
+  private buildRequestBody(text: string, attachments?: ChatAttachmentUrls): Record<string, unknown> {
+    const body: Record<string, unknown> = { thread_id: this.threadId, question: text };
+    const pictures = cleanUrlList(attachments?.pictures);
+    const files = cleanUrlList(attachments?.files);
+    if (pictures.length) body.pictures = pictures;
+    if (files.length) body.files = files;
+    return body;
+  }
+
+  async generateAnswer(text: string, attachments?: ChatAttachmentUrls): Promise<void> {
     if (this.generating) {
       // 同窗口上一请求仍在执行(后端会返回 409 thread_busy)。
       // 这里只提示,不能发 close/error,否则会误结束正在进行的这一轮。
@@ -953,8 +977,8 @@ export class ApiChatCoordinator {
           'Content-Type': 'application/json',
           Accept: 'text/event-stream',
         },
-        // 严格只发这两个字段:多余字段会被后端判为 422
-        body: JSON.stringify({ thread_id: this.threadId, question: text }),
+        // 基础字段只有这两个;多模态时再加 pictures / files(见 buildRequestBody 的说明)
+        body: JSON.stringify(this.buildRequestBody(text, attachments)),
         signal: this.ctrl.signal,
       });
     } catch (err) {

+ 55 - 10
src/components/business-assistant/shared.ts

@@ -342,17 +342,62 @@ export const normalizeSessionHistory = (session: BusinessAssistantSession) => {
  * 免得凭空发明一套后端可能不认识的措辞;URL 本身就说明了一切)。
  * 若后端要求特定标记,改这一个函数即可。
  */
-export const buildQuestionWithAttachments = (
-  question: string,
-  fileUrls?: Array<string | null | undefined> | null,
-): string => {
-  const urls = (Array.isArray(fileUrls) ? fileUrls : [])
-    .map((url) => String(url ?? '').trim())
-    .filter(Boolean);
+/**
+ * 多模态附件的两种 URL 列表(`/api/chat` 请求体的 `pictures` / `files` 字段)。
+ *
+ * 实测(2026-09-23):
+ * - 两者都是 **URL 字符串数组**(传对象数组会被 422 `invalid_request` 拒掉)
+ * - `pictures` 里的图后端**自己去取**(所以必须是可公网访问的地址),
+ *   实测带上它之后模型确实"看到"了图并能描述内容
+ */
+export interface ChatAttachmentUrls {
+  /** 图片(后端按视觉输入处理) */
+  pictures: string[];
+  /** 非图片文件(pdf/docx…) */
+  files: string[];
+}
 
-  if (!urls.length) {
-    return question;
+/**
+ * 把上传模块交出来的 transmission 拆成 `pictures` / `files`。
+ *
+ * 按 `attachments[].isImage` 分(上传时已经判好),**两个列表都只保留非空 URL**。
+ * 拿不到 `attachments`(只有 `files`)时,只能全部当普通文件 —— 分不出图片,
+ * 但至少地址不丢。
+ */
+export const toChatAttachmentUrls = (
+  transmission?: BusinessAssistantInputFileTransmission | null,
+): ChatAttachmentUrls => {
+  const attachments = Array.isArray(transmission?.attachments) ? transmission?.attachments : [];
+  const pick = (list: BusinessAssistantMessageAttachment[] | undefined) =>
+    (list || []).map((item) => String(item?.url ?? '').trim()).filter(Boolean);
+
+  if (attachments && attachments.length) {
+    return {
+      pictures: pick(attachments.filter((item) => item?.isImage)),
+      files: pick(attachments.filter((item) => !item?.isImage)),
+    };
   }
 
-  return `${question}\n${urls.join('\n')}`;
+  const fallback = (Array.isArray(transmission?.files) ? transmission?.files : [])
+    .map((url) => String(url ?? '').trim())
+    .filter(Boolean);
+  return { pictures: [], files: fallback };
 };
+
+// [已停用] 附件地址拼进 question 文本(方案②)—— 2026-09-23 后端支持了
+// `pictures` / `files` 两个多模态字段(方案①),地址不再拼进正文:
+// 拼进去会让模型把 URL 当正文内容读,而且本地消息、会话标题、DMS 记录都跟着变脏。
+// export const buildQuestionWithAttachments = (
+//   question: string,
+//   fileUrls?: Array<string | null | undefined> | null,
+// ): string => {
+//   const urls = (Array.isArray(fileUrls) ? fileUrls : [])
+//     .map((url) => String(url ?? '').trim())
+//     .filter(Boolean);
+//
+//   if (!urls.length) {
+//     return question;
+//   }
+//
+//   return `${question}\n${urls.join('\n')}`;
+// };

+ 8 - 10
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -19,7 +19,7 @@ import {
   BusinessAssistantSuggestion,
   createBusinessAssistantId,
   appendAiMessageChunk,
-  buildQuestionWithAttachments,
+  toChatAttachmentUrls,
   businessAssistantSuggestions,
   createAiMessageChunkBuffer,
   createUserMessage,
@@ -586,14 +586,12 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
     const userMessagePos = session.messages.filter((message) => message.role === 'user').length;
     const fileTransmission = options.getInputFileTransmission?.(userMessagePos);
-    // [旧协议] 新接口 /api/chat 只接受 {thread_id, question},多一个字段 422,
-    // 因此 transmission.files / file_pos 发不出去(下面两行停用)。
-    // 现改为把附件的 OSS 地址**拼进 question 文本**(用户 2026-09-18 拍板的方案②):
-    //   · questionForBackend —— 只发给后端,含附件地址
-    //   · text               —— 本地消息内容、会话标题、DMS 记录,保持原文(不显示 URL)
-    // globalThis.transmission.files = fileTransmission?.files || [];
-    // globalThis.transmission.file_pos = fileTransmission?.file_pos || [];
-    const questionForBackend = buildQuestionWithAttachments(text, fileTransmission?.files);
+    // 附件走**请求体的多模态字段** `pictures` / `files`(2026-09-23 起后端支持,实测契约):
+    //   · pictures —— 图片 URL 数组(后端自己去取图,必须公网可达)
+    //   · files    —— 非图片文件 URL 数组
+    // 不再把地址拼进 question:那样模型会把 URL 当正文读,而且会话标题、DMS 记录都跟着变脏。
+    // question 发给后端的就是用户原文。
+    const attachmentsForBackend = toChatAttachmentUrls(fileTransmission);
 
     session.messages.push(createUserMessage(text, fileTransmission?.attachments || []));
     // 预置一条空的 AI 消息:它的 id 既是本轮的 DMS 记录标识(c_record_id),
@@ -618,7 +616,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     // fire-and-forget,失败只 warn,不影响聊天
     saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: text });
 
-    void coordinator.generateAnswer(questionForBackend);
+    void coordinator.generateAnswer(text, attachmentsForBackend);
   };
 
   const playMockStreamMessage = async ({