--- id: frontend.react.scenario.long-task-ui title: 长任务界面:Agent 任务的状态机、事件去重、乐观更新和恢复 type: scenario domain: frontend stack: [react, typescript, fastapi] tags: [任务状态, 状态机, useReducer, SSE, 幂等, 乐观更新, 异常恢复] level: advanced prerequisites: [frontend.react.hooks-rendering] est_hours: 2.5 related: [frontend.react.interview, ai.langgraph.scenario.refund-approval, frontend.vue.scenario.ai-chat-stream] week: [7, 12] --- # 长任务界面:Agent 任务的状态机、事件去重、乐观更新和恢复 ## 业务背景与问题 报表 Agent 生成一份报表要 1~3 分钟,中间可能需要用户补充信息(“要按大区还是按城市?”)。前端第一版的问题: 1. 用户刷新页面后,看不到正在运行的任务,以为丢了,又提交了一次; 2. 网络重连后收到重复的进度事件,步骤列表出现重复项; 3. 任务失败只显示“出错了”,用户不知道能不能重试; 4. 点击“取消”后界面立即显示已取消,但后端其实还在跑; 5. 同时打开两个标签页,状态不一致。 ## 约束 - 后端是 LangGraph + FastAPI,任务状态存在数据库中,事件通过 SSE 推送。 ## 方案设计 1. **任务以服务端为准**:提交后后端返回任务 ID;页面加载时先查询“我的进行中任务”并恢复显示;提交接口带幂等键(防止重复提交)。 2. **前端状态机**:`useReducer` 集中管理状态转换(排队 / 运行 / 等待输入 / 成功 / 失败 / 取消),事件带递增序号,旧事件和终态后的事件丢弃。 3. **重连**:断开后先调用查询接口拿到最新快照(包含当前序号),再从该序号之后订阅事件。 4. **失败分类**:后端返回 `retryable`,前端据此显示“重试”按钮或“联系管理员”。 5. **取消**:点击后进入“取消中”,后端确认(事件或接口返回)后再显示“已取消”;不做乐观的终态显示。 6. **等待输入**:显示问题和输入框,提交后通过接口恢复任务(对应后端的 `interrupt` / `Command(resume=...)`)。 7. **多标签页**:都以服务端事件为准;必要时用 `BroadcastChannel` 同步。 ## 关键代码 状态机和组件见 [examples/task-panel](../examples/task-panel/)。重连时先取快照再订阅(示意): ```ts async function connect(taskId: string, dispatch: (e: TaskEvent) => void, signal: AbortSignal) { const snap = await fetchJSON(`/api/tasks/${taskId}`, { signal }); dispatch({ type: "snapshot", snapshot: snap }); // 用快照覆盖本地状态 await streamEvents(`/api/tasks/${taskId}/events?after=${snap.seq}`, dispatch, signal); } ``` ## 取舍 | 选择 | 优点 | 缺点 | | --- | --- | --- | | **服务端为准 + 前端状态机(本方案)** | 刷新、重连、多标签页都一致 | 需要快照接口和事件序号 | | 只靠前端内存状态 | 简单 | 刷新即丢失 | | 轮询任务状态 | 实现简单、天然可恢复 | 延迟和请求量;可作为 SSE 不可用时的降级 | | 乐观更新所有操作 | 体验快 | 对“取消”这类不一定成功的操作会误导用户 | ## 易错点 - 状态转换分散在多个 `useState` 和事件处理函数中,难以保证一致。 - 不区分“已取消”和“取消中”。 - 重连后不取快照,直接订阅,错过断线期间的事件。 - `useEffect` 中订阅没有清理,切换任务后旧任务的事件串进来。 ## 面试怎么讲 > “长任务界面我以服务端为准:提交带幂等键,页面加载时恢复进行中的任务。前端用 useReducer 做状态机,事件带序号,重复、乱序、终态后的事件都丢弃;断线重连先取快照再从序号之后订阅。失败按 retryable 显示重试,取消要等后端确认,不做乐观的终态。等待用户输入时,提交后调用接口恢复后端的 LangGraph 任务。reducer 是纯函数,状态转换都有单元测试。”