Forráskód Böngészése

feat(chat): 参考资料正文定稿即上屏,不再等 done 的 result

用户反馈「正文结束后要等 1~2 秒才看到参考资料」。实测(probe-answer-tail-timing,三次):
answer_end → result 的间隔是 0.31s / 3.07s / 10.09s,result 是 117~390KB 的完整快照,
done 紧贴它;而参考资料的数据(source 事件)在正文开流前 0.4~0.85 秒就已发完 —— 等的是事件,不是数据。

实现(渲染层零改动):
- 协调器把尾部拆成带 key 的块(notices → interrupt → cipa → refs):
  buildTrailingParts()
- 新增 flushTailEarly():正文定稿且 summary 已到时,把 notices + 参考资料立即上屏。
  触发点挂在 answer_end 与 summary 两处(幂等)。等 summary 是因为它带 notices,
  而 notices 排在参考资料之前(契约保证 answer_end 后必到,实测 +0.00s)。
  两种不抢跑的情形:卡片没提前上屏 / answer 概述要保留(done 时本就要整块重排)。
- 二维码来自 result.cipa,只能等 done;那时若需要,走整块重排把它插到参考资料之前
  (用户明确要求位置不变)—— answerStreamCompose 增加 replacedTrailing,与既有的
  replacedLeading 对称,应用层重排前抹掉已上屏的旧尾部。
- totalResponse 仍按最终顺序拼,落库内容不变。

验证:verify-answer-stream 150/150(新增 19~22;【19】第一版用索引判断是假通过,
改成带 600ms 真实延迟后,阉掉 flushTailEarly 立刻失败);探针加按原时间轴回放,
真载荷下参考资料确认在 done 之前上屏;回归全绿;build 与 validate-harness 通过。

待后端:把 cipa 放进 summary,即可让 CIPA 类问题也一次按正确顺序发完、不再需要重排。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 5 órája
szülő
commit
2b147caa9d

+ 32 - 0
harness/progress.md

@@ -3303,3 +3303,35 @@ DMS 主机(`121.43.55.7:10081`)是纯 API、根路径 404,没有登录页
 4. 前端**从 `result` 里只消费两个字段**(代码证据:`api-chat-coordinator.ts` 的 `case 'result'`):
    `company_info`(走 totalResponse 给 DMS 同步,**不在消息里渲染**)与 `cipa`(企业微信二维码,
    渲染时必须排在参考资料**之前**)—— 除了 `cipa`,尾部其余部分在 `summary` 到达时就已就绪。
+
+### 续 11:参考资料改为**正文定稿就上屏**(用户问「等 1~2 秒」的优化)
+
+**用户选的两个方案**:① 前端先做(尾部提前上屏);② 把 `cipa` 提前的需求提给后端。
+**用户的硬约束**:CIPA 二维码的位置**不能改**(必须仍在参考资料之前)。
+
+**实测依据**(`probe-answer-tail-timing.mjs`,三次真实后端):
+`answer_end`(正文定稿)→ `result` 的间隔是 **0.31s / 3.07s / 10.09s**,
+而 `result` 是 117KB~390KB 的完整快照、`done` 紧贴它;参考资料的数据(`source` 事件)
+在正文**开流前** 0.4~0.85 秒就发完了。**等的是事件,不是数据。**
+
+**实现**(渲染层零改动):
+- 协调器把尾部拆成带 key 的块(`notices → interrupt → cipa → refs`),`buildTrailingParts()`
+- 新增 `flushTailEarly()`:**正文定稿(answer_end)且 summary 已到**时,把
+  `notices` + 参考资料**立即上屏**,不等 done。触发点挂在 `answer_end` 与 `summary` 两处(幂等)
+  - 之所以等 `summary`:它带 `notices`,而 notices 排在参考资料之前;契约保证它在 answer_end 之后必到(实测 +0.00s)
+  - 两种不抢跑的情形:卡片没提前上屏 / `answer` 概述要保留(这两种 done 时要整块重排,提前发反而要抹回来)
+- 二维码来自 `result.cipa`,只能等 done;那时若需要,走**整块重排**把它插到参考资料之前 ——
+  `answerStreamCompose` 增加 `replacedTrailing`,应用层重排前先把已上屏的旧尾部抹掉(与既有的 `replacedLeading` 对称)
+- `totalResponse` 仍按**最终顺序**拼(与发放时机无关),落库/DMS 内容不变
+
+**运行过的验证**:
+- `verify-answer-stream.mjs` **150/150**。新增 4 节:19 尾部提前(**带 600ms 真实延迟**)、
+  20 二维码插到参考资料之前(无新旧两份)、21 无 cipa 时零重排、22 非流式轮行为不变。
+- **新增的 19 是真守卫**:第一版用「索引先后」判断,是**假通过**(done 路径里 message 本来也排在
+  totalResponse 前);改成带延迟后,阉掉 `flushTailEarly` 立刻失败(参考@607ms = done@607ms,白等整整 600ms)
+- 探针加了**按原时间轴回放**:真载荷下参考资料在 done 之前上屏 ✅
+- 回归全绿(适配层 27/27、多实例 11/11、判空 30/30、聊天任务 33/33、政策卡列表 24/24、DMS 载荷 12/12);
+  `npm run build` 与 `validate-harness` 通过
+
+**仍要给后端提的需求(方案 ②)**:把 `cipa` 放进 `summary`(或 `answer_end`)——
+那样连 CIPA 类问题也能一次按正确顺序发完,**不再需要那次整块重排**。

+ 52 - 1
harness/tools/probe-answer-tail-timing.mjs

@@ -34,11 +34,15 @@ const reader = res.body.getReader();
 const decoder = new TextDecoder();
 let buffer = '';
 const events = []; // { name, at, size }
+/** 原始事件块 + 到达时刻 —— 回放时按同样的节奏喂,才能量出「不动 done 能提前多久」 */
+const blocks = [];
 
 const handleEvent = (block) => {
   const m = block.match(/^event: (\S+)\r?\ndata: ([\s\S]*)$/);
   if (!m) return;
-  events.push({ name: m[1], at: Date.now() - startedAt, size: m[2].length });
+  const at = Date.now() - startedAt;
+  events.push({ name: m[1], at, size: m[2].length });
+  blocks.push({ text: `${block}\n\n`, at });
 };
 
 while (true) {
@@ -108,3 +112,50 @@ if (summary && result) {
 }
 console.log('  参考资料由 source 事件决定,而 source 在 answer_start 之前就发完了 ——');
 console.log('  也就是说「等参考资料」等的是 done,不是等数据。');
+
+/* ------------------------------------------------------------------ *
+ * ③ 按**原时间轴**回放给真实协调器:量「参考资料提前上屏」省了多久
+ * ------------------------------------------------------------------ */
+console.log('\n【回放】按原时间轴喂真实协调器,看参考资料什么时候上屏');
+const { ApiChatCoordinator } = await import('./_coordinator.mjs');
+
+globalThis.fetch = async () => {
+  const encoder = new TextEncoder();
+  return new Response(
+    new ReadableStream({
+      async start(controller) {
+        let elapsed = 0;
+        for (const block of blocks) {
+          const wait = Math.max(0, block.at - elapsed);
+          if (wait) await new Promise((r) => setTimeout(r, wait));
+          elapsed = block.at;
+          controller.enqueue(encoder.encode(block.text));
+        }
+        controller.close();
+      },
+    }),
+    { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
+  );
+};
+
+const replayStart = Date.now();
+const coordinator = new ApiChatCoordinator({ chatBaseUrl: undefined, baseUrl: 'http://stub', threadId: 'replay' });
+let refsAt = null;
+let doneAt = null;
+coordinator.addEventListener('message', (msg) => {
+  if (refsAt === null && msg.includes('<ref_links>')) refsAt = Date.now() - replayStart;
+});
+coordinator.addEventListener('totalResponse', () => {
+  if (doneAt === null) doneAt = Date.now() - replayStart;
+});
+await coordinator.generateAnswer(question);
+await new Promise((r) => setTimeout(r, 400));
+
+if (refsAt !== null && doneAt !== null) {
+  const savedMs = doneAt - refsAt;
+  console.log(`  参考资料上屏:${(refsAt / 1000).toFixed(1)}s`);
+  console.log(`  收尾完成(done):${(doneAt / 1000).toFixed(1)}s`);
+  console.log(`  → **提前了 ${(savedMs / 1000).toFixed(1)} 秒**(修前等于 done 时刻)`);
+} else {
+  console.log(`  ⚠️ 没测到:参考资料@${refsAt}ms 收尾@${doneAt}ms`);
+}

+ 143 - 1
harness/tools/verify-answer-stream.mjs

@@ -427,6 +427,7 @@ const runTurn = async (events) => {
   });
   coordinator.addEventListener('totalResponse', (p) => {
     seen.total = p;
+    seen.order.push('totalResponse'); // done 时才发;用来判断尾部是不是提前上屏了
   });
   coordinator.addEventListener('answerStreamStart', (p) => {
     seen.streamStart.push(p);
@@ -498,7 +499,8 @@ const runTurnWithApp = async (events) => {
       stream.body,
       String(p?.leading || ''),
       String(p?.trailing || ''),
-      String(p?.replacedLeading || '')
+      String(p?.replacedLeading || ''),
+      String(p?.replacedTrailing || '')
     );
     seen.content = r.next;
     seen.matched = r.matched;
@@ -908,5 +910,145 @@ console.log('\n【18】应用层:后端 error(不走 flushContent)撤稿
   check('草稿已被撤掉', !content.includes('半截草稿'), content.slice(-60));
 }
 
+console.log('\n【19】尾部提前上屏:正文定稿即发参考资料,不等 done 的 result');
+{
+  // 实测(probe-answer-tail-timing):done 要等 result(375~390KB),比正文定稿晚 3~10 秒;
+  // 而参考资料的 source 事件在 answer_start 之前就发完了。这条就是钉住「别白等」。
+  //
+  // ⚠️ 必须带**真实延迟**才测得出来:同步喂事件时,`done` 路径里 message 也排在 totalResponse 之前,
+  // 索引比较会假通过(第一版就是这么写的)。这里在 summary → result 之间插 600ms,
+  // 断言参考资料在这 600ms 走完**之前**就上屏了。
+  const DELAY_BEFORE_RESULT = 600;
+  const t0 = Date.now();
+  globalThis.fetch = async () => {
+    const encoder = new TextEncoder();
+    const events = [
+      ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }, 0],
+      ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }, 0],
+      ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }, 0],
+      ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }, 0]),
+      ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, status: 'completed' }, 0],
+      ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }, 0],
+      // ↓ 这段延迟就是实测里 result 让用户白等的时间
+      ['result', { response: FINAL_TEXT, recommendation: {}, errors: [] }, DELAY_BEFORE_RESULT],
+      ['done', { status: 'completed' }, 0],
+    ];
+    return new Response(
+      new ReadableStream({
+        async start(controller) {
+          for (const [event, data, delay] of events) {
+            if (delay) await new Promise((r) => setTimeout(r, delay));
+            controller.enqueue(
+              encoder.encode(
+                `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`
+              )
+            );
+          }
+          controller.close();
+        },
+      }),
+      { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
+    );
+  };
+
+  const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
+  let refsAt = null;
+  let doneAt = null;
+  let total = null;
+  let composeCount = 0;
+  let allText = '';
+  coordinator.addEventListener('message', (m) => {
+    allText += m;
+    if (refsAt === null && m.includes('<ref_links>')) refsAt = Date.now() - t0;
+  });
+  coordinator.addEventListener('answerStreamCompose', () => composeCount++);
+  coordinator.addEventListener('totalResponse', (p) => {
+    doneAt = Date.now() - t0;
+    total = p;
+  });
+  await coordinator.generateAnswer('测试问题');
+  await new Promise((r) => setTimeout(r, DELAY_BEFORE_RESULT + 200));
+
+  check('参考资料确实上屏了', refsAt !== null, `messages=${allText.length}`);
+  check(
+    `**参考资料在上面的 ${DELAY_BEFORE_RESULT}ms 延迟走完之前就上屏**(没有白等 result)`,
+    refsAt !== null && doneAt !== null && refsAt < doneAt - DELAY_BEFORE_RESULT / 2,
+    `参考@${refsAt}ms done@${doneAt}ms(延迟 ${DELAY_BEFORE_RESULT}ms)`
+  );
+  check('参考资料只出现一次(done 时不重发)', countOf(allText, '<ref_links>') === 1, countOf(allText, '<ref_links>'));
+  check('提前上屏不触发整块重排', composeCount === 0, composeCount);
+  check(
+    'totalResponse 顺序不变:卡片 → 正文 → 参考资料',
+    (() => {
+      const ans = String(total?.answer || '');
+      return ans.indexOf('POLICY_TABLE') < ans.indexOf(MARKER) && ans.indexOf(MARKER) < ans.indexOf('<ref_links>');
+    })(),
+    String(total?.answer || '').slice(0, 100)
+  );
+}
+
+console.log('\n【20】尾部提前上屏后,二维码(等 result)要插到参考资料**之前**');
+{
+  const seen = await runTurnWithApp([
+    ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
+    ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
+    ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, status: 'completed' }],
+    ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }],
+    // cipa 只有在 result 里才知道 —— 而二维码必须排在参考资料之前
+    ['result', { response: FINAL_TEXT, recommendation: {}, cipa: true, errors: [] }],
+    ['done', { status: 'completed' }],
+  ]);
+  const content = seen.content;
+  const atQr = content.indexOf('<image-scope>');
+  const atRefs = content.indexOf('<ref_links>');
+  check('发了整块重排(尾部顺序要调)', seen.compose.length === 1, seen.compose.length);
+  check('compose 带上了要抹掉的旧尾部', !!String(seen.compose[0]?.replacedTrailing || ''), String(seen.compose[0]?.replacedTrailing || '').slice(0, 40));
+  check('二维码在参考资料**之前**(版式不变)', atQr >= 0 && atRefs > atQr, `二维码@${atQr} 参考@${atRefs}`);
+  check('二维码只出现一次', countOf(content, '<image-scope>') === 1, countOf(content, '<image-scope>'));
+  check('参考资料只出现一次(旧的被抹掉)', countOf(content, '<ref_links>') === 1, countOf(content, '<ref_links>'));
+  check('正文只出现一次', countOf(content, MARKER) === 1, countOf(content, MARKER));
+  check('卡片只出现一次', countCards(content) === 1, countCards(content));
+}
+
+console.log('\n【21】没有 result.cipa 时不会重排(常见路径)');
+{
+  const seen = await runTurnWithApp([
+    ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
+    ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
+    ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, status: 'completed' }],
+    ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }],
+    ['result', { response: FINAL_TEXT, recommendation: {}, cipa: false, errors: [] }],
+    ['done', { status: 'completed' }],
+  ]);
+  check('没有二维码时零重排', seen.compose.length === 0, seen.compose.length);
+  check('无二维码', !seen.content.includes('<image-scope>'));
+  check('参考资料在正文之后', seen.content.indexOf('<ref_links>') > seen.content.indexOf(MARKER));
+}
+
+console.log('\n【22】非流式轮(旧后端)不受影响:尾部仍在 done 时一次性发出');
+{
+  const seen = await runTurn([
+    ['accepted', { message: '收到' }],
+    ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
+    ['answer', { text: FINAL_TEXT }],
+    ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }],
+    ['result', { response: FINAL_TEXT }],
+    ['done', { status: 'completed' }],
+  ]);
+  check('没有流式事件 → 不提前发', !seen.streamStart.length, seen.streamStart.length);
+  // 旧路径是「done 时一次把整段发出去」:整轮只有一条 message(不是逐片上屏)
+  check('整轮只有一条 message(一次性整段输出)', seen.messages.length === 1, seen.messages.length);
+  check('参考资料只出现一次', countOf(messageText(seen), '<ref_links>') === 1, countOf(messageText(seen), '<ref_links>'));
+  check('旧路径顺序不变:正文 → 卡片 → 参考资料', (() => {
+    const t = messageText(seen);
+    return t.indexOf(MARKER) < t.indexOf('POLICY_TABLE') && t.indexOf('POLICY_TABLE') < t.indexOf('<ref_links>');
+  })(), messageText(seen).slice(0, 120));
+}
+
 console.log(`\n===== 总计:通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 9 - 1
src/components/answer-stream.ts

@@ -290,7 +290,9 @@ export const composeAroundStreamBody = (
   leading: string,
   trailing: string,
   /** 需要先从内容里抹掉的、**之前已上屏**的「正文之上」内容(卡片集合变了时用;见下) */
-  replacedLeading = ''
+  replacedLeading = '',
+  /** 同上,但抹的是「正文之下」:尾部已提前上屏、现在需要重排时用(见 `flushTailEarly`) */
+  replacedTrailing = ''
 ): StreamComposeResult => {
   let before = String(content ?? '');
   const body = String(streamedBody ?? '');
@@ -300,6 +302,12 @@ export const composeAroundStreamBody = (
   if (stale && stale !== leading && before.includes(stale)) {
     before = before.replace(stale, '').replace(/\n{3,}/g, '\n\n');
   }
+  // 尾部提前上屏过,但现在要按新顺序重排(典型:二维码只能等 done,而它得排在参考资料**之前**)——
+  // 先把旧的尾部抹掉,新的 `trailing` 会把它按正确顺序重新拼进来
+  const staleTail = String(replacedTrailing ?? '');
+  if (staleTail && before.includes(staleTail)) {
+    before = before.replace(staleTail, '').replace(/\n{3,}/g, '\n\n');
+  }
 
   if (!body) {
     return { next: composeStreamedContent(before, leading, '', trailing), matched: true };

+ 120 - 33
src/components/api-chat-coordinator.ts

@@ -817,6 +817,8 @@ export type ApiChatEventMap = {
     trailing: string;
     /** 已在屏幕上、但已被 `leading` 取代的旧内容(卡片集合变了时非空) */
     replacedLeading?: string;
+    /** 已在屏幕上、但已被 `trailing` 取代的旧尾部(尾部提前上屏过、现在要重排时非空) */
+    replacedTrailing?: string;
   };
   /** 已翻译成原界面内容标记的文本 */
   message: string;
@@ -870,6 +872,10 @@ export class ApiChatCoordinator {
    * 记下来是为了 ①`flushContent` 不重复输出 ②`totalResponse` 里卡片仍排在正文之前。
    */
   private preStreamLeading = '';
+  /** 尾部已提前上屏的块(key 见 `buildTrailingParts`);done 时不能重发 */
+  private tailSent = new Set<string>();
+  /** 提前上屏的尾部原文,供 done 时重排把它从基线里抹掉 */
+  private earlyTailText = '';
   /** 最近一次补问的原始数据,供提交时反查候选用 */
   lastInterruptPayload: ChatInterruptPayload | null = null;
   /** 本轮是否输出过 scope 进度标记,决定 flush 时是否需要让出一帧 */
@@ -1070,6 +1076,8 @@ export class ApiChatCoordinator {
     this.answerStreamNormalizer = createIncrementalMarkdownNormalizer();
     this.contentFlushed = false;
     this.preStreamLeading = '';
+    this.tailSent = new Set();
+    this.earlyTailText = '';
     this.scopeEmitted = false;
     this.doneReceived = false;
   }
@@ -1151,34 +1159,13 @@ export class ApiChatCoordinator {
     if (streamBody === null && this.pendingSummary?.text) {
       trailing.push(normalizeChatMarkdown(this.pendingSummary.text));
     }
-    // notices 与正文分开:流式只替换正文本体,notices 仍来自 summary 事件
-    if (this.pendingSummary) {
-      const notices = (this.pendingSummary.notices ?? []).filter(Boolean);
-      if (notices.length) {
-        trailing.push(notices.map((notice) => `- ${notice}`).join('\n'));
-      }
-    }
-
-    if (this.pendingInterrupt) {
-      // 传入已展示的正文,避免卡片里重复同一段话
-      trailing.push(buildQuestionCardsContent(this.pendingInterrupt, streamBody ?? this.answerText));
-    }
 
-    // 企业微信二维码:后端标记为 CIPA / 出海类问题时追加。
-    // 位置在**参考资料之前**(用户 2026-09-20 指定)——正文/卡片之后、参考资料之前。
-    //
-    // ⚠️ 本轮若是**补问轮**(interrupt:等用户补充公司信息 / 确认候选)则**不展示**:
-    // 那一轮界面上是补问卡片,还没给出正文回答,此时贴二维码既突兀也没意义
-    // (用户 2026-09-20 从截图里指出)。
-    if (this.pendingCipa && !this.pendingInterrupt) {
-      trailing.push(buildCipaQrcodeContent());
-    }
-
-    // 参考资料放在最后:它是引用来源,应该排在正文、卡片与二维码之后
-    const refLinks = buildRefLinksContent(this.pendingSources);
-    if (refLinks) {
-      trailing.push(refLinks);
-    }
+    // 尾部按块组装(顺序:notices → 补问卡 → 二维码 → 参考资料)
+    const tailParts = this.buildTrailingParts(streamBody);
+    const pendingTail = tailParts.filter((part) => !this.tailSent.has(part.key));
+    // 尾部整体(含已提前上屏的部分)—— `totalResponse` 与重排都用它,保证落库顺序与最终版式一致
+    const allTailText = tailParts.map((part) => part.text).join('\n\n');
+    trailing.push(allTailText);
 
     const leadingText = leading.filter((part) => String(part || '').trim().length > 0).join('\n\n');
     const trailingText = trailing.filter((part) => String(part || '').trim().length > 0).join('\n\n');
@@ -1188,25 +1175,37 @@ export class ApiChatCoordinator {
       const aboveText = [this.preStreamLeading, leadingText].filter(Boolean).join('\n\n');
       this.totalResponse += composeStreamedContent('', aboveText, streamBody ?? '', trailingText);
 
-      if (leadingText) {
-        // 还有内容必须插到正文**之上**(卡片没能提前上屏 / 开流后才到的 item / 要保留的 answer 概述)
-        // → 只能让应用层按三块整块重排一次(内容不是纯追加,渲染层会重置重解析)
+      // 已提前上屏的块里,若排在某个还没发的块**之后**,就必须重排(否则顺序颠倒)。
+      // 实际只发生在一种情况:参考资料已提前发,而二维码要等 result 才到 —— 它得排在参考资料之前。
+      const needsReorder = pendingTail.some((part) =>
+        [...this.tailSent].some(
+          (sent) => ApiChatCoordinator.TAIL_ORDER.indexOf(part.key) < ApiChatCoordinator.TAIL_ORDER.indexOf(sent)
+        )
+      );
+
+      if (leadingText || needsReorder) {
+        // 需要整块重排一次(内容不是纯追加,渲染层会重置重解析):
+        // - `leadingText`:卡片没能提前上屏 / 开流后才到的 item / 要保留的 answer 概述
+        // - `needsReorder`:尾部顺序必须调(见上)
         this._eventbus.emit('answerStreamCompose', {
           streamId: this.answerStream.streamId || '',
           leading: leadingText,
-          trailing: trailingText,
+          trailing: allTailText,
           // 卡片集合变了:旧卡片块已经在屏幕上,应用层重排时要先把它抹掉(否则新旧两份并存)
           replacedLeading: policyTable && !cardsAlreadyShown ? this.preStreamLeading : '',
+          // 尾部已提前上屏过:同样要先抹掉,否则新旧两份尾部并存
+          replacedTrailing: needsReorder ? this.earlyTailText : '',
         });
-      } else if (trailingText) {
+      } else if (pendingTail.length) {
         // 卡片已在正文之前上屏:尾部**纯追加**即可 —— 应用侧不重置、不闪
         // (让出两帧与旧路径同:确保先发出的 scope 已被解析,再追加尾部内容)
         if (this.scopeEmitted) {
           await this.nextFrame();
           await this.nextFrame();
         }
-        this._eventbus.emit('message', trailingText);
+        this._eventbus.emit('message', pendingTail.map((part) => part.text).join('\n\n'));
       }
+      tailParts.forEach((part) => this.tailSent.add(part.key));
       return;
     }
 
@@ -1252,6 +1251,90 @@ export class ApiChatCoordinator {
     this._eventbus.emit('message', cards);
   }
 
+  /**
+   * 组装「正文之下」的尾部各块(**按最终展示顺序**)。
+   *
+   * 拆成带 key 的几块,是为了能**按块提前发**:数据先到的先上屏,不必等 done
+   * (见 `flushTailEarly` 的说明)。
+   */
+  private buildTrailingParts(streamBody: string | null): { key: string; text: string }[] {
+    const parts: { key: string; text: string }[] = [];
+
+    const notices = (this.pendingSummary?.notices ?? []).filter(Boolean);
+    if (notices.length) {
+      parts.push({ key: 'notices', text: notices.map((notice) => `- ${notice}`).join('\n') });
+    }
+
+    if (this.pendingInterrupt) {
+      // 传入已展示的正文,避免卡片里重复同一段话
+      parts.push({
+        key: 'interrupt',
+        text: buildQuestionCardsContent(this.pendingInterrupt, streamBody ?? this.answerText),
+      });
+    }
+
+    // 企业微信二维码:后端标记为 CIPA / 出海类问题时追加。
+    // 位置在**参考资料之前**(用户 2026-09-20 指定)——正文/卡片之后、参考资料之前。
+    // ⚠️ 本轮若是**补问轮**(interrupt)则不展示:那时界面上是补问卡片,还没给出正文回答。
+    if (this.pendingCipa && !this.pendingInterrupt) {
+      parts.push({ key: 'cipa', text: buildCipaQrcodeContent() });
+    }
+
+    // 参考资料放在最后:它是引用来源,应该排在正文、卡片与二维码之后
+    const refLinks = buildRefLinksContent(this.pendingSources);
+    if (refLinks) {
+      parts.push({ key: 'refs', text: refLinks });
+    }
+
+    return parts;
+  }
+
+  /** 尾部各块的最终顺序(重排判断用) */
+  private static readonly TAIL_ORDER = ['notices', 'interrupt', 'cipa', 'refs'];
+
+  /**
+   * **正文定稿就把「数据已就绪」的尾部立即上屏**,不等 done。
+   *
+   * 为什么要有这一步(2026-09-21 实测,见 `harness/tools/probe-answer-tail-timing.mjs`):
+   * `done` 要等 `result`,而 result 是 **375~390KB** 的完整快照,实测比正文定稿晚 **3~10 秒**;
+   * 而参考资料的来源 `source` 事件在正文**开流前**就发完了 —— 用户看到的「等参考资料」
+   * 等的是事件、不是数据(两次实测里「数据到齐」到「界面显示」差了 3 秒 / 14.5 秒)。
+   *
+   * 只发**数据已就绪**的部分:`notices`(来自 summary)与参考资料(来自 source)。
+   * 二维码来自 `result.cipa`,只能等 done;那时若需要,会把它**插到参考资料之前**
+   * (版式不变,见 `flushContent` 的重排分支)。
+   *
+   * 触发点在 `summary`(契约保证它在 answer_end 之后必到,且实测 +0.00s)——
+   * 等它到齐再发,是为了让 `notices` 也能跟上,否则 notices 会在参考资料之后才到、顺序就颠倒了。
+   *
+   * 两种「不抢跑」的情况(都会在 done 时走整块重排,提前发反而要抹回来):
+   * ① 卡片没能提前上屏(`preStreamLeading` 为空)② `answer` 概述需要保留在正文之上。
+   */
+  private flushTailEarly(): void {
+    if (this.contentFlushed || !this.answerStream.completed || !this.pendingSummary) {
+      return;
+    }
+    if (!this.preStreamLeading) {
+      return;
+    }
+    if (this.answerText && shouldKeepAnswerOverview(this.answerText, this.answerStream.text)) {
+      return;
+    }
+
+    const pending = this.buildTrailingParts(this.answerStream.text).filter(
+      (part) => part.key !== 'cipa' && !this.tailSent.has(part.key)
+    );
+    if (!pending.length) {
+      return;
+    }
+    for (const part of pending) {
+      this.tailSent.add(part.key);
+    }
+    const text = pending.map((part) => part.text).join('\n\n');
+    this.earlyTailText = this.earlyTailText ? `${this.earlyTailText}\n\n${text}` : text;
+    this._eventbus.emit('message', text);
+  }
+
   /**
    * 撤去本轮尚未定稿的流式正文(后端 answer_abort / 出错 / 断流)。
    *
@@ -1348,6 +1431,8 @@ export class ApiChatCoordinator {
             text: finalText,
             status: effect.status,
           });
+          // 正文定稿 → 尾部能发就发(不等 done;summary 未到时会在它的 case 里再试一次)
+          this.flushTailEarly();
         } else if (effect.kind === 'discard') {
           this.answerStreamNormalizer = createIncrementalMarkdownNormalizer();
           this._eventbus.emit('answerStreamAbort', {
@@ -1377,6 +1462,8 @@ export class ApiChatCoordinator {
 
       case 'summary':
         this.pendingSummary = data as ChatSummaryPayload;
+        // summary 到齐(notices 也在里面)→ 尾部可以按正确顺序发出去,不必等 done 的 result
+        this.flushTailEarly();
         break;
 
       // 补问不代表本轮结束,后面仍有 result / done

+ 2 - 1
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -408,7 +408,8 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
         body,
         String(payload?.leading || ''),
         String(payload?.trailing || ''),
-        String(payload?.replacedLeading || '')
+        String(payload?.replacedLeading || ''),
+        String(payload?.replacedTrailing || '')
       );
       if (!matched) {
         console.warn('[answer-stream] 上屏内容末尾不是流式正文,重排走兜底(不重复、不丢字)');