docs(analysis): add polling and conversation behavior notes

This commit is contained in:
JOJO 2026-04-04 19:47:28 +08:00
parent 3b710020fe
commit dfc763c7c5
4 changed files with 2425 additions and 0 deletions

202
docs/README.md Normal file
View 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不持久化
**打字机效果:**
- 思考块:无打字机效果,直接追加
- 工具块 intent0.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. 子智能体通知改为 SSEServer-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分钟

View 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` 防止路由冲突
这套机制为多对话管理提供了坚实的基础,值得在类似系统中借鉴。

View 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. **页面刷新恢复**: 状态恢复逻辑复杂,容易出错
建议采用状态机模式统一管理任务状态,增加停止确认机制,改进停止标志检查频率,并添加详细的调试日志。

View 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. **状态同步**: 恢复思考块、工具块、文本块的完整状态
这套机制确保了用户在页面刷新后能够无缝恢复任务,不会丢失任何进度,同时保持了良好的用户体验。