|
|
@@ -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】协调器:流式轮之后跑旧轮,状态不残留');
|