feat: 支持 agent 运行取消功能

This commit is contained in:
qzl
2026-03-25 18:33:25 +08:00
parent 599c597e69
commit 96fc4a1e77
21 changed files with 778 additions and 85 deletions
@@ -6,7 +6,7 @@
**Architecture:** 使用“协作取消 + 主任务中断”方案:API 层写入 Redis cancel 信号,runtime 在 worker 进程内并行 watcher 监听信号,命中后先调用 active agent 的 `interrupt()` 做优雅收尾,再 `cancel()` 当前 run 主任务做硬兜底。终态统一通过 `RUN_ERROR` 事件落库,复用现有 `FAILED` 会话语义,避免数据库枚举迁移。
**Tech Stack:** FastAPI, TaskIQ, Redis, AgentScope, SQLAlchemy, Pytest, Ruff, BasedPyright
**Tech Stack:** FastAPI, TaskIQ, Redis, AgentScope, SQLAlchemy, Flutter, Pytest, Ruff, BasedPyright
---
@@ -390,3 +390,81 @@ git commit -m "feat: support run cancellation with RUN_CANCELED failed semantics
- 回滚 `router/service/dependencies` cancel 新接口
- 回滚 `runner/orchestrator/tasks` cancel 注入逻辑
- 保持原 `POST /runs` 与 SSE 流程不变
### Task 7: 前端接入 cancel API(发送后“停止生成”按钮走后端真实取消)
**Files:**
- Modify: `apps/lib/features/chat/data/services/ag_ui_service.dart`
- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart`
- Modify: `apps/lib/features/chat/data/models/ag_ui_event.dart`
- Modify: `apps/lib/features/home/ui/screens/home_screen_interactions.dart`
- Test: `apps/test/features/chat/data/services/ag_ui_service_test.dart`
- Test: `apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart`
**Step 1: 在 AgUiService 维护当前运行态标识**
`AgUiService` 增加字段:
- `_activeThreadIdForRun: String?`
- `_activeRunId: String?`
并在 `sendMessage` 成功拿到 `/runs` 响应后设置这两个字段;在收到目标 run 的终态事件(`RUN_FINISHED` / `RUN_ERROR`)后清理。
**Step 2: 将 cancelCurrentRun 从“仅断 SSE”升级为“先调用后端 cancel,再本地收流”**
`AgUiService.cancelCurrentRun()` 改为:
1.`_activeThreadIdForRun``_activeRunId` 为空:退化为当前行为(仅关闭 SSE)
2. 否则先调用:
```text
POST /api/v1/agent/runs/{threadId}/cancel?runId={runId}
```
3. 请求成功后再执行 `_cancelActiveSseSubscription()`(避免继续占用本地连接)
4. 不论后端是否即时生效,都清理本地 active run 字段,防止重复 cancel
说明:这一步就是把“发送消息后的停止按钮”真正连到后端取消能力。
**Step 3: 错误语义细化(前端展示友好)**
`chat_bloc.dart` 处理 `RunErrorEvent` 时:
- 如果 `errorEvent.code == 'RUN_CANCELED'`,错误文案不按失败提示展示(可置空或显示“已停止生成”)
- 仍执行 `_resetRunState` 与 tool 卡片收尾,保持 UI 一致性
**Step 4: 保持现有按钮入口,不改交互入口路径**
`home_screen_interactions.dart` 里的 `_onStopGenerating -> _chatBloc.cancelCurrentRun()` 已经是正确入口,继续复用。
仅调整 Toast 文案策略:
- 请求已发出:`已请求停止`
- 收到 `RUN_ERROR(code=RUN_CANCELED)`:最终态 `已停止生成`
**Step 5: 写 AgUiService 测试(先红)**
`ag_ui_service_test.dart` 增加:
- `cancelCurrentRun` 会调用新端点 `/api/v1/agent/runs/{threadId}/cancel`
- query 参数包含 `runId`
- 调用后会关闭当前 SSE subscription
**Step 6: 写 ChatBloc 测试(先红)**
`chat_bloc_attachment_sync_test.dart` 增加:
- 收到 `RunErrorEvent(message: 'run canceled by user', code: 'RUN_CANCELED')` 后:
- `isWaitingFirstToken/isStreaming/isCancelling` 全部归零
- 不显示普通失败文案(或显示取消态文案,按你们最终文案策略断言)
**Step 7: 运行 Flutter 测试**
Run:
```bash
flutter test apps/test/features/chat/data/services/ag_ui_service_test.dart apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart
```
Expected: PASS
**Step 8: 前端接入提交**
```bash
git add apps/lib/features/chat/data/services/ag_ui_service.dart apps/lib/features/chat/presentation/bloc/chat_bloc.dart apps/lib/features/chat/data/models/ag_ui_event.dart apps/lib/features/home/ui/screens/home_screen_interactions.dart apps/test/features/chat/data/services/ag_ui_service_test.dart apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart
git commit -m "feat: wire stop-generating button to backend run cancel API"
```
+41 -4
View File
@@ -11,6 +11,7 @@ Base URL: `/api/v1/agent`
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/runs` | 创建一次 agent run(异步入队) |
| POST | `/runs/{thread_id}/cancel` | 请求取消指定 run |
| GET | `/runs/{thread_id}/events` | 订阅 SSE 事件流 |
| GET | `/history` | 获取历史快照(按天分页) |
| POST | `/attachments` | 上传用户图片附件 |
@@ -95,7 +96,43 @@ Base URL: `/api/v1/agent`
---
## 3) GET `/history`
## 3) POST `/runs/{thread_id}/cancel`
请求取消指定 run。该接口返回 `202` 仅表示取消请求已被后端接收,不保证运行已在同一时刻停止。
### Path
| 参数 | 类型 | 说明 |
|---|---|---|
| `thread_id` | string | 会话 ID |
### Query
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `runId` | string | 是 | 需要取消的 run ID |
### Response
`202 Accepted`
```ts
{
threadId: string;
runId: string;
accepted: true;
}
```
### 错误码
- `401` 未认证
- `403` 非会话所有者
- `422` 参数非法
---
## 4) GET `/history`
返回历史快照(`HistorySnapshotResponse`),不是 SSE 包装事件。
@@ -146,7 +183,7 @@ tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外
---
## 4) POST `/attachments`
## 5) POST `/attachments`
上传图片附件,返回可直接用于 `RunAgentInput.messages[].content[].url` 的签名链接。
@@ -182,7 +219,7 @@ tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外
---
## 5) GET `/attachments/signed-url`
## 6) GET `/attachments/signed-url`
对已有存储对象重新签名。
@@ -205,7 +242,7 @@ tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外
---
## 6) POST `/transcribe`
## 7) POST `/transcribe`
WAV 音频转写。
+14
View File
@@ -75,6 +75,20 @@ data: <json>
}
```
取消语义(当前实现):
```json
{
"type": "RUN_ERROR",
"threadId": "...",
"runId": "...",
"message": "run canceled by user",
"code": "RUN_CANCELED"
}
```
说明:`RUN_CANCELED` 表示用户主动中断,本阶段后端仍复用会话 `failed` 状态以保持兼容。
### 3.2 阶段事件
#### `STEP_STARTED`