diff --git a/static/src/app/methods/taskPolling/compression.ts b/static/src/app/methods/taskPolling/compression.ts index 84a3c69c..f4fddef8 100644 --- a/static/src/app/methods/taskPolling/compression.ts +++ b/static/src/app/methods/taskPolling/compression.ts @@ -401,28 +401,87 @@ export const compressionMethods = { if (needsRebuild) { debugLog('[TaskPolling] 需要从头重建 assistant 响应'); + // 定位「本次运行区间」:从消息末尾向前,越过 assistant 消息与运行期注入的 + // user 消息(运行中引导/通知、多智能体内联消息、压缩续接引导语),直到发起 + // 本次任务的 user 消息(或更早的历史消息)为止。 + // + // 背景:事件重放会从 0 重建本次运行的全部 assistant 内容。若运行区间中夹着 + // 静态的运行期 user 消息(典型:运行中发送了引导对话、运行中触发对话压缩), + // 只清空末条 assistant 会让引导/压缩消息之前的静态分段残留,重放又把全部 + // 内容重建到新 assistant 中,导致同一段运行记录显示两遍。 + // + // 处理:复用运行区间内【第一条】assistant 作为重放容器(清空 actions 并绑定 + // currentMessageIndex),并移除区间内其后的所有消息——运行期注入的 user + // 消息在任务事件流中都有对应 user_message 事件,会与后续 assistant 段一起 + // 由事件重放按原顺序重建,位置与运行时一致。 + const isMidRunRuntimeUserMessage = (msg: any): boolean => { + if (!msg || msg.role !== 'user') { + return false; + } + const meta = msg.metadata || {}; + if (meta.runtime_injected === true) { + return true; + } + // 压缩续接引导语走任务递归入口持久化,没有 runtime_injected 标记, + // 但它同样是运行中插入、且事件流中有对应 user_message 事件。 + const src = String(meta.message_source || '').trim().toLowerCase(); + return src === 'compression' || src === 'compression_handoff'; + }; + let runBoundaryIdx = this.messages.length - 1; + while (runBoundaryIdx >= 0) { + const msg = this.messages[runBoundaryIdx]; + if (msg && msg.role === 'assistant') { + runBoundaryIdx -= 1; + continue; + } + if (isMidRunRuntimeUserMessage(msg)) { + runBoundaryIdx -= 1; + continue; + } + break; + } + let rebuildContainer: any = null; + let rebuildContainerIdx = -1; + for (let j = Math.max(runBoundaryIdx + 1, 0); j < this.messages.length; j += 1) { + if (this.messages[j] && this.messages[j].role === 'assistant') { + rebuildContainer = this.messages[j]; + rebuildContainerIdx = j; + break; + } + } + // 关键修复:不要 pop assistant 消息,而是清空其 actions 并设置标记, // 让 handleAiMessageStart 检测到 isRefreshRestore 并复用现有消息容器。 // 这样避免视觉闪回(消息不会突然消失)+ 新事件通过轮询增量追加,不会一次性同步处理几百个事件导致卡死。 - if (isAssistantMessage) { + if (rebuildContainer) { debugLog('[TaskPolling] 复用现有 assistant 消息容器,清空 actions 等待事件重建', { - hasAssistantContentEvent + hasAssistantContentEvent, + rebuildContainerIdx, + removedAfterContainer: this.messages.length - 1 - rebuildContainerIdx }); - lastMessage.actions = []; + // 移除重建容器之后的运行区间消息(运行期注入的 user 消息 / 后续 assistant + // 段),它们会随事件重放按原位置重建;不移除会导致同一段运行记录显示两遍。 + if (rebuildContainerIdx < this.messages.length - 1) { + this.messages.splice(rebuildContainerIdx + 1); + } + rebuildContainer.actions = []; // 如果事件流中已经有内容事件(tool/thinking/text),说明回复已经开始过, // 重放事件会恢复内容,不显示等待提示。 // 如果还没有内容事件,说明回复还没开始,应该显示等待提示。 - lastMessage.awaitingFirstContent = !hasAssistantContentEvent; - lastMessage.generatingLabel = lastMessage.generatingLabel || '思考中...'; + rebuildContainer.awaitingFirstContent = !hasAssistantContentEvent; + rebuildContainer.generatingLabel = rebuildContainer.generatingLabel || '思考中...'; // 清理旧的流式标记,确保新事件能正确设置 - if (typeof lastMessage.streaming === 'boolean') { - lastMessage.streaming = true; + if (typeof rebuildContainer.streaming === 'boolean') { + rebuildContainer.streaming = true; } + // 绑定流式写入索引,确保重放事件追加进该容器(否则 ensureAssistantMessage + // 会因 currentMessageIndex=-1 新建 assistant,留下空壳并错位)。 + this.currentMessageIndex = rebuildContainerIdx; } this.streamingMessage = true; this.taskInProgress = true; - if (!hasAssistantContentEvent && !isAssistantMessage) { + if (!hasAssistantContentEvent && !rebuildContainer) { this.ensureRunningAssistantPlaceholder?.(runningTask, 'restore:no-visible-content-yet'); } this.$forceUpdate();