- 通知:inject_runtime_user_message 不再把子智能体任务 id 透传为事件顶层 task_id(sender setdefault 不覆盖,前端任务归属守卫丢弃事件;socket 通道 在轮询模式下不渲染,双通道断头导致只能刷新后从历史看到)。后台指令因透传 的是 command_id 而不受影响 - 通知:修复 _persist_and_echo_preceding_notice 回显写在 except 分支内, 批量通知前 N-1 条从不实时回显 - 压缩:移除「压缩中切换/新建对话需确认并取消压缩」拦截(多对话独立运行后 无存在意义) - 压缩:新增 compressionConversationId,输入/发送/停止锁按对话隔离,/new 新建页不再被其他对话的压缩状态锁定 - 压缩:metadata 记录 compression_pid,status/compression_status 读取时按 pid 懒清理进程重启后的残留 in_progress 标记;run_deep_compression 异常时 自动清理标记
704 lines
30 KiB
TypeScript
704 lines
30 KiB
TypeScript
// @ts-nocheck
|
||
import { debugLog } from '../common';
|
||
import { useTaskStore } from '../../../stores/task';
|
||
import { getMessageVisibility, messageStartsWork } from '../../../utils/messageVisibility';
|
||
import {
|
||
debugNotifyLog,
|
||
keyNotifyLog,
|
||
jsonDebug,
|
||
userMDebug,
|
||
isRestoreDebugEnabled,
|
||
restoreDebugLog,
|
||
isSystemAutoUserMessagePayload,
|
||
isRuntimeModeNoticePayload,
|
||
resolveUserMessageSource,
|
||
resolveUserMessageMetadata,
|
||
isEmptyAssistantPlaceholderMessage,
|
||
getOptimisticUserEchoTarget,
|
||
findRecentMatchingUserMessage,
|
||
} from './shared';
|
||
|
||
export const compressionMethods = {
|
||
handleCompressionState(data: any) {
|
||
if (!data || typeof data !== 'object') {
|
||
return;
|
||
}
|
||
const wasInProgress = !!this.compressionInProgress;
|
||
this.compressionInProgress = !!data.in_progress;
|
||
// 记录压缩所属对话:压缩锁只作用于该对话,不影响其他对话与 /new 新建页
|
||
this.compressionConversationId = this.compressionInProgress
|
||
? (data.conversation_id || this.currentConversationId || null)
|
||
: null;
|
||
this.compressionMode = data.mode || '';
|
||
this.compressionStage = data.stage || '';
|
||
if (this.compressionInProgress && !wasInProgress) {
|
||
const modeLabel = this.compressionMode === 'manual' ? '手动' : '自动';
|
||
if (this.compressionToastId) {
|
||
this.uiDismissToast(this.compressionToastId);
|
||
this.compressionToastId = null;
|
||
}
|
||
this.compressionToastId = this.uiPushToast({
|
||
title: '压缩中',
|
||
message: `对话正在${modeLabel}压缩,请稍候…`,
|
||
type: 'info',
|
||
duration: null,
|
||
closable: false
|
||
});
|
||
}
|
||
if (!this.compressionInProgress) {
|
||
this.compressionError = data.error || '';
|
||
if (this.compressionToastId) {
|
||
this.uiDismissToast(this.compressionToastId);
|
||
this.compressionToastId = null;
|
||
}
|
||
}
|
||
},
|
||
handleShallowCompression(data: any, eventIdx?: number) {
|
||
const count = Number(data?.compressed_count || 0);
|
||
if (count <= 0) {
|
||
return;
|
||
}
|
||
debugLog('[TaskPolling] 自动浅层压缩触发, idx:', eventIdx, data);
|
||
this.uiPushToast({
|
||
title: '自动浅层压缩',
|
||
message: `已自动压缩 ${count} 条较早工具结果`,
|
||
type: 'info',
|
||
duration: 2500
|
||
});
|
||
},
|
||
async handleCompressionFinished(data: any) {
|
||
if (this.compressionToastId) {
|
||
this.uiDismissToast(this.compressionToastId);
|
||
this.compressionToastId = null;
|
||
}
|
||
this.compressionInProgress = false;
|
||
this.compressionConversationId = null;
|
||
this.compressionMode = '';
|
||
this.compressionStage = '';
|
||
this.compressionError = '';
|
||
const newId = data?.conversation_id;
|
||
const isInPlace = newId && newId === this.currentConversationId;
|
||
if (newId && !isInPlace) {
|
||
// 旧行为兼容:压缩产生了新对话 id(非 in-place),需要切换并重新加载。
|
||
await this.loadConversation(newId, { force: true });
|
||
this.conversationsOffset = 0;
|
||
if (typeof this.loadConversationsList === 'function') {
|
||
await this.loadConversationsList();
|
||
}
|
||
await this.refreshRunningWorkspaceTasks?.();
|
||
await this.restoreTaskState?.();
|
||
}
|
||
// in-place 压缩:对话 id 不变,不重新加载对话,也不触发 restoreTaskState。
|
||
// loadConversation 会 clearTask 停止轮询才需要 restore;in-place 跳过了
|
||
// loadConversation,轮询仍在运行,restoreTaskState 的 rebuild 反而会
|
||
this.uiPushToast({
|
||
title: '压缩完成',
|
||
message: '已压缩较早的对话内容',
|
||
type: 'success',
|
||
duration: 2400
|
||
});
|
||
},
|
||
async restoreTaskState(options = {}) {
|
||
// 统一加载协议快速路径:bootstrap 已聚合任务摘要/全量事件/判据输入,
|
||
// 跳过 GET /api/tasks、历史死等与 GET /api/tasks/{id} 三次请求,行为与原有逻辑等价。
|
||
const bootstrapReplay = options && options.bootstrapReplay ? options.bootstrapReplay : null;
|
||
// 清理已处理的事件索引
|
||
this.clearProcessedEvents();
|
||
// 启动运行状态对账循环(幂等):事件负责即时性,对账负责正确性
|
||
if (typeof this.startRunningStateReconcile === 'function') {
|
||
this.startRunningStateReconcile();
|
||
}
|
||
restoreDebugLog('restore:start', {
|
||
currentConversationId: this.currentConversationId,
|
||
streamingMessage: this.streamingMessage,
|
||
taskInProgress: this.taskInProgress,
|
||
historyLoading: this.historyLoading,
|
||
historyLoadingFor: this.historyLoadingFor,
|
||
messagesLen: Array.isArray(this.messages) ? this.messages.length : -1
|
||
});
|
||
|
||
// 显式新建对话路由(/new、/multiagent/new):运行中的任务属于上一个对话,
|
||
// 用户主动选择了空白页。这里不能接管任务/重放事件/恢复轮询——
|
||
// loadRunningTask(null) 会匹配任意运行中任务,一旦接管,该对话的
|
||
// 用户输入/AI输出/工具调用会被全量重放到空白页(刷新也复现)。
|
||
// 侧边栏运行标识由 refreshRunningWorkspaceTasks 独立维护,不受影响。
|
||
if (
|
||
!this.currentConversationId &&
|
||
typeof this.isExplicitNewConversationRoute === 'function' &&
|
||
this.isExplicitNewConversationRoute()
|
||
) {
|
||
restoreDebugLog('restore:skip-explicit-new-route', {
|
||
path: typeof window !== 'undefined' ? window.location.pathname : ''
|
||
});
|
||
this._restoreTaskStateWaitCount = 0;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const { useTaskStore } = await import('../../../stores/task');
|
||
const taskStore = useTaskStore();
|
||
|
||
// 如果已经在流式输出中,不重复恢复
|
||
if (this.streamingMessage || this.taskInProgress) {
|
||
restoreDebugLog('restore:skip-already-running', {
|
||
streamingMessage: this.streamingMessage,
|
||
taskInProgress: this.taskInProgress,
|
||
currentConversationId: this.currentConversationId
|
||
});
|
||
debugLog('[TaskPolling] 任务已在进行中,跳过恢复', {
|
||
streamingMessage: this.streamingMessage,
|
||
taskInProgress: this.taskInProgress,
|
||
currentConversationId: this.currentConversationId
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 查找运行中的任务(快速路径:复用 bootstrap 任务摘要,
|
||
// 复刻 loadRunningTask 的 store 设置与续播偏移量)
|
||
let runningTask;
|
||
if (bootstrapReplay && bootstrapReplay.task && bootstrapReplay.task.task_id) {
|
||
runningTask = bootstrapReplay.task;
|
||
taskStore.currentTaskId = runningTask.task_id;
|
||
taskStore.taskStatus = runningTask.status;
|
||
taskStore.taskCreatedAt = runningTask.created_at;
|
||
taskStore.taskUpdatedAt = runningTask.updated_at;
|
||
taskStore.pollingError = null;
|
||
taskStore.pollingErrorCount = 0;
|
||
taskStore.pollingWarned = false;
|
||
taskStore.runtimeQueueSnapshotKey = '';
|
||
taskStore.lastEventIndex =
|
||
typeof bootstrapReplay.replay_from === 'number'
|
||
? bootstrapReplay.replay_from
|
||
: (Array.isArray(bootstrapReplay.events) ? bootstrapReplay.events.length : 0);
|
||
} else {
|
||
runningTask = await taskStore.loadRunningTask(this.currentConversationId);
|
||
}
|
||
|
||
if (!runningTask) {
|
||
restoreDebugLog('restore:no-running-task', {
|
||
currentConversationId: this.currentConversationId
|
||
});
|
||
debugLog('[TaskPolling] 没有运行中的任务', {
|
||
currentConversationId: this.currentConversationId
|
||
});
|
||
this._restoreTaskStateWaitCount = 0;
|
||
await this.restoreSubAgentWaitingState();
|
||
return;
|
||
}
|
||
if (
|
||
(this.versioningHostMode || this.dockerProjectMode) &&
|
||
this.currentHostWorkspaceId &&
|
||
runningTask?.workspace_id &&
|
||
runningTask.workspace_id !== this.currentHostWorkspaceId
|
||
) {
|
||
restoreDebugLog('restore:skip-workspace-mismatch', {
|
||
taskId: runningTask?.task_id,
|
||
taskWorkspaceId: runningTask?.workspace_id,
|
||
currentHostWorkspaceId: this.currentHostWorkspaceId
|
||
});
|
||
taskStore.clearTask();
|
||
await this.restoreSubAgentWaitingState();
|
||
return;
|
||
}
|
||
|
||
debugLog('[TaskPolling] 发现运行中的任务,开始恢复状态', {
|
||
taskId: runningTask?.task_id,
|
||
status: runningTask?.status,
|
||
conversationId: runningTask?.conversation_id
|
||
});
|
||
if (runningTask?.goal_mode) {
|
||
const existingGoalProgress = this.goalProgress && typeof this.goalProgress === 'object'
|
||
? this.goalProgress
|
||
: {};
|
||
const taskGoalProgress = runningTask?.goal_progress && typeof runningTask.goal_progress === 'object'
|
||
? runningTask.goal_progress
|
||
: {};
|
||
this.goalRunning = true;
|
||
this.goalModeArmed = false;
|
||
this.goalProgress = {
|
||
...existingGoalProgress,
|
||
...taskGoalProgress,
|
||
goal: taskGoalProgress?.goal || existingGoalProgress?.goal || runningTask?.message || '',
|
||
status: 'running',
|
||
turn_count: Number(taskGoalProgress?.turn_count ?? existingGoalProgress?.turn_count ?? 0),
|
||
tokens_used: Number(taskGoalProgress?.tokens_used ?? existingGoalProgress?.tokens_used ?? 0),
|
||
tool_calls: Number(taskGoalProgress?.tool_calls ?? existingGoalProgress?.tool_calls ?? 0),
|
||
duration_seconds: Number(taskGoalProgress?.duration_seconds ?? existingGoalProgress?.duration_seconds ?? 0)
|
||
};
|
||
}
|
||
restoreDebugLog('restore:running-task-found', {
|
||
taskId: runningTask?.task_id,
|
||
status: runningTask?.status,
|
||
taskConversationId: runningTask?.conversation_id,
|
||
currentConversationId: this.currentConversationId
|
||
});
|
||
|
||
// 检查历史是否已加载(快速路径:历史已由 enterConversation 渲染,跳过死等)
|
||
const hasMessages = Array.isArray(this.messages) && this.messages.length > 0;
|
||
const historyLoadingSameConversation =
|
||
!!this.historyLoading && this.historyLoadingFor === this.currentConversationId;
|
||
|
||
if (!hasMessages && !bootstrapReplay) {
|
||
this._restoreTaskStateWaitCount = (this._restoreTaskStateWaitCount || 0) + 1;
|
||
const waitedTooLong = this._restoreTaskStateWaitCount >= 8; // ~4s
|
||
restoreDebugLog('restore:history-empty', {
|
||
waitCount: this._restoreTaskStateWaitCount,
|
||
waitedTooLong,
|
||
historyLoading: this.historyLoading,
|
||
historyLoadingFor: this.historyLoadingFor,
|
||
currentConversationId: this.currentConversationId
|
||
});
|
||
if (historyLoadingSameConversation && !waitedTooLong) {
|
||
debugLog('[TaskPolling] 历史未加载,等待历史加载完成', {
|
||
waitCount: this._restoreTaskStateWaitCount,
|
||
historyLoading: this.historyLoading,
|
||
historyLoadingFor: this.historyLoadingFor
|
||
});
|
||
setTimeout(() => {
|
||
this.restoreTaskState();
|
||
}, 500);
|
||
return;
|
||
}
|
||
// 兜底:即使历史未成功拉取,也允许直接通过任务事件重放恢复界面,
|
||
// 避免 show_html 流式阶段刷新后“本次请求内容完全消失”。
|
||
debugLog('[TaskPolling] 历史为空,启用事件重放兜底恢复', {
|
||
waitCount: this._restoreTaskStateWaitCount,
|
||
historyLoading: this.historyLoading,
|
||
historyLoadingFor: this.historyLoadingFor
|
||
});
|
||
} else {
|
||
this._restoreTaskStateWaitCount = 0;
|
||
}
|
||
|
||
debugLog('[TaskPolling] 开始精细恢复', {
|
||
hasMessages,
|
||
historyLoading: this.historyLoading,
|
||
historyLoadingFor: this.historyLoadingFor
|
||
});
|
||
|
||
// 获取任务的所有事件(快速路径:bootstrap 已聚合全量事件,跳过详情请求)
|
||
let allEvents;
|
||
if (bootstrapReplay) {
|
||
allEvents = Array.isArray(bootstrapReplay.events) ? bootstrapReplay.events : [];
|
||
} else {
|
||
const detailResponse = await fetch(`/api/tasks/${taskStore.currentTaskId}`);
|
||
if (!detailResponse.ok) {
|
||
debugLog('[TaskPolling] 获取任务详情失败');
|
||
return;
|
||
}
|
||
|
||
const detailResult = await detailResponse.json();
|
||
if (!detailResult.success || !detailResult.data.events) {
|
||
restoreDebugLog('restore:task-detail-invalid', {
|
||
ok: !!detailResult?.success,
|
||
hasData: !!detailResult?.data,
|
||
hasEvents: !!detailResult?.data?.events
|
||
});
|
||
debugLog('[TaskPolling] 任务详情无效');
|
||
return;
|
||
}
|
||
|
||
allEvents = detailResult.data.events;
|
||
}
|
||
debugLog(`[TaskPolling] 获取到 ${allEvents.length} 个事件`);
|
||
restoreDebugLog('restore:task-detail-events', {
|
||
total: allEvents.length,
|
||
firstIdx: allEvents[0]?.idx,
|
||
firstType: allEvents[0]?.type,
|
||
lastIdx: allEvents[allEvents.length - 1]?.idx,
|
||
lastType: allEvents[allEvents.length - 1]?.type
|
||
});
|
||
|
||
// 找到最后一条消息
|
||
const lastMessage = this.messages[this.messages.length - 1];
|
||
const isAssistantMessage = lastMessage && lastMessage.role === 'assistant';
|
||
|
||
// console.log('[TaskPolling] 最后一条消息:', {
|
||
// exists: !!lastMessage,
|
||
// role: lastMessage?.role,
|
||
// actionsCount: lastMessage?.actions?.length || 0,
|
||
// isAssistant: isAssistantMessage
|
||
// });
|
||
|
||
// 先分析事件状态(用于判定是否强制重建)
|
||
// 快速路径:分析输入直接取自 bootstrap 的服务端判据(语义与下方循环等价)
|
||
const injectedDecision = bootstrapReplay ? bootstrapReplay.decision_inputs || null : null;
|
||
let inThinking = false;
|
||
let inText = false;
|
||
let hasTextChunkEvent = false;
|
||
let hasAssistantResponseEvent = false;
|
||
let hasAssistantContentEvent = false;
|
||
|
||
if (injectedDecision) {
|
||
inThinking = !!injectedDecision.in_thinking;
|
||
inText = !!injectedDecision.in_text;
|
||
hasTextChunkEvent = !!injectedDecision.has_text_chunk_event;
|
||
hasAssistantResponseEvent = !!injectedDecision.has_assistant_response_event;
|
||
hasAssistantContentEvent = !!injectedDecision.has_assistant_content_event;
|
||
} else {
|
||
for (let i = 0; i < allEvents.length; i++) {
|
||
const event = allEvents[i];
|
||
const eventType = String(event?.type || '');
|
||
if (
|
||
[
|
||
'ai_message_start',
|
||
'thinking_start',
|
||
'thinking_chunk',
|
||
'thinking_end',
|
||
'text_start',
|
||
'text_chunk',
|
||
'text_end',
|
||
'tool_preparing',
|
||
'tool_start',
|
||
'tool_update',
|
||
'tool_complete'
|
||
].includes(eventType)
|
||
) {
|
||
hasAssistantResponseEvent = true;
|
||
}
|
||
if (
|
||
[
|
||
'thinking_chunk',
|
||
'text_chunk',
|
||
'tool_preparing',
|
||
'tool_start',
|
||
'tool_update',
|
||
'tool_complete'
|
||
].includes(eventType)
|
||
) {
|
||
hasAssistantContentEvent = true;
|
||
}
|
||
if (event.type === 'thinking_start') {
|
||
inThinking = true;
|
||
} else if (event.type === 'thinking_end') {
|
||
inThinking = false;
|
||
}
|
||
if (event.type === 'text_start') {
|
||
inText = true;
|
||
} else if (event.type === 'text_end') {
|
||
inText = false;
|
||
}
|
||
if (event.type === 'text_chunk') {
|
||
hasTextChunkEvent = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查是否需要从头重建
|
||
// 1. 最后一条不是 assistant 消息
|
||
// 2. 最后一条是空的 assistant 消息
|
||
// 3. 事件数量远大于历史中的 actions 数量(说明历史不完整)
|
||
// 4. 任务正处于文本流式阶段(刷新时易丢分段内容,强制重放全部事件)
|
||
const forceRebuildForStreamingText = inText || hasTextChunkEvent || inThinking;
|
||
|
||
// 检查 assistant 消息的 actions 是否有仍在进行中的
|
||
const hasInProgressActions = isAssistantMessage && Array.isArray(lastMessage.actions) && lastMessage.actions.some((action: any) => {
|
||
if (!action) return false;
|
||
if (action.streaming) return true;
|
||
if (action.type === 'tool' && action.tool) {
|
||
const status = String(action.tool.status || '').toLowerCase();
|
||
if (['preparing', 'running', 'pending', 'queued'].includes(status)) return true;
|
||
}
|
||
return false;
|
||
});
|
||
|
||
// 仅当确实需要时才重建,避免引导消息等场景下不必要的 assistant 消息移除和事件重放
|
||
//
|
||
// 【工具调用期间刷新重复显示修复】bootstrap 快速路径下必须采纳服务端判定。
|
||
// 原因:renderHistoryMessages 会把历史中进行中的工具状态改写为 'stale'(避免
|
||
// 按钮卡死),而上方 hasInProgressActions 只认 preparing/running/pending/queued,
|
||
// 导致「思考→工具调用(无文本输出)期间刷新」时本地误判为无需重建;但服务端
|
||
// 因文件态末条 assistant 带 tool_calls(工具结果未落盘)判定 needs_rebuild=True
|
||
// 并把 replay_from 置 0。此时若误入 follow 分支,轮询会从事件 0 全量重放,
|
||
// handleAiMessageStart 再新建 assistant,同一段运行记录显示两遍。
|
||
// 注意:思考→输出→工具调用 场景不会触发,因为 text_chunk 事件会让
|
||
// forceRebuildForStreamingText 在两侧一致为 true,掩盖该分歧。
|
||
// 方向考量:重建(重放)是安全方向,follow 是风险方向,故对服务端判定取 OR。
|
||
const serverNeedsRebuild = !!(
|
||
bootstrapReplay && bootstrapReplay.needs_rebuild === true
|
||
);
|
||
const serverHasPendingToolCalls = !!(
|
||
injectedDecision && injectedDecision.has_pending_tool_calls === true
|
||
);
|
||
const needsRebuild =
|
||
!isAssistantMessage ||
|
||
(isAssistantMessage && (!lastMessage.actions || lastMessage.actions.length === 0)) ||
|
||
forceRebuildForStreamingText ||
|
||
hasInProgressActions ||
|
||
serverNeedsRebuild ||
|
||
serverHasPendingToolCalls;
|
||
restoreDebugLog('restore:rebuild-decision', {
|
||
needsRebuild,
|
||
isAssistantMessage,
|
||
hasAssistantResponseEvent,
|
||
hasAssistantContentEvent,
|
||
inText,
|
||
hasTextChunkEvent,
|
||
inThinking,
|
||
forceRebuildForStreamingText,
|
||
hasInProgressActions,
|
||
serverNeedsRebuild,
|
||
serverHasPendingToolCalls
|
||
});
|
||
|
||
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 (rebuildContainer) {
|
||
debugLog('[TaskPolling] 复用现有 assistant 消息容器,清空 actions 等待事件重建', {
|
||
hasAssistantContentEvent,
|
||
rebuildContainerIdx,
|
||
removedAfterContainer: this.messages.length - 1 - rebuildContainerIdx
|
||
});
|
||
// 移除重建容器之后的运行区间消息(运行期注入的 user 消息 / 后续 assistant
|
||
// 段),它们会随事件重放按原位置重建;不移除会导致同一段运行记录显示两遍。
|
||
if (rebuildContainerIdx < this.messages.length - 1) {
|
||
this.messages.splice(rebuildContainerIdx + 1);
|
||
}
|
||
rebuildContainer.actions = [];
|
||
// 如果事件流中已经有内容事件(tool/thinking/text),说明回复已经开始过,
|
||
// 重放事件会恢复内容,不显示等待提示。
|
||
// 如果还没有内容事件,说明回复还没开始,应该显示等待提示。
|
||
rebuildContainer.awaitingFirstContent = !hasAssistantContentEvent;
|
||
rebuildContainer.generatingLabel = rebuildContainer.generatingLabel || '思考中...';
|
||
// 清理旧的流式标记,确保新事件能正确设置
|
||
if (typeof rebuildContainer.streaming === 'boolean') {
|
||
rebuildContainer.streaming = true;
|
||
}
|
||
// 绑定流式写入索引,确保重放事件追加进该容器(否则 ensureAssistantMessage
|
||
// 会因 currentMessageIndex=-1 新建 assistant,留下空壳并错位)。
|
||
this.currentMessageIndex = rebuildContainerIdx;
|
||
}
|
||
|
||
this.streamingMessage = true;
|
||
this.taskInProgress = true;
|
||
if (!hasAssistantContentEvent && !rebuildContainer) {
|
||
this.ensureRunningAssistantPlaceholder?.(runningTask, 'restore:no-visible-content-yet');
|
||
}
|
||
this.$forceUpdate();
|
||
|
||
// 重置偏移量为 0,从头获取所有事件来重建 assistant 消息
|
||
taskStore.lastEventIndex = 0;
|
||
debugLog('[TaskPolling] 重置偏移量为 0,从头开始轮询');
|
||
|
||
// 标记正在从头重建,用于后续处理
|
||
this._rebuildingFromScratch = true;
|
||
this._rebuildingEventCount = allEvents.length; // 记录当前事件总数
|
||
|
||
(window as any).__taskEventHandler = (event: any) => {
|
||
this.handleTaskEvent(event);
|
||
};
|
||
|
||
taskStore.startPolling((event: any) => {
|
||
this.handleTaskEvent(event);
|
||
});
|
||
restoreDebugLog('restore:rebuild-polling-started', {
|
||
lastEventIndex: taskStore.lastEventIndex,
|
||
currentConversationId: this.currentConversationId
|
||
});
|
||
|
||
// 延迟清除重建标记,确保所有历史事件都处理完毕
|
||
setTimeout(() => {
|
||
debugLog('[TaskPolling] 历史事件处理完毕,清除重建标记');
|
||
this._rebuildingFromScratch = false;
|
||
this._rebuildingEventCount = 0;
|
||
}, 2000);
|
||
|
||
return;
|
||
}
|
||
|
||
// console.log('[TaskPolling] 分析结果:', {
|
||
// inThinking,
|
||
// inText,
|
||
// totalEvents: allEvents.length,
|
||
// lastEventType: allEvents[allEvents.length - 1]?.type,
|
||
// lastEventIdx: allEvents[allEvents.length - 1]?.idx
|
||
// });
|
||
|
||
// 恢复思考块状态
|
||
if (lastMessage.actions) {
|
||
// console.log('[TaskPolling] 历史中的 actions 详情:', lastMessage.actions.map((a, idx) => ({
|
||
// index: idx,
|
||
// type: a.type,
|
||
// id: a.id,
|
||
// hasContent: !!a.content,
|
||
// contentLength: a.content?.length || 0,
|
||
// toolName: a.tool?.name,
|
||
// hasBlockId: !!a.blockId,
|
||
// blockId: a.blockId,
|
||
// collapsed: a.collapsed,
|
||
// streaming: a.streaming
|
||
// })));
|
||
|
||
const thinkingActions = lastMessage.actions.filter((a) => a.type === 'thinking');
|
||
// console.log('[TaskPolling] 思考块数量:', thinkingActions.length);
|
||
|
||
if (inThinking && thinkingActions.length > 0) {
|
||
// 正在思考中,检查最后一个思考块是否正在流式输出
|
||
const lastThinking = thinkingActions[thinkingActions.length - 1];
|
||
|
||
// 只有当思考块正在流式输出时才设置锁定状态(但不展开)
|
||
if (lastThinking.streaming && lastThinking.blockId) {
|
||
// console.log('[TaskPolling] 找到正在流式输出的思考块,设置锁定状态:', lastThinking.blockId);
|
||
this.$nextTick(() => {
|
||
this.chatSetThinkingLock(lastThinking.blockId, true);
|
||
});
|
||
}
|
||
}
|
||
|
||
// 确保所有思考块都是折叠状态
|
||
for (const thinking of thinkingActions) {
|
||
if (thinking.blockId) {
|
||
thinking.collapsed = true;
|
||
}
|
||
}
|
||
|
||
// 检查思考块状态(在设置之后)(已禁用)
|
||
// thinkingActions.forEach((thinking, idx) => {
|
||
// console.log(`[TaskPolling] 思考块 ${idx} (设置后):`, {
|
||
// hasBlockId: !!thinking.blockId,
|
||
// blockId: thinking.blockId,
|
||
// collapsed: thinking.collapsed,
|
||
// contentLength: thinking.content?.length || 0
|
||
// });
|
||
// });
|
||
|
||
// 恢复文本块状态
|
||
const textActions = lastMessage.actions.filter((a) => a.type === 'text');
|
||
// console.log('[TaskPolling] 文本块数量:', textActions.length);
|
||
|
||
if (inText && textActions.length > 0) {
|
||
const lastText = textActions[textActions.length - 1];
|
||
// console.log('[TaskPolling] 标记文本块为流式状态');
|
||
lastText.streaming = true;
|
||
}
|
||
|
||
// 注册历史中的工具块到 toolActionIndex
|
||
// 这样后续的 update_action 事件可以找到对应的块进行状态更新
|
||
const toolActions = lastMessage.actions.filter((a) => a.type === 'tool');
|
||
// console.log('[TaskPolling] 工具块数量:', toolActions.length);
|
||
|
||
for (const toolAction of toolActions) {
|
||
if (toolAction.tool && toolAction.tool.id) {
|
||
// console.log('[TaskPolling] 注册工具块:', {
|
||
// id: toolAction.tool.id,
|
||
// name: toolAction.tool.name,
|
||
// status: toolAction.tool.status
|
||
// });
|
||
// 注册到 toolActionIndex
|
||
this.toolRegisterAction(toolAction, toolAction.tool.id);
|
||
// 如果有 executionId,也注册
|
||
if (toolAction.tool.executionId) {
|
||
this.toolRegisterAction(toolAction, toolAction.tool.executionId);
|
||
}
|
||
// 追踪工具调用
|
||
if (toolAction.tool.name) {
|
||
this.toolTrackAction(toolAction.tool.name, toolAction);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 标记状态为进行中
|
||
this.streamingMessage = true;
|
||
this.taskInProgress = true;
|
||
|
||
// 设置 currentMessageIndex 指向最后一条 assistant 消息
|
||
// 这样后续添加的 action 会添加到正确的消息中
|
||
const lastMessageIndex = this.messages.length - 1;
|
||
if (lastMessage && lastMessage.role === 'assistant') {
|
||
this.currentMessageIndex = lastMessageIndex;
|
||
// console.log('[TaskPolling] 设置 currentMessageIndex 为:', lastMessageIndex);
|
||
}
|
||
|
||
// 强制更新界面
|
||
this.$forceUpdate();
|
||
|
||
// 滚动到底部
|
||
this.$nextTick(() => {
|
||
this.conditionalScrollToBottom();
|
||
});
|
||
|
||
// 注册事件处理器到全局
|
||
(window as any).__taskEventHandler = (event: any) => {
|
||
this.handleTaskEvent(event);
|
||
};
|
||
|
||
// 启动轮询(从当前偏移量开始,只处理新事件)
|
||
debugLog('[TaskPolling] 启动轮询,起始偏移量:', taskStore.lastEventIndex);
|
||
|
||
taskStore.startPolling((event: any) => {
|
||
this.handleTaskEvent(event);
|
||
});
|
||
restoreDebugLog('restore:polling-started-follow', {
|
||
lastEventIndex: taskStore.lastEventIndex,
|
||
currentConversationId: this.currentConversationId
|
||
});
|
||
|
||
this.uiPushToast({
|
||
title: '任务恢复',
|
||
message: '检测到进行中的任务,已恢复连接',
|
||
type: 'info',
|
||
duration: 3000
|
||
});
|
||
} catch (error) {
|
||
restoreDebugLog('restore:error', {
|
||
message: error?.message || String(error)
|
||
});
|
||
console.error('[TaskPolling] 恢复任务状态失败:', error);
|
||
}
|
||
}
|
||
};
|