# Agent 事件流录制与回放系统 — 技术设计方案

> 基于 Manus AI 逆向分析还原的事件流架构，提炼为可复用的技术方案

---

## 一、核心思路

**不录视频，录事件。** 把 Agent 的每一步操作记录为一条结构化事件，存到数据库。回放时按时间顺序逐条渲染，前端重建当时的画面。

好处：
- 存储量极小（一个完整任务的事件流 < 1MB，而视频录屏要 100MB+）
- 可以搜索、过滤、分析（结构化数据 vs 视频帧）
- 可以跳转到任意步骤（不用拖进度条）
- 可以分享链接，别人打开就能回放
- 回放速度可调（1x / 2x / 跳过）

---

## 二、事件数据模型

### 2.1 事件通用结构

```typescript
interface AgentEvent {
  id: string;              // UUID
  sessionId: string;       // 会话 ID
  type: EventType;         // 事件类型（见下方枚举）
  timestamp: number;       // 毫秒时间戳
  sender?: "user" | "assistant" | "system";

  // 以下字段根据 type 不同而不同
  [key: string]: any;
}
```

### 2.2 事件类型枚举（从 Manus 逆向提取，50+ 种）

```typescript
enum EventType {
  // === Agent 核心 ===
  CHAT = "chat",                       // 用户/Agent 消息
  STATUS_UPDATE = "statusUpdate",       // Agent 状态变化
  EXPLANATION = "explanation",          // Agent 思考过程
  TOOL_USED = "toolUsed",             // 工具调用（最重要）
  LIVE_STATUS = "liveStatus",          // 实时状态
  QUEUE_STATUS_CHANGE = "queueStatusChange",  // 排队状态

  // === 工具结果 ===
  TERMINAL_UPDATE = "terminalUpdate",   // 终端输出流
  SPEECH_UPDATE = "speechGenerationUpdate",
  VIDEO_UPDATE = "videoGenerationUpdate",

  // === 资源 ===
  CANVAS = "canvas",                   // 画布更新
  CANVAS_IMAGE = "canvasImage",        // 画布图片
  FILE = "file",                       // 文件产出
  ATTACHMENT = "attachment",           // 附件

  // === 部署 ===
  DEPLOY = "deploy",                   // 部署事件
  SANDBOX = "sandbox",                 // 沙箱状态
  SANDBOX_UPDATE = "sandboxUpdate",
  SPACE = "space",                     // Space 部署

  // === 协作 ===
  COLLABORATION = "collaborationStatusUpdate",
  RESOURCE_ACCESSED = "resourceAccessed",
}
```

### 2.3 核心事件的详细字段

#### chat 事件
```typescript
interface ChatEvent extends AgentEvent {
  type: "chat";
  sender: "user" | "assistant";
  messageType: "text" | "voice";
  content: string;           // 消息文本
  contents?: any[];          // 富文本内容
  attachments?: Attachment[];
  taskMode?: string;         // "agent" 等
  extData?: Record<string, any>;
}
```

#### toolUsed 事件（最关键）
```typescript
interface ToolUsedEvent extends AgentEvent {
  type: "toolUsed";
  tool: ToolType;            // 工具名称
  status: ToolStatus;        // 工具状态
  toolName?: string;         // 子工具名（如 browser_navigate）

  // browser 工具特有
  url?: string;              // 浏览的 URL
  screenshot?: string;       // 截图 CDN URL
  consoleMessages?: any[];   // 控制台输出
  fromMyBrowser?: boolean;   // 是否用户浏览器
  isYtbVideo?: boolean;      // 是否 YouTube 视频

  // 通用
  content?: string;          // 输出内容
  description?: string;      // 描述
  errorMsg?: string;         // 错误信息
  feedback?: any;            // 反馈数据
  actionId?: string;         // 关联的 action ID
  result?: any;              // 返回结果
  platform?: string;         // 平台
}

type ToolType =
  | "browser"           // 浏览器（子: navigate/console_exec/console_view/save_image）
  | "terminal"          // 终端命令
  | "text_editor"       // 文本编辑器
  | "search"            // 网页搜索
  | "search_scholar"    // 学术搜索
  | "search_image"      // 图片搜索
  | "media_viewer"      // 媒体（图片/视频生成）
  | "webdev"            // Web 开发
  | "computer_use"      // 计算机操作
  | "map_reduce"        // 并行任务
  | "slides"            // 幻灯片
  | "gmail"             // Gmail
  | "meta_marketing"    // Meta 营销
  | "task_scheduled"    // 定时任务
  | "need_accept";      // 需要用户确认

type ToolStatus = "start" | "argumentsFinished" | "streaming" | "success" | "error" | "fallback";
```

#### statusUpdate 事件
```typescript
interface StatusUpdateEvent extends AgentEvent {
  type: "statusUpdate";
  agentStatus: "running" | "stopped" | "waiting" | "error";
}
```

#### explanation 事件
```typescript
interface ExplanationEvent extends AgentEvent {
  type: "explanation";
  content: string;  // Agent 的思考过程文本
}
```

#### liveStatus 事件
```typescript
interface LiveStatusEvent extends AgentEvent {
  type: "liveStatus";
  // 实时状态快照（Agent 当前在做什么）
}
```

---

## 三、录制（写入）

### 3.1 录制架构

```
Agent 执行循环
  │
  ├── LLM 返回 action
  │     └── 写入 explanation 事件
  │
  ├── 执行工具
  │     ├── 写入 toolUsed (status: start)
  │     ├── 执行中: 写入 liveStatus / terminalUpdate（流式）
  │     └── 完成: 写入 toolUsed (status: success/error)
  │
  ├── Agent 发消息
  │     └── 写入 chat 事件
  │
  └── 状态变化
        └── 写入 statusUpdate 事件
```

### 3.2 写入方式

**方式 1: 直接写数据库（Manus 的做法）**
```
Agent 进程 → 写入事件表 → 同时推送 WebSocket 给前端
```

**方式 2: 事件总线**
```
Agent 进程 → EventBus (Redis Stream / Kafka) → 消费者写数据库 + 推送 WS
```

推荐方式 2，解耦录制和推送。

### 3.3 数据库 Schema

```sql
CREATE TABLE agent_events (
  id UUID PRIMARY KEY,
  session_id UUID NOT NULL,
  type VARCHAR(50) NOT NULL,
  timestamp BIGINT NOT NULL,       -- 毫秒时间戳
  sender VARCHAR(20),
  payload JSONB NOT NULL,          -- 事件具体数据
  created_at TIMESTAMPTZ DEFAULT NOW(),

  INDEX idx_session_ts (session_id, timestamp)
);

CREATE TABLE sessions (
  id UUID PRIMARY KEY,
  user_id BIGINT NOT NULL,
  title TEXT,
  status VARCHAR(30),              -- running / stopped / waiting / error
  agent_task_mode VARCHAR(50),     -- high_effort 等
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
```

### 3.4 事件写入示例

```python
# Agent 执行一步工具调用的事件序列
events = [
    # 1. Agent 思考
    {
        "id": uuid4(),
        "sessionId": session_id,
        "type": "explanation",
        "timestamp": now_ms(),
        "content": "我需要先搜索一下相关信息..."
    },
    # 2. 工具开始
    {
        "id": uuid4(),
        "sessionId": session_id,
        "type": "toolUsed",
        "timestamp": now_ms(),
        "tool": "search",
        "status": "start",
        "content": "searching: Python web scraping tutorial"
    },
    # 3. 工具完成
    {
        "id": uuid4(),
        "sessionId": session_id,
        "type": "toolUsed",
        "timestamp": now_ms(),
        "tool": "search",
        "status": "success",
        "result": [{"title": "...", "url": "...", "snippet": "..."}]
    },
    # 4. Agent 回复
    {
        "id": uuid4(),
        "sessionId": session_id,
        "type": "chat",
        "timestamp": now_ms(),
        "sender": "assistant",
        "content": "找到了相关信息，让我开始写代码..."
    }
]

# 批量写入 + 推送
for event in events:
    db.insert("agent_events", event)
    websocket.emit("event", event)  # 实时推送
```

---

## 四、回放（读取）

### 4.1 回放 API

```
GET /api/session/{sessionId}/replay
  ?startEventId=xxx     # 从某个事件开始（分段加载）
  &endEventId=xxx       # 到某个事件结束

返回:
{
  "session": { "id": "...", "title": "...", "status": "stopped" },
  "events": [
    { "id": "...", "type": "chat", "timestamp": 1712200000000, ... },
    { "id": "...", "type": "toolUsed", "timestamp": 1712200001000, ... },
    ...
  ],
  "hasMore": false
}
```

### 4.2 前端回放引擎

```typescript
class ReplayEngine {
  private events: AgentEvent[];
  private currentIndex: number = 0;
  private speed: number = 1;       // 1x, 2x, 0.5x
  private timer: number | null = null;

  load(events: AgentEvent[]) {
    this.events = events;
    this.currentIndex = 0;
  }

  play() {
    if (this.currentIndex >= this.events.length) return;

    const current = this.events[this.currentIndex];
    const next = this.events[this.currentIndex + 1];

    // 渲染当前事件
    this.renderEvent(current);

    this.currentIndex++;

    if (next) {
      // 计算到下一个事件的间隔
      const delay = (next.timestamp - current.timestamp) / this.speed;
      // 最小 50ms，最大 3000ms（避免长等待）
      const clampedDelay = Math.max(50, Math.min(delay, 3000));
      this.timer = setTimeout(() => this.play(), clampedDelay);
    }
  }

  pause() {
    if (this.timer) clearTimeout(this.timer);
  }

  seekTo(index: number) {
    this.pause();
    // 快速渲染到目标位置（不播放动画）
    for (let i = 0; i <= index; i++) {
      this.renderEvent(this.events[i], { animate: false });
    }
    this.currentIndex = index + 1;
  }

  renderEvent(event: AgentEvent, opts = { animate: true }) {
    switch (event.type) {
      case "chat":
        // 渲染消息气泡
        this.ui.addMessage(event.sender, event.content, opts.animate);
        break;

      case "toolUsed":
        if (event.status === "start") {
          // 显示工具面板（浏览器/终端/编辑器）
          this.ui.showToolPanel(event.tool, event);
        } else if (event.status === "success") {
          // 更新工具面板为完成状态
          this.ui.updateToolPanel(event.tool, event);
          if (event.screenshot) {
            this.ui.showScreenshot(event.screenshot);
          }
        }
        break;

      case "explanation":
        // 显示思考过程（可折叠）
        this.ui.showThinking(event.content, opts.animate);
        break;

      case "statusUpdate":
        // 更新 Agent 状态指示器
        this.ui.setAgentStatus(event.agentStatus);
        break;

      case "terminalUpdate":
        // 追加终端输出
        this.ui.appendTerminal(event.content);
        break;

      case "liveStatus":
        // 更新实时状态条
        this.ui.setLiveStatus(event);
        break;
    }
  }
}
```

### 4.3 回放 UI 布局

```
┌──────────────────────────────────────────────────────┐
│  回放控制栏                                           │
│  [◀] [▶ 播放] [▶▶] [1x ▾]  ──────●─────── 12:34    │
├──────────────────────────────────────────────────────┤
│                    │                                  │
│  消息时间线        │  工具面板                         │
│  ┌──────────┐     │  ┌────────────────────────────┐  │
│  │ 用户消息  │     │  │  浏览器截图 / 终端输出      │  │
│  │ Agent思考 │     │  │  / 代码编辑器 / 搜索结果    │  │
│  │ 工具调用  │     │  │                            │  │
│  │ Agent回复 │     │  │  [根据 toolUsed.tool 切换]  │  │
│  │ ...       │     │  │                            │  │
│  └──────────┘     │  └────────────────────────────┘  │
│                    │                                  │
├──────────────────────────────────────────────────────┤
│  步骤列表（从 toolUsed 事件提取）                     │
│  ✓ Step 1: 搜索信息         ✓ Step 3: 运行脚本      │
│  ✓ Step 2: 编写代码         ▶ Step 4: 生成报告      │
└──────────────────────────────────────────────────────┘
```

---

## 五、Canvas 画布存档（图片追踪）

### 5.1 Manus 的做法

```
Agent 生成图片 → 上传到 S3
  → 创建 Canvas Artifact（元数据）
  → 前端轮询 GetCanvas（~1次/秒）
  → 状态: PENDING → GENERATING → LOADED/FAILED
```

### 5.2 Canvas Artifact 数据结构

```typescript
interface CanvasArtifact {
  uid: string;
  type: "CANVAS_ARTIFACT_TYPE_IMAGE";
  fileName: string;           // "og-image-v2.png"
  width: string;              // "2752"
  height: string;             // "1536"
  status: "PENDING" | "GENERATING" | "LOADED" | "FAILED";
}

interface Canvas {
  sessionUid: string;
  artifacts: CanvasArtifact[];
  s3UpdateAt: string;         // ISO 时间戳
}
```

### 5.3 画布快照（feedback JSON）

每次画布变化时，将完整的画布状态保存为 JSON：

```typescript
interface CanvasSnapshot {
  shapes: {
    [shapeId: string]: {
      id: string;
      type: "image";
      x: number;              // 画布坐标
      y: number;
      w: number;              // 宽高
      h: number;
      src: string;            // CDN URL（原图）
      previewSrc: string;     // CDN URL（预览图）
      fileName: string;
      loadingState: "loaded" | "loading" | "error";
      artifactId: string;
      rowId: string;
    }
  }
}
```

---

## 六、存储估算

| 数据类型 | 单个任务 | 存储方式 |
|----------|---------|---------|
| 事件流 JSON | ~200KB (50 个事件) | 数据库 JSONB |
| 浏览器截图 | ~5MB (10 张 webp) | S3/OSS |
| Canvas 快照 | ~50KB (30 次快照) | S3 JSON |
| 终端输出日志 | ~100KB | 事件流内 |
| 生成的文件 | 10-100MB | S3/OSS |
| **总计** | **~15-110MB** | |
| 对比: 视频录屏 | **500MB-2GB** | - |

---

## 七、分享与权限

### 7.1 分享链接

```
https://your-app.com/share/{sessionId}?replay=1
```

### 7.2 权限控制

```typescript
// 三种访问模式
type ShareMode =
  | "private"     // 仅创建者
  | "shared"      // 有链接可看（只读回放）
  | "public";     // 公开可搜索

// 分享时可以选择隐藏敏感信息
interface ShareOptions {
  hideSecrets: boolean;    // 隐藏 API Key 等
  hideTerminal: boolean;   // 隐藏终端输出
  hideFiles: boolean;      // 隐藏文件内容
}
```

---

## 八、最小可行方案（MVP）

如果你想快速做一个，核心只需要 4 个部分：

### 8.1 后端

```python
# FastAPI + PostgreSQL + S3

@app.post("/api/events")
async def record_event(event: AgentEvent):
    """Agent 每一步调用这个 API 记录事件"""
    db.insert("agent_events", event.dict())
    await ws_manager.broadcast(event.session_id, event.dict())

@app.get("/api/session/{session_id}/replay")
async def get_replay(session_id: str):
    """回放时拉取完整事件流"""
    events = db.query(
        "SELECT * FROM agent_events WHERE session_id = ? ORDER BY timestamp",
        session_id
    )
    return {"events": events}
```

### 8.2 Agent 侧（记录器）

```python
class EventRecorder:
    def __init__(self, session_id: str, api_url: str):
        self.session_id = session_id
        self.api = api_url

    def record(self, event_type: str, **data):
        event = {
            "id": str(uuid4()),
            "sessionId": self.session_id,
            "type": event_type,
            "timestamp": int(time.time() * 1000),
            **data
        }
        requests.post(f"{self.api}/api/events", json=event)

    # 便捷方法
    def tool_start(self, tool: str, **args):
        self.record("toolUsed", tool=tool, status="start", **args)

    def tool_success(self, tool: str, **result):
        self.record("toolUsed", tool=tool, status="success", **result)

    def tool_error(self, tool: str, error: str):
        self.record("toolUsed", tool=tool, status="error", errorMsg=error)

    def thinking(self, content: str):
        self.record("explanation", content=content)

    def message(self, content: str):
        self.record("chat", sender="assistant", content=content)

    def status(self, agent_status: str):
        self.record("statusUpdate", agentStatus=agent_status)
```

### 8.3 前端回放（React 组件）

```tsx
function ReplayPlayer({ sessionId }: { sessionId: string }) {
  const [events, setEvents] = useState<AgentEvent[]>([]);
  const [currentIdx, setCurrentIdx] = useState(0);
  const [playing, setPlaying] = useState(false);

  useEffect(() => {
    fetch(`/api/session/${sessionId}/replay`)
      .then(r => r.json())
      .then(d => setEvents(d.events));
  }, [sessionId]);

  useEffect(() => {
    if (!playing || currentIdx >= events.length) return;
    const current = events[currentIdx];
    const next = events[currentIdx + 1];
    const delay = next ? Math.min(next.timestamp - current.timestamp, 3000) : 0;
    const timer = setTimeout(() => setCurrentIdx(i => i + 1), delay);
    return () => clearTimeout(timer);
  }, [playing, currentIdx]);

  const visibleEvents = events.slice(0, currentIdx + 1);

  return (
    <div>
      <Controls playing={playing} onToggle={() => setPlaying(!playing)} />
      <Timeline events={visibleEvents} />
      <ToolPanel event={visibleEvents.findLast(e => e.type === "toolUsed")} />
    </div>
  );
}
```

### 8.4 技术栈推荐

| 组件 | 推荐 | 原因 |
|------|------|------|
| 后端 | FastAPI / Go Fiber | 轻量、高性能 |
| 数据库 | PostgreSQL + JSONB | 事件流天然适合 JSONB |
| 对象存储 | S3 / MinIO / Cloudflare R2 | 截图和文件 |
| 实时推送 | Socket.IO / SSE | 事件流式推送 |
| 前端 | React + Zustand | 状态管理简单 |
| 事件总线（可选） | Redis Stream | 解耦录制和推送 |
