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:
parent
463d89f295
commit
801d20591c
@ -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")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
857
static/src/app/methods/taskPolling.ts
Normal file
857
static/src/app/methods/taskPolling.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -18,7 +18,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>AI Assistant</span>
|
||||
</div>
|
||||
@ -331,14 +333,14 @@
|
||||
</div>
|
||||
</template>
|
||||
</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-header" @click="toggleBlock(`system-${index}`)">
|
||||
<div class="arrow"></div>
|
||||
<div class="status-icon">
|
||||
<span class="tool-icon icon icon-md" :style="iconStyleSafe('info')" aria-hidden="true"></span>
|
||||
</div>
|
||||
<span class="status-text">系统消息</span>
|
||||
<span class="status-text">系统消息 (role: {{ msg.role }})</span>
|
||||
</div>
|
||||
<div
|
||||
class="collapsible-content"
|
||||
@ -379,7 +381,11 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const personalization = usePersonalizationStore();
|
||||
const stackedBlocksEnabled = computed(() => personalization.experiments.stackedBlocksEnabled);
|
||||
const stackedBlocksEnabled = computed(() => {
|
||||
// 默认启用堆叠块显示,除非明确设置为 false
|
||||
const enabled = personalization.experiments.stackedBlocksEnabled;
|
||||
return enabled !== false;
|
||||
});
|
||||
const filteredMessages = computed(() =>
|
||||
(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[] = [];
|
||||
|
||||
// 调试:记录输入
|
||||
if (actions.length > 0) {
|
||||
console.log(`[splitActionGroups] 消息 ${messageIndex}:`, {
|
||||
totalActions: actions.length,
|
||||
actionTypes: actions.map(a => a.type),
|
||||
stackedBlocksEnabled: props.stackedBlocksEnabled
|
||||
});
|
||||
}
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (buffer.length >= 2) {
|
||||
result.push({
|
||||
@ -475,6 +490,19 @@ const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
|
||||
}
|
||||
});
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@ -848,6 +848,11 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// AI消息开始
|
||||
ctx.socket.on('ai_message_start', () => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
socketLog('AI消息开始 (轮询模式,跳过)');
|
||||
return;
|
||||
}
|
||||
socketLog('AI消息开始');
|
||||
logStreamingDebug('socket:ai_message_start');
|
||||
finalizeStreamingText({ force: true });
|
||||
@ -867,6 +872,11 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 思考流开始
|
||||
ctx.socket.on('thinking_start', () => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
socketLog('思考开始 (轮询模式,跳过)');
|
||||
return;
|
||||
}
|
||||
socketLog('思考开始');
|
||||
const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false;
|
||||
streamingState.ignoreThinking = ignoreThinking;
|
||||
@ -888,6 +898,10 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 思考内容块
|
||||
ctx.socket.on('thinking_chunk', (data) => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
return;
|
||||
}
|
||||
if (streamingState.ignoreThinking) {
|
||||
return;
|
||||
}
|
||||
@ -906,6 +920,11 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 思考结束
|
||||
ctx.socket.on('thinking_end', (data) => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
socketLog('思考结束 (轮询模式,跳过)');
|
||||
return;
|
||||
}
|
||||
socketLog('思考结束');
|
||||
if (streamingState.ignoreThinking) {
|
||||
streamingState.ignoreThinking = false;
|
||||
@ -926,6 +945,11 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 文本流开始
|
||||
ctx.socket.on('text_start', () => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
socketLog('文本开始 (轮询模式,跳过)');
|
||||
return;
|
||||
}
|
||||
socketLog('文本开始');
|
||||
logStreamingDebug('socket:text_start');
|
||||
finalizeStreamingText({ force: true });
|
||||
@ -940,6 +964,10 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 文本内容块
|
||||
ctx.socket.on('text_chunk', (data) => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
return;
|
||||
}
|
||||
logStreamingDebug('socket:text_chunk', {
|
||||
index: data?.index ?? null,
|
||||
elapsed: data?.elapsed ?? null,
|
||||
@ -973,6 +1001,11 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 文本结束
|
||||
ctx.socket.on('text_end', (data) => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
socketLog('文本结束 (轮询模式,跳过)');
|
||||
return;
|
||||
}
|
||||
socketLog('文本结束');
|
||||
logStreamingDebug('socket:text_end', {
|
||||
finalLength: (data?.full_content || '').length,
|
||||
@ -1022,6 +1055,10 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 工具准备中事件 - 实时显示
|
||||
ctx.socket.on('tool_preparing', (data) => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
return;
|
||||
}
|
||||
if (ctx.dropToolEvents) {
|
||||
return;
|
||||
}
|
||||
@ -1079,6 +1116,10 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 工具意图(流式增量)事件
|
||||
ctx.socket.on('tool_intent', (data) => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
return;
|
||||
}
|
||||
if (ctx.dropToolEvents) {
|
||||
return;
|
||||
}
|
||||
@ -1104,6 +1145,10 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 工具状态更新事件 - 实时显示详细状态
|
||||
ctx.socket.on('tool_status', (data) => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
return;
|
||||
}
|
||||
if (ctx.dropToolEvents) {
|
||||
return;
|
||||
}
|
||||
@ -1136,6 +1181,10 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 工具开始(从准备转为执行)
|
||||
ctx.socket.on('tool_start', (data) => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
return;
|
||||
}
|
||||
if (ctx.dropToolEvents) {
|
||||
return;
|
||||
}
|
||||
@ -1204,6 +1253,10 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
|
||||
// 更新action(工具完成)
|
||||
ctx.socket.on('update_action', (data) => {
|
||||
// 轮询模式下跳过 WebSocket 事件
|
||||
if (ctx.usePollingMode) {
|
||||
return;
|
||||
}
|
||||
if (ctx.dropToolEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
280
static/src/stores/task.ts
Normal file
280
static/src/stores/task.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -787,10 +787,10 @@ class ConversationManager:
|
||||
|
||||
index = self._ensure_index_covering(limit=limit, offset=offset)
|
||||
|
||||
# 按更新时间倒序排列
|
||||
# 按更新时间倒序排列(处理 None 值)
|
||||
sorted_conversations = sorted(
|
||||
index.items(),
|
||||
key=lambda x: x[1].get("updated_at", ""),
|
||||
key=lambda x: x[1].get("updated_at") or "",
|
||||
reverse=True
|
||||
)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user