# 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. **状态同步**: 恢复思考块、工具块、文本块的完整状态

这套机制确保了用户在页面刷新后能够无缝恢复任务，不会丢失任何进度，同时保持了良好的用户体验。
