|
|
@@ -309,7 +309,19 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
* `baseIndex/baseText` 是「流开始那一刻消息已有内容」的快照:正文替换/撤回都以它为界,
|
|
|
* 这样 `<scope>` 进度标记等此前已上屏的内容不会被误伤。
|
|
|
*/
|
|
|
- const stream = { active: false, streamId: '', baseIndex: -1, baseText: '', lastHistorySaveAt: 0 };
|
|
|
+ const stream = { active: false, streamId: '', baseIndex: -1, baseText: '', body: '', lastHistorySaveAt: 0 };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 流式相关的诊断日志(仅非生产环境)。
|
|
|
+ *
|
|
|
+ * 为什么留着:内容被整块替换时用户会看到「先出现的内容突然消失」,
|
|
|
+ * 而这类问题只看最终结果看不出来 —— 得知道**是哪条路径改写了内容**、改了多少。
|
|
|
+ * 出问题时报这几行日志即可定位(见 F073 的记录)。
|
|
|
+ */
|
|
|
+ const debugStream = (tag: string, detail: Record<string, unknown>) => {
|
|
|
+ if (import.meta.env.MODE === 'production') return;
|
|
|
+ console.warn(`[answer-stream] ${tag}`, detail);
|
|
|
+ };
|
|
|
|
|
|
/** 取本轮的 AI 消息(双守卫:任务还在表里 + 就是这条消息) */
|
|
|
const streamTargetMessage = () => {
|
|
|
@@ -343,8 +355,12 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
task.chunkBuffer.flush(); // 先把已发出的 scope 标记落盘,基线才准
|
|
|
stream.baseIndex = ai.content.length;
|
|
|
stream.baseText = ai.content;
|
|
|
+ // 自己累计「流出来的正文」:后面改写内容都以它为准做**后缀匹配**,
|
|
|
+ // 不再用「按基线长度切片」——基线可能已经被别的路径改过(那样切片会错位)
|
|
|
+ stream.body = '';
|
|
|
stream.streamId = String(payload?.streamId || '');
|
|
|
stream.active = true;
|
|
|
+ debugStream('start', { base: stream.baseText.length });
|
|
|
// 关掉打字机:正文已经是「真实增量」,不该再被逐字重放(见 BusinessRecord 的守卫)
|
|
|
ai.contentStreaming = true;
|
|
|
});
|
|
|
@@ -356,12 +372,17 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
if (!stream.active || String(payload?.streamId || '') !== stream.streamId) return; // 过期流
|
|
|
task.chunkBuffer.flush(); // 残余增量先落,再按最终全文对齐
|
|
|
|
|
|
- // 正常路径是纯尾缀追加;罕见分歧走整块重建(语义见 applyAnswerStreamEnd)
|
|
|
- const next = applyAnswerStreamEnd(ai.content, stream.baseText, String(payload?.text ?? ''));
|
|
|
- if (next !== ai.content) {
|
|
|
- ai.content = '';
|
|
|
- ai.content = next;
|
|
|
- }
|
|
|
+ // 用**自己累计的流式正文**做前缀比较/切片(不要按基线长度切:基线可能已被别的路径改过)
|
|
|
+ const before = ai.content;
|
|
|
+ const finalText = String(payload?.text ?? '');
|
|
|
+ const next = applyAnswerStreamEnd(before, stream.body, finalText);
|
|
|
+ if (next !== before) ai.content = next;
|
|
|
+ debugStream('end', {
|
|
|
+ 流式正文: stream.body.length,
|
|
|
+ 最终正文: finalText.length,
|
|
|
+ 追加: next.length - before.length,
|
|
|
+ 整块重建: !finalText.startsWith(stream.body),
|
|
|
+ });
|
|
|
stream.active = false; // contentStreaming 不清:之后的卡片等仍即时渲染,由 finishTask 统一收尾
|
|
|
});
|
|
|
|
|
|
@@ -371,12 +392,16 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
const ai = streamTargetMessage();
|
|
|
if (!ai) return;
|
|
|
task.chunkBuffer.flush(); // 残余增量先落,正文才是完整的
|
|
|
- const base = stream.baseText;
|
|
|
- const body = ai.content.slice(base.length);
|
|
|
+ const before = ai.content;
|
|
|
+ const body = stream.body;
|
|
|
+ // 基线由「上屏内容减去流式正文」**推导**,不用开流时的快照
|
|
|
+ const base = before.endsWith(body) ? before.slice(0, before.length - body.length) : before;
|
|
|
+ if (!before.endsWith(body)) {
|
|
|
+ console.warn('[answer-stream] 上屏内容与流式正文对不上,重排时按当前内容兜底(不丢字)');
|
|
|
+ }
|
|
|
const next = composeStreamedContent(base, String(payload?.leading || ''), body, String(payload?.trailing || ''));
|
|
|
- if (next === ai.content) return;
|
|
|
- ai.content = '';
|
|
|
- ai.content = next;
|
|
|
+ if (next !== before) ai.content = next;
|
|
|
+ debugStream('compose', { 正文: body.length, 卡片: String(payload?.leading || '').length, 尾部: String(payload?.trailing || '').length });
|
|
|
});
|
|
|
|
|
|
coordinator?.addEventListener('answerStreamAbort', (payload: any) => {
|
|
|
@@ -385,14 +410,20 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
if (!ai) return;
|
|
|
if (!stream.active || String(payload?.streamId || '') !== stream.streamId) return; // 过期流
|
|
|
task.chunkBuffer.flush(); // 残余先落(随后被截掉,顺序无污染)
|
|
|
- ai.content = ''; // 触发渲染层的「清空重解析」,把已渲染的草稿行撤掉
|
|
|
- ai.content = applyAnswerStreamAbort(stream.baseText); // 回到流开始前(scope 卡片随之复现)
|
|
|
+ const before = ai.content;
|
|
|
+ const body = stream.body;
|
|
|
+ // 撤稿 = 从上屏内容里去掉这段流式正文(同样用后缀匹配,不按快照长度切)
|
|
|
+ ai.content = applyAnswerStreamAbort(before, body);
|
|
|
+ debugStream('abort', { reason: payload?.reason, 撤掉: before.length - ai.content.length });
|
|
|
stream.active = false;
|
|
|
});
|
|
|
|
|
|
coordinator?.addEventListener('message', (msg: string) => {
|
|
|
if (tasks.value.get(sessionId) !== task) return;
|
|
|
if (!getSessionById(sessionId)) return;
|
|
|
+ // 正在流式时,这条通道上跑的就是正文增量 —— 自己留一份,
|
|
|
+ // 收尾对齐/卡片重排/撤稿都以它为准(见 stream.body 的说明)
|
|
|
+ if (stream.active) stream.body += msg;
|
|
|
task.chunkBuffer.push(msg);
|
|
|
});
|
|
|
|