From 265f71e297b2e5758f1886f532199e2751881ba2 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 21 Jul 2026 14:39:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(static):=20=E4=BF=AE=E5=A4=8D=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=9C=9F=E5=8A=A0=E8=BD=BD=E5=AF=B9=E8=AF=9D=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E5=9D=97=E5=91=A8=E6=9C=9F=E6=80=A7=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对账循环把 pending/cancel_requested 的活任务误判为「缺轮询」(服务端活跃 集合含 pending/cancel_requested,而 hasActiveTask 只认 running),每 2.5s 触发一次「清去重 + 偏移归零」的全量事件重放;思考/文本/tool_preparing 块 创建无幂等,每轮重放整份追加,十几份后页面卡死。 A: 对账 resume 增加 alreadyTrackingMain 判定(同任务且轮询在跑则不介入, 仅幂等对齐 taskInProgress 标志位) B: resumeTask 收编 resetOffset——同任务且轮询在跑时拒绝归零事件偏移, 仅冷启动恢复(不同任务或轮询已停)允许归零 D: startThinkingAction/startTextAction/handleToolPreparing 幂等创建, 已有流式块/同 id 块时复用而非新建,作为去重集合被清后的最后防线 --- static/src/app/methods/taskPolling/probe.ts | 13 ++++++++++++- static/src/app/methods/taskPolling/tool.ts | 20 ++++++++++++++++++++ static/src/stores/chat.ts | 14 ++++++++++++++ static/src/stores/task.ts | 13 ++++++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/static/src/app/methods/taskPolling/probe.ts b/static/src/app/methods/taskPolling/probe.ts index 342a4c97..21cc52c0 100644 --- a/static/src/app/methods/taskPolling/probe.ts +++ b/static/src/app/methods/taskPolling/probe.ts @@ -73,7 +73,15 @@ export const probeMethods = { if (status.is_truly_active) { this._runningStateIdleStreak = 0; // 恢复方向(立即执行,宁多勿漏):服务端活跃但本地缺轮询/缺标志位 - if (status.is_main_running && status.main_task_id && !taskStore.hasActiveTask) { + // 幂等约束:本地已在轮询同一任务时不介入。服务端把 pending/cancel_requested + // 也算活跃,而 hasActiveTask 只认 'running',旧判定(!hasActiveTask)会把 + // 排队中/停止收尾中的活任务误判为「缺轮询」,每 2.5s 触发一次「清去重 + + // 偏移归零」的全量事件重放,导致进度块周期性整份重复追加(页面卡死根因)。 + const alreadyTrackingMain = + !!status.main_task_id && + taskStore.currentTaskId === status.main_task_id && + taskStore.isPolling; + if (status.is_main_running && status.main_task_id && !alreadyTrackingMain) { debugNotifyLog('[DEBUG_NOTIFY][ui] reconcile:resume-main-task', { taskId: status.main_task_id, conversationId @@ -87,6 +95,9 @@ export const probeMethods = { eventHandler: (event: any) => this.handleTaskEvent(event) }); this.taskInProgress = true; + } else if (status.is_main_running && alreadyTrackingMain) { + // 已在跟踪:仅幂等对齐标志位,绝不触碰去重集合与事件偏移 + this.taskInProgress = true; } if (status.has_running_sub_agents) { this.waitingForSubAgent = true; diff --git a/static/src/app/methods/taskPolling/tool.ts b/static/src/app/methods/taskPolling/tool.ts index a89de13d..ef568e75 100644 --- a/static/src/app/methods/taskPolling/tool.ts +++ b/static/src/app/methods/taskPolling/tool.ts @@ -43,6 +43,26 @@ export const toolMethods = { msg.generatingLabel = ''; } + // 幂等兜底:同 id 的工具块已存在(重复/重放事件)时复用,不重复 push。 + // 若块已进入 running(tool_start 已处理),不得把状态回退为 preparing。 + let existingAction = null; + if (data.id && this.preparingTools.has(data.id)) { + existingAction = this.preparingTools.get(data.id); + } else if (data.id) { + existingAction = this.toolFindAction(data.id, data.preparing_id, data.execution_id); + } + if (existingAction) { + if (String(existingAction.tool?.status || '').toLowerCase() === 'preparing') { + existingAction.tool.message = data.message || existingAction.tool.message; + if (data.intent) { + existingAction.tool.intent_full = data.intent; + existingAction.tool.intent_rendered = data.intent; + } + this.$forceUpdate(); + } + return; + } + const action = { id: data.id, type: 'tool', diff --git a/static/src/stores/chat.ts b/static/src/stores/chat.ts index 81c770bc..9a9bedc0 100644 --- a/static/src/stores/chat.ts +++ b/static/src/stores/chat.ts @@ -232,6 +232,13 @@ export const useChatStore = defineStore('chat', { startThinkingAction() { const msg = this.ensureAssistantMessage(); clearAwaitingFirstContent(msg); + // 幂等兜底:已有「流式中」的思考块时复用而非新建。正常流程 + // thinking_start/thinking_end 成对出现,end 会把 streaming 置 false, + // 因此命中流式块只可能来自重复/重放事件(去重集合被清后的最后防线)。 + const existingThinking = this.getActiveThinkingAction(msg); + if (existingThinking && existingThinking.streaming === true) { + return { action: existingThinking, blockId: existingThinking.blockId || existingThinking.id }; + } msg.streamingThinking = ''; msg.currentStreamingType = 'thinking'; const actionId = randomId('thinking'); @@ -279,6 +286,13 @@ export const useChatStore = defineStore('chat', { return null; } clearAwaitingFirstContent(msg); + // 幂等兜底:末尾已有「流式中」的文本块时复用而非新建。正常流程 + // text_start/text_end 成对出现,end 会把 streaming 置 false, + // 因此命中流式块只可能来自重复/重放事件(去重集合被清后的最后防线)。 + const lastAction = msg.actions[msg.actions.length - 1]; + if (lastAction && lastAction.type === 'text' && lastAction.streaming === true) { + return lastAction; + } msg.streamingText = ''; msg.currentStreamingType = 'text'; (msg as any).__splitByShowHtml = false; diff --git a/static/src/stores/task.ts b/static/src/stores/task.ts index e33bd70f..c7d599b4 100644 --- a/static/src/stores/task.ts +++ b/static/src/stores/task.ts @@ -499,13 +499,24 @@ export const useTaskStore = defineStore('task', { currentTaskId: this.currentTaskId, resetOffset: options?.resetOffset !== false }); + const isSameTask = !!this.currentTaskId && this.currentTaskId === taskId; + const wasPolling = this.isPolling; if (this.currentTaskId && this.currentTaskId !== taskId) { this.stopPolling(); } this.currentTaskId = taskId; this.taskStatus = options.status || 'running'; - if (options.resetOffset !== false) { + // resetOffset 收编:仅允许「真正的冷启动恢复」(不同任务或轮询已停)归零偏移。 + // 同任务且轮询在跑时归零,下一次轮询会从 0 全量重拉事件;若去重集合恰好 + // 被清(对账/恢复路径),进度块会整份重复追加。 + if (options.resetOffset !== false && !(isSameTask && wasPolling)) { this.lastEventIndex = 0; + } else if (options.resetOffset !== false) { + goalModeDebugLog('taskStore.resumeTask_resetOffset_skipped', { + taskId, + reason: 'same-task-polling-active', + lastEventIndex: this.lastEventIndex + }); } this.pollingError = null; this.runtimeQueueSnapshotKey = '';