Kaynağa Gözat

fix(chat): 政策卡片改排到正文之上(用户指出,我漏掉的版式回归)

用户反馈:卡片应该在回答顶部最先渲染,现在排在正文之后,不合适。

根因:改造前的版式其实是「短开场(134 字)→ 卡片 → 长综合说明(2199 字)」;
F073 把整篇综合说明当正文流式铺满后,卡片照旧排在正文之后,用户要先滚过整篇长文
才看到卡片。这是实现流式时漏掉的版式回归(当时只盯着"正文别出现两份")。

改法(协调器 + 应用层,渲染层零改动):
- flushContent 把输出拆成 leading(政策卡片)/ trailing(notices、补问卡片、二维码、
  参考资料)两块
- 流式轮通过新事件 answerStreamCompose {streamId, leading, trailing} 让应用层按
  「基线 + leading + 正文 + trailing」重写一次内容 → 卡片落在正文之上
- 组装抽成纯函数 composeStreamedContent(顺带剥掉基线里的 <scope>,避免卡片闪现重播)
- 旧路径完全不变(正文 → 卡片 → 参考资料),有断言守着

验证:verify-answer-stream.mjs 78 → 87/87(新增三块组装顺序、剥 scope、空块跳过、
流式轮 totalResponse 里卡片在正文之前、compose 事件的 leading/trailing 内容、
旧路径顺序回归);其余脚本绿;build 与 validate-harness 通过。

文档:api-chat.md 的 F073 实测补充加「版式」、exec-plan 加「修正」小节、feature_list 更新。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 9 saat önce
ebeveyn
işleme
238da18cfb

+ 11 - 0
harness/docs/exec-plans/active/answer-streaming.md

@@ -45,6 +45,17 @@ mock 与旧后端轮次从未置位。用户停止走 `stopAiMessage(state=Stop)
 4. `BusinessRecord.vue`:`shouldDriveTypewriter` 加 `&& !record.contentStreaming`(唯一改动点)
 5. harness:`_entry-coordinator.ts` 导出 answer-stream;新增 `verify-answer-stream.mjs`、`probe-answer-stream.mjs`
 
+### 修正:卡片放正文**之上**(用户 2026-09-20 指出)
+
+流式把整篇综合说明当正文先铺满,卡片照旧排在正文之后 → 用户要先滚过全文才看到卡片,
+与改造前「短开场 → 卡片 → 综合说明」的观感不一致(这次是我漏掉的版式回归)。
+
+改法:`flushContent` 把输出拆成 **leading(卡片)/ trailing(notices、补问、二维码、参考资料)** 两块;
+流式轮通过新事件 **`answerStreamCompose {streamId, leading, trailing}`** 交给应用层按
+`基线 + leading + 正文 + trailing` **重写一次内容**(卡片因此在正文之上);
+旧路径**完全不变**(正文 → 卡片 → 参考资料,有断言守着)。
+组装逻辑抽成纯函数 `composeStreamedContent`(顺带剥掉基线里的 `<scope>`,避免卡片闪现)。
+
 ## 验证
 
 - **纯函数**:规整器前缀不变式(固定用例 + 种子伪随机 ≥500 轮 + 逐字/整段等价 + `finish()`);

+ 2 - 0
harness/docs/reference/api-chat.md

@@ -181,6 +181,8 @@ data: {"thread_id":"window-20260914-001","request_id":"a78b17a3b38241fa83c8ee91f
 **前端处理**:流式目标实测都是 `summary`;正文以 `answer_end` 为准,
 `summary.text` 不再重复渲染(跳过),`answer` 概述在「与正文公共前缀 ≥90%」时也不显示
 (否则开头会重复一遍),只有确认是另一段内容时才保留。
+**版式**:正文是整篇综合说明,政策卡片改排到**正文之上**(否则用户要先滚过全文才看到卡片,
+与改造前「短开场 → 卡片 → 综合说明」不一致);参考资料/二维码仍在正文之下。
 `notices` 仍照常展示。另:该轮 `summary.notices` 可能是
 「本轮检索或逐条分析未完整完成」这类提示,属正常返回。
 

+ 8 - 0
harness/docs/reference/token-streaming.md

@@ -4,6 +4,14 @@ POST `/api/chat` 保留阶段、心跳、公司确认、来源、卡片及最终
 
 ## 事件契约
 
+### 卡片先于正文(F079,2026-09-20)
+
+有入选政策时,先完成筛选、排序、扶持短文案及条件整理,再发送完整 `source`、`item`,然后开始综合讲解的 `answer_start` / `answer_delta`。普通政策总结和政策+其他知识的联合讲解都遵循此顺序。卡片不逐字段流式生成;同题QA排除的政策不会提前露出。
+
+前端收到 `item` 即可展示卡片(默认前6张、其余展开),卡片下方接续流式正文;不要等 `summary` 或 `result` 才显示卡片。节点结束不会重复发送已发来源和卡片,`result` 仍含完整快照,用于校准而非重复追加。卡片及来源去重仅限当前请求,不跨会话。
+
+讲解降级或草稿撤回时,已校验卡片保留,正文按原替换/撤回协议处理;请求发生终止错误或断线时,仍须标记本轮未完整完成。无卡片知识问答及闲聊沿用原流程。检索、逐条分析及卡片短文案仍需等待,本次不改变模型轮次或筛选依据。后端需重启,外部前端/代理尚未联调。
+
 沿用外层 `{thread_id, request_id, data}`。以下字段位于 `data`,以一次请求的 `request_id` 和 `stream_id` 定位正文块。
 
 | 事件 | 字段及含义 | 展示动作 |

Dosya farkı çok büyük olduğundan ihmal edildi
+ 4 - 5
harness/feature_list.json


+ 22 - 0
harness/progress.md

@@ -2999,3 +2999,25 @@ exec-plans/active/answer-streaming.md}`、`harness/feature_list.json`、根 `REA
 ### 下一步最佳动作
 
 浏览器验收(上面 9 点,重点是 mock 按钮回归与 500+ 片的流畅度)→ 通过后把 `answer-streaming` 转 passing
+
+### 续:卡片改排到正文**之上**(用户指出,我漏掉的版式回归)
+
+用户反馈:「卡片应该在回答顶部最先渲染,现在是在正文后渲染,现在的不合适」。
+
+**根因**:改造前的版式其实是「**短开场(134 字)→ 卡片 → 长综合说明(2199 字)**」;
+F073 把整篇综合说明当正文流式铺满后,卡片照旧排在正文之后 → 用户要先滚过整篇长文才看到卡片。
+这是我实现流式时漏掉的一处**版式回归**(当时只盯着"正文不要出现两份")。
+
+**改法**(协调器 + 应用层,渲染层零改动):
+- `flushContent` 把输出拆成 **leading(政策卡片)/ trailing(notices、补问卡片、二维码、参考资料)** 两块
+- 流式轮通过新事件 **`answerStreamCompose {streamId, leading, trailing}`** 让应用层
+  按 `基线 + leading + 正文 + trailing` **重写一次内容** → 卡片落在正文之上
+- 组装抽成纯函数 **`composeStreamedContent`**(顺带剥掉基线里的 `<scope>`,避免卡片闪现重播)
+- **旧路径完全不变**(正文 → 卡片 → 参考资料),并有断言守着
+
+**验证**:`verify-answer-stream.mjs` 78 → **87/87 通过**(新增:三块组装顺序、剥 scope、
+空块跳过、流式轮 **totalResponse 里卡片在正文之前**、compose 事件的 leading/trailing 内容、
+**旧路径顺序回归**);`npm run build` 通过。
+
+文档同步:`api-chat.md` 的 F073 实测补充加「版式」一句、`exec-plans/active/answer-streaming.md`
+加「修正」小节、`feature_list.json` 的 `answer-streaming` 更新。

+ 46 - 4
harness/tools/verify-answer-stream.mjs

@@ -21,6 +21,7 @@ import {
   applyAnswerStreamEnd,
   applyAnswerStreamEvent,
   createAnswerStreamState,
+  composeStreamedContent,
   createIncrementalMarkdownNormalizer,
   normalizeChatMarkdown,
   parseAnswerStreamEvent,
@@ -319,6 +320,20 @@ console.log('\n【3b】应用层的内容对齐(end 替换 / abort 撤稿)')
   check('真正的另一段内容(不是前缀)→ 必须保留', shouldKeepAnswerOverview('这是另一段完全不同的开场说明。', body) === true);
   check('概述为空 → 不显示', shouldKeepAnswerOverview('', body) === false && shouldKeepAnswerOverview(null, body) === false);
   check('流式正文为空 → 保留概述(不能丢字)', shouldKeepAnswerOverview('开场', '') === true);
+
+  // 流式轮的最终组装:基线 + 卡片 + 正文 + 尾部(卡片在正文**之上**)
+  const SCOPE = '<scope title="思考中">\n</scope>\n';
+  check(
+    '组装顺序:基线 → leading(卡片)→ 正文 → trailing(参考资料)',
+    composeStreamedContent(SCOPE + '前言', '【卡片】', '正文正文', '【参考资料】') === '前言\n\n【卡片】\n\n正文正文\n\n【参考资料】',
+    composeStreamedContent(SCOPE + '前言', '【卡片】', '正文正文', '【参考资料】')
+  );
+  check(
+    '组装时剥掉基线里的 <scope>(否则卡片会闪现重播)',
+    !composeStreamedContent(SCOPE + '前言', '', '正文', '').includes('<scope')
+  );
+  check('没有卡片时只剩正文(不留空行)', composeStreamedContent('', '', '正文', '') === '正文');
+  check('空块被跳过、不产生多余空行', composeStreamedContent('', '', '正文', '') === '正文' && composeStreamedContent('', '', '', '尾') === '尾');
 }
 
 console.log(`\n===== 纯逻辑部分:通过 ${pass} 项,失败 ${fail} 项 =====`);
@@ -351,7 +366,7 @@ const stubFetchWith = (sseText) => {
 const runTurn = async (events) => {
   stubFetchWith(sse(events));
   const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
-  const seen = { messages: [], total: null, streamStart: [], streamEnd: [], streamAbort: [], errors: [], order: [] };
+  const seen = { messages: [], total: null, streamStart: [], streamEnd: [], streamAbort: [], compose: [], errors: [], order: [] };
   coordinator.addEventListener('message', (m) => {
     seen.messages.push(m);
     seen.order.push(`message:${m.slice(0, 24)}`);
@@ -371,6 +386,10 @@ const runTurn = async (events) => {
     seen.streamAbort.push(p);
     seen.order.push('streamAbort');
   });
+  coordinator.addEventListener('answerStreamCompose', (p) => {
+    seen.compose.push(p);
+    seen.order.push('streamCompose');
+  });
   coordinator.addEventListener('error', (p) => {
     seen.errors.push(p);
     seen.order.push('error');
@@ -424,9 +443,23 @@ console.log('\n【4】协调器假流:happy path(带卡片轮形态,含流
     text.includes(normalizeChatMarkdown(FINAL_TEXT).split('\n')[0]),
     normalizeChatMarkdown(FINAL_TEXT).slice(0, 30)
   );
-  check('totalResponse.answer 以最终全文开头', String(seen.total?.answer || '').startsWith('一、第一段说明。'), String(seen.total?.answer || '').slice(0, 40));
+  check('totalResponse.answer 含最终全文', String(seen.total?.answer || '').includes('一、第一段说明。'), String(seen.total?.answer || '').slice(0, 40));
   check('totalResponse 里含政策卡片与参考资料', String(seen.total?.answer || '').includes('POLICY_TABLE') && String(seen.total?.answer || '').includes('<ref_links>'));
-  check('卡片/参考资料是在流结束之后补发的', text.indexOf('POLICY_TABLE') > text.indexOf(MARKER));
+  // 卡片顺序:流式轮**卡片在正文之上**(用户 2026-09-20 要求);正文已上屏,故重排通过
+  // answerStreamCompose 事件交给应用层(message 通道里只有正文)
+  const compose = seen.compose[0];
+  check('发出 answerStreamCompose(流式轮的重排信号)', !!compose, seen.compose);
+  check('compose.leading 是政策卡片', String(compose?.leading || '').includes('POLICY_TABLE'), String(compose?.leading || '').slice(0, 60));
+  check('compose.trailing 是参考资料', String(compose?.trailing || '').includes('<ref_links>'), String(compose?.trailing || '').slice(0, 60));
+  check(
+    '**totalResponse 里卡片排在正文之前**',
+    (() => {
+      const ans = String(seen.total?.answer || '');
+      return ans.indexOf('POLICY_TABLE') > -1 && ans.indexOf('POLICY_TABLE') < ans.indexOf(MARKER);
+    })(),
+    String(seen.total?.answer || '').slice(0, 80)
+  );
+  check('正文本身没有在 message 通道里被卡片打断(卡片走 compose)', !text.includes('POLICY_TABLE'));
 }
 
 console.log('\n【5】协调器假流:纯正文轮(无卡片、无 answer 事件)不丢正文');
@@ -492,15 +525,24 @@ console.log('\n【9】协调器假流:旧后端(无任何流式事件)行
     ['progress', { stage: 'retrieve', message: '正在检索……' }],
     ['answer', { text: FINAL_TEXT }],
     ['source', { id: 's1', title: '测试政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['item', { source_id: 's1', title: '测试政策', card: { name: { text: '测试政策' } } }],
     ['summary', { text: FINAL_TEXT }],
     ['result', { response: FINAL_TEXT }],
     ['done', { status: 'completed' }],
   ]);
   const text = messageText(seen);
   check('没有流事件、没有 abort', seen.streamStart.length === 0 && seen.streamAbort.length === 0);
+  check('不发 answerStreamCompose(旧路径不重排)', seen.compose.length === 0, seen.compose);
   check('scope 卡片正常', countOf(text, '<scope') === 1);
   check('正文按原路径整段输出(规整后)', text.includes(normalizeChatMarkdown(FINAL_TEXT)));
-  check('顺序仍是 正文 → 卡片 → 参考资料', text.indexOf('正文') === -1 || text.indexOf('第一段说明') < text.indexOf('POLICY_TABLE') || text.indexOf('POLICY_TABLE') === -1);
+  const bodyAt = text.indexOf('第一段说明');
+  const cardAt = text.indexOf('POLICY_TABLE');
+  const refAt = text.indexOf('<ref_links>');
+  check(
+    '**旧路径顺序不变**:正文 → 卡片 → 参考资料',
+    bodyAt > -1 && cardAt > -1 && refAt > -1 && bodyAt < cardAt && cardAt < refAt,
+    { bodyAt, cardAt, refAt }
+  );
 }
 
 console.log('\n【10】协调器:流式轮之后跑旧轮,状态不残留');

+ 23 - 0
src/components/answer-stream.ts

@@ -220,6 +220,29 @@ export const shouldKeepAnswerOverview = (overview: string, streamBody: string):
   return common < collapsed.length * 0.9;
 };
 
+/**
+ * 流式轮结束时的**最终组装**:把「正文之上」「正文」「正文之下」三块拼成完整内容。
+ *
+ * 为什么要分「之上/之下」:正文(`summary`)是整篇长文,若把政策卡片照旧排在正文之后,
+ * 用户要先滚过整篇文章才看到卡片 —— 与改造前的观感(短开场 → 卡片 → 综合说明)不一致。
+ * 所以流式轮把卡片放到**正文之上**(用户 2026-09-20 指出),参考资料/二维码等仍在正文之下。
+ *
+ * 组装时把基线上的 `<scope>` 进度标记剥掉:这一步是整块重写,留着会让「思考中」卡片
+ * 被重新解析出来、闪现并重播一次退场动画。
+ */
+export const composeStreamedContent = (
+  baseText: string,
+  leading: string,
+  body: string,
+  trailing: string
+): string => {
+  const base = String(baseText || '').replace(/<scope\b[^>]*>[\s\S]*?<\/scope>\s*/gi, '');
+  return [base, leading, body, trailing]
+    .map((part) => String(part || '').trimEnd())
+    .filter((part) => part.trim().length > 0)
+    .join('\n\n');
+};
+
 /**
  * `answer_abort` / 断流:撤掉未定稿的草稿,回到流开始前的内容。
  *

+ 43 - 19
src/components/api-chat-coordinator.ts

@@ -35,6 +35,7 @@ import cipaQrcodeUrl from '@/assets/qiyeweixin.png';
 import {
   applyAnswerStreamEvent,
   createAnswerStreamState,
+  composeStreamedContent,
   createIncrementalMarkdownNormalizer,
   normalizeChatMarkdown,
   parseAnswerStreamEvent,
@@ -801,6 +802,11 @@ export type ApiChatEventMap = {
   };
   /** 撤去本轮临时正文(后端 answer_abort / 断流 / 出错) */
   answerStreamAbort: { streamId: string; reason: AnswerAbortReason };
+  /**
+   * 流式轮的**最终重排**:正文已上屏,但「卡片」要排在正文**之上**、
+   * 「参考资料/二维码」在正文之下 —— 应用层按 基线 + leading + 正文 + trailing 重写一次内容。
+   */
+  answerStreamCompose: { streamId: string; leading: string; trailing: string };
   /** 已翻译成原界面内容标记的文本 */
   message: string;
   error: ChatErrorPayload;
@@ -1094,38 +1100,45 @@ export class ApiChatCoordinator {
     this.contentFlushed = true;
 
     // 流式轮:正文已实时上屏,这里只补卡片等;未完成/未启用流式:正文走原来的整段路径
-    const streamBody = this.answerStream.completed ? this.answerStream.text || '' : null;
+    const streaming = this.answerStream.completed;
+    const streamBody = streaming ? this.answerStream.text || '' : null;
+
+    // 「正文之上」与「正文之下」分开收集:
+    // - 旧路径(无流式):正文与卡片都算「之上」,顺序 = 正文 → 卡片 → …(保持原样)
+    // - 流式轮:正文已上屏,**卡片改放正文之上**(用户 2026-09-20 指出:正文是整篇长文,
+    //   卡片排在其后要先滚过全文才看得到,与改造前「短开场 → 卡片 → 综合说明」的观感不一致)
+    const leading: string[] = [];
+    const trailing: string[] = [];
 
-    const parts: string[] = [];
     if (streamBody === null) {
       if (this.answerText) {
-        parts.push(normalizeChatMarkdown(this.answerText));
+        leading.push(normalizeChatMarkdown(this.answerText));
       }
     } else if (this.answerText && shouldKeepAnswerOverview(this.answerText, streamBody)) {
-      // 概况不是流式正文的前缀(= 是另一段内容,不是压缩版)→ 保留,不能丢
+      // 概况不是流式正文的前缀(= 是另一段内容,不是压缩版)→ 保留在正文之上,不能丢
       console.warn('[answer-stream] answer 概述不属于流式正文,已保留展示');
-      parts.push(normalizeChatMarkdown(this.answerText));
+      leading.push(normalizeChatMarkdown(this.answerText));
     }
 
     const policyTable = buildPolicyTableContent(this.pendingItems, this.pendingSources);
     if (policyTable) {
-      parts.push(policyTable);
+      (streaming ? leading : trailing).push(policyTable);
     }
 
     if (streamBody === null && this.pendingSummary?.text) {
-      parts.push(normalizeChatMarkdown(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) {
-        parts.push(notices.map((notice) => `- ${notice}`).join('\n'));
+        trailing.push(notices.map((notice) => `- ${notice}`).join('\n'));
       }
     }
 
     if (this.pendingInterrupt) {
       // 传入已展示的正文,避免卡片里重复同一段话
-      parts.push(buildQuestionCardsContent(this.pendingInterrupt, streamBody ?? this.answerText));
+      trailing.push(buildQuestionCardsContent(this.pendingInterrupt, streamBody ?? this.answerText));
     }
 
     // 企业微信二维码:后端标记为 CIPA / 出海类问题时追加。
@@ -1135,26 +1148,37 @@ export class ApiChatCoordinator {
     // 那一轮界面上是补问卡片,还没给出正文回答,此时贴二维码既突兀也没意义
     // (用户 2026-09-20 从截图里指出)。
     if (this.pendingCipa && !this.pendingInterrupt) {
-      parts.push(buildCipaQrcodeContent());
+      trailing.push(buildCipaQrcodeContent());
     }
 
     // 参考资料放在最后:它是引用来源,应该排在正文、卡片与二维码之后
     const refLinks = buildRefLinksContent(this.pendingSources);
     if (refLinks) {
-      parts.push(refLinks);
+      trailing.push(refLinks);
     }
 
-    const content = parts.filter((part) => String(part || '').trim().length > 0).join('\n\n');
+    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');
+
+    if (streaming) {
+      // 流式轮:正文已经上屏,但卡片要排在它**之前**,只能让应用层按三块重排一次
+      // (卡片在 answer_end 之前就齐了,这一步发生在 done,观感上是"卡片插到正文上方")
+      this.totalResponse += composeStreamedContent('', leadingText, streamBody ?? '', trailingText);
+      if (leadingText || trailingText) {
+        this._eventbus.emit('answerStreamCompose', {
+          streamId: this.answerStream.streamId || '',
+          leading: leadingText,
+          trailing: trailingText,
+        });
+      }
+      return;
+    }
 
-    // totalResponse 必须是**最终全文**(DMS 落库、历史回读都靠它):
-    // 流式轮的正文在上面被跳过了,这里单独拼回最前面
-    this.totalResponse +=
-      streamBody === null
-        ? content
-        : [streamBody, content].filter((part) => String(part || '').trim().length > 0).join('\n\n');
+    const content = [leadingText, trailingText].filter((part) => part.trim().length > 0).join('\n\n');
+    this.totalResponse += content;
 
     if (!content) {
-      return; // 没有卡片等要补发(纯正文的流式轮就是这种)
+      return; // 没有卡片等要补发
     }
 
     // 原 StrXMLFilter 在遇到 <!-- POLICY_TABLE 时,会把同一批 buffer 里位于它

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

@@ -37,7 +37,7 @@ import { fetchRemoteSessionRecords, deleteSessionInfo, type RemoteSession } from
 import { upsertDmsRecord } from '@/network/api/dms/chat-sessions-dms';
 import { syncCompanyInfoFromChat } from '@/network/api/dms/company-info-sync';
 import { hasVisibleMessageContent } from '@/utils/interrupted-message';
-import { applyAnswerStreamAbort, applyAnswerStreamEnd } from '../answer-stream';
+import { applyAnswerStreamAbort, applyAnswerStreamEnd, composeStreamedContent } from '../answer-stream';
 // [已停用] upsertDmsSession 随 1887 会话写入一并停用
 
 interface UseBusinessAssistantChatOptions {
@@ -365,6 +365,20 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       stream.active = false; // contentStreaming 不清:之后的卡片等仍即时渲染,由 finishTask 统一收尾
     });
 
+    // 流式轮收尾时把内容重排一次:**卡片放正文之上**(用户要求),参考资料等仍在正文之下
+    coordinator?.addEventListener('answerStreamCompose', (payload: any) => {
+      if (tasks.value.get(sessionId) !== task) return;
+      const ai = streamTargetMessage();
+      if (!ai) return;
+      task.chunkBuffer.flush(); // 残余增量先落,正文才是完整的
+      const base = stream.baseText;
+      const body = ai.content.slice(base.length);
+      const next = composeStreamedContent(base, String(payload?.leading || ''), body, String(payload?.trailing || ''));
+      if (next === ai.content) return;
+      ai.content = '';
+      ai.content = next;
+    });
+
     coordinator?.addEventListener('answerStreamAbort', (payload: any) => {
       if (tasks.value.get(sessionId) !== task) return;
       const ai = streamTargetMessage();

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor