feat: 实现 REST API + 轮询模式,支持页面刷新后任务继续执行

主要改进:
- 新增 REST API 任务管理接口 (/api/tasks)
- 实现 150ms 轮询机制,提供流畅的流式输出体验
- 支持页面刷新后自动恢复任务状态
- WebSocket 断开时检测 REST API 任务,避免误停止
- 修复堆叠块融合问题,刷新后内容正确合并
- 修复思考块展开/折叠逻辑,只展开正在流式输出的块
- 修复工具块重复显示问题,通过注册机制实现状态更新
- 修复历史不完整导致内容丢失的问题
- 新增 tool_intent 事件处理,支持打字机效果显示
- 修复对话列表排序时 None 值比较错误

技术细节:
- 前端:新增 taskPolling.ts 和 task store 处理轮询逻辑
- 后端:TaskManager 管理任务生命周期和事件存储
- 状态恢复:智能判断是否需要从头重建,避免内容重复
- 工具块注册:恢复时注册到 toolActionIndex,支持状态更新
- Intent 显示:0.5-1秒打字机效果,历史加载直接显示

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-03-08 03:12:46 +08:00
parent 463d89f295
commit 801d20591c
12 changed files with 1329 additions and 25 deletions

View File

@ -84,8 +84,22 @@ def handle_disconnect():
has_other_connection = True has_other_connection = True
break break
# 检查是否有通过 REST API 创建的运行中任务
# 如果有,说明使用轮询模式,不应该停止任务
has_rest_api_task = False
if username and not has_other_connection:
try:
from .tasks import task_manager
running_tasks = [t for t in task_manager.list_tasks(username) if t.status == "running"]
if running_tasks:
has_rest_api_task = True
debug_log(f"[WebSocket] 用户 {username} 有运行中的 REST API 任务,不停止")
except Exception as e:
debug_log(f"[WebSocket] 检查 REST API 任务失败: {e}")
task_info = get_stop_flag(request.sid, username) task_info = get_stop_flag(request.sid, username)
if isinstance(task_info, dict) and not has_other_connection: # 只有在没有其他连接且没有 REST API 任务时才停止
if isinstance(task_info, dict) and not has_other_connection and not has_rest_api_task:
task_info['stop'] = True task_info['stop'] = True
pending_task = task_info.get('task') pending_task = task_info.get('task')
if pending_task and not pending_task.done(): if pending_task and not pending_task.done():
@ -94,16 +108,17 @@ def handle_disconnect():
terminal = task_info.get('terminal') terminal = task_info.get('terminal')
if terminal: if terminal:
reset_system_state(terminal) reset_system_state(terminal)
# 清理停止标志 # 清理停止标志(只清理 sid 级别的,不清理 user 级别的)
clear_stop_flag(request.sid, None) if request.sid in stop_flags:
stop_flags.pop(request.sid, None)
# 从所有房间移除 # 从所有房间移除
for room in list(terminal_rooms.get(request.sid, [])): for room in list(terminal_rooms.get(request.sid, [])):
leave_room(room) leave_room(room)
if request.sid in terminal_rooms: if request.sid in terminal_rooms:
del terminal_rooms[request.sid] del terminal_rooms[request.sid]
if username: if username:
leave_room(f"user_{username}") leave_room(f"user_{username}")
leave_room(f"user_{username}_terminal") leave_room(f"user_{username}_terminal")

View File

@ -297,21 +297,28 @@ def create_task_api():
payload = request.get_json() or {} payload = request.get_json() or {}
message = (payload.get("message") or "").strip() message = (payload.get("message") or "").strip()
images = payload.get("images") or [] images = payload.get("images") or []
videos = payload.get("videos") or []
conversation_id = payload.get("conversation_id") conversation_id = payload.get("conversation_id")
if not message and not images: if not message and not images and not videos:
return jsonify({"success": False, "error": "消息不能为空"}), 400 return jsonify({"success": False, "error": "消息不能为空"}), 400
model_key = payload.get("model_key") model_key = payload.get("model_key")
thinking_mode = payload.get("thinking_mode") thinking_mode = payload.get("thinking_mode")
run_mode = payload.get("run_mode")
max_iterations = payload.get("max_iterations") max_iterations = payload.get("max_iterations")
# 合并 images 和 videos 到 images 参数(后端统一处理)
all_media = images + videos
try: try:
rec = task_manager.create_chat_task( rec = task_manager.create_chat_task(
username, username,
workspace_id, workspace_id,
message, message,
images, all_media,
conversation_id, conversation_id,
model_key=model_key, model_key=model_key,
thinking_mode=thinking_mode, thinking_mode=thinking_mode,
run_mode=run_mode,
max_iterations=max_iterations, max_iterations=max_iterations,
) )
except RuntimeError as exc: except RuntimeError as exc:

View File

@ -26,6 +26,7 @@ import { uploadMethods } from './app/methods/upload';
import { resourceMethods } from './app/methods/resources'; import { resourceMethods } from './app/methods/resources';
import { toolingMethods } from './app/methods/tooling'; import { toolingMethods } from './app/methods/tooling';
import { uiMethods } from './app/methods/ui'; import { uiMethods } from './app/methods/ui';
import { taskPollingMethods } from './app/methods/taskPolling';
import { monitorMethods } from './app/methods/monitor'; import { monitorMethods } from './app/methods/monitor';
// 其他初始化逻辑已迁移到 app/bootstrap.ts // 其他初始化逻辑已迁移到 app/bootstrap.ts
@ -37,7 +38,7 @@ const appOptions = {
beforeUnmount, beforeUnmount,
computed, computed,
watch: watchers, watch: watchers,
methods: { methods: {
...conversationMethods, ...conversationMethods,
...historyMethods, ...historyMethods,
@ -48,6 +49,7 @@ const appOptions = {
...toolingMethods, ...toolingMethods,
...uiMethods, ...uiMethods,
...monitorMethods, ...monitorMethods,
...taskPollingMethods,
...mapActions(useUiStore, { ...mapActions(useUiStore, {
uiToggleSidebar: 'toggleSidebar', uiToggleSidebar: 'toggleSidebar',
uiSetSidebarCollapsed: 'setSidebarCollapsed', uiSetSidebarCollapsed: 'setSidebarCollapsed',

View File

@ -43,6 +43,18 @@ export async function mounted() {
// 立即加载初始数据(并行获取状态,优先同步运行模式) // 立即加载初始数据(并行获取状态,优先同步运行模式)
this.loadInitialData(); this.loadInitialData();
// 注册全局事件处理器(用于任务轮询)
(window as any).__taskEventHandler = (event: any) => {
if (typeof this.handleTaskEvent === 'function') {
this.handleTaskEvent(event);
}
};
// 立即尝试恢复运行中的任务(不延迟)
if (typeof this.restoreTaskState === 'function') {
this.restoreTaskState();
}
document.addEventListener('click', this.handleClickOutsideQuickMenu); document.addEventListener('click', this.handleClickOutsideQuickMenu);
document.addEventListener('click', this.handleClickOutsidePanelMenu); document.addEventListener('click', this.handleClickOutsidePanelMenu);
document.addEventListener('click', this.handleClickOutsideHeaderMenu); document.addEventListener('click', this.handleClickOutsideHeaderMenu);
@ -64,6 +76,15 @@ export async function mounted() {
} }
export function beforeUnmount() { export function beforeUnmount() {
// 停止任务轮询
try {
const { useTaskStore } = require('../stores/task');
const taskStore = useTaskStore();
taskStore.stopPolling();
} catch (error) {
// ignore
}
document.removeEventListener('click', this.handleClickOutsideQuickMenu); document.removeEventListener('click', this.handleClickOutsideQuickMenu);
document.removeEventListener('click', this.handleClickOutsidePanelMenu); document.removeEventListener('click', this.handleClickOutsidePanelMenu);
document.removeEventListener('click', this.handleClickOutsideHeaderMenu); document.removeEventListener('click', this.handleClickOutsideHeaderMenu);

View File

@ -272,6 +272,8 @@ export const historyMethods = {
id: toolCall.id, id: toolCall.id,
name: toolCall.function.name, name: toolCall.function.name,
arguments: arguments_obj, arguments: arguments_obj,
argumentSnapshot: this.cloneToolArguments(arguments_obj),
argumentLabel: this.buildToolLabel(arguments_obj),
intent_full: arguments_obj.intent || '', intent_full: arguments_obj.intent || '',
intent_rendered: arguments_obj.intent || '', intent_rendered: arguments_obj.intent || '',
status: 'preparing', status: 'preparing',

View File

@ -10,8 +10,8 @@ export const messageMethods = {
} }
}, },
sendMessage() { async sendMessage() {
if (this.streamingUi || !this.isConnected) { if (this.streamingUi) {
return; return;
} }
if (this.mediaUploading) { if (this.mediaUploading) {
@ -79,7 +79,9 @@ export const messageMethods = {
const message = text; const message = text;
const isCommand = hasText && !hasImages && !hasVideos && message.startsWith('/'); const isCommand = hasText && !hasImages && !hasVideos && message.startsWith('/');
if (isCommand) { if (isCommand) {
this.socket.emit('send_command', { command: message }); if (this.socket && this.isConnected) {
this.socket.emit('send_command', { command: message });
}
this.inputClearMessage(); this.inputClearMessage();
this.inputClearSelectedImages(); this.inputClearSelectedImages();
this.inputClearSelectedVideos(); this.inputClearSelectedVideos();
@ -100,7 +102,26 @@ export const messageMethods = {
// 标记任务进行中,直到任务完成或用户手动停止 // 标记任务进行中,直到任务完成或用户手动停止
this.taskInProgress = true; this.taskInProgress = true;
this.chatAddUserMessage(message, images, videos); this.chatAddUserMessage(message, images, videos);
this.socket.emit('send_message', { message: message, images, videos, conversation_id: this.currentConversationId });
// 使用 REST API 创建任务(轮询模式)
try {
const { useTaskStore } = await import('../../stores/task');
const taskStore = useTaskStore();
await taskStore.createTask(message, images, videos, this.currentConversationId);
debugLog('[Message] 任务已创建,开始轮询');
} catch (error) {
console.error('[Message] 创建任务失败:', error);
this.uiPushToast({
title: '发送失败',
message: error.message || '创建任务失败,请重试',
type: 'error'
});
this.taskInProgress = false;
return;
}
if (typeof this.monitorShowPendingReply === 'function') { if (typeof this.monitorShowPendingReply === 'function') {
this.monitorShowPendingReply(); this.monitorShowPendingReply();
} }
@ -133,7 +154,7 @@ export const messageMethods = {
}, },
// 新增:停止任务方法 // 新增:停止任务方法
stopTask() { async stopTask() {
const canStop = this.composerBusy && !this.stopRequested; const canStop = this.composerBusy && !this.stopRequested;
if (!canStop) { if (!canStop) {
return; return;
@ -142,12 +163,27 @@ export const messageMethods = {
const shouldDropToolEvents = this.streamingUi; const shouldDropToolEvents = this.streamingUi;
this.stopRequested = true; this.stopRequested = true;
this.dropToolEvents = shouldDropToolEvents; this.dropToolEvents = shouldDropToolEvents;
if (this.socket) {
this.socket.emit('stop_task'); // 使用 REST API 取消任务
debugLog('发送停止请求'); try {
const { useTaskStore } = await import('../../stores/task');
const taskStore = useTaskStore();
if (taskStore.currentTaskId) {
await taskStore.cancelTask();
debugLog('[Message] 任务已取消');
}
} catch (error) {
console.error('[Message] 取消任务失败:', error);
} }
// 立即清理前端状态,避免出现“不可输入也不可停止”的卡死状态 // 兼容 WebSocket 模式
if (this.socket && this.isConnected) {
this.socket.emit('stop_task');
debugLog('发送停止请求WebSocket');
}
// 立即清理前端状态,避免出现"不可输入也不可停止"的卡死状态
this.clearPendingTools('user_stop'); this.clearPendingTools('user_stop');
this.streamingMessage = false; this.streamingMessage = false;
this.taskInProgress = false; this.taskInProgress = false;

View File

@ -0,0 +1,857 @@
// @ts-nocheck
import { debugLog } from './common';
/**
*
* REST API
*/
export const taskPollingMethods = {
/**
*
*/
handleTaskEvent(event: any) {
if (!event || !event.type) {
return;
}
const eventType = event.type;
const eventData = event.data || {};
const eventIdx = event.idx;
debugLog(`[TaskPolling] 处理事件 #${eventIdx}: ${eventType}`, eventData);
// 根据事件类型调用对应的处理方法
switch (eventType) {
case 'ai_message_start':
this.handleAiMessageStart(eventData, eventIdx);
break;
case 'thinking_start':
this.handleThinkingStart(eventData, eventIdx);
break;
case 'thinking_chunk':
this.handleThinkingChunk(eventData, eventIdx);
break;
case 'thinking_end':
this.handleThinkingEnd(eventData, eventIdx);
break;
case 'text_start':
this.handleTextStart(eventData, eventIdx);
break;
case 'text_chunk':
this.handleTextChunk(eventData, eventIdx);
break;
case 'text_end':
this.handleTextEnd(eventData, eventIdx);
break;
case 'tool_preparing':
this.handleToolPreparing(eventData, eventIdx);
break;
case 'tool_start':
this.handleToolStart(eventData, eventIdx);
break;
case 'tool_intent':
this.handleToolIntent(eventData, eventIdx);
break;
case 'tool_update_action':
case 'update_action':
this.handleToolUpdateAction(eventData, eventIdx);
break;
case 'append_payload':
this.handleAppendPayload(eventData, eventIdx);
break;
case 'modify_payload':
this.handleModifyPayload(eventData, eventIdx);
break;
case 'task_complete':
this.handleTaskComplete(eventData, eventIdx);
break;
case 'error':
this.handleTaskError(eventData, eventIdx);
break;
case 'token_update':
this.handleTokenUpdate(eventData, eventIdx);
break;
case 'conversation_resolved':
this.handleConversationResolved(eventData, eventIdx);
break;
default:
debugLog(`[TaskPolling] 未知事件类型: ${eventType}`);
}
// 如果正在从头重建,检查是否已经处理到当前事件
// 当处理到工具相关事件时,说明思考已经结束,可以清除重建标记
if (this._rebuildingFromScratch && (eventType === 'tool_preparing' || eventType === 'tool_start')) {
debugLog('[TaskPolling] 检测到工具事件,清除重建标记');
this._rebuildingFromScratch = false;
}
},
handleAiMessageStart(data: any, eventIdx: number) {
debugLog('[TaskPolling] AI消息开始, idx:', eventIdx);
// 检查是否已经有 assistant 消息(刷新恢复的情况)
const lastMessage = this.messages[this.messages.length - 1];
const hasAssistantMessage = lastMessage && lastMessage.role === 'assistant';
if (hasAssistantMessage) {
debugLog('[TaskPolling] 已有 assistant 消息,跳过创建新消息');
// 只更新状态,不创建新消息
this.taskInProgress = true;
this.stopRequested = false;
this.streamingMessage = true;
return;
}
// 没有 assistant 消息,创建新的
debugLog('[TaskPolling] 创建新的 assistant 消息');
this.monitorResetSpeech();
this.cleanupStaleToolActions();
this.taskInProgress = true;
this.chatStartAssistantMessage();
this.stopRequested = false;
this.streamingMessage = true;
// 如果是从头重建,标记消息为静默恢复
if (this._rebuildingFromScratch) {
const newMessage = this.messages[this.messages.length - 1];
if (newMessage && newMessage.role === 'assistant') {
debugLog('[TaskPolling] 标记消息为静默恢复(从头重建)');
newMessage.awaitingFirstContent = false;
newMessage.generatingLabel = '';
}
}
this.scrollToBottom();
},
handleThinkingStart(data: any, eventIdx: number) {
debugLog('[TaskPolling] 思考开始, idx:', eventIdx);
const ignoreThinking = this.runMode === 'fast' || this.thinkingMode === false;
if (ignoreThinking) {
this.monitorEndModelOutput();
return;
}
this.monitorShowThinking();
// 获取当前 actions 数量,用于调试
const lastMessage = this.messages[this.messages.length - 1];
const beforeCount = lastMessage?.actions?.length || 0;
console.log('[TaskPolling] 添加思考块前actions 数量:', beforeCount);
console.log('[TaskPolling] 当前 currentMessageIndex:', this.currentMessageIndex);
console.log('[TaskPolling] messages 总数:', this.messages.length);
console.log('[TaskPolling] 最后一条消息 role:', lastMessage?.role);
const result = this.chatStartThinkingAction();
console.log('[TaskPolling] chatStartThinkingAction 返回:', result);
if (result && result.blockId) {
const blockId = result.blockId;
// 检查新添加的 action
const afterCount = lastMessage?.actions?.length || 0;
console.log('[TaskPolling] 添加思考块后actions 数量:', afterCount);
// 检查 currentMessageIndex 指向的消息
const currentMsg = this.messages[this.currentMessageIndex];
if (currentMsg) {
console.log('[TaskPolling] currentMessageIndex 指向的消息 actions 数量:', currentMsg.actions?.length || 0);
}
if (afterCount > beforeCount) {
const newAction = lastMessage.actions[afterCount - 1];
console.log('[TaskPolling] 新添加的思考块:', {
type: newAction.type,
blockId: newAction.blockId,
hasId: !!newAction.id,
timestamp: newAction.timestamp
});
}
this.chatExpandBlock(blockId);
this.scrollToBottom();
this.chatSetThinkingLock(blockId, true);
// 强制触发 actions 数组的响应式更新
if (lastMessage && lastMessage.actions) {
lastMessage.actions = [...lastMessage.actions];
}
this.$forceUpdate();
}
},
handleThinkingChunk(data: any, eventIdx: number) {
if (this.runMode === 'fast' || this.thinkingMode === false) {
return;
}
const thinkingAction = this.chatAppendThinkingChunk(data.content);
if (thinkingAction) {
this.$forceUpdate();
this.$nextTick(() => {
if (thinkingAction && thinkingAction.blockId) {
this.scrollThinkingToBottom(thinkingAction.blockId);
}
this.conditionalScrollToBottom();
});
}
this.monitorShowThinking();
},
handleThinkingEnd(data: any) {
debugLog('[TaskPolling] 思考结束');
if (this.runMode === 'fast' || this.thinkingMode === false) {
return;
}
const blockId = this.chatCompleteThinkingAction(data.full_content);
if (blockId) {
// 解锁思考块
this.chatSetThinkingLock(blockId, false);
// 延迟折叠思考块(给用户一点时间看到思考完成)
setTimeout(() => {
this.chatCollapseBlock(blockId);
this.$forceUpdate();
}, 1000);
this.$nextTick(() => this.scrollThinkingToBottom(blockId));
}
this.$forceUpdate();
this.monitorEndModelOutput();
},
handleTextStart(data: any) {
debugLog('[TaskPolling] 文本开始');
this.chatStartTextAction();
this.$forceUpdate();
},
handleTextChunk(data: any) {
if (data && typeof data.content === 'string' && data.content.length) {
this.chatAppendTextChunk(data.content);
this.$forceUpdate();
this.conditionalScrollToBottom();
const speech = data.content.replace(/\r/g, '');
if (speech) {
this.monitorShowSpeech(speech);
}
}
},
handleTextEnd(data: any) {
debugLog('[TaskPolling] 文本结束');
const full = data?.full_content || '';
this.chatCompleteTextAction(full);
this.$forceUpdate();
this.monitorEndModelOutput();
},
handleToolPreparing(data: any) {
debugLog('[TaskPolling] 工具准备中:', data.name);
if (this.dropToolEvents) {
return;
}
const msg = this.chatEnsureAssistantMessage();
if (!msg) {
return;
}
if (msg.awaitingFirstContent) {
msg.awaitingFirstContent = false;
msg.generatingLabel = '';
}
const action = {
id: data.id,
type: 'tool',
tool: {
id: data.id,
name: data.name,
arguments: {},
argumentSnapshot: null,
argumentLabel: '',
status: 'preparing',
result: null,
message: data.message || `准备调用 ${data.name}...`,
intent_full: data.intent || '',
intent_rendered: data.intent || ''
},
timestamp: Date.now()
};
msg.actions.push(action);
this.preparingTools.set(data.id, action);
this.toolRegisterAction(action, data.id);
this.toolTrackAction(data.name, action);
this.$forceUpdate();
this.conditionalScrollToBottom();
if (this.monitorPreviewTool) {
this.monitorPreviewTool(data);
}
},
handleToolStart(data: any) {
debugLog('[TaskPolling] 工具开始:', data.name);
if (this.dropToolEvents) {
return;
}
let action = null;
if (data.preparing_id && this.preparingTools.has(data.preparing_id)) {
action = this.preparingTools.get(data.preparing_id);
this.preparingTools.delete(data.preparing_id);
} else {
action = this.toolFindAction(data.id, data.preparing_id, data.execution_id);
}
if (!action) {
const msg = this.chatEnsureAssistantMessage();
if (!msg) {
return;
}
action = {
id: data.id,
type: 'tool',
tool: {
id: data.id,
name: data.name,
arguments: {},
argumentSnapshot: null,
argumentLabel: '',
status: 'running',
result: null
},
timestamp: Date.now()
};
msg.actions.push(action);
}
action.tool.status = 'running';
action.tool.arguments = data.arguments;
action.tool.argumentSnapshot = this.cloneToolArguments(data.arguments);
action.tool.argumentLabel = this.buildToolLabel(action.tool.argumentSnapshot);
action.tool.message = null;
action.tool.id = data.id;
action.tool.executionId = data.id;
this.toolRegisterAction(action, data.id);
this.toolTrackAction(data.name, action);
this.$forceUpdate();
this.conditionalScrollToBottom();
if (this.monitorQueueTool) {
this.monitorQueueTool(data);
}
},
handleToolIntent(data: any) {
debugLog('[TaskPolling] 工具意图:', data.name, data.intent);
if (this.dropToolEvents) {
return;
}
// 查找对应的工具 action
const action = this.toolFindAction(data.id, data.preparing_id, data.execution_id);
if (action && action.tool) {
const newIntent = data.intent || '';
// 如果 intent 没有变化,跳过
if (action.tool.intent_full === newIntent) {
return;
}
// 停止之前的打字机效果
if (action.tool._intentTyping) {
action.tool._intentTyping = false;
if (action.tool._intentTimer) {
clearTimeout(action.tool._intentTimer);
action.tool._intentTimer = null;
}
}
// 更新完整 intent
action.tool.intent_full = newIntent;
// 判断是否是历史恢复:只有从头重建时才直接显示
const isHistoryRestore = this._rebuildingFromScratch;
if (isHistoryRestore) {
// 历史恢复,直接显示完整 intent
action.tool.intent_rendered = newIntent;
debugLog('[TaskPolling] 历史恢复,直接显示 intent');
} else {
// 新工具块,逐字符显示
debugLog('[TaskPolling] 新工具块,开始打字机效果');
action.tool.intent_rendered = '';
action.tool._intentTyping = true;
// 计算每个字符的间隔时间
// 总时长1秒但最少0.5秒
const totalDuration = Math.max(500, Math.min(1000, newIntent.length * 50));
const charInterval = totalDuration / newIntent.length;
let charIndex = 0;
const typeNextChar = () => {
if (charIndex < newIntent.length && action.tool._intentTyping) {
action.tool.intent_rendered += newIntent[charIndex];
charIndex++;
this.$forceUpdate();
action.tool._intentTimer = setTimeout(typeNextChar, charInterval);
} else {
action.tool._intentTyping = false;
action.tool.intent_rendered = newIntent; // 确保完整
action.tool._intentTimer = null;
this.$forceUpdate();
}
};
action.tool._intentTimer = setTimeout(typeNextChar, 50); // 延迟50ms开始
}
// 更新 arguments 和 label
if (action.tool.arguments) {
action.tool.arguments.intent = newIntent;
action.tool.argumentSnapshot = this.cloneToolArguments(action.tool.arguments);
action.tool.argumentLabel = this.buildToolLabel(action.tool.argumentSnapshot);
}
this.$forceUpdate();
debugLog('[TaskPolling] 已更新工具意图:', data.name);
} else {
debugLog('[TaskPolling] 未找到对应的工具 action:', data.id);
}
},
handleToolUpdateAction(data: any) {
if (this.dropToolEvents) {
return;
}
debugLog('[TaskPolling] 更新action:', data.id, 'status:', data.status);
let targetAction = this.toolFindAction(data.id, data.preparing_id, data.execution_id);
if (!targetAction && data.preparing_id && this.preparingTools.has(data.preparing_id)) {
targetAction = this.preparingTools.get(data.preparing_id);
}
if (!targetAction) {
return;
}
if (data.status) {
targetAction.tool.status = data.status;
}
if (data.result !== undefined) {
targetAction.tool.result = data.result;
}
if (data.message !== undefined) {
targetAction.tool.message = data.message;
}
if (data.content !== undefined) {
targetAction.tool.content = data.content;
}
this.$forceUpdate();
this.conditionalScrollToBottom();
if (this.monitorResolveTool && data.status === 'completed') {
this.monitorResolveTool(data);
}
},
handleAppendPayload(data: any) {
debugLog('[TaskPolling] 文件追加:', data.path);
this.chatAddAppendPayloadAction(data);
this.$forceUpdate();
this.conditionalScrollToBottom();
},
handleModifyPayload(data: any) {
debugLog('[TaskPolling] 文件修改:', data.path);
this.chatAddModifyPayloadAction(data);
this.$forceUpdate();
this.conditionalScrollToBottom();
},
handleTaskComplete(data: any) {
debugLog('[TaskPolling] 任务完成');
this.streamingMessage = false;
this.taskInProgress = false;
this.stopRequested = false;
this.$forceUpdate();
// 停止轮询
(async () => {
const { useTaskStore } = await import('../../stores/task');
const taskStore = useTaskStore();
taskStore.stopPolling();
})();
// 只更新统计,不重新加载历史(避免重复显示)
setTimeout(() => {
if (this.currentConversationId) {
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();
}
}, 500);
},
handleTaskError(data: any) {
console.error('[TaskPolling] 任务错误:', data.message);
this.uiPushToast({
title: '任务执行失败',
message: data.message || '未知错误',
type: 'error'
});
this.streamingMessage = false;
this.taskInProgress = false;
this.stopRequested = false;
this.$forceUpdate();
// 停止轮询
(async () => {
const { useTaskStore } = await import('../../stores/task');
const taskStore = useTaskStore();
taskStore.stopPolling();
})();
},
handleTokenUpdate(data: any) {
if (data.conversation_id === this.currentConversationId) {
this.currentConversationTokens.cumulative_input_tokens = data.cumulative_input_tokens || 0;
this.currentConversationTokens.cumulative_output_tokens = data.cumulative_output_tokens || 0;
this.currentConversationTokens.cumulative_total_tokens = data.cumulative_total_tokens || 0;
if (typeof data.current_context_tokens === 'number') {
this.resourceSetCurrentContextTokens(data.current_context_tokens);
} else {
this.updateCurrentContextTokens();
}
this.$forceUpdate();
}
},
handleConversationResolved(data: any) {
if (data && data.conversation_id) {
this.currentConversationId = data.conversation_id;
if (data.title) {
this.currentConversationTitle = data.title;
}
this.promoteConversationToTop(data.conversation_id);
const pathFragment = this.stripConversationPrefix(data.conversation_id);
const currentPath = window.location.pathname.replace(/^\/+/, '');
if (data.created) {
history.pushState({ conversationId: data.conversation_id }, '', `/${pathFragment}`);
} else if (currentPath !== pathFragment) {
history.replaceState({ conversationId: data.conversation_id }, '', `/${pathFragment}`);
}
}
},
/**
*
*/
async restoreTaskState() {
try {
const { useTaskStore } = await import('../../stores/task');
const taskStore = useTaskStore();
// 如果已经在流式输出中,不重复恢复
if (this.streamingMessage || this.taskInProgress) {
debugLog('[TaskPolling] 任务已在进行中,跳过恢复');
return;
}
// 查找运行中的任务
const runningTask = await taskStore.loadRunningTask(this.currentConversationId);
if (!runningTask) {
debugLog('[TaskPolling] 没有运行中的任务');
return;
}
debugLog('[TaskPolling] 发现运行中的任务,开始恢复状态');
// 检查历史是否已加载
const hasMessages = Array.isArray(this.messages) && this.messages.length > 0;
if (!hasMessages) {
debugLog('[TaskPolling] 历史未加载,等待历史加载完成');
setTimeout(() => {
this.restoreTaskState();
}, 500);
return;
}
debugLog('[TaskPolling] 历史已加载,开始精细恢复');
// 获取任务的所有事件
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) {
debugLog('[TaskPolling] 任务详情无效');
return;
}
const allEvents = detailResult.data.events;
debugLog(`[TaskPolling] 获取到 ${allEvents.length} 个事件`);
// 找到最后一条消息
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
});
// 检查是否需要从头重建
// 1. 最后一条不是 assistant 消息
// 2. 最后一条是空的 assistant 消息
// 3. 事件数量远大于历史中的 actions 数量(说明历史不完整)
const historyActionsCount = lastMessage?.actions?.length || 0;
const eventCount = allEvents.length;
const historyIncomplete = eventCount > historyActionsCount + 5; // 允许5个事件的误差
const needsRebuild = !isAssistantMessage ||
(isAssistantMessage && (!lastMessage.actions || lastMessage.actions.length === 0)) ||
historyIncomplete;
if (needsRebuild) {
if (historyIncomplete) {
console.log('[TaskPolling] 历史不完整,从头重建:', {
historyActionsCount,
eventCount,
diff: eventCount - historyActionsCount
});
}
debugLog('[TaskPolling] 需要从头重建 assistant 响应');
// 清空所有消息,准备从头重建
// 保留用户消息,只删除最后的 assistant 消息
if (isAssistantMessage) {
debugLog('[TaskPolling] 删除不完整的 assistant 消息');
this.messages.pop();
}
this.streamingMessage = true;
this.taskInProgress = true;
this.$forceUpdate();
// 重置偏移量为 0从头获取所有事件来重建 assistant 消息
taskStore.lastEventIndex = 0;
debugLog('[TaskPolling] 重置偏移量为 0从头开始轮询');
// 标记正在从头重建,用于后续处理
this._rebuildingFromScratch = true;
(window as any).__taskEventHandler = (event: any) => {
this.handleTaskEvent(event);
};
taskStore.startPolling((event: any) => {
this.handleTaskEvent(event);
});
return;
}
// 分析事件,找到当前正在进行的操作
let inThinking = false;
let inText = false;
for (let i = 0; i < allEvents.length; i++) {
const event = allEvents[i];
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;
}
}
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);
lastThinking.collapsed = false;
this.$nextTick(() => {
this.chatExpandBlock(lastThinking.blockId);
this.chatSetThinkingLock(lastThinking.blockId, true);
});
} else {
console.log('[TaskPolling] 没有找到正在流式输出的思考块,不展开');
// 折叠所有思考块
for (const thinking of thinkingActions) {
if (thinking.blockId) {
thinking.collapsed = true;
}
}
}
} else {
// 不在思考中,折叠所有思考块
console.log('[TaskPolling] 不在思考中,折叠所有思考块');
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);
});
this.uiPushToast({
title: '任务恢复',
message: '检测到进行中的任务,已恢复连接',
type: 'info',
duration: 3000
});
} catch (error) {
console.error('[TaskPolling] 恢复任务状态失败:', error);
}
},
};

View File

@ -7,12 +7,15 @@ export function dataState() {
initialRouteResolved: false, initialRouteResolved: false,
dropToolEvents: false, dropToolEvents: false,
// 轮询模式标志(禁用 WebSocket 事件处理)
usePollingMode: true,
// 工具状态跟踪 // 工具状态跟踪
preparingTools: new Map(), preparingTools: new Map(),
activeTools: new Map(), activeTools: new Map(),
toolActionIndex: new Map(), toolActionIndex: new Map(),
toolStacks: new Map(), toolStacks: new Map(),
// 当前任务是否仍在进行中(用于保持输入区的“停止”状态) // 当前任务是否仍在进行中(用于保持输入区的"停止"状态)
taskInProgress: false, taskInProgress: false,
// 记录上一次成功加载历史的对话ID防止初始化阶段重复加载导致动画播放两次 // 记录上一次成功加载历史的对话ID防止初始化阶段重复加载导致动画播放两次
lastHistoryLoadedConversationId: null, lastHistoryLoadedConversationId: null,

View File

@ -18,7 +18,9 @@
</div> </div>
</div> </div>
<div v-else-if="msg.role === 'assistant'" class="assistant-message"> <div v-else-if="msg.role === 'assistant'" class="assistant-message">
<div class="message-header icon-label"> <!-- assistant 消息前显示 AI Assistant 头部 -->
<!-- 只有当前一条消息是 user 且当前消息有内容时才显示 -->
<div v-if="index > 0 && filteredMessages[index - 1].role === 'user' && (msg.actions?.length > 0 || msg.awaitingFirstContent)" class="message-header icon-label">
<span class="icon icon-sm" :style="iconStyleSafe('bot')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyleSafe('bot')" aria-hidden="true"></span>
<span>AI Assistant</span> <span>AI Assistant</span>
</div> </div>
@ -331,14 +333,14 @@
</div> </div>
</template> </template>
</div> </div>
<div v-else class="system-message"> <div v-else class="system-message" @vue:mounted="() => console.log('[ChatArea] 渲染系统消息:', { index, role: msg.role, content: msg.content })">
<div class="collapsible-block system-block" :class="{ expanded: expandedBlocks?.has(`system-${index}`) }"> <div class="collapsible-block system-block" :class="{ expanded: expandedBlocks?.has(`system-${index}`) }">
<div class="collapsible-header" @click="toggleBlock(`system-${index}`)"> <div class="collapsible-header" @click="toggleBlock(`system-${index}`)">
<div class="arrow"></div> <div class="arrow"></div>
<div class="status-icon"> <div class="status-icon">
<span class="tool-icon icon icon-md" :style="iconStyleSafe('info')" aria-hidden="true"></span> <span class="tool-icon icon icon-md" :style="iconStyleSafe('info')" aria-hidden="true"></span>
</div> </div>
<span class="status-text">系统消息</span> <span class="status-text">系统消息 (role: {{ msg.role }})</span>
</div> </div>
<div <div
class="collapsible-content" class="collapsible-content"
@ -379,7 +381,11 @@ const props = defineProps<{
}>(); }>();
const personalization = usePersonalizationStore(); const personalization = usePersonalizationStore();
const stackedBlocksEnabled = computed(() => personalization.experiments.stackedBlocksEnabled); const stackedBlocksEnabled = computed(() => {
// false
const enabled = personalization.experiments.stackedBlocksEnabled;
return enabled !== false;
});
const filteredMessages = computed(() => const filteredMessages = computed(() =>
(props.messages || []).filter(m => !(m && m.metadata && m.metadata.system_injected_image)) (props.messages || []).filter(m => !(m && m.metadata && m.metadata.system_injected_image))
); );
@ -439,6 +445,15 @@ const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
> = []; > = [];
let buffer: any[] = []; let buffer: any[] = [];
//
if (actions.length > 0) {
console.log(`[splitActionGroups] 消息 ${messageIndex}:`, {
totalActions: actions.length,
actionTypes: actions.map(a => a.type),
stackedBlocksEnabled: props.stackedBlocksEnabled
});
}
const flushBuffer = () => { const flushBuffer = () => {
if (buffer.length >= 2) { if (buffer.length >= 2) {
result.push({ result.push({
@ -475,6 +490,19 @@ const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
} }
}); });
flushBuffer(); flushBuffer();
//
if (actions.length > 0) {
console.log(`[splitActionGroups] 消息 ${messageIndex} 结果:`, {
totalGroups: result.length,
groups: result.map(g => ({
kind: g.kind,
actionsCount: g.kind === 'stack' ? g.actions.length : 1,
actionType: g.kind === 'stack' ? g.actions[0]?.type : g.action?.type
}))
});
}
return result; return result;
}; };

View File

@ -848,6 +848,11 @@ export async function initializeLegacySocket(ctx: any) {
// AI消息开始 // AI消息开始
ctx.socket.on('ai_message_start', () => { ctx.socket.on('ai_message_start', () => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
socketLog('AI消息开始 (轮询模式,跳过)');
return;
}
socketLog('AI消息开始'); socketLog('AI消息开始');
logStreamingDebug('socket:ai_message_start'); logStreamingDebug('socket:ai_message_start');
finalizeStreamingText({ force: true }); finalizeStreamingText({ force: true });
@ -867,6 +872,11 @@ export async function initializeLegacySocket(ctx: any) {
// 思考流开始 // 思考流开始
ctx.socket.on('thinking_start', () => { ctx.socket.on('thinking_start', () => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
socketLog('思考开始 (轮询模式,跳过)');
return;
}
socketLog('思考开始'); socketLog('思考开始');
const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false; const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false;
streamingState.ignoreThinking = ignoreThinking; streamingState.ignoreThinking = ignoreThinking;
@ -888,6 +898,10 @@ export async function initializeLegacySocket(ctx: any) {
// 思考内容块 // 思考内容块
ctx.socket.on('thinking_chunk', (data) => { ctx.socket.on('thinking_chunk', (data) => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
return;
}
if (streamingState.ignoreThinking) { if (streamingState.ignoreThinking) {
return; return;
} }
@ -906,6 +920,11 @@ export async function initializeLegacySocket(ctx: any) {
// 思考结束 // 思考结束
ctx.socket.on('thinking_end', (data) => { ctx.socket.on('thinking_end', (data) => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
socketLog('思考结束 (轮询模式,跳过)');
return;
}
socketLog('思考结束'); socketLog('思考结束');
if (streamingState.ignoreThinking) { if (streamingState.ignoreThinking) {
streamingState.ignoreThinking = false; streamingState.ignoreThinking = false;
@ -926,6 +945,11 @@ export async function initializeLegacySocket(ctx: any) {
// 文本流开始 // 文本流开始
ctx.socket.on('text_start', () => { ctx.socket.on('text_start', () => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
socketLog('文本开始 (轮询模式,跳过)');
return;
}
socketLog('文本开始'); socketLog('文本开始');
logStreamingDebug('socket:text_start'); logStreamingDebug('socket:text_start');
finalizeStreamingText({ force: true }); finalizeStreamingText({ force: true });
@ -940,6 +964,10 @@ export async function initializeLegacySocket(ctx: any) {
// 文本内容块 // 文本内容块
ctx.socket.on('text_chunk', (data) => { ctx.socket.on('text_chunk', (data) => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
return;
}
logStreamingDebug('socket:text_chunk', { logStreamingDebug('socket:text_chunk', {
index: data?.index ?? null, index: data?.index ?? null,
elapsed: data?.elapsed ?? null, elapsed: data?.elapsed ?? null,
@ -973,6 +1001,11 @@ export async function initializeLegacySocket(ctx: any) {
// 文本结束 // 文本结束
ctx.socket.on('text_end', (data) => { ctx.socket.on('text_end', (data) => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
socketLog('文本结束 (轮询模式,跳过)');
return;
}
socketLog('文本结束'); socketLog('文本结束');
logStreamingDebug('socket:text_end', { logStreamingDebug('socket:text_end', {
finalLength: (data?.full_content || '').length, finalLength: (data?.full_content || '').length,
@ -1022,6 +1055,10 @@ export async function initializeLegacySocket(ctx: any) {
// 工具准备中事件 - 实时显示 // 工具准备中事件 - 实时显示
ctx.socket.on('tool_preparing', (data) => { ctx.socket.on('tool_preparing', (data) => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
return;
}
if (ctx.dropToolEvents) { if (ctx.dropToolEvents) {
return; return;
} }
@ -1079,6 +1116,10 @@ export async function initializeLegacySocket(ctx: any) {
// 工具意图(流式增量)事件 // 工具意图(流式增量)事件
ctx.socket.on('tool_intent', (data) => { ctx.socket.on('tool_intent', (data) => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
return;
}
if (ctx.dropToolEvents) { if (ctx.dropToolEvents) {
return; return;
} }
@ -1104,6 +1145,10 @@ export async function initializeLegacySocket(ctx: any) {
// 工具状态更新事件 - 实时显示详细状态 // 工具状态更新事件 - 实时显示详细状态
ctx.socket.on('tool_status', (data) => { ctx.socket.on('tool_status', (data) => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
return;
}
if (ctx.dropToolEvents) { if (ctx.dropToolEvents) {
return; return;
} }
@ -1136,6 +1181,10 @@ export async function initializeLegacySocket(ctx: any) {
// 工具开始(从准备转为执行) // 工具开始(从准备转为执行)
ctx.socket.on('tool_start', (data) => { ctx.socket.on('tool_start', (data) => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
return;
}
if (ctx.dropToolEvents) { if (ctx.dropToolEvents) {
return; return;
} }
@ -1204,6 +1253,10 @@ export async function initializeLegacySocket(ctx: any) {
// 更新action工具完成 // 更新action工具完成
ctx.socket.on('update_action', (data) => { ctx.socket.on('update_action', (data) => {
// 轮询模式下跳过 WebSocket 事件
if (ctx.usePollingMode) {
return;
}
if (ctx.dropToolEvents) { if (ctx.dropToolEvents) {
return; return;
} }

280
static/src/stores/task.ts Normal file
View File

@ -0,0 +1,280 @@
// @ts-nocheck
import { defineStore } from 'pinia';
import { debugLog } from '../app/methods/common';
export const useTaskStore = defineStore('task', {
state: () => ({
currentTaskId: null as string | null,
lastEventIndex: 0,
pollingInterval: null as number | null,
taskStatus: 'idle' as 'idle' | 'running' | 'succeeded' | 'failed' | 'canceled',
isPolling: false,
pollingError: null as string | null,
taskCreatedAt: null as number | null,
taskUpdatedAt: null as number | null,
}),
getters: {
hasActiveTask: (state) => state.currentTaskId !== null && state.taskStatus === 'running',
isTaskCompleted: (state) => ['succeeded', 'failed', 'canceled'].includes(state.taskStatus),
},
actions: {
async createTask(message: string, images: any[] = [], videos: any[] = [], conversationId: string | null = null) {
try {
debugLog('[Task] 创建任务:', { message, conversationId });
const response = await fetch('/api/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
images,
videos,
conversation_id: conversationId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '创建任务失败');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '创建任务失败');
}
this.currentTaskId = result.data.task_id;
this.taskStatus = result.data.status;
this.lastEventIndex = 0;
this.taskCreatedAt = result.data.created_at;
this.pollingError = null;
debugLog('[Task] 任务创建成功:', result.data.task_id);
// 立即开始轮询
this.startPolling();
return result.data;
} catch (error) {
debugLog('[Task] 创建任务失败:', error);
throw error;
}
},
async pollTaskEvents(eventHandler: (event: any) => void) {
if (!this.currentTaskId) {
debugLog('[Task] 没有活跃任务,停止轮询');
this.stopPolling();
return;
}
try {
const response = await fetch(
`/api/tasks/${this.currentTaskId}?from=${this.lastEventIndex}`
);
if (!response.ok) {
throw new Error('轮询任务失败');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '轮询任务失败');
}
const data = result.data;
// 更新任务状态
this.taskStatus = data.status;
this.taskUpdatedAt = data.updated_at;
// 处理新事件
if (data.events && data.events.length > 0) {
debugLog(`[Task] 收到 ${data.events.length} 个新事件`);
for (const event of data.events) {
try {
eventHandler(event);
} catch (err) {
console.error('[Task] 处理事件失败:', err, event);
}
}
this.lastEventIndex = data.next_offset;
}
// 如果任务已完成,停止轮询
if (this.isTaskCompleted) {
debugLog('[Task] 任务已完成,停止轮询:', this.taskStatus);
this.stopPolling();
}
this.pollingError = null;
} catch (error) {
console.error('[Task] 轮询失败:', error);
this.pollingError = error.message;
// 连续失败3次后停止轮询
if (this.pollingError) {
// 可以在这里添加重试逻辑
}
}
},
startPolling(eventHandler?: (event: any) => void) {
if (this.isPolling) {
debugLog('[Task] 轮询已在运行');
return;
}
if (!this.currentTaskId) {
debugLog('[Task] 没有任务ID无法启动轮询');
return;
}
debugLog('[Task] 启动轮询:', this.currentTaskId);
this.isPolling = true;
// 如果没有传入 eventHandler从根实例获取
const handler = eventHandler || ((window as any).__taskEventHandler);
if (!handler) {
console.error('[Task] 没有事件处理器,无法启动轮询');
this.isPolling = false;
return;
}
// 立即执行一次
this.pollTaskEvents(handler);
// 设置定时轮询150ms 间隔,接近流式输出效果)
this.pollingInterval = window.setInterval(() => {
this.pollTaskEvents(handler);
}, 150);
},
stopPolling() {
if (this.pollingInterval) {
debugLog('[Task] 停止轮询');
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
this.isPolling = false;
},
async cancelTask() {
if (!this.currentTaskId) {
return;
}
try {
debugLog('[Task] 取消任务:', this.currentTaskId);
const response = await fetch(`/api/tasks/${this.currentTaskId}/cancel`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('取消任务失败');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '取消任务失败');
}
this.taskStatus = 'canceled';
this.stopPolling();
debugLog('[Task] 任务已取消');
} catch (error) {
console.error('[Task] 取消任务失败:', error);
throw error;
}
},
async loadRunningTask(conversationId: string | null = null) {
try {
debugLog('[Task] 查找运行中的任务');
const response = await fetch('/api/tasks');
if (!response.ok) {
throw new Error('获取任务列表失败');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '获取任务列表失败');
}
// 查找运行中的任务
const runningTask = result.data.find((task: any) =>
task.status === 'running' &&
(!conversationId || task.conversation_id === conversationId)
);
if (runningTask) {
debugLog('[Task] 发现运行中的任务:', runningTask.task_id);
this.currentTaskId = runningTask.task_id;
this.taskStatus = runningTask.status;
this.taskCreatedAt = runningTask.created_at;
this.taskUpdatedAt = runningTask.updated_at;
this.pollingError = null;
// 获取任务详情,计算已处理的事件数量
try {
const detailResponse = await fetch(`/api/tasks/${runningTask.task_id}`);
if (detailResponse.ok) {
const detailResult = await detailResponse.json();
if (detailResult.success && detailResult.data.events) {
// 设置为当前事件数量,只获取新事件
this.lastEventIndex = detailResult.data.next_offset || detailResult.data.events.length;
debugLog('[Task] 设置起始偏移量:', this.lastEventIndex);
} else {
this.lastEventIndex = 0;
}
} else {
this.lastEventIndex = 0;
}
} catch (error) {
console.warn('[Task] 获取任务详情失败,从头开始:', error);
this.lastEventIndex = 0;
}
return runningTask;
}
debugLog('[Task] 没有运行中的任务');
return null;
} catch (error) {
console.error('[Task] 加载运行中任务失败:', error);
return null;
}
},
clearTask() {
debugLog('[Task] 清理任务状态');
this.stopPolling();
this.currentTaskId = null;
this.lastEventIndex = 0;
this.taskStatus = 'idle';
this.pollingError = null;
this.taskCreatedAt = null;
this.taskUpdatedAt = null;
},
resetForNewConversation() {
debugLog('[Task] 重置任务状态(新对话)');
this.stopPolling();
this.currentTaskId = null;
this.lastEventIndex = 0;
this.taskStatus = 'idle';
this.pollingError = null;
},
},
});

View File

@ -787,10 +787,10 @@ class ConversationManager:
index = self._ensure_index_covering(limit=limit, offset=offset) index = self._ensure_index_covering(limit=limit, offset=offset)
# 按更新时间倒序排列 # 按更新时间倒序排列(处理 None 值)
sorted_conversations = sorted( sorted_conversations = sorted(
index.items(), index.items(),
key=lambda x: x[1].get("updated_at", ""), key=lambda x: x[1].get("updated_at") or "",
reverse=True reverse=True
) )