/** * 验证「多模态附件」这条链路(`/api/chat` 的 `pictures` / `files` 两个字段)。 * * 历史: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]` 问「这张图片里是什么」,模型确实描述了图的内容 * - 空数组与不传都接受 * * 不变量: * ① **没有附件时请求体逐字节与从前一致**(只有 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 --alias:@=./src \ * --define:import.meta.env='{}' --loader:.png=dataurl * # ⚠️ 必须带 --loader:.png=dataurl:协调器 import 了企业微信二维码 * node harness/tools/verify-attachment-question.mjs */ import { readFileSync, readdirSync } from 'node:fs'; const { toChatAttachmentUrls, ApiChatCoordinator } = await import('./_attachments.mjs'); let pass = 0; let fail = 0; const check = (name, cond, extra = '') => { if (cond) { pass++; console.log(' ok ' + name); } else { fail++; console.log(' FAIL ' + name + ' ' + extra); } }; 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);