fix(前端): 目标模式刷新/切换对话稳定性修复

- 切换/停止任务后立即 stopPolling,防止旧任务目标事件污染新对话
- 目标进度/完成/停止回调增加 conversation_id 归属校验
- 刷新恢复改为复用现有 assistant 容器+增量追加事件,去掉无条件强制重建,避免视觉闪回与同步重放卡死
- 压缩消息不再另起 work segment,继续前一段

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-06-16 19:46:19 +08:00
parent ea4e5dc14b
commit 1da269e2aa
3 changed files with 53 additions and 27 deletions

View File

@ -935,6 +935,8 @@ export const messageMethods = {
if (taskStore.currentTaskId) {
await taskStore.cancelTask();
// 立即停止轮询,防止旧任务的目标模式事件在切换对话后仍被处理
taskStore.stopPolling('user_stop');
}
// 等待后端确认

View File

@ -783,6 +783,8 @@ export const taskPollingMethods = {
this.handleSystemMessage(eventData, eventIdx);
break;
case 'runtime_queue_sync':
// 从头重建期间跳过 runtime_queue_sync避免与已加载的历史冲突导致 UI 闪回卡死
if (this._rebuildingFromScratch) break;
this.handleRuntimeQueueSync(eventData);
break;
@ -1905,6 +1907,11 @@ export const taskPollingMethods = {
// 目标模式:运行中进度
handleGoalProgress(data: any) {
// 安全检查:确保目标事件属于当前对话,防止切换对话后旧任务事件污染
if (data?.conversation_id && this.currentConversationId && data.conversation_id !== this.currentConversationId) {
debugLog('[Goal] 忽略不匹配对话的目标进度事件', { eventCid: data.conversation_id, currentCid: this.currentConversationId });
return;
}
debugLog('[Goal] 目标进度更新', data);
const status = String(data?.status || 'running').toLowerCase();
this.goalRunning = status === 'running';
@ -1958,6 +1965,11 @@ export const taskPollingMethods = {
// 目标模式:目标达成
handleGoalCompleted(data: any) {
// 安全检查:确保目标事件属于当前对话
if (data?.conversation_id && this.currentConversationId && data.conversation_id !== this.currentConversationId) {
debugLog('[Goal] 忽略不匹配对话的目标完成事件', { eventCid: data.conversation_id, currentCid: this.currentConversationId });
return;
}
debugLog('[Goal] 目标已达成', data);
this.goalRunning = false;
this.goalModeArmed = false;
@ -1968,6 +1980,11 @@ export const taskPollingMethods = {
// 目标模式:目标停止(空转/轮数/token/用户取消)
handleGoalStopped(data: any) {
// 安全检查:确保目标事件属于当前对话
if (data?.conversation_id && this.currentConversationId && data.conversation_id !== this.currentConversationId) {
debugLog('[Goal] 忽略不匹配对话的目标停止事件', { eventCid: data.conversation_id, currentCid: this.currentConversationId });
return;
}
debugLog('[Goal] 目标已停止', data);
this.goalRunning = false;
this.goalModeArmed = false;
@ -2721,54 +2738,57 @@ export const taskPollingMethods = {
// 2. 最后一条是空的 assistant 消息
// 3. 事件数量远大于历史中的 actions 数量(说明历史不完整)
// 4. 任务正处于文本流式阶段(刷新时易丢分段内容,强制重放全部事件)
const historyActionsCount = lastMessage?.actions?.length || 0;
const eventCount = allEvents.length;
const historyIncomplete = eventCount > historyActionsCount + 5; // 允许5个事件的误差
const forceRebuildForStreamingText = inText || hasTextChunkEvent;
const forceRebuildForStreamingText = inText || hasTextChunkEvent || inThinking;
// 刷新恢复场景下,优先强制从头重放当前任务事件,避免“有运行中任务但界面无输出”
const forceRebuildOnRefresh = true;
// 检查 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 消息移除和事件重放
const needsRebuild =
forceRebuildOnRefresh ||
!isAssistantMessage ||
(isAssistantMessage && (!lastMessage.actions || lastMessage.actions.length === 0)) ||
historyIncomplete ||
forceRebuildForStreamingText;
forceRebuildForStreamingText ||
hasInProgressActions;
restoreDebugLog('restore:rebuild-decision', {
needsRebuild,
forceRebuildOnRefresh,
isAssistantMessage,
historyActionsCount,
eventCount,
hasAssistantResponseEvent,
hasAssistantContentEvent,
historyIncomplete,
inText,
hasTextChunkEvent,
forceRebuildForStreamingText
inThinking,
forceRebuildForStreamingText,
hasInProgressActions
});
if (needsRebuild) {
// if (historyIncomplete) {
// console.log('[TaskPolling] 历史不完整,从头重建:', {
// historyActionsCount,
// eventCount,
// diff: eventCount - historyActionsCount
// });
// }
debugLog('[TaskPolling] 需要从头重建 assistant 响应');
// 清空所有消息,准备从头重建
// 保留用户消息,只删除最后的 assistant 消息
// 关键修复:不要 pop assistant 消息,而是清空其 actions 并设置标记,
// 让 handleAiMessageStart 检测到 isRefreshRestore 并复用现有消息容器。
// 这样避免视觉闪回(消息不会突然消失)+ 新事件通过轮询增量追加,不会一次性同步处理几百个事件导致卡死。
if (isAssistantMessage) {
debugLog('[TaskPolling] 删除不完整的 assistant 消息');
this.messages.pop();
debugLog('[TaskPolling] 复用现有 assistant 消息容器,清空 actions 等待事件重建');
lastMessage.actions = [];
lastMessage.awaitingFirstContent = true;
lastMessage.generatingLabel = lastMessage.generatingLabel || '思考中...';
// 清理旧的流式标记,确保新事件能正确设置
if (typeof lastMessage.streaming === 'boolean') {
lastMessage.streaming = true;
}
}
this.streamingMessage = true;
this.taskInProgress = true;
if (!hasAssistantContentEvent) {
if (!hasAssistantContentEvent && !isAssistantMessage) {
this.ensureRunningAssistantPlaceholder?.(runningTask, 'restore:no-visible-content-yet');
}
this.$forceUpdate();

View File

@ -73,8 +73,12 @@ export function messageStartsWork(message: any): boolean {
if (source === 'guidance' || source === 'goal_prompt' || source === 'skill') {
return false;
}
if (source === 'goal_review' || source === 'compression' || source === 'compression_handoff' || isLegacyGoalReview(message)) {
if (source === 'goal_review' || isLegacyGoalReview(message)) {
return true;
}
// 压缩消息应该继续前一个 work segment不开启新的回复头与计时器
if (source === 'compression' || source === 'compression_handoff') {
return false;
}
return LEGACY_STARTS_WORK_SOURCES.has(source || 'user');
}