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
|
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")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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,
|
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,
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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
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)
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user