verify-attachment-question.mjs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. /**
  2. * 验证「多模态附件」这条链路(`/api/chat` 的 `pictures` / `files` 两个字段)。
  3. *
  4. * 历史:2026-09-18 时新接口只收 `{thread_id, question}`,多一个字段 422,
  5. * 附件只能**把 OSS 地址拼进 question 文本**(方案②)。
  6. * 2026-09-23 后端支持了 `pictures` / `files`(方案①),改为走请求体字段。
  7. *
  8. * **实测到的契约**(2026-09-23,打真实后端):
  9. * - 两者都是 **URL 字符串数组**;传对象数组(`[{url,name}]`)会被 `422 invalid_request` 拒掉
  10. * - `pictures` 里的图**由后端自己去取**(必须公网可达)——
  11. * 实测带上 `pictures:[图URL]` 问「这张图片里是什么」,模型确实描述了图的内容
  12. * - 空数组与不传都接受
  13. *
  14. * 不变量:
  15. * ① **没有附件时请求体逐字节与从前一致**(只有 thread_id + question)——
  16. * 既不多传无意义字段,也让「后端若回滚」只影响带附件的轮次
  17. * ② 有附件时:`question` 仍是**用户原文**(地址不拼进去),附件在 `pictures` / `files` 里
  18. * ③ 图片按 `isImage` 进 `pictures`,其余进 `files`;空 URL 被过滤掉
  19. *
  20. * 怎么跑(在项目根目录):
  21. * npx esbuild harness/tools/_entry-attachments.ts --bundle --format=esm \
  22. * --outfile=harness/tools/_attachments.mjs --alias:@=./src \
  23. * --define:import.meta.env='{}' --loader:.png=dataurl
  24. * # ⚠️ 必须带 --loader:.png=dataurl:协调器 import 了企业微信二维码
  25. * node harness/tools/verify-attachment-question.mjs
  26. */
  27. import { readFileSync, readdirSync } from 'node:fs';
  28. const { toChatAttachmentUrls, ApiChatCoordinator } = await import('./_attachments.mjs');
  29. let pass = 0;
  30. let fail = 0;
  31. const check = (name, cond, extra = '') => {
  32. if (cond) {
  33. pass++;
  34. console.log(' ok ' + name);
  35. } else {
  36. fail++;
  37. console.log(' FAIL ' + name + ' ' + extra);
  38. }
  39. };
  40. const IMG1 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/a.png';
  41. const IMG2 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/b.jpg';
  42. const PDF = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/c.pdf';
  43. const DOC = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/d.docx';
  44. const att = (url, isImage) => ({ id: url, name: url.split('/').pop(), size: 1, isImage, url });
  45. const tx = (attachments, files) => ({
  46. files: files ?? attachments.map((a) => a.url),
  47. file_pos: attachments.map(() => 0),
  48. attachments,
  49. });
  50. console.log('\n【1】按类型拆分(纯逻辑)');
  51. {
  52. const mixed = toChatAttachmentUrls(tx([att(IMG1, true), att(PDF, false), att(IMG2, true)]));
  53. check('图片进 pictures', JSON.stringify(mixed.pictures) === JSON.stringify([IMG1, IMG2]), JSON.stringify(mixed.pictures));
  54. check('非图片进 files', JSON.stringify(mixed.files) === JSON.stringify([PDF]), JSON.stringify(mixed.files));
  55. const onlyImages = toChatAttachmentUrls(tx([att(IMG1, true)]));
  56. check('只有图片时 files 为空数组', onlyImages.files.length === 0 && onlyImages.pictures.length === 1);
  57. const none = toChatAttachmentUrls(undefined);
  58. check('没有附件 → 两个空数组', none.pictures.length === 0 && none.files.length === 0);
  59. check('null 也安全', JSON.stringify(toChatAttachmentUrls(null)) === JSON.stringify({ pictures: [], files: [] }));
  60. check('空 attachments 数组 → 两个空数组', JSON.stringify(toChatAttachmentUrls(tx([]))) === JSON.stringify({ pictures: [], files: [] }));
  61. const dirty = toChatAttachmentUrls(tx([att(' ' + IMG1 + ' ', true), att('', false)]));
  62. check('URL 前后空格去掉、空 URL 过滤掉', JSON.stringify(dirty.pictures) === JSON.stringify([IMG1]) && dirty.files.length === 0, JSON.stringify(dirty));
  63. const noAttachments = toChatAttachmentUrls({ files: [PDF, DOC], file_pos: [0, 0] });
  64. check('只有 files(没 attachments)→ 全落 files,地址不丢', JSON.stringify(noAttachments.files) === JSON.stringify([PDF, DOC]), JSON.stringify(noAttachments));
  65. }
  66. /* ------------------------------------------------------------------ *
  67. * 协调器:真正发出的请求体
  68. * ------------------------------------------------------------------ */
  69. const sse = (events) =>
  70. events
  71. .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`)
  72. .join('');
  73. let captured = [];
  74. const stubFetch = () => {
  75. globalThis.fetch = async (url, init = {}) => {
  76. captured.push(init.body ? JSON.parse(String(init.body)) : null);
  77. return new Response(
  78. new ReadableStream({
  79. start(controller) {
  80. controller.enqueue(new TextEncoder().encode(sse([['done', { status: 'completed' }]])));
  81. controller.close();
  82. },
  83. }),
  84. { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
  85. );
  86. };
  87. };
  88. const runTurn = async (question, attachments) => {
  89. captured = [];
  90. stubFetch();
  91. const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
  92. await coordinator.generateAnswer(question, attachments);
  93. await new Promise((r) => setTimeout(r, 60));
  94. return captured[0];
  95. };
  96. console.log('\n【2】请求体:没有附件时与从前逐字节一致');
  97. {
  98. const body = await runTurn('帮我看看这份材料符合条件吗');
  99. check(
  100. '只有 thread_id + question',
  101. JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['question', 'thread_id']),
  102. JSON.stringify(body)
  103. );
  104. check('不该出现空数组字段', !('pictures' in body) && !('files' in body), JSON.stringify(body));
  105. const empty = await runTurn('没有附件', { pictures: [], files: [] });
  106. check('显式传空数组也不发字段', !('pictures' in empty) && !('files' in empty), JSON.stringify(empty));
  107. }
  108. console.log('\n【3】请求体:带附件时');
  109. {
  110. const Q = '帮我看看这份材料符合条件吗';
  111. const body = await runTurn(Q, { pictures: [IMG1], files: [PDF] });
  112. check('**question 是用户原文**(地址不拼进去)', body.question === Q, JSON.stringify(body.question));
  113. check('pictures 是 URL 字符串数组', JSON.stringify(body.pictures) === JSON.stringify([IMG1]), JSON.stringify(body.pictures));
  114. check('files 是 URL 字符串数组', JSON.stringify(body.files) === JSON.stringify([PDF]), JSON.stringify(body.files));
  115. check('没有多余字段', JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['files', 'pictures', 'question', 'thread_id']), JSON.stringify(Object.keys(body)));
  116. check(
  117. 'pictures 里**不带**对象结构(后端只认字符串数组,传对象 422)',
  118. body.pictures.every((item) => typeof item === 'string'),
  119. JSON.stringify(body.pictures)
  120. );
  121. const multi = await runTurn(Q, { pictures: [IMG1, IMG2], files: [PDF, DOC] });
  122. check('支持多个图片与文件', multi.pictures.length === 2 && multi.files.length === 2, JSON.stringify(multi));
  123. const dirty = await runTurn(Q, { pictures: ['', ' '], files: [PDF] });
  124. check('空 URL 被清掉(不会发出 [""])', !('pictures' in dirty) && JSON.stringify(dirty.files) === JSON.stringify([PDF]), JSON.stringify(dirty));
  125. }
  126. console.log('\n【4】旧方案已停用(源码级)');
  127. {
  128. const files = [];
  129. const walk = (dir) => {
  130. for (const entry of readdirSync(dir, { withFileTypes: true })) {
  131. const full = `${dir}/${entry.name}`;
  132. if (entry.isDirectory()) walk(full);
  133. else if (/\.(ts|vue)$/.test(entry.name)) files.push(full);
  134. }
  135. };
  136. walk('src');
  137. const callers = files.filter((file) => {
  138. const text = readFileSync(file, 'utf8');
  139. // 跳过它自己的定义(已在 shared.ts 里注释掉)
  140. return text.split('\n').some((line) => !line.trim().startsWith('//') && line.includes('buildQuestionWithAttachments'));
  141. });
  142. check('没有活跃调用点(地址不再拼进 question)', callers.length === 0, callers.join(', '));
  143. }
  144. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  145. process.exit(fail ? 1 : 0);