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 = '';