docs(analysis): add polling and conversation behavior notes
This commit is contained in:
parent
3b710020fe
commit
dfc763c7c5
202
docs/README.md
Normal file
202
docs/README.md
Normal file
@ -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分钟
|
||||||
790
docs/analysis-conversation-loading.md
Normal file
790
docs/analysis-conversation-loading.md
Normal file
@ -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/<conversation_id>/load`
|
||||||
|
|
||||||
|
**文件位置**: `/server/conversation.py` (232-271行)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@conversation_bp.route('/api/conversations/<conversation_id>/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/<conversation_id>/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
|
||||||
|
<div v-if="historyLoading" class="loading-indicator">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>加载对话历史...</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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` 防止路由冲突
|
||||||
|
|
||||||
|
这套机制为多对话管理提供了坚实的基础,值得在类似系统中借鉴。
|
||||||
741
docs/analysis-stop-button.md
Normal file
741
docs/analysis-stop-button.md
Normal file
@ -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
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stadium-btn send-btn"
|
||||||
|
@click="$emit('send-or-stop')"
|
||||||
|
:disabled="
|
||||||
|
!isConnected ||
|
||||||
|
(inputLocked && !streamingMessage) ||
|
||||||
|
(mediaUploading && !streamingMessage) ||
|
||||||
|
((!(inputMessage || '').trim() && (!selectedImages?.length && !selectedVideos?.length)) && !streamingMessage)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span v-if="streamingMessage" class="stop-icon"></span>
|
||||||
|
<span v-else class="send-icon"></span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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/<task_id>/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
|
||||||
|
<span v-if="streamingMessage" class="stop-icon"></span>
|
||||||
|
<span v-else class="send-icon"></span>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **停止前**: 显示停止图标
|
||||||
|
- **停止后**: 显示发送图标
|
||||||
|
|
||||||
|
### 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. **页面刷新恢复**: 状态恢复逻辑复杂,容易出错
|
||||||
|
|
||||||
|
建议采用状态机模式统一管理任务状态,增加停止确认机制,改进停止标志检查频率,并添加详细的调试日志。
|
||||||
692
docs/analysis-task-recovery.md
Normal file
692
docs/analysis-task-recovery.md
Normal file
@ -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/<task_id>", 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
|
||||||
|
// 在模板中
|
||||||
|
<input :disabled="taskInProgress" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**状态**:
|
||||||
|
- `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. **状态同步**: 恢复思考块、工具块、文本块的完整状态
|
||||||
|
|
||||||
|
这套机制确保了用户在页面刷新后能够无缝恢复任务,不会丢失任何进度,同时保持了良好的用户体验。
|
||||||
Loading…
Reference in New Issue
Block a user