From dfc763c7c58899fc6754d3a56893e300d21cc327 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 4 Apr 2026 19:47:28 +0800 Subject: [PATCH] docs(analysis): add polling and conversation behavior notes --- docs/README.md | 202 +++++++ docs/analysis-conversation-loading.md | 790 ++++++++++++++++++++++++++ docs/analysis-stop-button.md | 741 ++++++++++++++++++++++++ docs/analysis-task-recovery.md | 692 ++++++++++++++++++++++ 4 files changed, 2425 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/analysis-conversation-loading.md create mode 100644 docs/analysis-stop-button.md create mode 100644 docs/analysis-task-recovery.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2c67eca --- /dev/null +++ b/docs/README.md @@ -0,0 +1,202 @@ +# AI Agent 系统对话区域功能分析文档 + +本目录包含对 AI Agent 系统对话显示区域的详细功能分析,用于识别历史包袱和需要改造为纯轮询模式的部分。 + +## 📋 分析文档列表 + +### ✅ 已完成的分析 + +1. **[停止按钮和运行状态机制](./analysis-stop-button.md)** (22KB) + - 停止按钮的显示/隐藏逻辑 + - 运行状态的设置和清除 + - WebSocket vs REST API 的停止机制 + - 已知问题:状态同步、竞态条件、页面刷新恢复 + +2. **[对话记录加载机制](./analysis-conversation-loading.md)** (21KB) + - 切换对话时的加载流程 + - 历史消息的数据结构 + - 防止重复加载的机制 + - 初始化阶段的特殊处理 + +3. **[运行中对话恢复机制](./analysis-task-recovery.md)** (20KB) + - 页面刷新后的任务恢复 + - `_rebuildingFromScratch` 标志的作用 + - 历史事件的重放机制 + - 轮询的恢复逻辑 + +4. **思考块机制** (分析已完成) + - 思考块的创建和内容追加 + - 展开/折叠逻辑(流式输出 vs 历史加载) + - 自动折叠机制(1秒延迟) + - 无打字机效果,直接追加内容 + +5. **工具块机制** (分析已完成) + - 工具块的创建和状态变化(preparing → running → completed/failed) + - tool_intent 的打字机效果(0.5-1秒) + - 工具块的堆叠和融合机制 + - toolActionIndex 注册机制 + - append_payload 和 modify_payload 处理 + +6. **模型输出内容显示** (分析已完成) + - 文本流式显示(text_start/chunk/end) + - Markdown 渲染(marked.js + Prism.js + KaTeX) + - 代码块的语法高亮和复制功能 + - 双事件监听系统(可能导致复制bug) + - 图片和视频的显示 + +### ⏸️ 未完成的分析 + +7. **子智能体机制** (分析中断) + - waitingForSubAgent 状态 + - sub_agent_waiting 事件 + - WebSocket 在子智能体场景下的特殊作用 + +## 🎯 重点关注的问题区域 + +### 1. 停止按钮(Bug最多) + +**主要问题:** +- 前端在后端确认前就清理状态,导致同步问题 +- 多个布尔标志(taskInProgress, streamingMessage, stopRequested)难以维护 +- 存在竞态条件 +- 页面刷新后状态恢复复杂 + +**涉及文件:** +- `static/src/components/input/InputComposer.vue` - 停止按钮UI +- `static/src/app/methods/message.ts` - 停止逻辑 +- `static/src/stores/task.ts` - 任务状态管理 +- `server/tasks.py` - 后端停止处理 + +### 2. 思考块和工具块 + +**展开/折叠机制:** +- 流式输出时:思考块自动展开,1秒后折叠 +- 历史加载时:所有块默认折叠 +- 用户手动点击:状态存储在 `expandedBlocks` Set(不持久化) + +**打字机效果:** +- 思考块:无打字机效果,直接追加 +- 工具块 intent:0.5-1秒打字机效果 +- 历史加载时:跳过打字机效果 + +**堆叠机制:** +- 连续的思考/工具块会被分组 +- 默认显示最后6个,其余折叠 +- 使用 ResizeObserver 跟踪高度变化 + +### 3. 对话恢复机制 + +**`_rebuildingFromScratch` 标志:** +- 用于区分"从头重建"和"增量恢复" +- 影响思考块展开、工具意图动画、生成标签显示 +- 页面刷新后,如果历史不完整,从 offset=0 重放所有事件 + +**轮询恢复:** +- 150ms 间隔轮询 +- 通过 `lastEventIndex` 避免重复处理 +- 支持从任意 offset 恢复 + +### 4. 代码块复制功能 + +**可能的Bug原因:** +- 双事件监听系统(全局 + Vue方法) +- Clipboard API 权限问题 +- HTML 实体编码/解码问题 +- 流式输出时按钮添加时机 + +**实现位置:** +- `static/index.html` - 全局事件监听 +- `static/src/app/methods/ui.ts` - Vue 复制方法 +- `static/src/app/bootstrap.ts` - Markdown 渲染初始化 + +## 🔄 WebSocket vs 轮询模式 + +### 当前状态 + +系统已设置 `usePollingMode: true`,大部分聊天事件通过 REST API 轮询处理。 + +**WebSocket 仍在使用的场景:** +1. 子智能体通知(`sub_agent_waiting`) +2. 配额更新(`quota_update`) +3. 系统消息(`system_message`) +4. 错误处理(`error`) + +**事件过滤机制:** +```typescript +if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + // 跳过 WebSocket 事件处理 + return; +} +``` + +### 需要改造的部分 + +**完全移除 WebSocket 依赖:** +1. 删除 `useLegacySocket.ts` 中的聊天事件处理(~1200行) +2. 删除流式文本处理代码(`streamingState`,~500行) +3. 统一停止逻辑到 REST API +4. 子智能体通知改为 SSE(Server-Sent Events) + +**后端改造:** +1. `server/socket_handlers.py` - 简化为仅处理连接管理 +2. `server/tasks.py` - 添加任务清理、重试、优先级 +3. `server/chat_flow.py` - 移除 WebSocket emit + +## 📊 代码统计 + +### 前端关键文件 + +| 文件 | 行数 | 功能 | 需要改造 | +|------|------|------|---------| +| `useLegacySocket.ts` | 1706 | WebSocket 事件处理 | ✅ 删除聊天事件 | +| `taskPolling.ts` | 989 | 轮询事件处理 | ⚠️ 修复bug | +| `task.ts` (store) | 296 | 任务状态管理 | ⚠️ 添加重试 | +| `ChatArea.vue` | ~800 | 对话显示 | ⚠️ 修复UI bug | +| `InputComposer.vue` | ~200 | 输入框和停止按钮 | ⚠️ 简化状态 | + +### 后端关键文件 + +| 文件 | 行数 | 功能 | 需要改造 | +|------|------|------|---------| +| `socket_handlers.py` | 353 | WebSocket 处理 | ✅ 移除聊天事件 | +| `tasks.py` | 380 | 任务管理 | ⚠️ 增强功能 | +| `chat_flow.py` | 241 | 聊天流程 | ⚠️ 移除 WebSocket | + +## 🚀 重构优先级 + +### 优先级1(必须做) +1. ✅ 修复停止按钮的状态同步问题 +2. ✅ 修复代码块复制功能 +3. ✅ 修复重复推送内容的问题 +4. ✅ 修复任务无法结束的问题 + +### 优先级2(建议做) +1. 删除 `useLegacySocket.ts` 中的聊天事件处理 +2. 统一停止逻辑到 REST API +3. 添加轮询重试机制 +4. 任务清理机制 + +### 优先级3(可选) +1. WebSocket 改为 SSE +2. 任务存储改为 Redis +3. 改进错误提示 + +## 📝 使用说明 + +1. **阅读分析文档**:按照上述列表顺序阅读各个分析文档 +2. **对比实际网页**:在浏览器中打开系统,对比文档描述和实际行为 +3. **标记问题点**:记录不一致的地方、bug、历史包袱 +4. **制定重构计划**:根据优先级制定具体的重构步骤 + +## 🔗 相关资源 + +- [项目说明](../CLAUDE.md) +- [Git提交历史](../.git) - 查看架构演变 +- [前端源码](../static/src/) +- [后端源码](../server/) + +--- + +**生成时间**: 2026-04-04 +**分析工具**: Claude Code + 7个并行子智能体 +**总分析时间**: ~15分钟 diff --git a/docs/analysis-conversation-loading.md b/docs/analysis-conversation-loading.md new file mode 100644 index 0000000..b6828b5 --- /dev/null +++ b/docs/analysis-conversation-loading.md @@ -0,0 +1,790 @@ +# AI Agent 系统对话记录加载和显示机制分析 + +## 概述 + +本文档详细分析 AI Agent 系统中对话记录的加载和显示机制,包括切换对话时的完整流程、数据结构、防重复加载机制、加载动画、初始化处理等核心功能。 + +--- + +## 1. 切换对话时的加载流程 + +### 1.1 用户触发对话切换 + +用户可以通过以下方式切换对话: +- 点击侧边栏对话列表中的对话项 +- 通过浏览器前进/后退按钮(popstate 事件) +- 直接访问对话 URL(如 `/conv_20240101_120000_123`) + +### 1.2 前端发送的请求 + +当用户点击对话列表项时,触发 `loadConversation` 方法: + +**文件位置**: `/static/src/app/methods/conversation.ts` (164-276行) + +```typescript +async loadConversation(conversationId, options = {}) { + const force = Boolean(options.force); + + // 1. 检查是否已是当前对话 + if (!force && conversationId === this.currentConversationId) { + return; // 跳过加载 + } + + // 2. 检查是否有运行中的任务,提示用户确认 + if (hasActiveTask) { + const confirmed = await this.confirmAction({...}); + if (!confirmed) return; + } + + // 3. 调用加载API + const response = await fetch(`/api/conversations/${conversationId}/load`, { + method: 'PUT' + }); + + // 4. 更新当前对话信息 + this.currentConversationId = conversationId; + this.currentConversationTitle = result.title; + + // 5. 重置UI状态 + this.resetAllStates(`loadConversation:${conversationId}`); + + // 6. 立即加载历史和统计 + await this.fetchAndDisplayHistory(); + this.fetchConversationTokenStatistics(); +} +``` + +### 1.3 后端返回的数据结构 + +**API端点**: `PUT /api/conversations//load` + +**文件位置**: `/server/conversation.py` (232-271行) + +```python +@conversation_bp.route('/api/conversations//load', methods=['PUT']) +def load_conversation(conversation_id, terminal, workspace, username): + # 1. 终止当前对话的运行中子智能体 + _terminate_running_sub_agents(terminal, reason="用户切换对话") + + # 2. 调用终端的加载方法 + result = terminal.load_conversation(conversation_id) + + # 3. 广播对话切换事件 + socketio.emit('conversation_changed', { + 'conversation_id': conversation_id, + 'title': result.get("title", "未知对话"), + 'messages_count': result.get("messages_count", 0) + }, room=f"user_{username}") + + # 4. 清理和重置相关UI状态 + socketio.emit('conversation_loaded', { + 'conversation_id': conversation_id, + 'clear_ui': True + }, room=f"user_{username}") + + return jsonify(result) +``` + +**返回数据结构**: +```json +{ + "success": true, + "title": "对话标题", + "messages_count": 15, + "conversation_id": "conv_20240101_120000_123" +} +``` + +### 1.4 前端渲染历史消息 + +加载对话后,前端立即调用 `fetchAndDisplayHistory` 获取完整消息历史: + +**文件位置**: `/static/src/app/methods/history.ts` (8-123行) + +```typescript +async fetchAndDisplayHistory(options = {}) { + const targetConversationId = this.currentConversationId; + + // 1. 防止重复加载 + if (this.historyLoading && this.historyLoadingFor === targetConversationId) { + return; // 同一对话正在加载 + } + + const alreadyHydrated = + !force && + this.lastHistoryLoadedConversationId === targetConversationId && + this.messages.length > 0; + if (alreadyHydrated) { + return; // 历史已加载,跳过 + } + + // 2. 设置加载状态 + this.historyLoading = true; + this.historyLoadingFor = targetConversationId; + + // 3. 获取消息历史 + const messagesResponse = await fetch( + `/api/conversations/${targetConversationId}/messages` + ); + const messagesData = await messagesResponse.json(); + + // 4. 检查对话是否已切换 + if (this.currentConversationId !== targetConversationId) { + return; // 丢弃过期的历史加载结果 + } + + // 5. 渲染历史消息 + this.messages = []; + this.renderHistoryMessages(messages); + + // 6. 滚动到底部 + this.$nextTick(() => { + this.scrollToBottom(); + }); +} +``` + +--- + +## 2. 历史消息的数据结构 + +### 2.1 消息格式 + +**API端点**: `GET /api/conversations//messages` + +**文件位置**: `/server/conversation.py` (353-391行) + +**返回数据结构**: +```json +{ + "success": true, + "data": { + "conversation_id": "conv_20240101_120000_123", + "messages": [ + { + "role": "user", + "content": "用户消息内容", + "images": ["path/to/image.png"], + "videos": ["path/to/video.mp4"], + "metadata": { + "images": [...], + "videos": [...] + } + }, + { + "role": "assistant", + "content": "AI回复内容", + "reasoning_content": "思考过程(可选)", + "tool_calls": [ + { + "id": "call_abc123", + "function": { + "name": "read_file", + "arguments": "{\"path\": \"/workspace/file.txt\"}" + } + } + ], + "metadata": { + "model_key": "deepseek-chat" + } + }, + { + "role": "tool", + "name": "read_file", + "tool_call_id": "call_abc123", + "content": "{\"output\": \"文件内容\", \"success\": true}", + "metadata": { + "tool_payload": {...} + } + } + ], + "total_count": 3 + } +} +``` + +### 2.2 思考块的存储 + +思考内容存储在 `assistant` 消息的 `reasoning_content` 字段中: + +```json +{ + "role": "assistant", + "reasoning_content": "让我分析一下这个问题...", + "content": "根据分析,我建议..." +} +``` + +前端渲染时,将思考内容转换为独立的 action: + +```typescript +if (reasoningText) { + currentAssistantMessage.actions.push({ + id: `history-thinking-${Date.now()}-${Math.random()}`, + type: 'thinking', + content: reasoningText, + streaming: false, + collapsed: true, // 默认折叠 + timestamp: Date.now() + }); +} +``` + +### 2.3 工具块的存储 + +工具调用分为两部分: + +1. **工具调用请求** (assistant 消息的 `tool_calls` 字段) +2. **工具执行结果** (独立的 tool 消息) + +前端渲染时,将工具调用和结果关联: + +**文件位置**: `/static/src/app/methods/history.ts` (218-309行) + +```typescript +// 处理工具调用 +if (message.tool_calls && Array.isArray(message.tool_calls)) { + message.tool_calls.forEach((toolCall) => { + const action = { + id: `history-tool-${toolCall.id}`, + type: 'tool', + tool: { + id: toolCall.id, + name: toolCall.function.name, + arguments: JSON.parse(toolCall.function.arguments), + status: 'preparing', + result: null + } + }; + currentAssistantMessage.actions.push(action); + }); +} + +// 处理工具结果 +if (message.role === 'tool') { + // 查找对应的工具action + const toolAction = currentAssistantMessage.actions.find(action => + action.type === 'tool' && + action.tool.id === message.tool_call_id + ); + + if (toolAction) { + toolAction.tool.status = 'completed'; + toolAction.tool.result = JSON.parse(message.content); + } +} +``` + +### 2.4 图片和视频的存储 + +图片和视频路径存储在用户消息的 `images` 和 `videos` 字段中: + +```json +{ + "role": "user", + "content": "请看这张图片", + "images": ["/user_upload/images/photo.png"], + "videos": ["/user_upload/videos/demo.mp4"], + "metadata": { + "images": [...], + "videos": [...] + } +} +``` + +前端渲染时,从 `images` 或 `metadata.images` 中提取: + +```typescript +const images = message.images || (message.metadata && message.metadata.images) || []; +const videos = message.videos || (message.metadata && message.metadata.videos) || []; + +this.messages.push({ + role: 'user', + content: message.content || '', + images, + videos +}); +``` + +--- + +## 3. 防止重复加载的机制 + +### 3.1 lastHistoryLoadedConversationId 的作用 + +**文件位置**: `/static/src/app/state.ts` (24-25行) + +```typescript +// 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次 +lastHistoryLoadedConversationId: null, +``` + +**作用**: +- 记录最后一次成功加载历史的对话 ID +- 防止同一对话的历史被重复加载 +- 避免初始化阶段动画播放两次 + +### 3.2 如何判断是否需要重新加载 + +**文件位置**: `/static/src/app/methods/history.ts` (24-35行) + +```typescript +const alreadyHydrated = + !force && + this.lastHistoryLoadedConversationId === targetConversationId && + Array.isArray(this.messages) && + this.messages.length > 0; + +if (alreadyHydrated) { + debugLog('历史已加载,跳过重复请求'); + return; +} +``` + +**判断条件**: +1. 非强制刷新 (`!force`) +2. 目标对话 ID 与上次加载的 ID 相同 +3. 消息数组存在且不为空 + +### 3.3 缓存机制 + +系统使用多层缓存机制: + +1. **前端消息缓存**: `this.messages` 数组 +2. **历史加载标记**: `lastHistoryLoadedConversationId` +3. **加载状态跟踪**: `historyLoading` 和 `historyLoadingFor` + +**并发加载保护**: + +```typescript +// 若同一对话正在加载,直接复用 +if (this.historyLoading && this.historyLoadingFor === targetConversationId) { + debugLog('同一对话历史正在加载,跳过重复请求'); + return; +} + +// 使用序列号防止过期响应 +const loadSeq = ++this.historyLoadSeq; +this.historyLoading = true; +this.historyLoadingFor = targetConversationId; + +// 响应返回时检查序列号 +if (loadSeq !== this.historyLoadSeq || + this.currentConversationId !== targetConversationId) { + debugLog('检测到对话已切换,丢弃过期的历史加载结果'); + return; +} +``` + +--- + +## 4. 加载动画和状态提示 + +### 4.1 historyLoading 状态 + +**文件位置**: `/static/src/app/state.ts` (46-48行) + +```typescript +historyLoading: false, // 是否正在加载历史 +historyLoadingFor: null, // 正在加载哪个对话的历史 +historyLoadSeq: 0, // 加载序列号 +``` + +**状态管理**: + +```typescript +// 开始加载 +const loadSeq = ++this.historyLoadSeq; +this.historyLoading = true; +this.historyLoadingFor = targetConversationId; + +// 加载完成 +finally { + // 仅在本次加载仍是最新请求时清除 loading 状态 + if (loadSeq === this.historyLoadSeq) { + this.historyLoading = false; + this.historyLoadingFor = null; + } +} +``` + +### 4.2 加载动画的显示 + +前端可以根据 `historyLoading` 状态显示加载动画: + +```vue +
+
+

加载对话历史...

+
+``` + +### 4.3 加载失败的处理 + +**文件位置**: `/static/src/app/methods/history.ts` (107-114行) + +```typescript +catch (error) { + console.error('获取历史对话失败:', error); + debugLog('尝试不显示错误弹窗,仅在控制台记录'); + // 不显示alert,避免打断用户体验 + this.messages = []; +} +``` + +**失败处理策略**: +- 仅在控制台记录错误 +- 不显示弹窗,避免打断用户体验 +- 清空消息数组,显示空白状态 + +--- + +## 5. 初始化阶段的特殊处理 + +### 5.1 防止动画播放两次 + +**问题**: 初始化时可能触发多次历史加载,导致动画重复播放 + +**解决方案**: 使用 `lastHistoryLoadedConversationId` 标记 + +**文件位置**: `/static/src/app/methods/history.ts` (336行) + +```typescript +this.lastHistoryLoadedConversationId = this.currentConversationId || null; +``` + +**流程**: +1. 首次加载历史后,记录对话 ID +2. 后续加载前检查是否已加载 +3. 如果已加载且消息不为空,跳过加载 + +### 5.2 initialRouteResolved 的作用 + +**文件位置**: `/static/src/app/state.ts` (7行) + +```typescript +initialRouteResolved: false, +``` + +**作用**: +- 标记初始路由是否已解析完成 +- 防止路由解析期间触发不必要的对话加载 +- 避免与 `bootstrapRoute` 冲突 + +**使用场景**: + +1. **对话列表自动加载**: + +```typescript +// 只有在初始化完成后,才自动加载第一个对话 +if (this.initialRouteResolved) { + const latestConversation = this.conversations[0]; + if (latestConversation && latestConversation.id) { + await this.loadConversation(latestConversation.id); + } +} +``` + +2. **Socket 事件处理**: + +```typescript +// 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突 +if (ctx.initialRouteResolved && data && data.conversation_id) { + ctx.currentConversationId = data.conversation_id; +} +``` + +### 5.3 路由解析的流程 + +**文件位置**: `/static/src/app/methods/ui.ts` (967-1014行) + +```typescript +async bootstrapRoute() { + // 1. 抑制标题动画 + this.suppressTitleTyping = true; + this.titleReady = false; + + // 2. 解析路径 + const path = window.location.pathname.replace(/^\/+/, ''); + + // 3. 处理新对话 + if (!path || path === 'new') { + this.currentConversationId = null; + this.currentConversationTitle = '新对话'; + this.initialRouteResolved = true; + return; + } + + // 4. 加载指定对话 + const convId = path.startsWith('conv_') ? path : `conv_${path}`; + const resp = await fetch(`/api/conversations/${convId}/load`, { + method: 'PUT' + }); + const result = await resp.json(); + + if (result.success) { + this.currentConversationId = convId; + this.currentConversationTitle = result.title || ''; + history.replaceState({ conversationId: convId }, '', `/${convId}`); + } else { + // 加载失败,回退到新对话 + history.replaceState({}, '', '/new'); + this.currentConversationId = null; + this.currentConversationTitle = '新对话'; + } + + // 5. 标记路由解析完成 + this.initialRouteResolved = true; +} +``` + +**调用时机**: + +**文件位置**: `/static/src/app/lifecycle.ts` (32-33行) + +```typescript +async mounted() { + // 并行启动路由解析与初始化数据 + const routePromise = this.bootstrapRoute(); + await routePromise; + + // 立即加载初始数据 + this.loadInitialData(); +} +``` + +--- + +## 6. 对话切换的状态管理 + +### 6.1 当前对话ID的存储 + +**状态存储位置**: + +1. **前端状态**: `this.currentConversationId` +2. **浏览器历史**: `history.pushState({ conversationId }, '', '/conv_id')` +3. **后端会话**: 服务器端 `terminal.context_manager.current_conversation_id` + +**同步机制**: + +```typescript +// 前端切换对话 +this.currentConversationId = conversationId; +history.pushState({ conversationId }, '', `/${conversationId}`); + +// 后端加载对话 +terminal.load_conversation(conversation_id) +``` + +### 6.2 切换时的状态清理 + +**文件位置**: `/static/src/app/methods/conversation.ts` (6-79行) + +```typescript +resetAllStates(reason = 'unspecified', options = {}) { + // 1. 检查是否正在等待子智能体 + if (this.waitingForSubAgent) { + return; // 跳过状态重置 + } + + // 2. 重置消息和流状态 + this.streamingMessage = false; + this.currentMessageIndex = -1; + this.stopRequested = false; + this.taskInProgress = false; + this.dropToolEvents = false; + + // 3. 清理工具状态 + this.toolResetTracking(); + + // 4. 将所有未完成的工具标记为已完成 + this.messages.forEach(msg => { + if (msg.role === 'assistant' && msg.actions) { + msg.actions.forEach(action => { + if (action.type === 'tool' && + (action.tool.status === 'preparing' || + action.tool.status === 'running')) { + action.tool.status = 'completed'; + } + }); + } + }); + + // 5. 清理Markdown缓存 + if (this.markdownCache) { + this.markdownCache.clear(); + } + + // 6. 重置输入状态 + this.inputClearMessage(); + this.inputClearSelectedImages(); + this.imageEntries = []; + this.imageLoading = false; + + // 7. 重置已加载对话标记 + this.lastHistoryLoadedConversationId = null; + + // 8. 强制更新视图 + this.$forceUpdate(); +} +``` + +### 6.3 任务状态的处理 + +**切换对话前检查运行中的任务**: + +**文件位置**: `/static/src/app/methods/conversation.ts` (182-214行) + +```typescript +// 有任务或后台子智能体运行时,提示用户确认切换 +const hasActiveTask = taskStore.hasActiveTask || + this.taskInProgress || + this.waitingForSubAgent; + +if (hasActiveTask) { + const confirmed = await this.confirmAction({ + title: '切换对话', + message: this.waitingForSubAgent + ? '后台子智能体正在运行,切换对话后将不会自动接收完成提示。确定要切换吗?' + : '当前有任务正在执行,切换对话后任务会停止。确定要切换吗?', + confirmText: '切换', + cancelText: '取消' + }); + + if (!confirmed) { + return; // 用户取消切换 + } + + // 停止任务 + if (taskStore.hasActiveTask) { + await taskStore.cancelTask(); + taskStore.clearTask(); + } + + // 重置任务状态 + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; + this.waitingForSubAgent = false; +} +``` + +--- + +## 7. 完整流程图 + +``` +用户点击对话列表 + ↓ +检查是否已是当前对话 + ↓ (否) +检查是否有运行中的任务 + ↓ (无任务或用户确认) +调用 loadConversation(conversationId) + ↓ +发送 PUT /api/conversations/{id}/load + ↓ +后端终止运行中的子智能体 + ↓ +后端加载对话数据 + ↓ +后端广播 conversation_changed 事件 + ↓ +前端更新 currentConversationId + ↓ +前端更新 currentConversationTitle + ↓ +前端调用 resetAllStates() + ├─ 重置消息和流状态 + ├─ 清理工具状态 + ├─ 清理Markdown缓存 + ├─ 重置输入状态 + └─ 重置 lastHistoryLoadedConversationId + ↓ +前端调用 fetchAndDisplayHistory() + ├─ 检查是否已加载 (lastHistoryLoadedConversationId) + ├─ 设置加载状态 (historyLoading = true) + ├─ 发送 GET /api/conversations/{id}/messages + ├─ 检查对话是否已切换 (防止过期响应) + ├─ 清空当前消息 (this.messages = []) + └─ 调用 renderHistoryMessages(messages) + ├─ 遍历历史消息 + ├─ 处理用户消息 (images, videos) + ├─ 处理AI消息 (reasoning_content, tool_calls) + ├─ 处理工具结果 (tool role) + ├─ 关联工具调用和结果 + └─ 更新 lastHistoryLoadedConversationId + ↓ +前端滚动到底部 + ↓ +前端获取Token统计 + ↓ +完成 +``` + +--- + +## 8. 关键文件索引 + +### 前端文件 + +| 文件路径 | 主要功能 | +|---------|---------| +| `/static/src/app/methods/conversation.ts` | 对话管理核心功能 | +| `/static/src/app/methods/history.ts` | 历史消息加载和渲染 | +| `/static/src/app/methods/ui.ts` | UI状态管理和路由解析 | +| `/static/src/app/state.ts` | 应用状态定义 | +| `/static/src/app/lifecycle.ts` | 生命周期钩子 | +| `/static/src/stores/conversation.ts` | 对话状态存储 | + +### 后端文件 + +| 文件路径 | 主要功能 | +|---------|---------| +| `/server/conversation.py` | 对话API端点 | +| `/utils/conversation_manager.py` | 对话持久化管理 | + +--- + +## 9. 最佳实践和注意事项 + +### 9.1 防止重复加载 + +1. 使用 `lastHistoryLoadedConversationId` 标记已加载的对话 +2. 使用 `historyLoadSeq` 序列号防止过期响应 +3. 检查 `historyLoading` 状态避免并发加载 + +### 9.2 用户体验优化 + +1. 切换对话前检查运行中的任务,提示用户确认 +2. 加载失败时不显示弹窗,仅在控制台记录 +3. 使用标题打字效果提升视觉体验 +4. 自动滚动到底部,确保用户看到最新消息 + +### 9.3 性能优化 + +1. 并行加载路由和初始数据 +2. 使用 `$nextTick` 确保DOM更新后再滚动 +3. 清理Markdown缓存避免内存泄漏 +4. 使用序列号机制丢弃过期响应 + +### 9.4 状态一致性 + +1. 前端和后端同步 `currentConversationId` +2. 浏览器历史状态与应用状态保持一致 +3. 切换对话时完整重置UI状态 +4. 使用 `initialRouteResolved` 标记防止冲突 + +--- + +## 10. 总结 + +AI Agent 系统的对话加载机制设计精巧,通过多层缓存、状态标记、序列号机制等手段,确保了对话切换的流畅性和数据一致性。核心特点包括: + +1. **防重复加载**: 使用 `lastHistoryLoadedConversationId` 和 `historyLoadSeq` 防止重复加载 +2. **并发保护**: 使用序列号机制丢弃过期响应 +3. **用户体验**: 任务确认、加载动画、自动滚动等细节优化 +4. **状态管理**: 完整的状态重置和同步机制 +5. **初始化处理**: 使用 `initialRouteResolved` 防止路由冲突 + +这套机制为多对话管理提供了坚实的基础,值得在类似系统中借鉴。 diff --git a/docs/analysis-stop-button.md b/docs/analysis-stop-button.md new file mode 100644 index 0000000..2f21b06 --- /dev/null +++ b/docs/analysis-stop-button.md @@ -0,0 +1,741 @@ +# AI Agent 系统停止按钮和运行状态机制分析 + +## 概述 + +本文档详细分析 AI Agent 系统中停止按钮和运行状态机制的实现,这是系统中 bug 最多的部分。系统采用 REST API + 轮询模式(`usePollingMode: true`),已禁用 WebSocket 实时推送。 + +## 1. 停止按钮的显示/隐藏逻辑 + +### 1.1 组件定义 + +**文件位置**: `/static/src/components/input/InputComposer.vue:48-61` + +```vue + +``` + +### 1.2 显示条件 + +停止按钮的显示由 `streamingMessage` 状态控制: +- **显示停止按钮**: `streamingMessage === true` +- **显示发送按钮**: `streamingMessage === false` + +### 1.3 禁用条件 + +按钮被禁用的情况: +1. 未连接服务器 (`!isConnected`) +2. 输入被锁定且不在流式输出中 (`inputLocked && !streamingMessage`) +3. 媒体上传中且不在流式输出中 (`mediaUploading && !streamingMessage`) +4. 没有输入内容且不在流式输出中 + +### 1.4 关键状态变量 + +**文件位置**: `/static/src/app/state.ts:23` + +```typescript +taskInProgress: false, // 任务是否在进行中(用于保持输入区的"停止"状态) +``` + +**文件位置**: `/static/src/stores/chat.ts:78` + +```typescript +streamingMessage: false, // 是否正在流式输出消息 +``` + +**文件位置**: `/static/src/app/computed.ts:160-166` + +```typescript +streamingUi() { + return this.streamingMessage || this.hasPendingToolActions(); +}, +composerBusy() { + const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor'; + return this.streamingUi || this.taskInProgress || monitorLock || this.stopRequested; +} +``` + +## 2. 点击停止按钮的完整流程 + +### 2.1 前端事件处理 + +**文件位置**: `/static/src/app/methods/message.ts:104-110` + +```typescript +handleSendOrStop() { + if (this.composerBusy) { + this.stopTask(); + } else { + this.sendMessage(); + } +}, +``` + +### 2.2 停止任务方法 + +**文件位置**: `/static/src/app/methods/message.ts:254-282` + +```typescript +async stopTask() { + const canStop = this.composerBusy && !this.stopRequested; + if (!canStop) { + return; + } + + const shouldDropToolEvents = this.streamingUi; + this.stopRequested = true; + this.dropToolEvents = shouldDropToolEvents; + + // 使用 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); + } + + // 立即清理前端状态,避免出现"不可输入也不可停止"的卡死状态 + this.clearPendingTools('user_stop'); + this.streamingMessage = false; + this.taskInProgress = false; + this.forceUnlockMonitor('user_stop'); +}, +``` + +### 2.3 REST API 取消请求 + +**文件位置**: `/static/src/stores/task.ts:185-214` + +```typescript +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; + } +}, +``` + +### 2.4 后端接收停止请求 + +**文件位置**: `/server/tasks.py:372-379` + +```python +@tasks_bp.route("/api/tasks//cancel", methods=["POST"]) +@api_login_required +def cancel_task_api(task_id: str): + username = get_current_username() + ok = task_manager.cancel_task(username, task_id) + if not ok: + return jsonify({"success": False, "error": "任务不存在"}), 404 + return jsonify({"success": True}) +``` + +### 2.5 设置停止标志 + +**文件位置**: `/server/tasks.py:144-164` + +```python +def cancel_task(self, username: str, task_id: str) -> bool: + rec = self.get_task(username, task_id) + if not rec: + return False + rec.stop_requested = True + # 标记停止标志;chat_flow 会检测 stop_flags + entry = stop_flags.get(task_id) + if not isinstance(entry, dict): + entry = {'stop': False, 'task': None, 'terminal': None} + stop_flags[task_id] = entry + entry['stop'] = True + # 注释掉直接取消任务,改为通过停止标志让任务内部处理 + # try: + # if entry.get('task') and hasattr(entry['task'], "cancel"): + # entry['task'].cancel() + # except Exception: + # pass + with self._lock: + rec.status = "cancel_requested" + rec.updated_at = time.time() + return True +``` + +### 2.6 聊天流程检测停止标志 + +系统在多个关键位置检查停止标志: + +**位置1**: `/server/chat_flow_tool_loop.py:23-30` - 工具调用前检查 + +```python +# 检查停止标志 +client_stop_info = get_stop_flag(client_sid, username) +if client_stop_info: + stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info + if stop_requested: + debug_log("在工具调用过程中检测到停止状态") + # ... 取消工具调用 +``` + +**位置2**: `/server/chat_flow_tool_loop.py:229-236` - 工具执行过程中检查 + +```python +check_count += 1 +client_stop_info = get_stop_flag(client_sid, username) +if client_stop_info: + stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info + if stop_requested: + debug_log(f"[停止检测] 工具执行过程中检测到停止请求(检查次数:{check_count}),立即取消工具") + tool_task.cancel() + tool_cancelled = True +``` + +**位置3**: `/server/chat_flow_stream_loop.py:46-53` - 流式输出过程中检查 + +```python +# 检查停止标志 +client_stop_info = get_stop_flag(client_sid, username) +if client_stop_info: + stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info + if stop_requested: + debug_log(f"检测到停止请求,中断流处理") + cancel_pending_tools(tool_calls_list=tool_calls, sender=sender, messages=messages) + sender('task_stopped', {...}) +``` + +**位置4**: `/server/chat_flow_task_support.py:147-154` - 命令执行过程中检查 + +```python +while time.time() < deadline: + client_stop_info = get_stop_flag(client_sid, username) + if client_stop_info: + stop_requested = client_stop_info.get('stop', False) if isinstance(client_stop_info, dict) else client_stop_info + if stop_requested: + sender('task_stopped', { + 'message': '命令执行被用户取消', + 'reason': 'user_stop' + }) +``` + +## 3. 运行状态的设置和清除 + +### 3.1 taskInProgress 状态管理 + +**设置为 true 的时机**: + +1. **发送消息时** (`/static/src/app/methods/message.ts:200`) +```typescript +this.taskInProgress = true; +``` + +2. **AI消息开始时** (`/static/src/app/methods/taskPolling.ts:128,138`) +```typescript +this.taskInProgress = true; +``` + +3. **收到用户消息事件时** (`/static/src/app/methods/taskPolling.ts:566`) +```typescript +this.taskInProgress = true; +``` + +4. **恢复任务状态时** (`/static/src/app/methods/taskPolling.ts:739,882`) +```typescript +this.taskInProgress = true; +``` + +**设置为 false 的时机**: + +1. **任务完成时** (`/static/src/app/methods/taskPolling.ts:538`) +```typescript +this.taskInProgress = false; +``` + +2. **任务错误时** (`/static/src/app/methods/taskPolling.ts:588`) +```typescript +this.taskInProgress = false; +``` + +3. **停止任务时** (`/static/src/app/methods/message.ts:280`) +```typescript +this.taskInProgress = false; +``` + +4. **创建任务失败时** (`/static/src/app/methods/message.ts:218`) +```typescript +this.taskInProgress = false; +``` + +### 3.2 streamingMessage 状态管理 + +**设置为 true 的时机**: + +1. **AI消息开始时** (`/static/src/app/methods/taskPolling.ts:130,141`) +```typescript +this.streamingMessage = true; +``` + +2. **恢复任务状态时** (`/static/src/app/methods/taskPolling.ts:738,881`) +```typescript +this.streamingMessage = true; +``` + +**设置为 false 的时机**: + +1. **任务完成时** (`/static/src/app/methods/taskPolling.ts:532`) +```typescript +this.streamingMessage = false; +``` + +2. **任务错误时** (`/static/src/app/methods/taskPolling.ts:587`) +```typescript +this.streamingMessage = false; +``` + +3. **停止任务时** (`/static/src/app/methods/message.ts:279`) +```typescript +this.streamingMessage = false; +``` + +4. **收到用户消息事件时** (`/static/src/app/methods/taskPolling.ts:567`) +```typescript +this.streamingMessage = false; +``` + +### 3.3 stopRequested 状态管理 + +**设置为 true 的时机**: + +1. **停止任务时** (`/static/src/app/methods/message.ts:261`) +```typescript +this.stopRequested = true; +``` + +**设置为 false 的时机**: + +1. **AI消息开始时** (`/static/src/app/methods/taskPolling.ts:129,140`) +```typescript +this.stopRequested = false; +``` + +2. **任务完成时** (`/static/src/app/methods/taskPolling.ts:533`) +```typescript +this.stopRequested = false; +``` + +3. **任务错误时** (`/static/src/app/methods/taskPolling.ts:589`) +```typescript +this.stopRequested = false; +``` + +## 4. 状态转换图 + +``` +[用户点击发送] + ↓ +[taskInProgress = true] +[streamingMessage = false] + ↓ +[创建任务 POST /api/tasks] + ↓ +[开始轮询 GET /api/tasks/{id}?from={offset}] + ↓ +[收到 ai_message_start 事件] + ↓ +[streamingMessage = true] + ↓ +[显示停止按钮] + ↓ +┌─────────────────────────────────┐ +│ 用户点击停止按钮 │ +│ ↓ │ +│ [stopRequested = true] │ +│ ↓ │ +│ [POST /api/tasks/{id}/cancel] │ +│ ↓ │ +│ [stop_flags[task_id]['stop'] = True] │ +│ ↓ │ +│ [立即清理前端状态] │ +│ [streamingMessage = false] │ +│ [taskInProgress = false] │ +│ [stopRequested = false] │ +│ ↓ │ +│ [显示发送按钮] │ +└─────────────────────────────────┘ + 或 + ↓ +[收到 task_complete 事件] + ↓ +[streamingMessage = false] +[taskInProgress = false] +[stopRequested = false] + ↓ +[停止轮询] + ↓ +[显示发送按钮] +``` + +## 5. 事件流程图 + +### 5.1 正常完成流程 + +``` +前端 后端 任务线程 +│ │ │ +├─ sendMessage() │ │ +├─ taskInProgress = true │ │ +├─ POST /api/tasks ─────────>│ │ +│ ├─ create_chat_task() │ +│ ├─ thread.start() ──────────>│ +│ │ ├─ run_chat_task_sync() +│<──── task_id ──────────────┤ │ +├─ startPolling() │ │ +│ │ │ +├─ GET /api/tasks/{id} ─────>│ │ +│<──── events ────────────────┤ │ +├─ handleTaskEvent() │ │ +├─ streamingMessage = true │ │ +│ │ │ +│ (轮询继续...) │ │ +│ │ │ +│ │ ├─ 任务完成 +│ │<──── task_complete ────────┤ +├─ GET /api/tasks/{id} ─────>│ │ +│<──── task_complete ─────────┤ │ +├─ handleTaskComplete() │ │ +├─ streamingMessage = false │ │ +├─ taskInProgress = false │ │ +├─ stopPolling() │ │ +``` + +### 5.2 用户停止流程 + +``` +前端 后端 任务线程 +│ │ │ +├─ (任务运行中) │ │ +├─ streamingMessage = true │ │ +│ │ │ +├─ stopTask() │ │ +├─ stopRequested = true │ │ +├─ POST /api/tasks/{id}/cancel ─>│ │ +│ ├─ cancel_task() │ +│ ├─ stop_flags[task_id]['stop'] = True +│<──── success ──────────────┤ │ +├─ streamingMessage = false │ │ +├─ taskInProgress = false │ │ +├─ stopRequested = false │ │ +│ │ │ +│ │ ├─ get_stop_flag() +│ │ ├─ 检测到停止标志 +│ │ ├─ 取消工具调用 +│ │ ├─ sender('task_stopped') +│ │<──── task_stopped ─────────┤ +│ ├─ status = 'canceled' │ +│ ├─ stop_flags.pop(task_id) │ +``` + +## 6. 停止后的UI状态变化 + +### 6.1 输入框状态 + +**文件位置**: `/static/src/components/input/InputComposer.vue:41` + +```vue +:disabled="!isConnected || streamingMessage || inputLocked" +``` + +- **停止前**: `streamingMessage = true` → 输入框禁用 +- **停止后**: `streamingMessage = false` → 输入框启用 + +### 6.2 停止按钮变为发送按钮 + +**文件位置**: `/static/src/components/input/InputComposer.vue:59-60` + +```vue + + +``` + +- **停止前**: 显示停止图标 +- **停止后**: 显示发送图标 + +### 6.3 工具块状态 + +停止时会调用 `clearPendingTools('user_stop')`,清理所有待处理的工具调用。 + +## 7. 已知问题和Bug + +### 7.1 停止按钮无法点击 + +**问题描述**: 在某些情况下,停止按钮显示但无法点击。 + +**可能原因**: +1. `composerBusy` 计算错误,导致 `canStop` 条件不满足 +2. `stopRequested` 已经为 true,阻止重复停止 +3. 按钮被其他元素遮挡(CSS z-index 问题) + +**相关代码**: `/static/src/app/methods/message.ts:255-258` + +```typescript +const canStop = this.composerBusy && !this.stopRequested; +if (!canStop) { + return; +} +``` + +### 7.2 停止后任务仍在运行 + +**问题描述**: 点击停止按钮后,前端状态已清除,但后端任务仍在执行。 + +**可能原因**: +1. 后端停止标志检查不够频繁 +2. 某些长时间运行的工具没有检查停止标志 +3. 停止标志设置和检查之间存在竞态条件 + +**相关代码**: +- `/server/chat_flow_tool_loop.py:229-236` - 工具执行过程中每 0.1 秒检查一次 +- `/server/chat_flow_task_support.py:147-154` - 命令执行过程中检查 + +### 7.3 状态不同步 + +**问题描述**: 前端和后端的任务状态不一致。 + +**可能原因**: +1. 轮询间隔过长(150ms),导致状态更新延迟 +2. 事件处理顺序错误 +3. 多个状态变量(`taskInProgress`, `streamingMessage`, `stopRequested`)更新不原子 + +**相关代码**: `/static/src/stores/task.ts:154-157` + +```typescript +// 设置定时轮询(150ms 间隔,接近流式输出效果) +this.pollingInterval = window.setInterval(() => { + this.pollTaskEvents(handler); +}, 150); +``` + +### 7.4 页面刷新后状态丢失 + +**问题描述**: 页面刷新后,运行中的任务状态无法正确恢复。 + +**解决方案**: 已实现 `restoreTaskState()` 方法,在页面加载时查找运行中的任务并恢复状态。 + +**相关代码**: `/static/src/app/methods/taskPolling.ts:637-921` + +```typescript +async restoreTaskState() { + // 查找运行中的任务 + const runningTask = await taskStore.loadRunningTask(this.currentConversationId); + + if (!runningTask) { + return; + } + + // 恢复状态 + this.streamingMessage = true; + this.taskInProgress = true; + + // 启动轮询 + taskStore.startPolling((event: any) => { + this.handleTaskEvent(event); + }); +} +``` + +### 7.5 立即清理导致的问题 + +**问题描述**: 停止任务时立即清理前端状态,可能导致后端仍在发送事件,前端无法正确处理。 + +**相关代码**: `/static/src/app/methods/message.ts:278-281` + +```typescript +// 立即清理前端状态,避免出现"不可输入也不可停止"的卡死状态 +this.clearPendingTools('user_stop'); +this.streamingMessage = false; +this.taskInProgress = false; +this.forceUnlockMonitor('user_stop'); +``` + +**潜在风险**: +- 后端可能仍在发送 `tool_start`, `text_chunk` 等事件 +- 前端已清理状态,这些事件会被忽略或导致错误 +- 可能出现 UI 不一致的情况 + +### 7.6 dropToolEvents 机制 + +**问题描述**: `dropToolEvents` 标志用于在停止后忽略工具事件,但可能导致状态不一致。 + +**相关代码**: +- `/static/src/app/methods/message.ts:262` - 设置标志 +- `/static/src/app/methods/taskPolling.ts:294-296` - 检查标志 + +```typescript +if (this.dropToolEvents) { + return; +} +``` + +**潜在问题**: +- 如果后端发送了 `task_complete` 事件,但前端已经 `dropToolEvents`,可能导致状态不清理 +- `dropToolEvents` 何时清除不明确 + +### 7.7 composerBusy 计算复杂 + +**问题描述**: `composerBusy` 依赖多个状态变量,计算逻辑复杂,容易出错。 + +**相关代码**: `/static/src/app/computed.ts:163-166` + +```typescript +composerBusy() { + const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor'; + return this.streamingUi || this.taskInProgress || monitorLock || this.stopRequested; +} +``` + +**问题**: +- `streamingUi` 又依赖 `streamingMessage` 和 `hasPendingToolActions()` +- 多层依赖导致状态更新顺序敏感 +- 难以调试和维护 + +## 8. 改进建议 + +### 8.1 统一状态管理 + +建议使用单一的状态机模式,而不是多个独立的布尔变量: + +```typescript +enum TaskState { + IDLE = 'idle', + CREATING = 'creating', + RUNNING = 'running', + STREAMING = 'streaming', + STOPPING = 'stopping', + STOPPED = 'stopped', + COMPLETED = 'completed', + FAILED = 'failed' +} + +state: { + taskState: TaskState.IDLE +} +``` + +### 8.2 增加停止确认机制 + +在前端立即清理状态后,等待后端确认停止成功: + +```typescript +async stopTask() { + this.stopRequested = true; + + // 发送停止请求 + await taskStore.cancelTask(); + + // 等待后端确认(轮询直到收到 task_stopped 或 task_complete) + await this.waitForStopConfirmation(); + + // 清理前端状态 + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; +} +``` + +### 8.3 增加停止超时机制 + +如果后端在一定时间内没有响应停止请求,强制清理前端状态: + +```typescript +const STOP_TIMEOUT = 5000; // 5秒 + +setTimeout(() => { + if (this.stopRequested) { + // 强制清理 + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; + } +}, STOP_TIMEOUT); +``` + +### 8.4 改进停止标志检查频率 + +在长时间运行的操作中,增加停止标志检查频率: + +```python +# 当前: 每 0.1 秒检查一次 +await asyncio.sleep(0.1) + +# 建议: 每 0.05 秒检查一次 +await asyncio.sleep(0.05) +``` + +### 8.5 添加停止日志 + +在所有停止相关的代码路径中添加详细日志,便于调试: + +```typescript +debugLog('[Stop] 用户点击停止按钮', { + composerBusy: this.composerBusy, + stopRequested: this.stopRequested, + taskInProgress: this.taskInProgress, + streamingMessage: this.streamingMessage, + currentTaskId: taskStore.currentTaskId +}); +``` + +### 8.6 前端状态恢复优化 + +页面刷新后的状态恢复逻辑复杂,建议: +1. 简化恢复逻辑,只恢复必要的状态 +2. 添加恢复失败的降级处理 +3. 提供手动刷新按钮,让用户可以重新加载状态 + +## 9. 总结 + +停止按钮和运行状态机制是系统中最复杂的部分之一,涉及前端多个状态变量、REST API 轮询、后端停止标志检查等多个环节。主要问题包括: + +1. **状态管理复杂**: 多个独立的布尔变量(`taskInProgress`, `streamingMessage`, `stopRequested`)难以维护 +2. **停止响应延迟**: 后端停止标志检查不够频繁,导致停止响应慢 +3. **状态同步问题**: 前端立即清理状态,但后端可能仍在运行,导致状态不一致 +4. **页面刷新恢复**: 状态恢复逻辑复杂,容易出错 + +建议采用状态机模式统一管理任务状态,增加停止确认机制,改进停止标志检查频率,并添加详细的调试日志。 diff --git a/docs/analysis-task-recovery.md b/docs/analysis-task-recovery.md new file mode 100644 index 0000000..d5211f9 --- /dev/null +++ b/docs/analysis-task-recovery.md @@ -0,0 +1,692 @@ +# AI Agent 系统任务恢复与推送机制分析 + +## 概述 + +本文档详细分析 AI Agent 系统中**正在运行中的对话恢复机制和推送机制**,重点关注页面刷新后如何恢复任务状态、重放历史事件以及维护 UI 一致性。 + +--- + +## 1. 页面刷新后的任务恢复流程 + +### 1.1 恢复触发时机 + +任务恢复在页面挂载时立即触发: + +**文件**: `/static/src/app/lifecycle.ts` + +```typescript +export async function mounted() { + // ... 其他初始化代码 + + // 注册全局事件处理器(用于任务轮询) + (window as any).__taskEventHandler = (event: any) => { + if (typeof this.handleTaskEvent === 'function') { + this.handleTaskEvent(event); + } + }; + + // 立即尝试恢复运行中的任务(不延迟) + if (typeof this.restoreTaskState === 'function') { + this.restoreTaskState(); + } +} +``` + +**关键点**: +- 在 `mounted()` 生命周期钩子中立即调用 `restoreTaskState()` +- 不延迟执行,确保用户刷新后能快速看到任务恢复 +- 先注册全局事件处理器 `__taskEventHandler`,用于后续轮询事件分发 + +### 1.2 查找运行中的任务 + +**文件**: `/static/src/stores/task.ts` - `loadRunningTask()` + +```typescript +async loadRunningTask(conversationId: string | null = null) { + try { + debugLog('[Task] 查找运行中的任务'); + + // 1. 获取所有任务列表 + const response = await fetch('/api/tasks'); + const result = await response.json(); + + // 2. 查找运行中的任务(可选按对话ID过滤) + const runningTask = result.data.find((task: any) => + task.status === 'running' && + (!conversationId || task.conversation_id === conversationId) + ); + + if (runningTask) { + // 3. 更新任务状态 + this.currentTaskId = runningTask.task_id; + this.taskStatus = runningTask.status; + this.taskCreatedAt = runningTask.created_at; + this.taskUpdatedAt = runningTask.updated_at; + + // 4. 获取任务详情,计算已处理的事件数量 + 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); + } + } + + return runningTask; + } + + return null; + } catch (error) { + console.error('[Task] 加载运行中任务失败:', error); + return null; + } +} +``` + +**关键逻辑**: +1. 调用 `/api/tasks` 获取用户的所有任务 +2. 筛选 `status === 'running'` 且匹配当前对话ID的任务 +3. 获取任务详情,计算 `lastEventIndex`(已处理的事件数量) +4. 返回运行中的任务信息 + +--- + +## 2. `_rebuildingFromScratch` 标志机制 + +### 2.1 标志的作用 + +`_rebuildingFromScratch` 是一个关键的布尔标志,用于区分两种恢复模式: + +- **从头重建模式** (`true`): 历史消息不完整或缺失,需要从头重放所有事件来重建 assistant 响应 +- **增量恢复模式** (`false`): 历史消息完整,只需恢复状态并继续轮询新事件 + +### 2.2 何时设置为 true + +**文件**: `/static/src/app/methods/taskPolling.ts` - `restoreTaskState()` + +```typescript +// 检查是否需要从头重建 +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) { + debugLog('[TaskPolling] 需要从头重建 assistant 响应'); + + // 清空不完整的 assistant 消息 + if (isAssistantMessage) { + this.messages.pop(); + } + + // 重置偏移量为 0,从头获取所有事件 + taskStore.lastEventIndex = 0; + + // 标记正在从头重建 + this._rebuildingFromScratch = true; + this._rebuildingEventCount = allEvents.length; + + // 启动轮询 + taskStore.startPolling((event: any) => { + this.handleTaskEvent(event); + }); + + // 延迟清除重建标记(2秒后) + setTimeout(() => { + this._rebuildingFromScratch = false; + this._rebuildingEventCount = 0; + }, 2000); +} +``` + +**触发条件**: +1. **最后一条消息不是 assistant 消息**: 说明历史加载有问题 +2. **最后一条是空的 assistant 消息**: `actions` 数组为空或不存在 +3. **历史不完整**: 事件数量远大于历史中的 actions 数量(允许5个事件的误差) + +### 2.3 对事件处理的影响 + +`_rebuildingFromScratch` 标志会影响多个事件处理函数的行为: + +#### 2.3.1 思考块处理 + +```typescript +handleThinkingStart(data: any, eventIdx: number) { + // ... 创建思考块 + + // 只在非历史恢复时展开思考块 + if (!this._rebuildingFromScratch) { + this.chatExpandBlock(blockId); + } +} + +handleThinkingEnd(data: any) { + // ... 完成思考块 + + // 只在非历史恢复时延迟折叠思考块 + if (!this._rebuildingFromScratch) { + setTimeout(() => { + this.chatCollapseBlock(blockId); + }, 1000); + } else { + // 历史恢复时立即折叠,不需要动画 + this.chatCollapseBlock(blockId); + } +} +``` + +**差异**: +- **从头重建**: 不展开思考块,立即折叠,跳过动画 +- **正常流式**: 展开思考块,延迟1秒后折叠,有动画效果 + +#### 2.3.2 工具意图打字机效果 + +```typescript +handleToolIntent(data: any) { + const isHistoryRestore = this._rebuildingFromScratch; + + if (isHistoryRestore) { + // 历史恢复,直接显示完整 intent + action.tool.intent_rendered = newIntent; + } else { + // 新工具块,逐字符显示(打字机效果) + action.tool.intent_rendered = ''; + action.tool._intentTyping = true; + + // 逐字符显示动画 + const typeNextChar = () => { + if (charIndex < newIntent.length && action.tool._intentTyping) { + action.tool.intent_rendered += newIntent[charIndex]; + charIndex++; + this.$forceUpdate(); + action.tool._intentTimer = setTimeout(typeNextChar, charInterval); + } + }; + action.tool._intentTimer = setTimeout(typeNextChar, 50); + } +} +``` + +**差异**: +- **从头重建**: 直接显示完整 intent,无打字机效果 +- **正常流式**: 逐字符显示,有打字机动画 + +#### 2.3.3 AI 消息开始处理 + +```typescript +handleAiMessageStart(data: any, eventIdx: number) { + // 如果是从头重建,标记消息为静默恢复 + if (this._rebuildingFromScratch) { + const newMessage = this.messages[this.messages.length - 1]; + if (newMessage && newMessage.role === 'assistant') { + newMessage.awaitingFirstContent = false; + newMessage.generatingLabel = ''; + } + } +} +``` + +**差异**: +- **从头重建**: 清除"生成中"标签,静默恢复 +- **正常流式**: 显示"生成中"动画 + +--- + +## 3. 历史事件的重放机制 + +### 3.1 事件获取 + +**后端**: `/server/tasks.py` - `get_task_api()` + +```python +@tasks_bp.route("/api/tasks/", methods=["GET"]) +@api_login_required +def get_task_api(task_id: str): + username = get_current_username() + rec = task_manager.get_task(username, task_id) + if not rec: + return jsonify({"success": False, "error": "任务不存在"}), 404 + + # 获取偏移量参数 + try: + offset = int(request.args.get("from", 0)) + except Exception: + offset = 0 + + # 过滤事件(只返回 idx >= offset 的事件) + events = [e for e in rec.events if e["idx"] >= offset] + next_offset = events[-1]["idx"] + 1 if events else offset + + return jsonify({ + "success": True, + "data": { + "task_id": rec.task_id, + "status": rec.status, + "events": events, + "next_offset": next_offset, + } + }) +``` + +**关键点**: +- 支持 `from` 参数,只返回 `idx >= from` 的事件 +- 返回 `next_offset`,用于下次轮询 +- 事件存储在 `deque(maxlen=1000)` 中,最多保留1000个事件 + +### 3.2 事件重放顺序 + +**文件**: `/static/src/stores/task.ts` - `pollTaskEvents()` + +```typescript +async pollTaskEvents(eventHandler: (event: any) => void) { + const response = await fetch( + `/api/tasks/${this.currentTaskId}?from=${this.lastEventIndex}` + ); + const result = await response.json(); + const data = result.data; + + // 处理新事件(按顺序) + if (data.events && data.events.length > 0) { + for (const event of data.events) { + try { + eventHandler(event); + } catch (err) { + console.error('[Task] 处理事件失败:', err, event); + } + } + + // 更新偏移量 + this.lastEventIndex = data.next_offset; + } +} +``` + +**重放顺序**: +1. 按事件的 `idx` 顺序(从小到大) +2. 同步处理,确保顺序正确 +3. 每个事件处理完后才处理下一个 + +### 3.3 重放时的特殊处理 + +**文件**: `/static/src/app/methods/taskPolling.ts` - `handleTaskEvent()` + +```typescript +handleTaskEvent(event: any) { + // 1. 检查事件的 conversation_id 是否匹配 + if (eventData.conversation_id && this.currentConversationId) { + if (eventData.conversation_id !== this.currentConversationId) { + debugLog(`忽略不匹配的事件`); + return; + } + } + + // 2. 根据事件类型调用对应的处理方法 + switch (eventType) { + case 'ai_message_start': + this.handleAiMessageStart(eventData, eventIdx); + break; + case 'thinking_start': + this.handleThinkingStart(eventData, eventIdx); + break; + // ... 其他事件类型 + } + + // 注意:不要在这里清除 _rebuildingFromScratch 标记 + // 该标记应该在恢复完所有历史事件后才清除 +} +``` + +**特殊处理**: +- **对话ID过滤**: 忽略不匹配当前对话的事件 +- **跳过动画**: 通过 `_rebuildingFromScratch` 标志跳过动画效果 +- **静默恢复**: 不显示"生成中"标签,不展开思考块 + +--- + +## 4. 恢复时的 UI 状态管理 + +### 4.1 思考块状态恢复 + +**文件**: `/static/src/app/methods/taskPolling.ts` - `restoreTaskState()` + +```typescript +// 恢复思考块状态 +if (lastMessage.actions) { + const thinkingActions = lastMessage.actions.filter(a => a.type === 'thinking'); + + if (inThinking && thinkingActions.length > 0) { + // 正在思考中,检查最后一个思考块是否正在流式输出 + const lastThinking = thinkingActions[thinkingActions.length - 1]; + + // 只有当思考块正在流式输出时才设置锁定状态(但不展开) + if (lastThinking.streaming && lastThinking.blockId) { + this.$nextTick(() => { + this.chatSetThinkingLock(lastThinking.blockId, true); + }); + } + } + + // 确保所有思考块都是折叠状态 + for (const thinking of thinkingActions) { + if (thinking.blockId) { + thinking.collapsed = true; + } + } +} +``` + +**状态恢复**: +- **折叠状态**: 所有思考块默认折叠 +- **锁定状态**: 如果正在流式输出,设置锁定(防止用户手动展开) +- **不展开**: 即使正在流式输出,也不自动展开(避免干扰用户) + +### 4.2 工具块状态恢复 + +```typescript +// 注册历史中的工具块到 toolActionIndex +const toolActions = lastMessage.actions.filter(a => a.type === 'tool'); + +for (const toolAction of toolActions) { + if (toolAction.tool && toolAction.tool.id) { + // 注册到 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); + } + } +} +``` + +**关键点**: +- **注册工具块**: 将历史中的工具块注册到 `toolActionIndex` +- **支持更新**: 后续的 `update_action` 事件可以找到对应的块进行状态更新 +- **双重注册**: 同时注册 `id` 和 `executionId`,确保能找到 + +### 4.3 停止按钮状态 + +```typescript +// 标记状态为进行中 +this.streamingMessage = true; +this.taskInProgress = true; +``` + +**状态设置**: +- `streamingMessage = true`: 显示停止按钮 +- `taskInProgress = true`: 禁用输入框 +- `stopRequested = false`: 重置停止请求标志 + +### 4.4 输入框状态 + +输入框状态由 `taskInProgress` 控制: + +```typescript +// 在模板中 + +``` + +**状态**: +- `taskInProgress = true`: 输入框禁用 +- `taskInProgress = false`: 输入框启用 + +--- + +## 5. 轮询的恢复机制 + +### 5.1 轮询启动 + +**文件**: `/static/src/stores/task.ts` - `startPolling()` + +```typescript +startPolling(eventHandler?: (event: any) => void) { + if (this.isPolling) { + return; + } + + this.isPolling = true; + + // 获取事件处理器 + const handler = eventHandler || ((window as any).__taskEventHandler); + + // 立即执行一次 + this.pollTaskEvents(handler); + + // 设置定时轮询(150ms 间隔) + this.pollingInterval = window.setInterval(() => { + this.pollTaskEvents(handler); + }, 150); +} +``` + +**关键点**: +- **立即执行**: 启动轮询后立即执行一次,不等待第一个间隔 +- **150ms 间隔**: 接近流式输出效果 +- **全局处理器**: 使用 `window.__taskEventHandler` 作为默认处理器 + +### 5.2 从哪个 offset 开始轮询 + +**两种模式**: + +#### 5.2.1 从头重建模式 + +```typescript +// 重置偏移量为 0,从头获取所有事件 +taskStore.lastEventIndex = 0; +``` + +- **offset = 0**: 从第一个事件开始 +- **目的**: 重建完整的 assistant 响应 + +#### 5.2.2 增量恢复模式 + +```typescript +// 获取任务详情,计算已处理的事件数量 +const detailResponse = await fetch(`/api/tasks/${runningTask.task_id}`); +const detailResult = await detailResponse.json(); +if (detailResult.success && detailResult.data.events) { + // 设置为当前事件数量,只获取新事件 + this.lastEventIndex = detailResult.data.next_offset || detailResult.data.events.length; +} +``` + +- **offset = next_offset**: 从历史事件之后开始 +- **目的**: 只获取新事件,避免重复处理 + +### 5.3 避免重复处理事件 + +**机制**: + +1. **事件索引**: 每个事件有唯一的 `idx` +2. **偏移量过滤**: 后端只返回 `idx >= offset` 的事件 +3. **偏移量更新**: 处理完事件后更新 `lastEventIndex = next_offset` + +```typescript +// 处理新事件 +if (data.events && data.events.length > 0) { + for (const event of data.events) { + eventHandler(event); + } + + // 更新偏移量,下次轮询从这里开始 + this.lastEventIndex = data.next_offset; +} +``` + +**保证**: +- 每个事件只处理一次 +- 不会遗漏事件 +- 不会重复处理 + +--- + +## 6. WebSocket vs 轮询的恢复差异 + +### 6.1 WebSocket 模式(已废弃) + +**特点**: +- **实时推送**: 服务器主动推送事件 +- **连接断开**: 刷新页面后 WebSocket 连接断开 +- **无法恢复**: 断开期间的事件丢失 + +**问题**: +- 页面刷新后无法恢复任务状态 +- 需要复杂的重连和事件补偿机制 + +### 6.2 轮询模式(当前实现) + +**特点**: +- **主动拉取**: 客户端主动轮询事件 +- **无连接状态**: 不依赖持久连接 +- **完整恢复**: 可以从任意 offset 开始拉取事件 + +**优势**: +- 页面刷新后可以完整恢复任务状态 +- 实现简单,无需处理重连逻辑 +- 支持从头重建和增量恢复两种模式 + +### 6.3 恢复流程对比 + +| 特性 | WebSocket 模式 | 轮询模式 | +|------|---------------|---------| +| 连接状态 | 有状态(需要重连) | 无状态 | +| 事件恢复 | 困难(需要补偿机制) | 简单(从 offset 拉取) | +| 实时性 | 高(服务器推送) | 中(150ms 轮询) | +| 可靠性 | 低(连接断开丢失事件) | 高(事件持久化) | +| 刷新恢复 | 不支持 | 完全支持 | + +--- + +## 7. 完整恢复流程图 + +``` +页面刷新 + ↓ +mounted() 生命周期 + ↓ +注册全局事件处理器 __taskEventHandler + ↓ +调用 restoreTaskState() + ↓ +检查是否已有任务在进行 ──→ 是 ──→ 跳过恢复 + ↓ 否 +调用 taskStore.loadRunningTask() + ↓ +查询 /api/tasks 获取任务列表 + ↓ +筛选 status === 'running' 的任务 ──→ 无 ──→ 恢复子智能体等待状态 + ↓ 有 +获取任务详情 /api/tasks/{task_id} + ↓ +计算 lastEventIndex(已处理的事件数量) + ↓ +等待历史加载完成 + ↓ +检查历史完整性 + ↓ + ├─→ 历史不完整 ──→ 从头重建模式 + │ ↓ + │ 删除不完整的 assistant 消息 + │ ↓ + │ 设置 _rebuildingFromScratch = true + │ ↓ + │ 设置 lastEventIndex = 0 + │ ↓ + │ 启动轮询(从头重放所有事件) + │ ↓ + │ 2秒后清除 _rebuildingFromScratch 标志 + │ + └─→ 历史完整 ──→ 增量恢复模式 + ↓ + 恢复思考块状态(折叠、锁定) + ↓ + 恢复工具块状态(注册到 toolActionIndex) + ↓ + 恢复文本块状态(streaming 标志) + ↓ + 设置 streamingMessage = true + ↓ + 设置 taskInProgress = true + ↓ + 启动轮询(只获取新事件) + ↓ + 显示"任务恢复"提示 +``` + +--- + +## 8. 关键代码文件索引 + +### 8.1 前端文件 + +| 文件路径 | 功能 | +|---------|------| +| `/static/src/app/lifecycle.ts` | 页面生命周期,触发恢复 | +| `/static/src/stores/task.ts` | 任务状态管理,轮询控制 | +| `/static/src/app/methods/taskPolling.ts` | 事件处理,状态恢复 | +| `/static/src/app/methods/history.ts` | 历史消息加载和渲染 | +| `/static/src/app/methods/conversation.ts` | 对话管理 | + +### 8.2 后端文件 + +| 文件路径 | 功能 | +|---------|------| +| `/server/tasks.py` | 任务 API,事件存储和查询 | +| `/server/chat_flow.py` | 聊天流程,事件生成 | + +--- + +## 9. 最佳实践和注意事项 + +### 9.1 事件设计 + +1. **每个事件必须有唯一的 idx**: 用于偏移量过滤 +2. **事件必须包含 conversation_id**: 用于过滤不匹配的事件 +3. **事件必须按顺序生成**: 确保 idx 递增 +4. **事件必须持久化**: 存储在 `deque(maxlen=1000)` 中 + +### 9.2 状态恢复 + +1. **先加载历史,再恢复任务**: 确保有完整的消息列表 +2. **检查历史完整性**: 对比事件数量和 actions 数量 +3. **注册工具块**: 确保后续的 `update_action` 事件能找到对应的块 +4. **设置正确的偏移量**: 避免重复处理或遗漏事件 + +### 9.3 UI 体验 + +1. **跳过动画**: 从头重建时跳过所有动画效果 +2. **静默恢复**: 不显示"生成中"标签 +3. **折叠思考块**: 默认折叠,避免干扰用户 +4. **显示提示**: 恢复成功后显示"任务恢复"提示 + +### 9.4 错误处理 + +1. **任务不存在**: 恢复子智能体等待状态 +2. **历史加载失败**: 延迟重试(最多5次) +3. **事件处理失败**: 捕获异常,继续处理下一个事件 +4. **对话切换**: 忽略不匹配的事件 + +--- + +## 10. 总结 + +AI Agent 系统的任务恢复机制通过以下关键技术实现: + +1. **事件持久化**: 所有事件存储在后端,支持从任意 offset 查询 +2. **轮询模式**: 客户端主动拉取事件,无需维护连接状态 +3. **双模式恢复**: 支持从头重建和增量恢复两种模式 +4. **智能判断**: 根据历史完整性自动选择恢复模式 +5. **UI 优化**: 跳过动画、静默恢复,提升用户体验 +6. **状态同步**: 恢复思考块、工具块、文本块的完整状态 + +这套机制确保了用户在页面刷新后能够无缝恢复任务,不会丢失任何进度,同时保持了良好的用户体验。