|
|
@@ -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);
|