diff --git a/server/socket_handlers.py b/server/socket_handlers.py index 19058b1..3418363 100644 --- a/server/socket_handlers.py +++ b/server/socket_handlers.py @@ -84,8 +84,22 @@ def handle_disconnect(): has_other_connection = True 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) - 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 pending_task = task_info.get('task') if pending_task and not pending_task.done(): @@ -94,16 +108,17 @@ def handle_disconnect(): terminal = task_info.get('terminal') if terminal: reset_system_state(terminal) - - # 清理停止标志 - clear_stop_flag(request.sid, None) - + + # 清理停止标志(只清理 sid 级别的,不清理 user 级别的) + if request.sid in stop_flags: + stop_flags.pop(request.sid, None) + # 从所有房间移除 for room in list(terminal_rooms.get(request.sid, [])): leave_room(room) if request.sid in terminal_rooms: del terminal_rooms[request.sid] - + if username: leave_room(f"user_{username}") leave_room(f"user_{username}_terminal") diff --git a/server/tasks.py b/server/tasks.py index 983c801..3b06ca6 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -297,21 +297,28 @@ def create_task_api(): payload = request.get_json() or {} message = (payload.get("message") or "").strip() images = payload.get("images") or [] + videos = payload.get("videos") or [] 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 model_key = payload.get("model_key") thinking_mode = payload.get("thinking_mode") + run_mode = payload.get("run_mode") max_iterations = payload.get("max_iterations") + + # 合并 images 和 videos 到 images 参数(后端统一处理) + all_media = images + videos + try: rec = task_manager.create_chat_task( username, workspace_id, message, - images, + all_media, conversation_id, model_key=model_key, thinking_mode=thinking_mode, + run_mode=run_mode, max_iterations=max_iterations, ) except RuntimeError as exc: diff --git a/static/src/app.ts b/static/src/app.ts index f36ea7f..9f8120b 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -26,6 +26,7 @@ import { uploadMethods } from './app/methods/upload'; import { resourceMethods } from './app/methods/resources'; import { toolingMethods } from './app/methods/tooling'; import { uiMethods } from './app/methods/ui'; +import { taskPollingMethods } from './app/methods/taskPolling'; import { monitorMethods } from './app/methods/monitor'; // 其他初始化逻辑已迁移到 app/bootstrap.ts @@ -37,7 +38,7 @@ const appOptions = { beforeUnmount, computed, watch: watchers, - + methods: { ...conversationMethods, ...historyMethods, @@ -48,6 +49,7 @@ const appOptions = { ...toolingMethods, ...uiMethods, ...monitorMethods, + ...taskPollingMethods, ...mapActions(useUiStore, { uiToggleSidebar: 'toggleSidebar', uiSetSidebarCollapsed: 'setSidebarCollapsed', diff --git a/static/src/app/lifecycle.ts b/static/src/app/lifecycle.ts index 9e185c5..e7bbdb2 100644 --- a/static/src/app/lifecycle.ts +++ b/static/src/app/lifecycle.ts @@ -43,6 +43,18 @@ export async function mounted() { // 立即加载初始数据(并行获取状态,优先同步运行模式) 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.handleClickOutsidePanelMenu); document.addEventListener('click', this.handleClickOutsideHeaderMenu); @@ -64,6 +76,15 @@ export async function mounted() { } 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.handleClickOutsidePanelMenu); document.removeEventListener('click', this.handleClickOutsideHeaderMenu); diff --git a/static/src/app/methods/history.ts b/static/src/app/methods/history.ts index 56b0a07..eb57c54 100644 --- a/static/src/app/methods/history.ts +++ b/static/src/app/methods/history.ts @@ -272,6 +272,8 @@ export const historyMethods = { id: toolCall.id, name: toolCall.function.name, arguments: arguments_obj, + argumentSnapshot: this.cloneToolArguments(arguments_obj), + argumentLabel: this.buildToolLabel(arguments_obj), intent_full: arguments_obj.intent || '', intent_rendered: arguments_obj.intent || '', status: 'preparing', diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index 618e98d..a0a4ee1 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -10,8 +10,8 @@ export const messageMethods = { } }, - sendMessage() { - if (this.streamingUi || !this.isConnected) { + async sendMessage() { + if (this.streamingUi) { return; } if (this.mediaUploading) { @@ -79,7 +79,9 @@ export const messageMethods = { const message = text; const isCommand = hasText && !hasImages && !hasVideos && message.startsWith('/'); if (isCommand) { - this.socket.emit('send_command', { command: message }); + if (this.socket && this.isConnected) { + this.socket.emit('send_command', { command: message }); + } this.inputClearMessage(); this.inputClearSelectedImages(); this.inputClearSelectedVideos(); @@ -100,7 +102,26 @@ export const messageMethods = { // 标记任务进行中,直到任务完成或用户手动停止 this.taskInProgress = true; 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') { this.monitorShowPendingReply(); } @@ -133,7 +154,7 @@ export const messageMethods = { }, // 新增:停止任务方法 - stopTask() { + async stopTask() { const canStop = this.composerBusy && !this.stopRequested; if (!canStop) { return; @@ -142,12 +163,27 @@ export const messageMethods = { const shouldDropToolEvents = this.streamingUi; this.stopRequested = true; this.dropToolEvents = shouldDropToolEvents; - if (this.socket) { - this.socket.emit('stop_task'); - debugLog('发送停止请求'); + + // 使用 REST API 取消任务 + 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.streamingMessage = false; this.taskInProgress = false; diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts new file mode 100644 index 0000000..a9422a3 --- /dev/null +++ b/static/src/app/methods/taskPolling.ts @@ -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); + } + }, +}; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index a8b21c3..973fcea 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -7,12 +7,15 @@ export function dataState() { initialRouteResolved: false, dropToolEvents: false, + // 轮询模式标志(禁用 WebSocket 事件处理) + usePollingMode: true, + // 工具状态跟踪 preparingTools: new Map(), activeTools: new Map(), toolActionIndex: new Map(), toolStacks: new Map(), - // 当前任务是否仍在进行中(用于保持输入区的“停止”状态) + // 当前任务是否仍在进行中(用于保持输入区的"停止"状态) taskInProgress: false, // 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次 lastHistoryLoadedConversationId: null, diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 9aa9395..73be804 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -18,7 +18,9 @@
-