Parcourir la source

feat(chat): CIPA / 出海类问答展示企业微信二维码(result.cipa)

后端新增 result.data.cipa(布尔):问 CIPA / 海外服务平台 / 出海服务时为 true。
实现走协议适配层,渲染层零改动:
- 协调器捕获 cipa === true(严格判定)→ pendingCipa,resetTurnContent 每轮清空,
  flushContent 在参考资料之后追加 <image-scope> 块
- 图片走 Vite 资源导入(构建后是带 hash 的产物地址);渲染层本来就认 image-scope
  块 → ImageContent 行类型(支持点击放大),因此没有新增组件
- BusinessRecord 的复制剥离规则补上 <image-scope>,避免复制出图片地址

验证:build 通过且图片进产物;适配层新增 9 项断言(19/19),含 false/缺字段/非布尔值
不显示、图片块在最末尾、跨轮不串;依赖同一 bundle 的三个脚本回归全绿
(11/11、8/8、33/33)。

⚠️ 实测后端**尚未下发**该字段(两份 CIPA/出海问法的 result.data 键仍是
response/company_info/recommendation/errors,SSE 里的 CIPA 均为正文文字)。
前端对缺字段安全,部署后即生效;feature_list 的 cipa-qrcode 先记 in_progress,
待浏览器确认后转 passing。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao il y a 1 mois
Parent
commit
4114051a24

+ 8 - 0
README.md

@@ -278,3 +278,11 @@
 - 政策详情面板的「政策名称 / 政策所属部门」一行**拉满整行宽度**(与下方两张卡片右对齐):
   原先名称列 `max-width: 462px` + 部门列写死 `110px`,整行只有约 520px 宽、右侧空一大块,
   部门写全称时还被挤成三行;改为名称列占满剩余空间、部门列可伸展(下限仍 110px)
+- **CIPA / 出海类问答展示企业微信二维码**:后端在 `result.data.cipa` 给出布尔值,
+  `true`(问的是 CIPA / 海外服务平台 / 出海服务)时在回答**最末尾**追加一张二维码图片
+  (`src/assets/qiyeweixin.png`,点击可放大)
+  - 走适配层翻译成既有的 `<image-scope>` 内容标记,**渲染层零改动**(该标记本来就有对应行类型)
+  - 复制按钮的剥离规则同步补上 `<image-scope>`,避免把图片地址复制进剪贴板
+  - 适配层 9 项新断言(含 `false`/缺字段/非布尔值不显示、跨轮不串)
+  - ⚠️ 实测**后端尚未下发该字段**(`result.data` 仍是 response/company_info/recommendation/errors)——
+    前端对缺字段安全,后端部署后即生效

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

@@ -77,7 +77,7 @@ data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f
 | item | source_id、card、answer、conditions等 | 渲染单张政策/服务推荐卡片 |
 | summary | text、source_ids、status、notices | 显示末尾综合说明 |
 | interrupt | kind、question、input_help等 | 提示用户补充公司信息或确认候选 |
-| result | response、recommendation、errors、company_info | 当前轮次的完整最终快照 |
+| result | response、recommendation、errors、company_info、cipa | 当前轮次的完整最终快照 |
 | done | status | 本次流正常结束或等待输入 |
 | error | code,可选message | 本次流异常结束 |
 
@@ -163,6 +163,21 @@ data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f
 }
 ```
 
+### cipa(`result.data.cipa`)
+
+**布尔值**,表示本轮是否需要展示**企业微信二维码**(2026-09-20 后端新增):
+
+| 取值 | 含义 | 前端行为 |
+|---|---|---|
+| `true` | 问的是 **CIPA / 海外服务平台 / 出海服务** | 在回答**最末尾**追加一张企业微信二维码(`<image-scope>` 块,点击可放大) |
+| `false` / 缺失 | 其他情况 | 不展示 |
+
+- 前端按 **`=== true` 严格判定**,非布尔值一律当 false
+- ⚠️ 该字段**目前尚未在开发环境(`192.168.2.23:8000`)下发**:
+  2026-09-20 实测两份 CIPA/出海相关的问答,`result.data` 的键仍是
+  `response / company_info / recommendation / errors`。前端已实现且对缺字段安全
+  (不显示),后端部署后即生效——**用 `curl` 打一次 `/api/chat` 看有没有 `cipa` 即可确认**
+
 ### company_info(`result.data.company_info`)
 
 公司类问题会额外带回企业资料,**结构是 `{company, profile, honors}` 三段**(2026-09-18 实测,

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
harness/feature_list.json


+ 42 - 0
harness/progress.md

@@ -2837,3 +2837,45 @@
 生成时踩了一处并修掉:荣誉字段显示的是**内部归一化名**(`level`)而非**载荷原始键**(`Level`)——
 文档是给人对着载荷看的,已改为显示原始键(`RAW_HONOR_KEYS` 映射)。
 
+
+## Session 067
+
+- **日期**:2026-09-20
+- **本轮目标**:后端新增 `result.data.cipa`(布尔)——问 CIPA / 海外服务平台 / 出海服务时为 true,
+  前端在 true 时展示 `src/assets/qiyeweixin.png`(企业微信二维码)
+- **已完成**(协议适配层,**渲染层零改动**):
+  - 协调器:`ChatResultPayload` 加 `cipa`;`case 'result'` 严格判定 `=== true` 存入 `pendingCipa`;
+    `resetTurnContent()` 每轮清空;`flushContent()` 在**参考资料之后**追加图片块
+  - 新增纯函数 `buildCipaQrcodeContent()`(与其它标记生成函数并列)
+  - **没有新增组件**:渲染层本来就认 `<image-scope>` 块 → `ImageContent` 行类型(点击可放大),
+    所以只按它的约定把图片地址放进去即可
+  - 图片走 Vite 资源导入(构建后是带 hash 的产物地址,不写死路径);
+    `BusinessRecord` 的**复制剥离规则**补了 `<image-scope>`,免得把图片地址复制进剪贴板
+- **运行过的验证**:
+  - `npm run build` 通过;**图片确实进产物**(`dist/assets/qiyeweixin-Bgo9IiEe.png`),
+    标记字符串在 bundle 里
+  - **适配层 9 项新断言**(`verify-company-info-passthrough.mjs` 扩到 **19/19**,用假 SSE 流喂真实协调器):
+    `true` → 追加块且含非空地址、块在**最末尾**、正文不被污染;`false`/缺字段/`"true"` 字符串 → 都不加;
+    **跨轮不串**(第一轮 true、第二轮缺省 → 第二轮没有)
+  - 回归:依赖同一 bundle 的三个脚本全绿(多实例隔离 11/11、卡字段 8/8、聊天任务 33/33)
+- **🚨 重要发现(如实记录)**:**真实后端目前还没有下发 `cipa` 字段**。用两种问法
+  (「CIPA是什么?出海服务怎么申请」「我想了解青浦区CIPA海外服务平台,出海服务有哪些支持」)
+  各打一次真实 `/api/chat`,`result.data` 的键仍是
+  `response / company_info / recommendation / errors`;SSE 全文里的 "CIPA" 全是**正文里的字样**
+  (`grep -i` 把大写也算上了,一度造成干扰)。→ 判断是**后端尚未部署到 `192.168.2.23:8000`**。
+  **前端对缺字段是安全的**(不显示图片),后端部署后即生效,无需再改前端
+- **过程中踩的坑**:协调器现在 import 了 `.png`,**node 侧 esbuild 打包必须带
+  `--loader:.png=dataurl`**,否则直接报错(已写进 `tools/README.md` 与 `_entry-coordinator.ts` 头部);
+  另外我用脚本插 helper 时把 `join("\n\n")` 写成了跨行字符串(语法错误),已修
+- **已记录证据**:本文件 Session 067;`reference/api-chat.md` 新增 `cipa` 字段说明(含「当前未下发」的实测);
+  `feature_list.json` 的 `cipa-qrcode`
+- **提交记录**:见下条提交(已按约定提交推送)
+- **更新过的文件或工件**:`src/components/api-chat-coordinator.ts`、`src/components/Chat/BusinessRecord.vue`、
+  `harness/tools/{_entry-coordinator.ts, verify-company-info-passthrough.mjs, README.md}`、
+  `harness/docs/reference/api-chat.md`、`harness/feature_list.json`、根 `README.md`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **`cipa-qrcode` 保持 `in_progress`**:自动化验证齐备,但**真实链路没被走过**
+    (后端未部署该字段)→ 部署后在浏览器确认一次再转 passing
+  - ⚠️ 图片放在 `src/assets/`(随构建产物发布);若将来要换图,替换文件即可(文件名不用改)
+  - ⚠️ 二维码出现在**回答最末尾**(参考资料之后)——位置是我选的,若用户希望贴在别处(如正文下方)说一声
+- **下一步最佳动作**:等后端部署 `cipa` → 浏览器实测 → 转 passing

+ 8 - 0
harness/tools/README.md

@@ -37,6 +37,14 @@ npx esbuild harness/tools/<某入口>.ts --bundle --format=esm --outfile=harness
 (入库会与源码漂移,让人误以为测的是新代码)。**clone 下来要跑验证脚本前,先重新生成**:
 
 ```bash
+# 协议适配层(入口 _entry-coordinator.ts)
+# ⚠️ 必须带 --loader:.png=dataurl:协调器 import 了企业微信二维码
+#    (@/assets/qiyeweixin.png,result.cipa=true 时展示用),
+#    node 侧的 esbuild 不认识 .png 会直接报错
+npx esbuild harness/tools/_entry-coordinator.ts --bundle --format=esm \
+  --outfile=harness/tools/_coordinator.mjs --alias:@=./src \
+  --define:import.meta.env='{}' --loader:.png=dataurl
+
 # 会话/问答记录 DMS 相关(入口 _entry-dms.ts)
 npx esbuild harness/tools/_entry-dms.ts --bundle --format=esm \
   --outfile=harness/tools/_dms.mjs --alias:@=./src --define:import.meta.env='{}'

+ 4 - 0
harness/tools/_entry-coordinator.ts

@@ -5,5 +5,9 @@
  * - `ApiChatCoordinator`:协调器类。**验证多实例事件隔离**用——
  *   每实例自带 mitt 事件总线,这是「按会话并行生成」的地基:
  *   若两个实例的事件会串台,并行就会互相污染。
+ *
+ * ⚠️ 打包时**必须带 `--loader:.png=dataurl`**:协调器 import 了企业微信二维码
+ *    (`@/assets/qiyeweixin.png`,`result.cipa=true` 时展示用),node 侧的 esbuild
+ *    不认识 .png 会直接报错。完整命令见 `harness/tools/README.md`。
  */
 export { buildPolicyTableContent, ApiChatCoordinator } from '../../src/components/api-chat-coordinator';

+ 67 - 0
harness/tools/verify-company-info-passthrough.mjs

@@ -52,6 +52,9 @@ const stubFetchWith = (sseText) => {
   };
 };
 
+/** 把一轮里收集到的 message 事件拼成整条内容 */
+const seen_message_text = (seen) => (seen.messages || []).join('\n\n');
+
 const runTurn = async (events) => {
   stubFetchWith(sse(events));
   const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
@@ -149,5 +152,69 @@ console.log('\n【4】跨轮不串:上一轮有 company_info,下一轮没有
   check('第二轮**没有**沿用上一轮的 company_info(resetTurnContent 生效)', !('company_info' in (payloads[1] || {})), payloads[1]);
 }
 
+console.log('\n【5】result.cipa → 企业微信二维码块(<image-scope>)');
+{
+  const shown = await runTurn([
+    ...BASE_EVENTS,
+    ['result', { response: '这是回答正文。', cipa: true }],
+    ['done', { status: 'completed' }],
+  ]);
+  const text = seen_message_text(shown);
+  check('cipa=true → 追加 <image-scope> 块', /<image-scope>[\s\S]*?<\/image-scope>/.test(text), text.slice(-120));
+  check('块里有非空的图片地址', /<image-scope>\s*\S[^\n]*\s*<\/image-scope>/.test(text));
+  check('图片块在整条内容的**最末尾**(正文之后)', text.trimEnd().endsWith('</image-scope>'));
+  check('正文本身没被污染(正文仍在最前面)', text.trimStart().startsWith('这是回答正文。'), text.slice(0, 40));
+
+  const off = await runTurn([
+    ...BASE_EVENTS,
+    ['result', { response: '这是回答正文。', cipa: false }],
+    ['done', { status: 'completed' }],
+  ]);
+  check('cipa=false → 不加图片块', !seen_message_text(off).includes('image-scope'));
+
+  const missing = await runTurn([
+    ...BASE_EVENTS,
+    ['result', { response: '这是回答正文。' }],
+    ['done', { status: 'completed' }],
+  ]);
+  check('result 里没有 cipa 字段 → 不加图片块', !seen_message_text(missing).includes('image-scope'));
+
+  const truthyString = await runTurn([
+    ...BASE_EVENTS,
+    ['result', { response: 'x', cipa: 'true' }],
+    ['done', { status: 'completed' }],
+  ]);
+  check('非布尔值一律当 false(严格 === true)', !seen_message_text(truthyString).includes('image-scope'));
+}
+
+console.log('\n【6】cipa 跨轮不串(resetTurnContent 生效)');
+{
+  stubFetchWith(
+    sse([
+      ...BASE_EVENTS,
+      ['result', { response: '第一轮', cipa: true }],
+      ['done', { status: 'completed' }],
+    ])
+  );
+  const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
+  const rounds = [];
+  coordinator.addEventListener('message', (msg) => rounds.push(msg));
+  await coordinator.generateAnswer('第一轮');
+  await new Promise((resolve) => setTimeout(resolve, 60));
+
+  stubFetchWith(
+    sse([
+      ...BASE_EVENTS,
+      ['result', { response: '第二轮' }], // 这一轮 cipa 缺省
+      ['done', { status: 'completed' }],
+    ])
+  );
+  await coordinator.generateAnswer('第二轮');
+  await new Promise((resolve) => setTimeout(resolve, 60));
+
+  check('第一轮有图片块', /<image-scope>/.test(rounds[0] || ''));
+  check('第二轮**没有**沿用上一轮的 cipa', !/<image-scope>/.test(rounds[1] || ''), (rounds[1] || '').slice(-80));
+}
+
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

BIN
src/assets/qiyeweixin.png


+ 3 - 1
src/components/Chat/BusinessRecord.vue

@@ -589,7 +589,8 @@ enum SchedulerTaskState {
 const getRefLinkItems = (content: string) => parseRefLinksContent(content);
 
 // 复制按钮只应复制可读正文:去掉 <scope>、<!-- POLICY_TABLE -->、<ref_links>、
-// <question-cards> 等协议标记,避免复制出一大段 JSON。
+// <question-cards>、<image-scope>(企业微信二维码)等协议标记,
+// 避免复制出一大段 JSON 或图片地址。
 //
 // 注意不能用 getStreamPlainText():它对整段内容做一次性解析,而过滤器在遇到
 // <!-- POLICY_TABLE 时会把同批 buffer 里位于它之前的内容直接当纯文本输出,
@@ -600,6 +601,7 @@ const copyableContent = computed(() => {
     .replace(/<scope\b[^>]*>[\s\S]*?<\/scope>\s*/gi, "")
     .replace(/<ref_links\b[^>]*>[\s\S]*?<\/ref_links>\s*/gi, "")
     .replace(/<question-cards\b[^>]*>[\s\S]*?<\/question-cards>\s*/gi, "")
+    .replace(/<image-scope\b[^>]*>[\s\S]*?<\/image-scope>\s*/gi, "")
     .replace(/<!--\s*POLICY_TABLE[\s\S]*?POLICY_TABLE\s*-->\s*/gi, "")
     .replace(/<\/?silence\b[^>]*>/gi, "")
     .replace(/<\/?scope\b[^>]*>/gi, "")

+ 34 - 2
src/components/api-chat-coordinator.ts

@@ -12,6 +12,8 @@
  * - item                 → <!-- POLICY_TABLE {json} POLICY_TABLE -->(原政策卡片 + 原详情面板)
  * - source               → <ref_links>[…]</ref_links>(原“参考资料”)
  * - interrupt            → <question-cards>{…}</question-cards>(原选项卡)
+ * - result.cipa(布尔)  → <image-scope>…</image-scope>(企业微信二维码,追加在最后;
+ *                          仅当后端判定问的是 CIPA / 海外服务平台 / 出海服务时为 true)
  * - answer / summary     → 纯文本
  *
  * 正文与卡片在 done 时按原顺序一次性输出:新接口本身不逐字流式返回,
@@ -23,6 +25,9 @@
  * - 未收到 done/error 就断开 → 按“连接中断”处理,不自动重试
  */
 import mitt, { Emitter } from 'mitt';
+// 企业微信二维码:后端 cipa=true(问的是 CIPA / 海外服务平台 / 出海服务)时展示。
+// 走 Vite 资源导入 → 构建时变成带 hash 的产物地址,不要写死路径。
+import cipaQrcodeUrl from '@/assets/qiyeweixin.png';
 
 /* ------------------------------------------------------------------ *
  * 事件负载类型(全部字段可选,保证与后端降级/缺字段时兼容)
@@ -161,6 +166,11 @@ export interface ChatResultPayload {
    * `syncCompanyInfoFromChat` 去调分类接口、落 DMS(见 company-info-sync.ts)。
    */
   company_info?: unknown;
+  /**
+   * 是否需要展示企业微信二维码(2026-09-20 后端新增):
+   * 问的是 CIPA / 海外服务平台 / 出海服务时为 `true`,其他情况 `false`。
+   */
+  cipa?: unknown;
 }
 
 export interface ChatDonePayload {
@@ -209,6 +219,16 @@ export function mapChatErrorText(code?: string, message?: string): string {
   return CHAT_ERROR_TEXT[code || ''] || '服务异常,请稍后再试';
 }
 
+/**
+ * 企业微信二维码的内容标记。
+ *
+ * 渲染层本来就认 `<image-scope>` 块(→ `ImageContent` 行类型,支持点击放大),
+ * 所以这里不需要新增组件,只要按它的约定把图片地址放进去。
+ */
+export function buildCipaQrcodeContent(): string {
+  return `<image-scope>\n${cipaQrcodeUrl}\n</image-scope>`;
+}
+
 export const CHAT_ENDPOINT_PATH = '/api/chat';
 
 /**
@@ -819,6 +839,8 @@ export class ApiChatCoordinator {
   private pendingSummary: ChatSummaryPayload | null = null;
   private pendingInterrupt: ChatInterruptPayload | null = null;
   private pendingCompanyInfo: unknown = null;
+  /** 本轮是否需要展示企业微信二维码(result.cipa) */
+  private pendingCipa = false;
   /** 正文是否已输出(输出后不再发送 scope 进度卡片) */
   private contentFlushed = false;
   /** 最近一次补问的原始数据,供提交时反查候选用 */
@@ -1013,6 +1035,7 @@ export class ApiChatCoordinator {
     this.pendingSummary = null;
     this.pendingInterrupt = null;
     this.pendingCompanyInfo = null;
+    this.pendingCipa = false;
     this.contentFlushed = false;
     this.scopeEmitted = false;
     this.doneReceived = false;
@@ -1085,6 +1108,11 @@ export class ApiChatCoordinator {
       parts.push(refLinks);
     }
 
+    // 企业微信二维码:后端标记为 CIPA / 出海类问题时追加(放在整条回答的最末尾)
+    if (this.pendingCipa) {
+      parts.push(buildCipaQrcodeContent());
+    }
+
     const content = parts.filter((part) => String(part || '').trim().length > 0).join('\n\n');
     if (!content) {
       return;
@@ -1175,9 +1203,13 @@ export class ApiChatCoordinator {
 
       // result.response 与 answer.text 相同,是最终快照,不再输出;
       // 但 company_info 只有这里有,暂存下来等 done 时随 totalResponse 交给应用侧
-      case 'result':
-        this.pendingCompanyInfo = (data as ChatResultPayload).company_info ?? null;
+      case 'result': {
+        const result = data as ChatResultPayload;
+        this.pendingCompanyInfo = result.company_info ?? null;
+        // 后端只在 CIPA / 海外服务平台 / 出海服务的问题上给 true,其余为 false
+        this.pendingCipa = result.cipa === true;
         break;
+      }
 
       case 'done': {
         const turn = this.turnToken;

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff