feat(maf): Phase 5 - action coordinator, approval flow, frontend MAF roundtable pages

- Backend: action_coordinator, capability_snapshot, event_normalizer, tool_executor updates
- Backend: approved_tool_executor with write guard, action leases, migration script
- Frontend: maf-roundtable module (API client, event timeline, approval cards, fact flow panel)
- Frontend: MafRoutes, MafSessionsPage, MafRunDetailPage
- Tests: action_leases, migration_phase5, phase5_entry_e2e, real_write_e2e/http_e2e
- Docs: update implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-02 13:07:21 +08:00
1 parent 21c4c898e7
commit f82363d81f
55 files changed
+6304 -167

No files matched your search

@@ -1,17 +1,87 @@
# Microsoft Agent Framework 多智能体会商对照页实施计划
> 文档状态:实施中;阶段 1(独立后端骨架与持久化)已验收通过
> 更新时间:2026-08-01
> 文档状态:实施中;已按当前工作区代码重新盘点,阶段 5 安全闭环处于收口验收,阶段 4 动态会商仍未达到退出标准
> 更新时间:2026-08-02
> 目标版本:实验性独立功能,不替换现有多智能体会商
> 建议页面名称:`MAF 会商(实验)`
## 0. 当前实施状态与前后端隔离决议
### 0.1 已完成并验收的阶段 1
### 0.1 代码盘点结论(以 2026-08-02 当前工作区为准)
新的后端骨架已经独立落在 `/api/maf-roundtable` 及 `maf_roundtable_*` 专用表中;会话、运行、事件、审批、用户隔离、启动/取消原子事务与配置校验均不复用旧会商的持久化状态。当前验收证据为:78 项 MAF 后端回归测试通过,且 Harness → App 导入边界检查通过。
本节的状态以当前 `master-dev`、尚未提交的 MAF 改动和本次实际运行结果为准,不沿用同事口头的阶段编号或测试数量。`已实现`不等于`退出验收通过`;只有满足本阶段退出标准、测试证据完整且没有 P0/P1 缺口时才能进入下一阶段的正式验收。
阶段 1 完成并不表示功能已对用户开放:`maf_roundtable.enabled` 继续默认关闭,直到后续 MAF 编排和新前端页面均完成验收。
| 阶段 | 当前状态 | 已有代码/证据 | 尚未满足的退出条件 |
|---|---|---|---|
| 0 依赖 Spike | 部分完成 | `agent-framework-core` 已锁定到 lock 中的 `1.13.0`;真实 SDK `WorkflowBuilder` 路径可由测试运行。 | 尚无当前部署模型的 OpenAI 兼容流式实测、离线 wheelhouse 全新安装实测和正式 Spike 记录。 |
| 1 独立后端骨架 | 代码完成 | `/api/maf-roundtable`、独立 `maf_roundtable_*` 表、事务化启动/取消、事件序号、用户隔离、管理员只读诊断均已实现。 | 本工作区缺少文档提及的 `tests/test_harness_boundary.py`,需在 CI 或恢复该用例后补一份边界验证证据。 |
| 2 DeerFlow Agent Adapter | 代码完成,集成验收待补 | 独立 thread、用户 principal、运行参数、SSE 标准化、事件脱敏、取消级联、seat-run 持久化均已实现;相关单测覆盖。 | 尚未用已部署模型和真实 MCP/技能完成一条从 API 到 DeerFlow Runtime 的人工验收记录。 |
| 3 固定流程编排 | 代码完成,测试通过 | `WorkflowBuilder` 的 planner → 并行席位 → 交叉审查 → 审批 → finalizer、事实账本、格式修复、checkpoint/恢复与 `execution_stage` 身份均已实现。 | 需在真实模型/真实能力快照下完成一次演示验收;不阻塞阶段 5 的安全收口。 |
| 4 动态 Group Chat | 部分实现,不能验收 | `group_chat` 模式能走真实 SDK 工作流路径,且会写 `manager.decision` 事件。 | 当前实质是 `WorkflowBuilder` 的单次 round-robin:没有主持人 Agent selector、没有多轮同席位发言策略、没有完整的总超时/弱模型收敛测试;详见阶段 4。 |
| 5 外部写入与审批 | 框架闭环完成,UAT/部署待输入 | 已有可信工具元数据、WriteGuard、加密参数、审批超时、服务端派生的目标租约、原子一次执行、`unknown` 失败关闭、审计 API、真实 `ToolNode` outbox 及 HTTP 决策→恢复全链路测试。 | 尚未指定 UAT 写入工具、最小权限凭据和核验/补偿协议;生产部署尚未注入 `MAF_ACTION_SECRET_KEY`。 |
| 6 前端工作台 | 原型已存在,未按新约束验收 | 独立 `/page/maf/*` 路由、健康门、会话/运行页、纯轮询、审批卡、事件代次隔离已实现;前端逻辑测试 19 项通过。 | 现有可视组件主要仍使用旧的本地 shadcn/Radix 封装,未改为用户指定的 `tdesign-react`;仍直接引用旧会商目录中的脱敏 hook;恢复/失败重试和组件级 API 验收未完成。 |
| 7 对比评测 | 未开始 | 无。 | 依赖阶段 4、5、6 的正式验收。 |
| 8 测试、离线打包与灰度 | 未开始 | 无。 | 依赖可验收版本。 |
本次重新运行的证据:
- 后端安全闭环重点用例:`293 passed in 140.10s`(包含真实入口 ToolNode/outbox、能力快照、WriteGuard、审批过期与目标租约、旧 DeerFlow 回归)。
- 后端编排重点用例:`40 passed in 5.46s`(真实 SDK structured/group_chat 路径、Agent Adapter、阶段 3 主持人流程)。
- 前端 MAF 逻辑用例:`19 passed`;`pnpm build` 通过。
- 全量 `test_maf_*.py` 已在无短时命令限制的终端完成,最终结果为 `293 passed in 140.10s`;PowerShell 下使用文档中的文件枚举方式展开测试文件。
阶段 1 完成并不表示旧会商被替换。当前配置已打开新的 MAF 功能开关;前端入口由健康检查和权限控制。旧 DeerFlow、旧 roundtable、AI 写作不携带 `maf_run_id`,因此不进入 MAF WriteGuard 分支。
### 0.3 阶段 5:真实工具、技能与外部写入审批闭环(当前收口)
本阶段已补齐以下安全边界:
- `use-maf-events.ts` 为每次 `runId`、`enabled`、`polling`、`resetSignal` 变化分配请求代次;旧请求返回后不能污染新 run,终态补拉不会被旧请求阻塞。
- 能力快照的工具副作用元数据只从服务端读取,优先级为 MAF 专用精确工具名配置、已有 `ToolConfig.extra`、内置白名单、保守默认 `write/R3`。浏览器、会话请求和模型输出不能提供或覆盖这些字段。
- 生产写工具登记时,必须在同一服务端元数据中配置 `target_key_fields`(稳定的顶层租户/业务对象字段)和可选 `target_key_namespace`;这些字段只参与服务端稳定哈希,禁止直接提交 `target_key`。未登记或无法识别对象的写操作共用保守串行目标,并在 `unknown` 后保持人工核验屏障。
- 当前真实发现的只读 MCP 工具名为 `weknora-retrieval_weknora_list_knowledge_bases` 和 `weknora-retrieval_weknora_search`,已按精确名称登记为 `read/R0`;不按 server 名或名称前缀猜测。
- 测试环境提供真实 LangGraph `ToolNode` 写工具 `maf_test_outbox_write`,只写临时 outbox;它经过真实 WriteGuard、加密参数、审批、`execute_approved_tool` 和一次性执行器。生产外部写入目标仍需业务方指定,不能用检索 MCP 代替,也不能擅自猜测业务系统。
- `write_guard.enabled=true` 后,缺少 `MAF_ACTION_SECRET_KEY` 时只读工具和技能仍可工作,外部写入在执行前失败关闭;健康检查只返回 `enabled`、`ready`、`secret_configured` 和状态码,不返回密钥。
本轮框架代码已完成阶段 5 的可自行实现项;下列项目必须由业务方/运维补齐后才能将阶段 5 标记为完整完成:
- `maf_test_outbox_write` 是测试专用写工具,已经证明真实 LangGraph `ToolNode` 与一次性执行器可用,但不等于已接入业务系统。
- 当前机器未注入生产 `MAF_ACTION_SECRET_KEY`,健康状态会显示 `missing_secret`;这符合失败关闭设计,但不能用于生产写入验收。
上线前必须由部署环境注入 `MAF_ACTION_SECRET_KEY`,不得写入仓库、`config.yaml`、前端、事件、checkpoint 或模型上下文。当前测试验收命令为:
```powershell
$env:PYTHONPATH="."
$env:MAF_ACTION_SECRET_KEY="仅测试环境临时值"
$mafTests = Get-ChildItem tests -Filter "test_maf_*.py" | ForEach-Object FullName
uv run --extra maf pytest $mafTests -q -W error
uv run ruff check app/maf_roundtable packages/harness/deerflow
```
### 0.3.1 同事 A:MAF-05-03A / 03B / 04A / 08A 收口记录
本轮已完成前阶段 5 的代码与自动化验收项,但按约定仍不将阶段 5 标记为最终验收完成:真实 UAT 写入系统、测试租户、最小权限凭据、核验 API/补偿协议以及生产环境密钥注入仍待业务方和运维提供。
- `MAF-05-03A`:Action 服务层使用公开白名单 DTO;`target_key` 与 `idempotency_key` 仅留在持久层和内部仲裁路径,不能通过两个 actions GET 接口、事件或前端类型返回。目标键只读取服务端登记的稳定标量字段,并使用 `MAF_ACTION_SECRET_KEY` 做域隔离 HMAC-SHA-256;缺少密钥时真实写入失败关闭。
- `MAF-05-03B`:`scripts/migrate_maf_roundtable_phase5.py` 按数据库方言生成 DDL:PostgreSQL 使用 `TIMESTAMP WITH TIME ZONE`,MySQL/MariaDB 使用 `DATETIME(6)`,SQLite 使用 `DATETIME`。迁移可重复执行,表、缺失索引和可安全补齐的列会自动补齐;已覆盖 SQLite 实际迁移以及三种方言 DDL 测试。
- `MAF-05-04A`:Gateway lifespan 启动和关闭 MAF 专用审批过期扫描任务,间隔由 `maf_roundtable.approval_scan_interval_seconds` 配置。扫描使用已有条件式过期 CAS,然后只对受影响 run 调用 `dispatcher.resume()`;普通审批 GET 保持只读,决定/恢复仍在操作前执行失败关闭过期检查。
- `MAF-05-08A`:入口级测试从 HTTP 创建 session/run 开始,确定性假实现只位于模型边界;真实 Agent Runtime、WriteGuard、LangGraph ToolNode、加密参数和 outbox 均参与执行。覆盖 HTTP 审批后恰好一次写入,以及同目标并发时第二个 run 的 `waiting_target_lease` 与显式 resume。
本轮证据:全部 `test_maf_*.py` 共 `293 passed`;阶段 5 定向回归 `15 passed`;`git diff --check` 通过。仓库现有的全目录 Ruff 基线仍有与本轮无关的历史问题,提交前需在 CI 中单独清理;本轮新增/修改文件已做定向检查。
### 0.4 下一批开工顺序与责任边界
当前应正式进入**阶段 5 收口**;阶段 4 的动态会商可与其中不依赖业务系统的任务并行。不要把阶段 6 原型继续扩展成正式页面,直到阶段 5 的动作一致性契约固定。
| 工作包 | 对应任务 | 难度 | 是否可立即开始 | 交付边界 |
|---|---|---:|---|---|
| WP-F1 目标一致性 P1 收口 | `MAF-05-03A`、`MAF-05-03B` | D4/D5 | 已完成 | 已封闭 `target_key`/内部幂等键公开边界,并使 Phase-5 迁移兼容 PostgreSQL/MySQL;真实 UAT 目标仍由 WP-F3 负责。 |
| WP-F2 审批恢复验收补强 | `MAF-05-04A`、`MAF-05-08A` | D4/D5 | 已完成 | 已完成主动过期、并发/重启补扫,以及从真实工作流入口生成审批到 HTTP 决策、ToolNode/outbox 的全链路验收。 |
| WP-F3 真实 UAT 工具 | `MAF-05-05`、`MAF-05-09`、`MAF-05-10` | D5 | 需业务方输入 | 仅在拿到目标系统、最小权限凭据、幂等与查询核验协议后实施;这是唯一不能自行假设的工作包。 |
| WP-E 动态会商 | `MAF-04-01` ~ `MAF-04-07` | D5 | 可以并行 | 真实多轮 selector、上下文/终止状态机和弱模型收敛测试;不得修改阶段 5 的 Action Coordinator。 |
| WP-G 前端重构准备 | `MAF-06-00` | D3 | 可以并行 | 仅拆除对旧会商 hook 的直接依赖、固定 API view-model 和测试基线;TDesign 页面重构等阶段 5 契约稳定后开始。 |
WP-F1、WP-F2 与 WP-E 必须分别使用独立分支/提交;公共 `config.yaml`、Gateway 注册和前端路由由集成人统一合并。WP-F3 进入开发前需由业务方书面确认第一个写入目标,缺少该信息时应保持阻塞状态而不是接入猜测的 MCP。
### 0.2 新前端页面的强制隔离原则
@@ -42,7 +112,7 @@
### 2.1 本期必须实现
- 新页面:`/page/strategy/qa/maf-roundtable`。
- 新页面:`/page/maf/*`(当前已落地为独立顶级菜单,不嵌入旧会商页)。
- 两种 MAF 编排模式:
- `structured`:专家并行分析 → 交叉审查 → 主持人综合。
- `group_chat`:主持人动态选择下一位发言人,多轮讨论后收敛。
@@ -79,7 +149,7 @@
### 2.4 新前端与新后台的对接边界
新前端页面固定使用独立路由 `/page/strategy/qa/maf-roundtable`,并且只允许请求以下新接口前缀:
新前端页面固定使用独立路由 `/page/maf/*`,并且只允许请求以下新接口前缀:
```text
GET /api/maf-roundtable/health
@@ -278,6 +348,18 @@ flowchart LR
快照用于审计和复现,不代表运行期间复制工具实例。
MAF 的副作用分类是服务端可信配置,不由上述请求体或模型生成的能力描述决定。部署配置使用实际运行时工具名作为键,例如:
```yaml
maf_roundtable:
tool_metadata:
weknora-retrieval_weknora_search:
side_effect: read
risk_level: R0
```
未登记工具保持保守的 `write/R3`,避免未知外部工具被误放行。技能可以继续使用,但技能触发的实际外部副作用必须落到已分类工具调用,不能绕过 WriteGuard。
### 6.2 技能接入
- 继续使用 Agent 当前技能白名单。
@@ -313,6 +395,8 @@ Action Coordinator 只负责执行一致性,不替代工具:
这套分级不禁止外部写入,而是保证它在不同运行策略下可控、可恢复、可审计。
当前已完成的可执行验收工具是测试专用 `maf_test_outbox_write`(`R2/write`),副作用限制在 `MAF_E2E_OUTBOX_DIR` 指定的临时目录。生产验收还需要业务方明确目标系统、测试租户、最小权限凭证和幂等/核验接口;在此之前不得宣称已经完成生产业务写入接入。
### 6.4 沙箱与产物冲突
- 一个席位一个 thread,一个 thread 一个沙箱上下文。
@@ -559,7 +643,7 @@ offline-backend-20260512/backend/
### 10.0 独立前端模块、路由和状态
新增目录固定为 `frontend-web/src/maf-roundtable/`,其 API client 只能封装 `/api/maf-roundtable/*`。新页面根组件为 `MafRoundtablePage.tsx`,路由仅新增 `/page/strategy/qa/maf-roundtable`;不得替换、重命名、迁移或修改旧 `multi-agent-roundtable` 路由及其页面组件。
新增目录固定为 `frontend-web/src/maf-roundtable/`,其 API client 只能封装 `/api/maf-roundtable/*`。当前页面根路由为 `MafRoutes.tsx`,路由仅新增 `/page/maf/*`;不得替换、重命名、迁移或修改旧 `multi-agent-roundtable` 路由及其页面组件。
入口展示前先读取 `GET /api/maf-roundtable/health`:`enabled=false` 时隐藏入口并禁止创建会商;开启后页面必须明确展示“实验版 / 独立后端”标识。TanStack Query 的 key 使用独立命名空间,例如 `['maf-roundtable', 'sessions', ...]`,不得复用任何旧 `roundtable`、`draft` 或 `job` key。所有页面本地状态、事件 reducer、轮询/补拉游标、取消控制器和历史恢复逻辑也必须归属于 MAF 模块。
@@ -745,15 +829,17 @@ frontend-web/src/maf-roundtable/
前置依赖:阶段 2;建议先完成阶段 3 以复用事实账本和恢复机制
主要评价点:弱模型条件下能否收敛、上下文是否准确、硬终止是否可靠
当前代码状态:`workflow_factory.py` 已为 `group_chat` 建了真实 MAF `WorkflowBuilder` 图,`CoordinatorExecutor` 会写 `manager.decision`,并有一条 SDK 路径测试。但它目前按 round-robin 让每个席位最多发言一次;这只是可运行的占位编排,不是本阶段承诺的动态多轮会商。
任务:
- [ ] `MAF-04-01`(D3)使用 `GroupChatBuilder` 建立主持人和参与席位。
- [ ] `MAF-04-02`(D4)实现确定性 selector 与主持人 Agent selector 两种选择方式。
- [ ] `MAF-04-03`(D4)实现最大轮次、连续发言限制、总超时和单席位超时。
- [ ] `MAF-04-04`(D5)实现完整上下文同步、长工具输出裁剪、引用保真和 token 预算。
- [ ] `MAF-04-05`(D4)实现主持人完成判定、schema 校验和结构化终稿。
- [ ] `MAF-04-06`(D3)把主持人调度原因写入 `manager.decision` 事件。
- [ ] `MAF-04-07`(D5)构造弱模型不结束、错误选人、重复发言等测试并保证工作流可收敛。
- [ ] `MAF-04-01`(D4)改为使用锁定 MAF 版本实际提供的 Group Chat 编排 API;若该版本没有名为 `GroupChatBuilder` 的公开类,必须在设计说明中给出等价的官方 API、版本和最小可运行证明。禁止把手写 `WorkflowBuilder` 分支称为已完成的 Group Chat。
- [ ] `MAF-04-02`(D5)实现两种 selector:可复现的确定性 selector,以及由主持人 Agent 根据已验证事实账本选择下一发言人的 selector。两者均须验证候选席位、返回结构和失败降级,不能相信模型随意返回的 agent id。
- [ ] `MAF-04-03`(D4)实现最大轮次、连续发言上限、总超时和单席位超时;状态必须持久化到 checkpoint,恢复后继续计数而不是重置。
- [ ] `MAF-04-04`(D5)以事实账本和摘要引用作为群聊输入来源,保留已执行/待审批/失败动作语义;超长工具结果只传播摘要和可追溯引用,并执行明确 token/字符预算。
- [ ] `MAF-04-05`(D4)由主持人显式输出完成判断与结构化终稿;不合法输出仅允许一次定向修复,仍失败时走确定性降级并留下原因事件。
- [x] `MAF-04-06`(D3)已写入 `manager.decision` 事件;后续须把现有 `round_robin` 原因扩展为 selector 类型、候选集、拒绝原因和终止依据。
- [ ] `MAF-04-07`(D5)新增弱模型不结束、幻觉席位、重复选人、席位超时、空输出和恢复后继续轮次的测试;至少包含一次真实 SDK 的多轮(同一席位可在合规策略下再次发言)验收。
退出标准:
@@ -771,14 +857,22 @@ frontend-web/src/maf-roundtable/
任务:
- [ ] `MAF-05-01`(D3)建立工具风险元数据、默认分类和覆盖规则。
- [ ] `MAF-05-02`(D3)实现 `auto`、`confirm_high_risk`、`confirm_all_writes` 策略解析。
- [ ] `MAF-05-03`(D5)实现 Action Coordinator、唯一幂等键、状态机和目标租约锁。
- [ ] `MAF-05-04`(D5)实现审批暂停、决策、checkpoint 恢复及过期处理。
- [ ] `MAF-05-05`(D5)实现网络结果未知时的核验流程与可选补偿动作。
- [ ] `MAF-05-06`(D4)实现凭据、参数、结果和异常日志脱敏,并编写泄漏测试。
- [ ] `MAF-05-07`(D3)实现审计查询页面所需 API。
- [ ] `MAF-05-08`(D5)实现并发/重启/重复事件故障注入测试,证明外部写入不会重复。
- [x] `MAF-05-01`(D3)已建立服务端可信的工具风险元数据、精确名称覆盖和未知工具 `write/R3` 保守分类;会话请求、浏览器和模型输出均不可覆盖它。
- [x] `MAF-05-02`(D3)已实现 `auto`、`confirm_high_risk`、`confirm_all_writes` 策略解析,并在 WriteGuard 之前执行分类。
- [x] `MAF-05-03`(D5)服务端目标租约与一次性执行核心已实现,并已完成 03A/03B 的公开边界和跨数据库迁移收口;阶段 5 仍受真实 UAT 工具与部署输入约束,不能据此标记为最终验收完成。
- [x] `MAF-05-03A`(D4,分配同事 A)已封闭目标租约键的公开边界:`target_key` 与内部 `idempotency_key` 仅留在持久层和内部仲裁路径,两个 actions GET、事件、日志和前端类型均不返回;目标键只接受服务端登记的稳定标量业务字段,并使用域隔离 HMAC-SHA-256 派生。HTTP 回归同时证明存储层仍能使用目标键完成租约仲裁。
- [x] `MAF-05-03B`(D4,分配同事 A)已修复 `migrate_maf_roundtable_phase5.py` 的跨数据库 DDL:PostgreSQL 使用 `TIMESTAMP WITH TIME ZONE`、MySQL/MariaDB 使用 `DATETIME(6)`、SQLite 使用 `DATETIME`;表、缺失索引和可安全补齐列可重复自动补齐。已覆盖 SQLite 实际迁移及 PostgreSQL/MySQL DDL 测试。
- [x] `MAF-05-04`(D5)审批创建时写入部署级超时,到期后在同一事务内标记 Approval=`expired`、Action=`rejected` 和审计事件。已覆盖真实 HTTP:`POST /approvals/{id}/decision` → `dispatcher.resume()` → `WorkflowRunner.resume()` → `ToolExecutor` → LangGraph `ToolNode` → 测试 outbox;重复决策/恢复仍只执行一次。
- [x] `MAF-05-04A`(D4,分配同事 A)已将审批过期收口为 Gateway lifespan 中可取消的 MAF 周期扫描任务;间隔可配置,扫描使用条件式 CAS 后恢复受影响 run,普通 GET 保持只读。已覆盖并发扫描、重复/重启补扫、决策并发和过期后 Action 拒绝且工具执行次数为零,测试不使用 `sleep`。
- [ ] `MAF-05-05`(D5)为指定的第一个真实写工具实现结果未知核验:外部请求必须携带业务幂等 operation id;网络超时/断连后先调用目标系统查询接口核验,再决定 `succeeded`、`failed` 或保留 `unknown`。补偿动作必须独立审批,不能由通用框架自动猜测或自动重试。
- [x] `MAF-05-06`(D4)参数加密、API/事件/日志脱敏和泄漏测试已覆盖;继续在新增真实工具适配器中复用,禁止把明文凭据或原始参数放进 checkpoint、事件或模型上下文。
- [x] `MAF-05-07`(D3)已提供按 run 查询 actions、approvals、事件及健康状态的 API,前端原型可显示审批和外部动作审计信息。
- [x] `MAF-05-08`(D5)已覆盖两个 Agent 同目标租约串行、进程重启后的过期租约 `unknown` 恢复、外部成功但本地终态落库失败时禁止重放、审批超时、HTTP 决策全链路、并发占用、重复 `resume()`、拒绝、缺失/损坏密钥、真实 ToolNode/outbox 与旧 DeerFlow 不回归。
- [x] `MAF-05-08A`(D5,分配同事 A)已补齐入口级验收:测试从 HTTP 创建 MAF session/run 和真实工作流入口开始,仅在模型边界使用确定性假实现;真实 WriteGuard、ToolNode、加密参数、HTTP 审批和测试 outbox 均参与,单次写入恰好一次。同目标双 run 覆盖 `waiting_target_lease`,第一个完成后第二个只能显式 `/runs/{id}/resume`,且无重复执行;公开 API 同时断言没有 `target_key`/内部幂等键。
- [ ] `MAF-05-09`(D5,需业务方输入)接入首个真实 UAT 写工具/MCP。开始前必须明确工具名与参数 schema、目标 UAT 地址/租户、最小权限凭据的注入方式、目标键提取规则、外部幂等键、查询核验 API、可选补偿边界和回滚联系人;未提供这些信息不得猜测并接入生产系统。
- [ ] `MAF-05-10`(D3)在部署环境通过环境变量注入 `MAF_ACTION_SECRET_KEY`,重启后确认 `/api/maf-roundtable/health` 返回 `write_guard_ready=true`;密钥不得提交到仓库或写入 `config.yaml`。
阶段 5 的当前可交付边界:测试专用 `maf_test_outbox_write` 已验证 WriteGuard → 审批超时/决策 → dispatcher → checkpoint 恢复 → 目标租约 → 直接受控 ToolNode 执行一次的链路,03A、03B、04A、08A 的框架代码与自动化验收项已完成。该结论仍仅代表**框架核心已实现**:真实 UAT 写工具、业务系统查询/补偿协议、最小权限凭据和生产 `MAF_ACTION_SECRET_KEY` 注入尚未提供,因此阶段 5 不得写为“验收完成”,也不得在生产执行真实外部写入。部署时先运行 `PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase5.py`;它只证明框架安全性,不替代 `MAF-05-09` 的真实业务系统验收。
退出标准:
@@ -787,40 +881,68 @@ frontend-web/src/maf-roundtable/
- 并行 Agent 写同一目标时不会互相覆盖。
- 高风险写入在相应策略下能暂停和恢复。
- 每个外部动作可追溯到用户、会商、run、Agent 和工具调用。
- `target_key`、内部幂等键和明文目标标识均不从公开 API、事件、日志或前端状态泄露;Phase-5 迁移能在项目支持的 SQLite、PostgreSQL、MySQL 上幂等执行。
- 审批即使无人刷新页面也会在到期后被后台任务关闭,且完整工作流入口到 HTTP 决策、真实 ToolNode 的链路经一次性测试验证。
### 阶段 6:前端工作台
阶段难度:**D4(较难)**
建议负责人:熟悉 React、TanStack Query、SSE 状态归并和复杂工作台交互的中高级前端开发
建议负责人:熟悉 React、TanStack Query、轮询事件归并和复杂工作台交互的中高级前端开发
前置依赖:阶段 1 已验收的 API 基线与正式事件契约;reducer 可使用本地 fixture 开发,但不得依赖公开 mock 事件接口;可与阶段 2–5 并行开发
主要评价点:事件状态准确、刷新恢复、审批交互、复杂状态下 UI 不误导用户
任务:
- [ ] `MAF-06-00`(D4)新建 MAF 专属目录、独立 query key、独立路由与 feature-flag 入口;确认旧会商页面、hooks、store、草稿与路由零改动。
- [ ] `MAF-06-01`(D2)新增路由、菜单、特性开关和实验标识。
- [ ] `MAF-06-02`(D3)实现创建会商、模型配置和席位选择。
- [ ] `MAF-06-03`(D2)实现 MAF 两种模式及写入策略切换。
- [ ] `MAF-06-04`(D5)实现支持断线、补拉、去重和乱序保护的 SSE reducer。
- [ ] `MAF-06-05`(D3)实现 Agent、工具、技能和外部动作时间线卡片。
- [ ] `MAF-06-06`(D4)实现审批卡片、澄清输入及并发操作防重。
- [ ] `MAF-06-07`(D3)实现事实账本、最终结论和产物展示。
- [ ] `MAF-06-08`(D4)实现取消、恢复、失败重试及对应状态禁用逻辑。
- [ ] `MAF-06-09`(D4)实现刷新恢复、历史列表和跨页面返回。
- [ ] `MAF-06-10`(D3)为 reducer 和关键交互编写前端测试。
组件与实现约束:
- 新 MAF 工作台保持独立路由和独立目录,**不修改**原多智能体会商的页面、组件、状态或视觉。
- 所有新增的可视 UI 组件优先使用项目本地已安装的 `tdesign-react`(React TDesign);可沿用项目既有的 TDesign 用法与主题配置。
- 不得接入远程 UI 组件库、CDN 样式/脚本、在线组件生成服务或运行时远程加载的组件。图标、样式和交互资源必须随前端构建产物离线交付。
- 第一版实时更新采用已确定的**纯前端轮询**:基于 `GET events?after_seq=N` 补拉、去重和游标推进;本阶段不新增 SSE 端点。若后续改用 SSE,另立任务并保持轮询回退路径。
- 使用 TDesign 时应复用 `Dialog`、`Form`、`Input`、`Select`、`Checkbox`、`Button`、`Tag`、`Card`、`Timeline`、`Loading`、`Message` 等本地组件;业务状态、权限判断、事件归并仍由 MAF 专属 hooks/API 层负责,不能写入组件内部的临时全局状态。
当前代码状态:已有独立 `frontend-web/src/maf-roundtable/`、`MafRoutes.tsx`、`/page/maf/*`、会话/运行页、轮询事件 hook、审批卡和 19 项逻辑测试。这些只能视作可编译原型,不能直接作为阶段 6 交付:当前页面主要使用既有 shadcn/Radix 封装,且直接引用 `roundtable-planning/lib/use-desensitize-subtree`,均与本阶段的独立性和 React TDesign 约束不一致。
任务:
- [ ] `MAF-06-00`(D4,部分完成)保留已有 MAF 专属目录、query key 和 `/page/maf/*`,把直接引用旧会商目录的脱敏 hook 抽到中性共享位置或通过公共服务使用;证明旧会商页面、hooks、store、草稿与路由零改动。
- [ ] `MAF-06-01`(D3,部分完成)保留现有独立菜单、健康门和实验标识,但把新增可视 UI 统一迁移到项目已安装的本地 `tdesign-react`;不加载任何远程 UI/CDN/在线资源。
- [ ] `MAF-06-02`(D3,部分完成)以 TDesign `Dialog`/`Form`/`Input`/`Select`/`Checkbox` 重做创建会商、模型配置和席位选择;保留经理与席位互斥、智能体 loading/error/empty 状态和 1–64 轮次校验。
- [ ] `MAF-06-03`(D2,部分完成)以 TDesign 表单控件呈现 `structured`/`group_chat` 与写入策略;后端配置默认值必须可见,前端不得自行伪造能力快照或风险等级。
- [x] `MAF-06-04`(D5)纯轮询事件归并已实现:断线重试、分页补拉、按 seq 去重、游标推进、终态补拉和 run/generation 隔离均已有逻辑测试。后续 UI 改造不得改弱这些语义。
- [ ] `MAF-06-05`(D3,部分完成)以 TDesign `Timeline`/`Card`/`Tag` 重做 Agent、工具、技能和外部动作时间线;事件展示必须只读取 MAF API,且不得显示原始敏感参数。
- [ ] `MAF-06-06`(D4,部分完成)以 TDesign `Card`/`Dialog`/`Button` 重做审批卡;批准/拒绝必须禁用重复提交、以实际 run id 刷新 actions/approvals,并展示后端的失败关闭/未知状态。澄清输入仅在后端提供正式 `waiting_input` 契约后接入,不能先造假接口。
- [ ] `MAF-06-07`(D3,部分完成)以本地 TDesign 呈现事实账本、最终结论和产物;明确区分“候选事实”“已确认事实”“待审批动作”“已执行动作”和“失败/未知动作”。
- [ ] `MAF-06-08`(D4)补齐取消、审批后恢复、失败后创建新 run/重试的交互与状态禁用;不存在的后端 retry 接口不得由前端伪造为已恢复。
- [ ] `MAF-06-09`(D4,部分完成)完成刷新恢复、会话历史、run 深链和跨页面返回;切换 run 时不串台,终态页面必须补齐完整历史。
- [ ] `MAF-06-10`(D4,部分完成)保留现有 19 项纯逻辑测试,并新增挂载 hook/组件级 API mock 测试:慢请求切换 run、终态补拉、审批重复点击、health 缺密钥提示、取消/恢复和错误状态。完整 `pnpm typecheck` 的既有非 MAF 错误需单独基线记录,不能以它们掩盖 MAF 新错误。
退出标准:
- 刷新、切页、重新登录后能恢复当前运行。
- 工具和技能调用过程可见。
- 外部写入状态不会仅显示在聊天文本中。
- 页面不请求或加载任何远程 UI 组件、CDN 样式或脚本;断网环境下仍可完整呈现 MAF 工作台。
- 新增工作台 UI 组件来自本地 `tdesign-react` 或项目已有本地基础组件,未引入第二套远程 UI 依赖。
- 原会商页面视觉和交互没有变化。
### 阶段 6.5:旧会商体验融合(阶段 6 验收后)
阶段难度:**D4(较难)**
建议负责人:熟悉现有会商产品交互、React 状态边界和 React TDesign 的中高级前端开发
前置依赖:阶段 5 的审批闭环验收、阶段 6 的独立工作台验收
主要评价点:保留用户认可的体验,不把旧业务状态或旧 API 重新耦合进 MAF
目标是让 MAF 页面借鉴旧会商的视觉层级、意图理解入口和“总控智能体派活”的可理解性,而不是把旧页面或其数据层搬过来。技术 session/thread 是 MAF 内部实现,普通用户不需要看见“创建线程”。
- [ ] `MAF-06.5-01`(D3)梳理旧会商的用户已认可布局、文案、信息层级和关键交互,形成只读视觉/交互对照清单;不复制旧的 store、jobs、草稿或 API 调用。
- [ ] `MAF-06.5-02`(D4)以本地 `tdesign-react` 重构 MAF 创建入口:用户输入任务/意图后,由 MAF 后端静默创建 session、冻结能力快照并启动 run,不展示底层 thread 创建步骤。
- [ ] `MAF-06.5-03`(D4)把旧体验中的“意图理解 → 总控智能体分工 → 席位执行 → 会商结论”映射到 MAF 的 planner/manager、事实账本和事件时间线;映射只在 MAF view-model/API adapter 层完成。
- [ ] `MAF-06.5-04`(D4)对照旧会商完成视觉和交互回归:新旧两个入口、路由、草稿和运行状态完全独立;关闭 `maf_roundtable.enabled` 后旧会商零变化。
### 阶段 7:对比评测
阶段难度:**D3(中等)**
建议负责人:具备数据分析意识、理解 Agent 错误分类的全栈/测试开发/算法工程师
前置依赖:阶段 3、阶段 4;指标接口可提前开发
前置依赖:阶段 3、阶段 4、阶段 5、阶段 6;指标接口可提前开发
主要评价点:对照是否公平、指标是否可解释、是否能区分模型/工具/编排错误
任务:
@@ -944,24 +1066,26 @@ frontend-web/src/maf-roundtable/
## 15. 发布与回退
- 配置项 `maf_roundtable.enabled` 控制后端 API 与前端入口。
- `write_guard.enabled` 已设为 `true`,但只有带 `maf_run_id` 的新 MAF 会商进入 WriteGuard;旧 DeerFlow、旧 roundtable 和 AI 写作保持透传。
- 上线前由启动环境注入 `MAF_ACTION_SECRET_KEY`;不在仓库或配置文件保存该值。访问 `/api/maf-roundtable/health`,确认 `write_guard_status=ready`、`write_guard_ready=true` 后,才允许验证真实外部写入。
- 上线顺序:先部署只读 MAF 与精确工具元数据 → 注入密钥并检查健康状态 → 在 UAT/测试租户启用已指定的真实写工具 → 验证审批、恢复、拒绝、重启和 unknown 场景 → 再扩大灰度。
- 首期仅管理员或指定白名单可见。
- 关闭入口时,已有运行可选择继续完成或管理员取消。
- 回退只需关闭特性开关和停止新 MAF jobs;旧 roundtable 不需要迁移或回滚。
- 出现异常时先关闭 `maf_roundtable.enabled` 停止新的 MAF 写入;必要时再关闭 `write_guard.enabled`。若密钥或外部系统异常,保持 MAF 开启也会因失败关闭而拒绝写操作。旧 roundtable 不需要迁移或回滚。
- MAF 独立表保留用于审计,不在代码回退时删除。
- MAF SDK 使用延迟导入,关闭功能时不影响 Gateway 启动。
## 16. 推荐开发顺序与工作量级
建议严格按以下顺序推进:
完整项目的依赖顺序仍是阶段 0 → 8;但基于当前代码盘点,**当前恢复开发的顺序**调整为:
1. 依赖/模型/离线 wheel Spike。
2. 独立 session、event 和 SSE 骨架。
3. DeerFlow AgentAdapter,先跑通真实工具、技能和一次外部写入。
4. 固定流程编排。
5. 动态 Group Chat。
6. Action Coordinator、审批和恢复。
7. 前端完整工作台。
8. 对比评测、回归和灰度。
1. 阶段 5:先完成 target-key 租约锁和 HTTP 审批恢复全链路;真实 UAT 工具仅在业务方提供协议后接入。
2. 阶段 4:与阶段 5 的无外部依赖任务并行,补齐真实多轮 Group Chat、selector 和弱模型收敛。
3. 阶段 6:在阶段 5 的动作/API 契约稳定后,将现有原型改为本地 React TDesign 工作台并完成组件级测试。
4. 阶段 6.5:以独立 MAF view-model 复用旧会商的视觉与交互思路,隐藏技术 session/thread,不复用旧状态。
5. 阶段 0 遗留项和阶段 1–3 的真实环境演示验收可穿插进行,但不再阻塞上述代码收口。
6. 阶段 7:在两种编排和前端均验收后做公平对比。
7. 阶段 8:最后完成全量回归、离线安装、灰度和观测。
在熟悉当前代码的前提下,这是一个中等偏大的完整功能,建议按 3 个可演示里程碑交付:
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, Network, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -196,6 +196,18 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/maf")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/maf">
<Network />
<span>MAF 会商</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -30,6 +30,7 @@ import {
List,
ListChecks,
ListTodo,
MessageSquareText,
Network,
Radar,
Settings,
@@ -0,0 +1,348 @@
/**
* MAF 会商前端回归测试。
*
* 运行(当前前端未引入 Vitest):
* pnpm exec esbuild src/maf-roundtable/__tests__/maf-roundtable.test.ts --bundle --platform=node --format=esm --outfile=.maf-roundtable.test.mjs --banner:js="import.meta.env={};"
* node --test .maf-roundtable.test.mjs
*
* 覆盖:
* 1. 经理与席位重复 → participants 构建时自动排除
* 2. 事件翻页 → 超过 PAGE_LIMIT 时 mergeBySeq 不丢不重
* 3. 终态历史 → 终态 run 的事件仍被折叠为时间线
* 4. 延迟出现的审批卡 → pending 审批在 actions 到达后正确关联
* 5. 风险等级映射 → R0–R4 正确映射
*/
import assert from "node:assert/strict";
import { describe, test } from "node:test";
// @ts-ignore Node's test runner resolves .ts directly.
import { foldEventsToTimeline, type TimelineEntry } from "../lib/event-timeline.ts";
// @ts-ignore
import { runIsLive, type MafEvent } from "../api/types.ts";
// @ts-ignore Node's test runner resolves .ts directly.
import { createMafEventRequestCoordinator } from "../hooks/use-maf-events.ts";
// ── helpers ─────────────────────────────────────────────────────────────────
function makeEvent(overrides: Partial<MafEvent> & { seq: number }): MafEvent {
return {
id: `ev-${overrides.seq}`,
session_id: "s1",
run_id: "r1",
event_type: "agent.message.delta",
agent_id: "agent-a",
turn_index: 0,
payload: { text: `msg-${overrides.seq}` },
created_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
/** 模拟 mergeBySeq(与 hook 内部逻辑一致)。 */
function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
if (incoming.length === 0) return prev;
const bySeq = new Map<number, MafEvent>();
for (const e of prev) bySeq.set(e.seq, e);
for (const e of incoming) bySeq.set(e.seq, e);
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
}
// ── 1. 经理与席位重复 ──────────────────────────────────────────────────────
describe("participants 构建:经理与席位互斥", () => {
test("切换经理后,席位列表自动排除该 agent", () => {
// 模拟 UI 逻辑:managerAgentId 变化时 filter
const seatAgentIds = ["a1", "a2", "a3"];
const managerAgentId = "a2";
const filtered = seatAgentIds.filter((id) => id !== managerAgentId);
assert.deepEqual(filtered, ["a1", "a3"]);
assert.ok(!filtered.includes(managerAgentId));
});
test("toggleSeat 拒绝添加当前经理", () => {
const managerAgentId = "a1";
const seatAgentIds = ["a2", "a3"];
// 模拟 toggleSeat 逻辑
const agentId = "a1";
const result = agentId === managerAgentId
? seatAgentIds // 不变
: seatAgentIds.includes(agentId)
? seatAgentIds.filter((id) => id !== agentId)
: [...seatAgentIds, agentId];
assert.deepEqual(result, ["a2", "a3"]);
assert.ok(!result.includes("a1"));
});
});
// ── 2. 事件翻页(超过 PAGE_LIMIT 不丢不重)─────────────────────────────────
describe("事件翻页 mergeBySeq", () => {
test("两页各 1000 条,合并后 2000 条无重复", () => {
const page1 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1 }));
const page2 = Array.from({ length: 1000 }, (_, i) => makeEvent({ seq: i + 1001 }));
let merged: MafEvent[] = [];
merged = mergeBySeq(merged, page1);
assert.equal(merged.length, 1000);
merged = mergeBySeq(merged, page2);
assert.equal(merged.length, 2000);
// 验证升序
for (let i = 1; i < merged.length; i++) {
assert.ok(merged[i].seq > merged[i - 1].seq, `seq not ascending at ${i}`);
}
});
test("重叠页(网络重传)不产生重复", () => {
const page1 = Array.from({ length: 100 }, (_, i) => makeEvent({ seq: i + 1 }));
// 第二页重叠了 seq 91-100,并新增 101-120
const page2 = Array.from({ length: 30 }, (_, i) => makeEvent({ seq: i + 91 }));
let merged: MafEvent[] = [];
merged = mergeBySeq(merged, page1);
merged = mergeBySeq(merged, page2);
assert.equal(merged.length, 120);
});
test("游标只推进到实际收到的最大 seq", () => {
// 模拟 fetchBacklog 的游标推进逻辑
const page = [makeEvent({ seq: 5 }), makeEvent({ seq: 3 }), makeEvent({ seq: 7 })];
let cursor = 0;
let maxSeq = cursor;
for (const e of page) {
if (e.seq > maxSeq) maxSeq = e.seq;
}
assert.equal(maxSeq, 7);
// 不是 latest_seq(可能是 100),而是实际最大 seq
assert.ok(maxSeq <= 7);
});
});
// ── 3. 终态历史(终态 run 的事件仍被折叠)──────────────────────────────────
describe("终态 run 事件折叠", () => {
test("completed run 的事件正常折叠为时间线", () => {
const events: MafEvent[] = [
makeEvent({ seq: 1, event_type: "run.started", payload: {} }),
makeEvent({ seq: 2, event_type: "agent.message.delta", payload: { text: "Hello " } }),
makeEvent({ seq: 3, event_type: "agent.message.delta", payload: { text: "world" } }),
makeEvent({ seq: 4, event_type: "agent.message.completed", payload: { content: "Hello world" } }),
makeEvent({ seq: 5, event_type: "run.completed", payload: {} }),
];
const timeline = foldEventsToTimeline(events);
assert.ok(timeline.length >= 3, `expected >=3 entries, got ${timeline.length}`);
// run.started 和 run.completed 应存在
const kinds = timeline.map((e) => e.eventType);
assert.ok(kinds.includes("run.started"));
assert.ok(kinds.includes("run.completed"));
// delta 聚合为一条 message
const messages = timeline.filter((e) => e.kind === "message");
assert.equal(messages.length, 1);
assert.equal(messages[0].body, "Hello world");
});
test("runIsLive 对终态返回 false", () => {
assert.equal(runIsLive("completed"), false);
assert.equal(runIsLive("failed"), false);
assert.equal(runIsLive("cancelled"), false);
assert.equal(runIsLive(null), false);
assert.equal(runIsLive(undefined), false);
});
test("runIsLive 对活跃态返回 true", () => {
assert.equal(runIsLive("running"), true);
assert.equal(runIsLive("pending"), true);
assert.equal(runIsLive("waiting_approval"), true);
assert.equal(runIsLive("cancelling"), true);
assert.equal(runIsLive("cancel_failed"), true);
});
});
// ── 4. 延迟出现的审批卡 ────────────────────────────────────────────────────
describe("审批关联", () => {
test("pending 审批在 actions 到达后正确关联", () => {
// 模拟 indexApprovalsByAction + indexActions
const approvals = [
{ id: "ap1", action_id: "act1", status: "pending", summary: "写入文件" },
{ id: "ap2", action_id: null, status: "pending", summary: "无关联动作" },
];
const actions = [
{ id: "act1", tool_name: "write_file", risk_level: "R3", arguments: { path: "/tmp/x" } },
];
// indexApprovalsByAction:只索引有 action_id 的 pending
const byAction = new Map<string, typeof approvals[0]>();
for (const a of approvals) {
if (a.status === "pending" && a.action_id) byAction.set(a.action_id, a);
}
assert.equal(byAction.size, 1);
assert.ok(byAction.has("act1"));
assert.ok(!byAction.has("")); // null action_id 不索引
// indexActions
const actMap = new Map<string, typeof actions[0]>();
for (const a of actions) actMap.set(a.id, a);
// 卡片取 action
const ap1 = approvals[0];
const linkedAction = ap1.action_id ? actMap.get(ap1.action_id) : undefined;
assert.equal(linkedAction?.tool_name, "write_file");
assert.equal(linkedAction?.risk_level, "R3");
// 无 action_id 的审批不报错
const ap2 = approvals[1];
const noAction = ap2.action_id ? actMap.get(ap2.action_id) : undefined;
assert.equal(noAction, undefined);
});
});
// ── 5. 风险等级映射 R0–R4 ─────────────────────────────────────────────────
describe("风险等级映射", () => {
const RISK_TONE: Record<string, string> = {
R0: "text-emerald-600 bg-emerald-50",
R1: "text-emerald-600 bg-emerald-50",
R2: "text-amber-600 bg-amber-50",
R3: "text-red-600 bg-red-50",
R4: "text-red-600 bg-red-50",
low: "text-emerald-600 bg-emerald-50",
medium: "text-amber-600 bg-amber-50",
high: "text-red-600 bg-red-50",
};
test("R0/R1 → 低风险(绿)", () => {
assert.ok(RISK_TONE["R0"].includes("emerald"));
assert.ok(RISK_TONE["R1"].includes("emerald"));
});
test("R2 → 中风险(琥珀)", () => {
assert.ok(RISK_TONE["R2"].includes("amber"));
});
test("R3/R4 → 高风险(红)", () => {
assert.ok(RISK_TONE["R3"].includes("red"));
assert.ok(RISK_TONE["R4"].includes("red"));
});
test("未知等级有兜底", () => {
// 未知等级不在 map 中,组件用 ?? 兜底
const fallback = RISK_TONE["R99"] ?? "text-slate-500 bg-slate-50";
assert.ok(fallback.includes("slate"));
});
});
// ── 6. 快速切换 run 时事件不串台(generation 隔离逻辑验证)─────────────────
describe("事件流代次隔离", () => {
test("generation 递增后旧回调被丢弃", () => {
// 模拟 generationRef 逻辑
let generation = 0;
const results: string[] = [];
// 模拟 run A 的请求(慢)
const genA = generation;
// 切换到 run B → generation++
generation += 1;
const genB = generation;
// run A 的响应到达(检查代次)
if (generation === genA) {
results.push("A-stale");
}
// run B 的响应到达
if (generation === genB) {
results.push("B-fresh");
}
assert.deepEqual(results, ["B-fresh"]);
assert.ok(!results.includes("A-stale"));
});
test("同一代次内多次 poll 正常累积", () => {
let generation = 0;
const gen = generation;
const events: number[] = [];
// 同一代次内三次 poll
for (const seq of [1, 2, 3]) {
if (generation === gen) {
events.push(seq);
}
}
assert.deepEqual(events, [1, 2, 3]);
});
test("旧请求延迟返回时,终态新请求仍执行且旧事件被丢弃", async () => {
const coordinator = createMafEventRequestCoordinator();
let resolveOld!: () => void;
const oldResponse = new Promise<void>((resolve) => {
resolveOld = resolve;
});
const acceptedEvents: number[] = [];
let latestSeq = 0;
const acceptPage = (generation: number, page: number[]) => {
if (!coordinator.isCurrent(generation)) return;
acceptedEvents.push(...page);
latestSeq = Math.max(latestSeq, ...page);
};
const oldGeneration = coordinator.nextGeneration();
assert.equal(coordinator.start(oldGeneration), true);
const oldRequest = (async () => {
await oldResponse;
// This is the delayed live poll response from the old run.
acceptPage(oldGeneration, [99]);
coordinator.finish(oldGeneration);
})();
// running -> completed creates a new generation. Its backlog request must
// not see the stale generation's in-flight slot.
const terminalGeneration = coordinator.nextGeneration();
assert.equal(coordinator.start(terminalGeneration), true);
// The terminal request starts from cursor 0 and returns the complete
// history, even though the old live request is still unresolved.
acceptPage(terminalGeneration, [1, 2, 3]);
coordinator.finish(terminalGeneration);
resolveOld();
await oldRequest;
assert.deepEqual(acceptedEvents, [1, 2, 3]);
assert.equal(latestSeq, 3);
assert.equal(coordinator.start(terminalGeneration), true);
});
});
// ── 7. maxRounds 校验 ─────────────────────────────────────────────────────
describe("maxRounds 前端校验", () => {
function validateMaxRounds(input: string): { valid: boolean; value?: number } {
if (!input.trim()) return { valid: true }; // 空 = 使用默认
const n = Number(input);
if (!Number.isInteger(n) || n < 1 || n > 64) return { valid: false };
return { valid: true, value: n };
}
test("空字符串 → 有效(使用默认)", () => {
assert.ok(validateMaxRounds("").valid);
assert.ok(validateMaxRounds(" ").valid);
});
test("1–64 整数 → 有效", () => {
assert.ok(validateMaxRounds("1").valid);
assert.ok(validateMaxRounds("64").valid);
assert.ok(validateMaxRounds("32").valid);
});
test("0 / 65 / 负数 / 小数 / 非数字 → 无效", () => {
assert.ok(!validateMaxRounds("0").valid);
assert.ok(!validateMaxRounds("65").valid);
assert.ok(!validateMaxRounds("-1").valid);
assert.ok(!validateMaxRounds("3.5").valid);
assert.ok(!validateMaxRounds("abc").valid);
});
});
@@ -0,0 +1,126 @@
/**
* MAF 审批 —— `/runs/{id}/approvals`、`/approvals/{id}/decision`、`/runs/{id}/actions`。
*
* decide 成功后 invalidate approvals + actions + events(事件轮询会自然看到
* action.approved/rejected,但显式 invalidate 让卡片状态即时翻转)。
*
* P0-4:hook 签名改为 (runId, isLive)。run 运行期间每 2s 刷新一次;终态停止。
* 不能以"当前有没有 pending 审批"决定是否刷新——审批通常是进入页面后才由工作流产生的。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BASE, getJson, sendJson } from "./client";
import type {
MafAction,
MafActionListResponse,
MafApproval,
MafApprovalDecisionRequest,
MafApprovalListResponse,
} from "./types";
const KEY = "maf-roundtable";
/** live 时审批/动作轮询间隔(ms)。 */
const LIVE_POLL_INTERVAL = 2000;
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
export async function listApprovals(runId: string): Promise<MafApprovalListResponse> {
return getJson<MafApprovalListResponse>(
`${BASE()}/runs/${runId}/approvals`,
"获取审批列表",
);
}
export async function listActions(runId: string): Promise<MafActionListResponse> {
return getJson<MafActionListResponse>(
`${BASE()}/runs/${runId}/actions`,
"获取动作列表",
);
}
export async function decideApproval(
approvalId: string,
body: MafApprovalDecisionRequest,
): Promise<MafApproval> {
return sendJson<MafApproval>(
`${BASE()}/approvals/${approvalId}/decision`,
"POST",
body,
"提交审批决策",
);
}
// ── React Query hooks ─────────────────────────────────────────────────────
/**
* 审批列表。run live 时每 LIVE_POLL_INTERVAL 刷新;终态停止。
* 不能以"当前有没有 pending"决定是否刷新——审批是进入页面后才产生的。
*/
export function useMafApprovals(runId: string | undefined, isLive: boolean) {
return useQuery({
queryKey: [KEY, "runs", runId, "approvals"],
queryFn: () => listApprovals(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
refetchInterval: isLive ? LIVE_POLL_INTERVAL : false,
});
}
/**
* 动作列表。run live 时每 LIVE_POLL_INTERVAL 刷新;终态停止。
*/
export function useMafActions(runId: string | undefined, isLive: boolean) {
return useQuery({
queryKey: [KEY, "runs", runId, "actions"],
queryFn: () => listActions(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
refetchInterval: isLive ? LIVE_POLL_INTERVAL : false,
});
}
export function useMafApprovalActions(runId: string | undefined) {
const queryClient = useQueryClient();
return {
decide: useMutation({
mutationFn: ({
approvalId,
decision,
}: {
approvalId: string;
decision: "approved" | "rejected";
}) => decideApproval(approvalId, { decision }),
onSuccess: () => {
if (!runId) return;
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId, "approvals"] });
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId, "actions"] });
// run 状态会因 resume 改变(approved → running),强制下一次轮询立即生效。
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId] });
},
}),
};
}
// ── 辅助:按 action_id 把 approval 和 action 关联起来 ─────────────────────
/** 把 pending 审批按 action_id 索引,便于卡片查找对应 action 的工具名/参数。 */
export function indexApprovalsByAction(
approvals: MafApproval[] | undefined,
): Map<string, MafApproval> {
const map = new Map<string, MafApproval>();
for (const a of approvals ?? []) {
if (a.status === "pending" && a.action_id) map.set(a.action_id, a);
}
return map;
}
/** 按 id 索引 action,供审批卡片取 tool_name/arguments。 */
export function indexActions(
actions: MafAction[] | undefined,
): Map<string, MafAction> {
const map = new Map<string, MafAction>();
for (const a of actions ?? []) map.set(a.id, map.get(a.id) ?? a);
return map;
}
@@ -0,0 +1,58 @@
/**
* MAF 多智能体会商 API 客户端地基。
*
* 复用全局 `apiFetch`(自动注入 Authorization + credentials:include),这里只做:
* - 拼 URL(`${getBackendBaseURL()}/api/maf-roundtable/...`)
* - 统一 `res.ok` 检查 + detail 提取(仿 task-buttons 的 readJson)
* - JSON 解析
*
* 不在此做 snake_case ⇄ camelCase 转换——后端即 snake_case,类型层直传(见 types.ts)。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
/** MAF roundtable 后端根路径(无尾斜杠)。 */
export const BASE = (): string => `${getBackendBaseURL()}/api/maf-roundtable`;
/**
* 检查 Response 并解析 JSON;失败时抛出带后端 `detail` 的 Error。
*
* - 404 = 不存在或非本人(后端统一返回 404 防止泄漏他人资源存在性)
* - 403 = 非所有者写(admin 也不能写别人的)
* - 503 = 功能关闭 / store 未就绪
*
* 这里不区分状态码语义,统一把 detail 透传给上层 toast/错误展示。
*/
export async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} 失败(HTTP ${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
/** GET JSON 的快捷封装。 */
export async function getJson<T>(url: string, ctx: string): Promise<T> {
const res = await apiFetch(url, { method: "GET" });
return readJson<T>(res, ctx);
}
/** 发送 JSON body 的快捷封装。 */
export async function sendJson<T>(
url: string,
method: "POST" | "PUT" | "DELETE" | "PATCH",
body: unknown,
ctx: string,
): Promise<T> {
const res = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return readJson<T>(res, ctx);
}
@@ -0,0 +1,34 @@
/**
* MAF 事件断线补拉 —— `GET /runs/{id}/events?after_seq=N&limit=...`。
*
* 这是**裸 fetch 函数**(非 hook):cursor `after_seq` 需在每次拉取后推进,
* useQuery 的 queryFn 不持有「上次 latest_seq」,所以轮询逻辑放在
* hooks/use-maf-events.ts(自定义 interval hook),这里只负责一次请求。
*
* 后端契约(events.py list_events):返回 `seq > after_seq` 升序;payload 已脱敏。
*/
import { BASE, getJson } from "./client";
import type { MafEventListResponse } from "./types";
/**
* 拉取 `after_seq` 之后的事件(不含 after_seq 本身)。
*
* @param runId run id
* @param afterSeq 上一次拉到的 latest_seq;首次传 0 补拉全部历史
* @param limit 单次上限,默认 1000、最大 10000(后端 Query 约束)
*/
export async function listEvents(
runId: string,
afterSeq = 0,
limit = 1000,
): Promise<MafEventListResponse> {
const qs = new URLSearchParams({
after_seq: String(afterSeq),
limit: String(limit),
});
return getJson<MafEventListResponse>(
`${BASE()}/runs/${runId}/events?${qs.toString()}`,
"获取会商事件",
);
}
+132
View File
@@ -0,0 +1,132 @@
/**
* MAF run 生命周期 + health 探测 —— `/sessions/{id}/runs`、`/runs/{id}`、`/health`。
*
* 轮询策略:
* - `useMafRun` 用函数式 `refetchInterval`:run 仍 live(pending/running/
* waiting_approval/cancelling/cancel_failed)时每 1.8s 重拉;终态停。
* 镜像 core/scheduled-tasks/hooks.ts:105-115 的模式。
* - events 轮询不在此处,见 hooks/use-maf-events.ts(cursor 推进需自定义 hook)。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BASE, getJson, sendJson } from "./client";
import { runIsLive, type MafHealthResponse, type MafRun, type MafRunListResponse } from "./types";
const KEY = "maf-roundtable";
/** run 轮询间隔(ms)。1.8s —— 平衡实时感与后端负载。 */
export const RUN_POLL_INTERVAL = 1800;
// ── health ────────────────────────────────────────────────────────────────
export async function getMafHealth(): Promise<MafHealthResponse> {
return getJson<MafHealthResponse>(`${BASE()}/health`, "获取 MAF 健康状态");
}
/** health 探测:决定入口页是否显示「功能未启用」空态。失败时视为 disabled。 */
export function useMafHealth() {
return useQuery({
queryKey: [KEY, "health"],
queryFn: getMafHealth,
refetchOnWindowFocus: false,
staleTime: 60_000,
retry: false, // 探测失败不重试,直接走 disabled 空态
});
}
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
export async function getRun(runId: string): Promise<MafRun> {
return getJson<MafRun>(`${BASE()}/runs/${runId}`, "获取会商 run");
}
export async function listRuns(sessionId: string): Promise<MafRunListResponse> {
return getJson<MafRunListResponse>(
`${BASE()}/sessions/${sessionId}/runs`,
"获取 run 列表",
);
}
export async function createRun(
sessionId: string,
workflowVersion?: string,
): Promise<MafRun> {
return sendJson<MafRun>(
`${BASE()}/sessions/${sessionId}/runs`,
"POST",
workflowVersion ? { workflow_version: workflowVersion } : {},
"启动会商 run",
);
}
export async function cancelRun(runId: string): Promise<MafRun> {
return sendJson<MafRun>(`${BASE()}/runs/${runId}/cancel`, "POST", {}, "取消会商");
}
export async function resumeRun(runId: string): Promise<MafRun> {
return sendJson<MafRun>(`${BASE()}/runs/${runId}/resume`, "POST", {}, "恢复会商");
}
export async function recoverRun(runId: string): Promise<MafRun> {
return sendJson<MafRun>(`${BASE()}/runs/${runId}/recover`, "POST", {}, "重试会商");
}
// ── React Query hooks ─────────────────────────────────────────────────────
/**
* 单个 run —— live 时每 1.8s 轮询,终态停。
* 这是事件轮询的「开关」:父组件用 `runIsLive(run.status)` 决定是否拉事件。
*/
export function useMafRun(runId: string | undefined | null) {
return useQuery({
queryKey: [KEY, "runs", runId],
queryFn: () => getRun(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
refetchInterval: (query) =>
runIsLive(query.state.data?.status) ? RUN_POLL_INTERVAL : false,
});
}
export function useMafRuns(sessionId: string | undefined) {
return useQuery({
queryKey: [KEY, "sessions", sessionId, "runs"],
queryFn: () => listRuns(sessionId!),
enabled: !!sessionId,
refetchOnWindowFocus: false,
staleTime: 10_000,
});
}
export function useMafRunActions() {
const queryClient = useQueryClient();
return {
start: useMutation({
mutationFn: ({ sessionId, workflowVersion }: { sessionId: string; workflowVersion?: string }) =>
createRun(sessionId, workflowVersion),
onSuccess: (run) => {
queryClient.invalidateQueries({ queryKey: [KEY, "sessions", run.session_id, "runs"] });
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
},
}),
cancel: useMutation({
mutationFn: cancelRun,
onSuccess: (run) => {
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
},
}),
resume: useMutation({
mutationFn: resumeRun,
onSuccess: (run) => {
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
},
}),
recover: useMutation({
mutationFn: recoverRun,
onSuccess: (run) => {
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
},
}),
};
}
@@ -0,0 +1,143 @@
/**
* MAF 会话 CRUD —— `/sessions`。
*
* 镜像 strategy-components/api/task-buttons.ts 的 colocated hook 模式:
* queryKey 用嵌套数组;mutation 成功后 invalidate 同前缀。
* snake_case 直传(见 types.ts)。
*
* 唯一依据:offline-backend-20260512/backend/app/maf_roundtable/schemas.py
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BASE, getJson, sendJson } from "./client";
import type {
MafSession,
MafSessionListResponse,
} from "./types";
const KEY = "maf-roundtable";
// ── 请求体(对齐 schemas.py CreateSessionRequest / UpdateSessionRequest)──
export interface ParticipantInput {
agent_id: string;
display_name?: string;
role: "manager" | "seat";
sort_order?: number;
excluded_tools?: string[];
}
export interface CreateSessionBody {
title: string;
task_text: string;
task_payload?: Record<string, unknown> | null;
mode?: "structured" | "group_chat";
model_name?: string | null;
reasoning_effort?: string | null;
manager_agent_id?: string | null;
max_rounds?: number;
write_policy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
participants: ParticipantInput[];
excluded_tools?: string[];
}
export interface UpdateSessionBody {
title?: string;
task_text?: string;
task_payload?: Record<string, unknown> | null;
mode?: "structured" | "group_chat";
model_name?: string | null;
reasoning_effort?: string | null;
manager_agent_id?: string | null;
max_rounds?: number;
write_policy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
participants?: ParticipantInput[];
fact_ledger?: Record<string, unknown> | null;
final_result?: Record<string, unknown> | null;
}
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
export async function listSessions(params?: {
status?: string;
mode?: string;
limit?: number;
offset?: number;
}): Promise<MafSessionListResponse> {
const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status);
if (params?.mode) qs.set("mode", params.mode);
if (params?.limit != null) qs.set("limit", String(params.limit));
if (params?.offset != null) qs.set("offset", String(params.offset));
const query = qs.toString();
return getJson<MafSessionListResponse>(
`${BASE()}/sessions${query ? `?${query}` : ""}`,
"获取会话列表",
);
}
export async function getSession(id: string): Promise<MafSession> {
return getJson<MafSession>(`${BASE()}/sessions/${id}`, "获取会话");
}
export async function createSession(body: CreateSessionBody): Promise<MafSession> {
return sendJson<MafSession>(`${BASE()}/sessions`, "POST", body, "创建会话");
}
export async function updateSession(
id: string,
body: UpdateSessionBody,
): Promise<MafSession> {
return sendJson<MafSession>(`${BASE()}/sessions/${id}`, "PUT", body, "更新会话");
}
export async function deleteSession(id: string): Promise<void> {
await sendJson<{ deleted: boolean }>(
`${BASE()}/sessions/${id}`,
"DELETE",
undefined,
"删除会话",
);
}
// ── React Query hooks ─────────────────────────────────────────────────────
export function useMafSessions(params?: {
status?: string;
mode?: string;
limit?: number;
offset?: number;
}) {
return useQuery({
queryKey: [KEY, "sessions", params],
queryFn: () => listSessions(params),
refetchOnWindowFocus: false,
staleTime: 30_000,
});
}
export function useMafSession(id: string | undefined) {
return useQuery({
queryKey: [KEY, "sessions", id],
queryFn: () => getSession(id!),
enabled: !!id,
refetchOnWindowFocus: false,
staleTime: 30_000,
});
}
export function useMafSessionActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [KEY, "sessions"] });
return {
create: useMutation({ mutationFn: createSession, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({ id, body }: { id: string; body: UpdateSessionBody }) =>
updateSession(id, body),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteSession, onSuccess: invalidate }),
};
}
@@ -0,0 +1,325 @@
/**
* MAF 多智能体会商 —— 后端 `/api/maf-roundtable` 的 TypeScript 类型。
*
* 唯一依据:offline-backend-20260512/backend/app/maf_roundtable/schemas.py。
* 后端持久化即 snake_case(见 packages/harness/deerflow/persistence/maf_roundtable),
* 这里**直传不转换**:与后端字段一一对应,避免边界处多一层映射。
* 所有 `payload` 已在后端写入时脱敏(`sanitize_payload`),前端拿到的就是可渲染的。
*/
// ── health(GET /health)───────────────────────────────────────────────────
export interface MafHealthResponse {
enabled: boolean;
store_available: boolean;
default_mode: string;
default_write_policy: string;
default_max_rounds: number;
admin_diagnostic_access: boolean;
write_guard_enabled: boolean;
write_guard_ready: boolean;
action_secret_configured: boolean;
write_guard_status:
| "disabled"
| "provider_unavailable"
| "missing_secret"
| "ready";
}
// ── session(/sessions)────────────────────────────────────────────────────
export type MafSessionStatus =
| "draft"
| "running"
| "waiting_approval"
| "waiting_input"
| "completed"
| "failed"
| "cancelled";
export interface MafParticipant {
id: string;
session_id: string;
agent_id: string;
display_name: string;
role: "manager" | "seat";
sort_order: number;
thread_id: string | null;
status: string;
last_turn_index: number | null;
capability_snapshot: Record<string, unknown> | null;
created_at: string | null;
updated_at: string | null;
}
export interface MafSession {
id: string;
user_id: string;
title: string;
task_text: string;
task_payload: Record<string, unknown> | null;
mode: "structured" | "group_chat";
status: MafSessionStatus;
model_name: string | null;
reasoning_effort: string | null;
manager_agent_id: string | null;
max_rounds: number;
write_policy: "auto" | "confirm_high_risk" | "confirm_all_writes";
capability_snapshot: Record<string, unknown> | null;
fact_ledger: Record<string, unknown> | null;
final_result: Record<string, unknown> | null;
participants: MafParticipant[];
created_at: string | null;
updated_at: string | null;
}
export interface MafSessionListResponse {
sessions: MafSession[];
total: number;
}
// ── run(/sessions/{id}/runs, /runs/{id})──────────────────────────────────
/**
* Run 状态机。`runIsLive` 判定为 true 的状态会继续轮询;其余(completed/failed/
* cancelled)是终态,停止轮询。cancelling / cancel_failed 是非终态中间态,仍需轮询。
*/
export type MafRunStatus =
| "pending"
| "running"
| "waiting_approval"
| "completed"
| "failed"
| "cancelled"
| "cancelling"
| "cancel_failed";
export interface MafRun {
id: string;
session_id: string;
status: MafRunStatus;
workflow_version: string | null;
/**
* MAF 工作流检查点。结构见 RoundtableState:含 fact_ledger / final_result /
* seat_outcomes / current_round 等。这里用宽松类型——前端只读其中几个已知键。
*/
maf_checkpoint: MafCheckpoint | null;
last_event_seq: number;
started_at: string | null;
finished_at: string | null;
error_code: string | null;
error_message_safe: string | null;
token_usage: Record<string, number> | null;
latency_ms: number | null;
created_at: string | null;
updated_at: string | null;
}
export interface MafRunListResponse {
runs: MafRun[];
}
// ── checkpoint 内部结构(maf_checkpoint,宽松类型)──────────────────────────
/** 事实账本(fact_ledger.empty_ledger 的形状)。 */
export interface MafFactLedger {
candidate_facts: MafCandidateFact[];
confirmed_facts: MafConfirmedFact[];
evidence: MafEvidence[];
tool_results: MafToolResult[];
executed_actions: MafLedgerAction[];
pending_actions: MafLedgerAction[];
failed_or_unknown_actions: MafLedgerAction[];
conflicts: MafLedgerConflict[];
proposed_actions: MafLedgerAction[];
}
export interface MafCandidateFact {
candidate_id: string;
text: string;
agent_id?: string;
source?: string;
confirmed?: boolean;
}
export interface MafConfirmedFact {
candidate_id: string;
text: string;
agent_id?: string;
source?: string;
confirmed_by?: string;
evidence_refs: string[];
}
export interface MafEvidence {
ref?: string;
text?: string;
agent_id?: string;
[k: string]: unknown;
}
export interface MafToolResult {
tool_name: string;
status: string; // "completed" | "waiting_approval" | "failed" | "unknown"
agent_id?: string;
turn_index?: number;
detail?: Record<string, unknown>;
}
export interface MafLedgerAction {
action_id?: string;
tool_name?: string;
status?: string;
agent_id?: string;
external_object_id?: string;
text?: string;
[k: string]: unknown;
}
export interface MafLedgerConflict {
text: string;
agent_id?: string;
}
/** 席位产出(maf_checkpoint.seat_outcomes 的 value)。 */
export interface MafSeatOutcome {
status: string; // "completed" | "running" | "failed" | ...
execution_stage?: string;
agent_id?: string;
[k: string]: unknown;
}
export interface MafFinalResult {
content: string;
agent_id?: string;
partial_failure?: boolean;
degraded?: boolean;
note?: string;
}
export interface MafCheckpoint {
fact_ledger?: MafFactLedger;
seat_outcomes?: Record<string, MafSeatOutcome>;
final_result?: MafFinalResult;
current_round?: number;
seat_agent_ids?: string[];
[k: string]: unknown;
}
// ── event(/runs/{id}/events)──────────────────────────────────────────────
/**
* 事件分类(见后端 app/maf_roundtable/event_normalizer.py)。
* payload 已脱敏,结构因 event_type 而异,这里用 Record 宽松承载。
*/
export type MafEventType =
| "run.started"
| "run.completed"
| "run.failed"
| "run.cancelled"
| "agent.turn_started"
| "agent.message.delta"
| "agent.message.completed"
| "tool.requested"
| "tool.started"
| "tool.completed"
| "tool.failed"
| "skill.selected"
| "skill.started"
| "skill.completed"
| "skill.failed"
| "artifact.created"
| "action.waiting_approval"
| "action.approved"
| "action.rejected"
| "workflow.checkpointed"
| string; // 兜底:后端可能新增,前端不强断
export interface MafEvent {
id: string;
session_id: string;
run_id: string;
seq: number;
event_type: MafEventType;
agent_id: string | null;
turn_index: number | null;
payload: Record<string, unknown> | null;
created_at: string | null;
}
export interface MafEventListResponse {
events: MafEvent[];
latest_seq: number;
}
// ── action(/runs/{id}/actions, /actions/{id})─────────────────────────────
export type MafActionStatus =
| "pending"
| "waiting_approval"
| "approved"
| "rejected"
| "executed"
| "failed"
| "unknown"
| "expired"
| string;
export interface MafAction {
id: string;
session_id: string;
run_id: string | null;
agent_id: string | null;
tool_name: string;
logical_action_id: string | null;
risk_level: string;
status: MafActionStatus;
arguments: Record<string, unknown> | null;
result: Record<string, unknown> | null;
external_object_id: string | null;
approval_id: string | null;
attempt_count: number;
started_at: string | null;
finished_at: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface MafActionListResponse {
actions: MafAction[];
}
// ── approval(/runs/{id}/approvals, /approvals/{id}/decision)──────────────
export type MafApprovalStatus = "pending" | "approved" | "rejected" | string;
export interface MafApproval {
id: string;
session_id: string;
run_id: string | null;
action_id: string | null;
requested_by_agent_id: string | null;
status: MafApprovalStatus;
summary: string;
payload: Record<string, unknown> | null;
decided_by: string | null;
decided_at: string | null;
expires_at: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface MafApprovalListResponse {
approvals: MafApproval[];
}
export interface MafApprovalDecisionRequest {
decision: "approved" | "rejected";
}
// ── 终态判定 ───────────────────────────────────────────────────────────────
const LIVE_STATUSES: ReadonlySet<MafRunStatus> = new Set([
"pending",
"running",
"waiting_approval",
"cancelling",
"cancel_failed",
]);
/** run 是否仍可能产生新事件 —— true 时继续轮询。 */
export function runIsLive(status: string | null | undefined): boolean {
return !!status && (LIVE_STATUSES as Set<string>).has(status);
}
@@ -0,0 +1,170 @@
/**
* MAF 写工具审批卡片。
*
* P0-4 对齐后端 schemas.py:
* - 标题:action.tool_name,没有 action 时显示 approval.summary
* - 风险:展示 action.risk_level(R0–R4 配色)
* - 参数:展示 action.arguments(已脱敏)
* - 审批摘要:展示 approval.summary
* - 审批 payload:仅在有值时展示已脱敏 JSON
* - 备注 textarea 已删除(后端 ApprovalDecisionRequest 只接收 decision,不保存备注)
*
* P1:风险等级按 R0–R4 配色(R0/R1 低、R2 中、R3/R4 高)。
* P1:invalidate 使用页面传入的 runId,不依赖 approval.run_id 可选字段。
*
* 仅 pending 审批渲染;approved/rejected 后由父组件过滤掉。
*/
import { Check, Loader2, ShieldCheck, X } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useMafApprovalActions } from "../api/approvals";
import type { MafAction, MafApproval } from "../api/types";
interface MafApprovalCardProps {
approval: MafApproval;
action: MafAction | undefined;
/** 当前页面的 runId(用于 invalidate,不依赖 approval.run_id 可选字段)。 */
runId: string;
}
function shortId(id: string | null): string {
if (!id) return "—";
return id.length > 8 ? `${id.slice(0, 8)}…` : id;
}
/**
* 风险等级配色。后端实际使用 R0–R4(不是 low/medium/high):
* R0/R1 → 低风险(绿)
* R2 → 中风险(琥珀)
* R3/R4 → 高风险(红)
*/
const RISK_TONE: Record<string, string> = {
R0: "text-emerald-600 bg-emerald-50",
R1: "text-emerald-600 bg-emerald-50",
R2: "text-amber-600 bg-amber-50",
R3: "text-red-600 bg-red-50",
R4: "text-red-600 bg-red-50",
// 兜底:后端可能返回旧值
low: "text-emerald-600 bg-emerald-50",
medium: "text-amber-600 bg-amber-50",
high: "text-red-600 bg-red-50",
};
const RISK_LABEL: Record<string, string> = {
R0: "R0 · 低",
R1: "R1 · 低",
R2: "R2 · 中",
R3: "R3 · 高",
R4: "R4 · 高",
};
export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProps) {
const actions = useMafApprovalActions(runId);
const pending = actions.decide.isPending;
const onDecide = (decision: "approved" | "rejected") => {
actions.decide.mutate(
{ approvalId: approval.id, decision },
{
onSuccess: () => {
toast.success(decision === "approved" ? "已批准" : "已拒绝");
},
onError: (e: Error) => toast.error(`决策失败:${e.message}`),
},
);
};
// 标题:action.tool_name 优先,没有 action 时用 approval.summary
const title = action?.tool_name ?? approval.summary;
const riskLevel = action?.risk_level;
const args = action?.arguments;
const approvalPayload = approval.payload;
return (
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-sm">
<header className="mb-2 flex items-center gap-1.5">
<Badge className="bg-red-50 text-red-600 hover:bg-red-50">
<ShieldCheck className="h-3 w-3" />
需要你的确认
</Badge>
{action?.id && (
<span className="text-[10px] font-mono text-slate-400">
#{shortId(approval.action_id ?? action.id)}
</span>
)}
</header>
{/* 标题 */}
<div className="mb-1.5 text-sm font-semibold text-slate-800">
{title || "未知操作"}
</div>
{/* 风险等级(R0–R4) */}
{riskLevel && (
<div className="mb-2 flex items-center gap-1.5">
<span className="text-[10px] text-slate-400">风险等级:</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${RISK_TONE[riskLevel] ?? "text-slate-500 bg-slate-50"}`}>
{RISK_LABEL[riskLevel] ?? riskLevel}
</span>
</div>
)}
{/* 审批摘要 */}
{approval.summary && (
<div className="mb-2 text-xs text-slate-600">
{approval.summary}
</div>
)}
{/* 参数(来自 action.arguments,已脱敏) */}
{args && Object.keys(args).length > 0 && (
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
参数(已脱敏)
</div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
{JSON.stringify(args, null, 2)}
</pre>
</div>
)}
{/* 审批 payload(仅有值时展示) */}
{approvalPayload && Object.keys(approvalPayload).length > 0 && (
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
审批载荷(已脱敏)
</div>
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
{JSON.stringify(approvalPayload, null, 2)}
</pre>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => onDecide("rejected")}
disabled={pending}
className="border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
>
{pending ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <X className="mr-1 h-3.5 w-3.5" />}
拒绝
</Button>
<Button
size="sm"
onClick={() => onDecide("approved")}
disabled={pending}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
{pending ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Check className="mr-1 h-3.5 w-3.5" />}
批准
</Button>
</div>
</div>
);
}
@@ -0,0 +1,144 @@
/**
* MAF 事件时间线 —— 把折叠后的 TimelineEntry[] 渲染成可读的对话流。
*
* 复用 roundtable-planning 的 useAutoScroll + ScrollToBottomButton(滚动跟随语义)。
* 视觉上借鉴 MessageBubble(头像 + header + 正文),但 MAF 事件类型多样(消息/工具/
* 审批/run),这里用统一的「条目卡片」而非消息气泡——每条带类型徽标 + 时间 + 内容。
*/
import { useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
AlertTriangle,
CheckCircle2,
FileText,
GitBranch,
Hammer,
Loader2,
MessageSquare,
Package,
ShieldQuestion,
Sparkles,
Workflow,
XCircle,
} from "lucide-react";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { shortTime, type TimelineEntry, type TimelineEntryKind } from "../lib/event-timeline";
interface MafEventTimelineProps {
entries: TimelineEntry[];
/** 仍在流式(run live)→ 显示底部 spinner;停则不显示。 */
streaming: boolean;
}
const KIND_ICON: Record<TimelineEntryKind, React.ReactNode> = {
message: <MessageSquare className="h-3.5 w-3.5" />,
tool: <Hammer className="h-3.5 w-3.5" />,
skill: <Sparkles className="h-3.5 w-3.5" />,
approval: <ShieldQuestion className="h-3.5 w-3.5" />,
artifact: <Package className="h-3.5 w-3.5" />,
run: <Workflow className="h-3.5 w-3.5" />,
checkpoint: <GitBranch className="h-3.5 w-3.5" />,
turn: <FileText className="h-3.5 w-3.5" />,
};
const KIND_TONE: Record<TimelineEntryKind, string> = {
message: "border-blue-100 bg-blue-50/40",
tool: "border-cyan-100 bg-cyan-50/40",
skill: "border-purple-100 bg-purple-50/40",
approval: "border-amber-100 bg-amber-50/50",
artifact: "border-emerald-100 bg-emerald-50/40",
run: "border-slate-200 bg-slate-50/60",
checkpoint: "border-slate-200 bg-slate-50/40",
turn: "border-slate-100 bg-white",
};
function toolStatusIcon(entry: TimelineEntry): React.ReactNode {
const status = (entry.meta?.status as string) ?? "";
if (status === "completed") return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />;
if (status === "failed") return <XCircle className="h-3.5 w-3.5 text-red-600" />;
if (status === "started" || status === "requested")
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />;
return null;
}
export function MafEventTimeline({ entries, streaming }: MafEventTimelineProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [entries], {
contentRef,
});
return (
<div className="relative flex h-full min-h-0 flex-1 flex-col">
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-slate-50/30"
>
<div ref={contentRef} className="space-y-2 p-4">
{entries.length === 0 && !streaming && (
<div className="flex h-full items-center justify-center py-12 text-sm text-slate-400">
<AlertTriangle className="mr-1.5 h-4 w-4" />
暂无事件
</div>
)}
{entries.map((entry) => (
<TimelineEntryCard key={entry.key} entry={entry} />
))}
{streaming && (
<div className="flex items-center gap-2 py-2 pl-7 text-xs text-blue-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
会商进行中…
</div>
)}
</div>
</div>
{!atBottom && (
<ScrollToBottomButton onClick={scrollToBottom} label="回到底部" />
)}
</div>
);
}
function TimelineEntryCard({ entry }: { entry: TimelineEntry }) {
const isMessage = entry.kind === "message";
return (
<div
className={cn(
"rounded-lg border px-3 py-2 transition",
KIND_TONE[entry.kind],
)}
>
<header className="mb-1 flex items-center gap-1.5">
<span className="text-slate-500">{KIND_ICON[entry.kind]}</span>
{entry.kind === "tool" && toolStatusIcon(entry)}
<span className="text-xs font-semibold text-slate-700">{entry.title}</span>
{entry.agentId && (
<Badge variant="outline" className="ml-1 text-[10px] text-slate-500">
{entry.agentId}
</Badge>
)}
<span className="ml-auto text-[10px] text-slate-400">{shortTime(entry.createdAt)}</span>
</header>
{entry.body && (
isMessage ? (
<div className="prose prose-sm max-w-none text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.body}</ReactMarkdown>
</div>
) : (
<p className="whitespace-pre-wrap break-words text-xs text-slate-600">{entry.body}</p>
)
)}
</div>
);
}
@@ -0,0 +1,129 @@
/**
* MAF 事实/结论流程图面板。
*
* 包一层 roundtable-planning 的 FlowGraphPanel:数据来自 lib/fact-flow-graph.ts
* (confirmed_facts 证据图 + final_result 的 flow-json 结论图)。run live 时
* streaming=true 让蚂蚁线动;终态转实线。
*
* FlowGraphPanel 是 portal 全屏弹窗风格(自带 onClose),这里用 Collapsible 折叠面板
* 内嵌一个固定高度的画布预览 + 「展开大图」按钮触发 FlowGraphPanel。
*/
import { useState } from "react";
import { GitBranch, Maximize2 } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
import type { MafCheckpoint } from "../api/types";
import { buildMafFlowGraph } from "../lib/fact-flow-graph";
interface MafFactFlowPanelProps {
checkpoint: MafCheckpoint | null;
/** run 仍 live → 蚂蚁线动;终态 → 实线。 */
streaming: boolean;
}
export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProps) {
const [open, setOpen] = useState(true);
const [fullscreen, setFullscreen] = useState(false);
const graph: FlowGraphData | null = buildMafFlowGraph(checkpoint);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="rounded-lg border border-slate-200 bg-white"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2">
<div className="flex items-center gap-1.5">
<GitBranch className="h-4 w-4 text-slate-500" />
<span className="text-sm font-semibold text-slate-700">事实/结论流程图</span>
{graph && (
<span className="text-xs text-slate-400">
({graph.nodes.length} 节点 / {graph.edges.length} 连线)
</span>
)}
</div>
<span className="text-xs text-slate-400">{open ? "收起" : "展开"}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="relative h-64 border-t border-slate-100">
{graph ? (
<>
{/* 内嵌预览:复用 FlowGraphPanel 的横向布局,占满面板高度 */}
<FlowGraphPreview graph={graph} streaming={streaming} />
<Button
size="sm"
variant="outline"
className="absolute right-2 top-2 z-10 h-7 gap-1 bg-white/90 text-xs"
onClick={() => setFullscreen(true)}
>
<Maximize2 className="h-3.5 w-3.5" />
大图
</Button>
</>
) : (
<div className="flex h-full items-center justify-center text-xs text-slate-400">
{streaming ? "等待已确认事实 / 结论输出…" : "尚无可绘制的事实或结论"}
</div>
)}
</div>
</CollapsibleContent>
{/* 全屏弹窗(portal 到 body,仿 Step3SummaryMode 用法) */}
{fullscreen && graph && (
<FlowGraphPanel
data={graph}
streaming={streaming}
onClose={() => setFullscreen(false)}
orientation="vertical"
miniMap
/>
)}
</Collapsible>
);
}
/**
* 内嵌预览。FlowGraphPanel 是 portal 全屏组件,不适合直接嵌进小面板;
* 这里用一个轻量的「节点列表」预览代替画布(点击「大图」看完整交互画布)。
*/
function FlowGraphPreview({ graph, streaming }: { graph: FlowGraphData; streaming: boolean }) {
return (
<div className="flex h-full flex-col overflow-hidden p-2">
{graph.title && (
<div className="mb-1 truncate text-xs font-medium text-slate-500">{graph.title}</div>
)}
<div className="flex-1 overflow-auto">
<ul className="space-y-1">
{graph.nodes.map((n) => {
const incomers = graph.edges.filter((e) => e.to === n.id).map((e) => e.from);
return (
<li key={n.id} className="rounded border border-slate-100 bg-slate-50/70 px-2 py-1">
<div className="flex items-center gap-1.5">
<span className="rounded bg-white px-1 py-0.5 text-[9px] font-mono text-slate-400">
{n.type}
</span>
<span className="truncate text-xs text-slate-700">{n.label}</span>
{n.group && (
<span className="ml-auto shrink-0 text-[10px] text-slate-400">{n.group}</span>
)}
</div>
{incomers.length > 0 && (
<div className="mt-0.5 text-[10px] text-slate-400">← {incomers.join(", ")}</div>
)}
</li>
);
})}
</ul>
</div>
{streaming && (
<div className="mt-1 text-center text-[10px] text-blue-400">绘制中…</div>
)}
</div>
);
}
@@ -0,0 +1,103 @@
/**
* MAF 功能开关守卫。
*
* P1:分别处理三种状态:
* - enabled=false → 「功能未启用」
* - enabled=true && store_available=false → 「功能已开启,但持久化存储未就绪」
* - 请求异常 → 「无法连接 MAF 服务」+ 重试按钮
*/
import { DatabaseZap, Loader2, RefreshCw, ShieldAlert, ShieldOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useMafHealth } from "../api/runs";
interface MafHealthGateProps {
/** enabled 时渲染的子树。 */
children: React.ReactNode;
/** 探测中时渲染的占位(默认 spinner)。 */
fallback?: React.ReactNode;
}
export function MafHealthGate({ children, fallback }: MafHealthGateProps) {
const { data, isLoading, isError, refetch, isFetching } = useMafHealth();
if (isLoading) {
return (
<>{fallback ?? (
<div className="flex h-full w-full items-center justify-center text-slate-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在检查 MAF 会商状态…
</div>
)}</>
);
}
// 请求异常 → 无法连接,给重试按钮
if (isError) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
<ShieldOff className="h-10 w-10 text-slate-300" />
<div className="text-base font-medium text-slate-500">无法连接 MAF 服务</div>
<p className="max-w-md text-sm text-slate-400">
请求 /api/maf-roundtable/health 失败,请检查后端服务是否正常运行。
</p>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
重试
</Button>
</div>
);
}
// 明确 disabled → 功能未启用
if (!data?.enabled) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
<ShieldOff className="h-10 w-10 text-slate-300" />
<div className="text-base font-medium text-slate-500">MAF 会商功能未启用</div>
<p className="max-w-md text-sm text-slate-400">
后端未开启多智能体会商功能(maf_roundtable.enabled=false)。
请联系管理员在 config.yaml 中启用后重试。
</p>
</div>
);
}
// enabled=true 但 store_available=false → 存储未就绪
if (!data.store_available) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
<DatabaseZap className="h-10 w-10 text-amber-400" />
<div className="text-base font-medium text-slate-500">持久化存储未就绪</div>
<p className="max-w-md text-sm text-slate-400">
MAF 会商功能已开启,但持久化存储(数据库)尚未配置或连接失败。
写操作将返回 503。请检查数据库配置后重试。
</p>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
重新检测
</Button>
</div>
);
}
const writeGuardNotReady =
data.write_guard_enabled && !data.write_guard_ready;
const writeGuardMessage =
data.write_guard_status === "missing_secret"
? "WriteGuard 未就绪:未注入 MAF_ACTION_SECRET_KEY,外部写入将失败关闭;只读工具和技能仍可用。"
: "WriteGuard 未就绪:外部写入将失败关闭,请检查审批桥接配置;只读工具和技能仍可用。";
return (
<div className="flex h-full min-h-0 flex-col">
{writeGuardNotReady && (
<div className="flex shrink-0 items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-800">
<ShieldAlert className="h-4 w-4 shrink-0" />
{writeGuardMessage}
</div>
)}
<div className="min-h-0 flex-1">{children}</div>
</div>
);
}
@@ -0,0 +1,138 @@
/**
* MAF run 顶部状态条:状态徽标 + token/延迟 + 生命周期操作按钮。
*
* 操作:
* - live(pending/running/waiting_approval)→ 取消
* - waiting_approval → 恢复(resume,配合审批后流程)
* - cancel_failed → 重试取消(仍可点取消)
* - failed → 重试(recover)
*
* 状态徽标配色按 run.status 区分。token_usage/latency_ms 展示在右侧。
*/
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, Square, XCircle } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useMafRunActions } from "../api/runs";
import { runIsLive, type MafRun, type MafRunStatus } from "../api/types";
interface MafRunHeaderProps {
run: MafRun;
}
/** run 状态 → 中文 + 徽标 variant + 图标。 */
function statusMeta(status: MafRunStatus): {
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
icon: React.ReactNode;
tone: string;
} {
switch (status) {
case "pending":
return { label: "排队中", variant: "secondary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-slate-500" };
case "running":
return { label: "进行中", variant: "default", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-blue-600" };
case "waiting_approval":
return { label: "等待审批", variant: "default", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-amber-600" };
case "completed":
return { label: "已完成", variant: "outline", icon: <CheckCircle2 className="h-3 w-3" />, tone: "text-emerald-600" };
case "failed":
return { label: "失败", variant: "destructive", icon: <XCircle className="h-3 w-3" />, tone: "text-red-600" };
case "cancelled":
return { label: "已取消", variant: "outline", icon: <Square className="h-3 w-3" />, tone: "text-slate-500" };
case "cancelling":
return { label: "取消中", variant: "secondary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-amber-600" };
case "cancel_failed":
return { label: "取消失败", variant: "destructive", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-red-600" };
default:
return { label: status, variant: "outline", icon: null, tone: "text-slate-500" };
}
}
export function MafRunHeader({ run }: MafRunHeaderProps) {
const actions = useMafRunActions();
const meta = statusMeta(run.status);
const live = runIsLive(run.status);
const onCancel = () => {
actions.cancel.mutate(run.id, {
onSuccess: () => toast.success("已发起取消"),
onError: (e: Error) => toast.error(`取消失败:${e.message}`),
});
};
const onResume = () => {
actions.resume.mutate(run.id, {
onSuccess: () => toast.success("已恢复会商"),
onError: (e: Error) => toast.error(`恢复失败:${e.message}`),
});
};
const onRecover = () => {
actions.recover.mutate(run.id, {
onSuccess: () => toast.success("已重试会商"),
onError: (e: Error) => toast.error(`重试失败:${e.message}`),
});
};
// token 汇总
const tokenTotal = run.token_usage
? Object.values(run.token_usage).reduce((a, b) => a + (b || 0), 0)
: null;
return (
<div className="flex flex-wrap items-center gap-3 border-b border-slate-200 bg-white px-4 py-2.5">
<div className={`flex items-center gap-1.5 ${meta.tone}`}>
{meta.icon}
<span className="text-sm font-semibold">{meta.label}</span>
</div>
<Badge variant={meta.variant} className="text-[11px]">
{run.workflow_version ?? "maf"}
</Badge>
<div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
{tokenTotal != null && (
<span title="累计 token">tokens: {tokenTotal.toLocaleString()}</span>
)}
{run.latency_ms != null && (
<span title="耗时">{(run.latency_ms / 1000).toFixed(1)}s</span>
)}
{run.last_event_seq > 0 && (
<span title="最新事件序号">seq#{run.last_event_seq}</span>
)}
</div>
<div className="flex items-center gap-2">
{run.status === "waiting_approval" && (
<Button size="sm" variant="outline" onClick={onResume} disabled={actions.resume.isPending}>
恢复
</Button>
)}
{run.status === "failed" && (
<Button size="sm" variant="outline" onClick={onRecover} disabled={actions.recover.isPending}>
<RotateCcw className="mr-1 h-3.5 w-3.5" />
重试
</Button>
)}
{(live || run.status === "cancel_failed") && (
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={actions.cancel.isPending}
className="text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Square className="mr-1 h-3.5 w-3.5" />
取消
</Button>
)}
</div>
{run.error_message_safe && (
<div className="w-full text-xs text-red-600">
错误:{run.error_message_safe}
</div>
)}
</div>
);
}
@@ -0,0 +1,91 @@
/**
* MAF 席位状态矩阵。
*
* 数据来自 `run.maf_checkpoint.seat_outcomes`({agent_id: {status, execution_stage, ...}})。
* 每个席位一行:agent_id + status 徽标 + execution_stage 徽标。
*
* execution_stage 取值(Phase-3 闭合枚举):
* legacy / planner / expert / format_repair / cross_review / cross_review_repair /
* finalizer / group_chat
*/
import { Badge } from "@/components/ui/badge";
import type { MafCheckpoint, MafSeatOutcome } from "../api/types";
interface MafSeatStatusPanelProps {
checkpoint: MafCheckpoint | null;
/** 当前轮次(展示用)。 */
currentRound?: number;
}
const STAGE_LABELS: Record<string, string> = {
legacy: "旧版",
planner: "规划",
expert: "专家分析",
format_repair: "格式修复",
cross_review: "交叉评审",
cross_review_repair: "评审修复",
finalizer: "总结",
group_chat: "群聊",
};
const SEAT_STATUS_TONE: Record<string, string> = {
completed: "text-emerald-600",
running: "text-blue-600",
pending: "text-slate-500",
failed: "text-red-600",
cancelled: "text-slate-400",
};
function seatStatusBadge(status: string) {
const variant =
status === "completed"
? ("outline" as const)
: status === "failed"
? ("destructive" as const)
: ("secondary" as const);
return <Badge variant={variant} className="text-[10px]">{status}</Badge>;
}
export function MafSeatStatusPanel({ checkpoint, currentRound }: MafSeatStatusPanelProps) {
const outcomes = checkpoint?.seat_outcomes;
const entries: Array<[string, MafSeatOutcome]> = outcomes
? Object.entries(outcomes)
: [];
return (
<section className="rounded-lg border border-slate-200 bg-white p-3">
<header className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-700">席位状态</h3>
{typeof currentRound === "number" && (
<span className="text-xs text-slate-400">第 {currentRound + 1} 轮</span>
)}
</header>
{entries.length === 0 ? (
<p className="py-4 text-center text-xs text-slate-400">尚无席位产出</p>
) : (
<ul className="space-y-1.5">
{entries.map(([agentId, outcome]) => (
<li
key={agentId}
className="flex items-center gap-2 rounded-md border border-slate-100 bg-slate-50/60 px-2.5 py-1.5"
>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-slate-700" title={agentId}>
{agentId}
</span>
{outcome.execution_stage && (
<Badge variant="outline" className="text-[10px] text-slate-500">
{STAGE_LABELS[outcome.execution_stage] ?? outcome.execution_stage}
</Badge>
)}
<span className={SEAT_STATUS_TONE[outcome.status ?? ""] ?? "text-slate-500"}>
{seatStatusBadge(outcome.status ?? "unknown")}
</span>
</li>
))}
</ul>
)}
</section>
);
}
@@ -0,0 +1,233 @@
/**
* MAF 事件 cursor 轮询 hook —— Phase-4 实时更新的核心。
*
* 为什么不用 `useQuery + refetchInterval`:
* cursor `after_seq` 必须在每次拉取后推进。useQuery 的 queryFn 不持有「上次的
* latest_seq」;把 after_seq 塞进 queryKey 会导致每次推进都重置查询、反复重拉全量,
* 产生抖动。所以用自定义 interval hook:闭包里用 ref 持有 cursor。
*
* P0-3 修复:
* 1. 终态 run 也拉历史事件(enabled=true 即补拉,不管是否 live)。
* 2. 一次超过 PAGE_LIMIT 条事件时,循环翻页直到返回数 < PAGE_LIMIT 或无更大 seq。
* 3. inFlightRef 防止网络慢时上一个请求尚未结束、下一个 interval 又开始请求。
* 4. 游标只推进到「本次实际收到的最大 seq」,不直接跳到 latest_seq。
*
* P0 竞态修复:
* 用 generationRef 隔离不同 runId/请求代次。切换 run 时 generation++,
* 所有异步回调在写 state 前检查 generation 是否仍匹配——旧 run 的慢响应
* 到达时 generation 已变,直接丢弃,不会把旧事件写进新页面。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { listEvents } from "../api/events";
import { useMafRun } from "../api/runs";
import { runIsLive, type MafEvent } from "../api/types";
/** 事件轮询间隔(ms)。1.5s —— 略快于 run 状态轮询,保证事件即时涌现。 */
const POLL_INTERVAL = 1500;
/** 单次拉取上限(后端最大 10000)。1000 足够覆盖一轮多席位的并发事件。 */
const PAGE_LIMIT = 1000;
/** 按 seq 去重合并(新事件覆盖同 seq 旧事件,正常不会发生但做幂等保险)。 */
function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
if (incoming.length === 0) return prev;
const bySeq = new Map<number, MafEvent>();
for (const e of prev) bySeq.set(e.seq, e);
for (const e of incoming) bySeq.set(e.seq, e);
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
}
/**
* Request-generation gate shared by the hook and its race-condition tests.
* Each generation has its own in-flight slot, so a stale request can finish
* independently without blocking a newer run's terminal-state backlog pull.
*/
export interface MafEventRequestCoordinator {
readonly currentGeneration: number;
nextGeneration: () => number;
start: (generation: number) => boolean;
finish: (generation: number) => void;
isCurrent: (generation: number) => boolean;
}
export function createMafEventRequestCoordinator(): MafEventRequestCoordinator {
let currentGeneration = 0;
const inFlight = new Set<number>();
return {
get currentGeneration() {
return currentGeneration;
},
nextGeneration() {
currentGeneration += 1;
return currentGeneration;
},
start(generation) {
if (inFlight.has(generation)) return false;
inFlight.add(generation);
return true;
},
finish(generation) {
inFlight.delete(generation);
},
isCurrent(generation) {
return generation === currentGeneration;
},
};
}
export interface UseMafEventsResult {
/** 按 seq 升序的事件列表(已去重)。 */
events: MafEvent[];
/** 当前已拉到的最大 seq(供调试/展示)。 */
latestSeq: number;
/** 手动重置(清空事件、cursor 归零、立即补拉)。 */
reset: () => void;
}
/**
* 轮询某 run 的事件流。
*
* @param runId run id;undefined/null 时不轮询
* @param opts.enabled 是否启用(有 run 即 true,无论终态/live——终态也要补拉历史)
* @param opts.polling 是否启动定时器(一般传 `runIsLive(run.status)`)
*/
export function useMafEvents(
runId: string | undefined | null,
opts: { enabled: boolean; polling: boolean },
): UseMafEventsResult {
const { enabled, polling } = opts;
const [events, setEvents] = useState<MafEvent[]>([]);
const [latestSeq, setLatestSeq] = useState(0);
const afterSeqRef = useRef(0);
const requestCoordinatorRef = useRef(createMafEventRequestCoordinator());
const [resetSignal, setResetSignal] = useState(0);
const initializedRef = useRef(false);
const lastRunIdRef = useRef<string | undefined | null>(runId);
const lastResetSignalRef = useRef(resetSignal);
const reset = useCallback(() => setResetSignal((n) => n + 1), []);
// Every runId/enabled/polling/resetSignal change starts a new request
// generation. Only run changes and explicit reset clear the accumulated
// data; toggling enabled/polling must cancel old work without losing the
// already accepted backlog.
useEffect(() => {
const identityChanged =
!initializedRef.current ||
lastRunIdRef.current !== runId ||
lastResetSignalRef.current !== resetSignal;
requestCoordinatorRef.current.nextGeneration();
if (identityChanged) {
setEvents([]);
setLatestSeq(0);
afterSeqRef.current = 0;
}
lastRunIdRef.current = runId;
lastResetSignalRef.current = resetSignal;
initializedRef.current = true;
}, [runId, enabled, polling, resetSignal]);
useEffect(() => {
if (!runId || !enabled) return;
const myGeneration = requestCoordinatorRef.current.currentGeneration;
let cancelled = false;
/**
* 从 afterSeqRef.current 开始循环翻页拉取,直到:
* - 返回事件数 < PAGE_LIMIT(没有更多了)
* - 本页最大 seq <= cursor(无进展,防死循环)
* - generation 已变(切换了 run,丢弃结果)
*/
async function fetchBacklog() {
if (!requestCoordinatorRef.current.start(myGeneration)) return;
try {
let cursor = afterSeqRef.current;
// eslint-disable-next-line no-constant-condition
while (true) {
if (
cancelled ||
!requestCoordinatorRef.current.isCurrent(myGeneration)
) return;
const res = await listEvents(runId!, cursor, PAGE_LIMIT);
// 响应回来后再次检查代次(网络慢时可能已切换 run)
if (
cancelled ||
!requestCoordinatorRef.current.isCurrent(myGeneration)
) return;
const page = res.events;
// 本页最大 seq(只推进到实际收到的最大 seq,不跳 latest_seq)
let maxSeq = cursor;
for (const e of page) {
if (e.seq > maxSeq) maxSeq = e.seq;
}
if (page.length > 0) {
setEvents((prev) => mergeBySeq(prev, page));
}
// 无进展 → 停
if (maxSeq <= cursor) break;
cursor = maxSeq;
afterSeqRef.current = cursor;
setLatestSeq(cursor);
// 不满一页 → 没有更多了
if (page.length < PAGE_LIMIT) break;
}
} catch {
// 单次拉取失败(网络抖动/瞬断)静默——下次 interval 重试。
} finally {
// Never clear a newer generation's in-flight marker from a stale
// request. This is what lets a terminal-state refetch start while
// the old live poll is still unresolved.
requestCoordinatorRef.current.finish(myGeneration);
}
}
// 进入即补拉历史(after_seq=0 全量,终态 run 也走这里)
void fetchBacklog();
// polling=true 才启动定时器
if (!polling) {
return () => { cancelled = true; };
}
const timer = window.setInterval(() => void fetchBacklog(), POLL_INTERVAL);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [runId, enabled, polling, resetSignal]);
return { events, latestSeq, reset };
}
/**
* 组合 hook:同时跟踪 run 状态 + 轮询事件。
*
* 用法:`const { run, events } = useMafRunWithEvents(runId)`。
* run live 时自动轮询事件;终态时 run 仍按 refetchInterval 停,事件补拉历史后停。
*/
export function useMafRunWithEvents(runId: string | undefined | null) {
const runQuery = useMafRun(runId);
const live = runIsLive(runQuery.data?.status);
const hasRun = !!runQuery.data;
const { events, latestSeq, reset } = useMafEvents(runId, {
enabled: hasRun,
polling: live,
});
return {
run: runQuery.data,
isLoadingRun: runQuery.isLoading,
runError: runQuery.error,
events,
latestSeq,
resetEvents: reset,
};
}
+42
View File
@@ -0,0 +1,42 @@
/**
* MAF 多智能体会商前端 —— barrel 导出。
*
* 对外只暴露页面(路由用)和必要的 hook/类型;内部 api/lib 组件按需从子路径导入。
*/
export { default as MafSessionsPage } from "./pages/MafSessionsPage";
export { default as MafRunDetailPage } from "./pages/MafRunDetailPage";
// 常用 hook(页面/外部消费)
export { useMafHealth, useMafRun, useMafRuns, useMafRunActions } from "./api/runs";
export { useMafSessions, useMafSession, useMafSessionActions } from "./api/sessions";
export { useMafApprovals, useMafActions, useMafApprovalActions } from "./api/approvals";
export { useMafEvents, useMafRunWithEvents } from "./hooks/use-maf-events";
// 类型
export type {
MafRun,
MafRunStatus,
MafSession,
MafEvent,
MafEventType,
MafApproval,
MafAction,
MafCheckpoint,
MafFactLedger,
MafHealthResponse,
} from "./api/types";
export { runIsLive } from "./api/types";
// 纯函数(可单测)
export { foldEventsToTimeline } from "./lib/event-timeline";
export type { TimelineEntry, TimelineEntryKind } from "./lib/event-timeline";
export { buildMafFlowGraph, buildFactFlowGraph, extractConclusionFlow } from "./lib/fact-flow-graph";
// 组件
export { MafHealthGate } from "./components/MafHealthGate";
export { MafRunHeader } from "./components/MafRunHeader";
export { MafEventTimeline } from "./components/MafEventTimeline";
export { MafSeatStatusPanel } from "./components/MafSeatStatusPanel";
export { MafFactFlowPanel } from "./components/MafFactFlowPanel";
export { MafApprovalCard } from "./components/MafApprovalCard";
@@ -0,0 +1,301 @@
/**
* 把 MAF 原始事件流(按 seq 升序)折叠成时间线可读条目。
*
* 后端事件分类见 app/maf_roundtable/event_normalizer.py:
* agent.message.delta —— 流式 token 片段,需聚合为一条消息
* agent.message.completed —— 一轮完整回复(含 content)
* tool.* —— 工具生命周期,折叠为单条「调用 → 结果」
* action.waiting_approval —— 审批锚点(右侧审批卡片入口)
* run.* / skill.* —— 状态/生命周期条目
*
* 设计:**纯函数**,无 React、无副作用,便于单测。输入 events[](已去重升序),
* 输出 TimelineEntry[](时间线渲染顺序)。
*/
import type { MafEvent } from "../api/types";
export type TimelineEntryKind =
| "message" // 智能体消息(delta 聚合 或 completed)
| "tool" // 工具调用生命周期(requested→completed/failed 折叠)
| "skill" // 技能生命周期
| "approval" // 审批锚点(waiting/approved/rejected)
| "artifact" // 产出物
| "run" // run 生命周期(started/completed/failed/cancelled)
| "checkpoint" // 工作流检查点
| "turn"; // 席位轮次开始
export interface TimelineEntry {
/** 用于 React key,全局唯一。 */
key: string;
kind: TimelineEntryKind;
/** 原始 event_type(供组件按类型配色/图标)。 */
eventType: string;
/** 触发该条目的 agent_id(席位/智能体标识)。 */
agentId: string | null;
/** 轮次索引(便于分组)。 */
turnIndex: number | null;
seq: number;
createdAt: string | null;
/** 主标题(工具名 / 状态文案 / 席位名)。 */
title: string;
/** 正文(消息内容 / 工具结果摘要 / 错误信息)。 */
body?: string;
/** 关联的二级信息(action_id/approval_id/tool_call_id 等)。 */
meta?: Record<string, unknown> | null;
}
/** delta 聚合的分组键:(agent_id, turn_index) —— 同一席位同一轮的 delta 拼成一条。 */
function deltaGroupKey(ev: MafEvent): string {
return `delta:${ev.agent_id ?? ""}:${ev.turn_index ?? 0}`;
}
/**
* 折叠事件流为时间线条目。
*
* 聚合规则:
* 1. `agent.message.delta` 按 (agent,turn) 分组拼接 → 一条 message(取最大 seq 为排序键)。
* 若同组有 `agent.message.completed`,则用 completed 的完整 content 覆盖。
* 2. `tool.requested`/`tool.started`/`tool.completed`/`tool.failed` 按 tool_call_id 折叠
* 为一条 tool 条目(最终状态 + 名称)。
* 3. 其余每事件一条。
*/
export function foldEventsToTimeline(events: MafEvent[]): TimelineEntry[] {
const entries: TimelineEntry[] = [];
// delta 分组缓冲:groupKey → { texts[], lastSeq, agent, turn, createdAt }
const deltaBuf = new Map<
string,
{ texts: string[]; seq: number; ev: MafEvent; completed?: MafEvent }
>();
// tool 折叠:tool_call_id → entry(先建占位,后续更新)
const toolMap = new Map<string, TimelineEntry>();
for (const ev of events) {
const p = ev.payload ?? {};
switch (ev.event_type) {
case "agent.message.delta": {
const text = typeof p.text === "string" ? p.text : "";
if (!text) break;
const k = deltaGroupKey(ev);
const cur = deltaBuf.get(k);
if (cur) {
cur.texts.push(text);
if (ev.seq > cur.seq) {
cur.seq = ev.seq;
cur.ev = ev;
}
} else {
deltaBuf.set(k, { texts: [text], seq: ev.seq, ev });
}
break;
}
case "agent.message.completed": {
const k = deltaGroupKey(ev);
const cur = deltaBuf.get(k);
const content = typeof p.content === "string" ? p.content : "";
if (cur) {
cur.completed = ev;
if (content) cur.texts = [content]; // completed 的完整内容覆盖 delta 拼接
if (ev.seq > cur.seq) cur.seq = ev.seq;
} else {
deltaBuf.set(k, {
texts: content ? [content] : [],
seq: ev.seq,
ev,
completed: ev,
});
}
break;
}
case "tool.requested":
case "tool.started":
case "tool.completed":
case "tool.failed": {
const tcId = typeof p.tool_call_id === "string" ? p.tool_call_id : "";
const toolName = typeof p.tool_name === "string" ? p.tool_name : "";
const mapKey = tcId || `${ev.event_type}:${ev.seq}`;
const status =
ev.event_type === "tool.failed"
? "failed"
: ev.event_type === "tool.completed"
? "completed"
: ev.event_type === "tool.started"
? "started"
: "requested";
const existing = toolMap.get(mapKey);
if (existing) {
// 更新到最新状态(seq 更大者覆盖)
if (ev.seq >= existing.seq) {
existing.seq = ev.seq;
existing.title = toolName || existing.title;
existing.body =
typeof p.content_preview === "string"
? p.content_preview
: existing.body;
existing.meta = { ...existing.meta, ...p, status };
}
} else {
toolMap.set(mapKey, {
key: `tool:${mapKey}`,
kind: "tool",
eventType: ev.event_type,
agentId: ev.agent_id,
turnIndex: ev.turn_index,
seq: ev.seq,
createdAt: ev.created_at,
title: toolName || "工具调用",
body: typeof p.content_preview === "string" ? p.content_preview : undefined,
meta: { ...p, status },
});
}
break;
}
default: {
// 其它事件一一映射(含 approval/run/skill/artifact/checkpoint/turn)。
const entry = mapSingleEvent(ev);
if (entry) entries.push(entry);
}
}
}
// delta 缓冲落盘
for (const { texts, seq, ev, completed } of deltaBuf.values()) {
entries.push({
key: `msg:${deltaGroupKey(ev)}`,
kind: "message",
eventType: completed ? "agent.message.completed" : "agent.message.delta",
agentId: ev.agent_id,
turnIndex: ev.turn_index,
seq,
createdAt: ev.created_at,
title: ev.agent_id ?? "智能体",
body: texts.join("").trim() || undefined,
meta: completed?.payload,
});
}
// tool 折叠落盘
for (const entry of toolMap.values()) entries.push(entry);
// 按 seq 升序(聚合条目用其最大 seq,自然排到出现时刻之后)
entries.sort((a, b) => a.seq - b.seq);
return entries;
}
/** 把单个非聚合事件映射成 TimelineEntry。 */
function mapSingleEvent(ev: MafEvent): TimelineEntry | null {
const p = ev.payload ?? {};
const base = {
key: `${ev.event_type}:${ev.id}`,
agentId: ev.agent_id,
turnIndex: ev.turn_index,
seq: ev.seq,
createdAt: ev.created_at,
};
switch (ev.event_type) {
case "agent.turn_started":
return {
...base,
kind: "turn",
eventType: ev.event_type,
title: `${ev.agent_id ?? "席位"} 开始第 ${(ev.turn_index ?? 0) + 1} 轮`,
meta: p,
};
case "run.started":
return { ...base, kind: "run", eventType: ev.event_type, title: "会商开始", meta: p };
case "run.completed":
return { ...base, kind: "run", eventType: ev.event_type, title: "会商完成", meta: p };
case "run.failed":
return {
...base,
kind: "run",
eventType: ev.event_type,
title: "会商失败",
body: typeof p.message === "string" ? p.message : undefined,
meta: p,
};
case "run.cancelled":
return { ...base, kind: "run", eventType: ev.event_type, title: "会商已取消", meta: p };
case "skill.selected":
case "skill.started":
case "skill.completed":
case "skill.failed": {
const skillName = typeof p.skill_name === "string" ? p.skill_name : "技能";
const phase = ev.event_type.split(".")[1];
const labelMap: Record<string, string> = {
selected: "选择",
started: "开始",
completed: "完成",
failed: "失败",
};
return {
...base,
kind: "skill",
eventType: ev.event_type,
title: `技能「${skillName}」${labelMap[phase] ?? phase}`,
body:
typeof p.result_preview === "string"
? p.result_preview
: typeof p.error === "string"
? p.error
: undefined,
meta: p,
};
}
case "artifact.created":
return {
...base,
kind: "artifact",
eventType: ev.event_type,
title: `产出:${typeof p.title === "string" ? p.title : typeof p.artifact_type === "string" ? p.artifact_type : "产物"}`,
meta: p,
};
case "action.waiting_approval":
case "action.approved":
case "action.rejected": {
const phase = ev.event_type.split(".")[1];
const labelMap: Record<string, string> = {
waiting_approval: "等待审批",
approved: "已批准",
rejected: "已拒绝",
};
return {
...base,
kind: "approval",
eventType: ev.event_type,
title: `写工具「${typeof p.tool_name === "string" ? p.tool_name : "未知"}」${labelMap[phase] ?? phase}`,
body: typeof p.summary === "string" ? p.summary : undefined,
meta: p,
};
}
case "workflow.checkpointed":
return {
...base,
kind: "checkpoint",
eventType: ev.event_type,
title: "工作流已检查点",
body: typeof p.reason === "string" ? p.reason : undefined,
meta: p,
};
default:
// 兜底:未知事件类型仍展示,避免遗漏。
return {
...base,
kind: "run",
eventType: ev.event_type,
title: ev.event_type,
meta: p,
};
}
}
/** 时间线条目的人类可读时间(HH:MM:SS)。null/无效返回空串。 */
export function shortTime(iso: string | null): string {
if (!iso) return "";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return "";
}
}
@@ -0,0 +1,139 @@
/**
* 把 MAF 事实账本 + 末轮结论转成 FlowGraphPanel 可渲染的 FlowGraphData。
*
* 两个数据源,优先级合并:
* 1. `final_result.content` 里的 ```flow-json``` 块(次源,结论流程图)—— 若 final_result
* 含 flow-json,用现成的 extractFlowGraph 解析,直接作为「结论流程图」返回(它已是
* 模型精心编排的层级图,比纯事实更有结构)。
* 2. `confirmed_facts`(主源,事实证据图)—— 每条已确认事实 → task 节点;其
* evidence_refs 连到对应证据节点(evidence → role / tool_results → deliverable);
* 证据根用 start。
*
* 设计:**纯函数**,输入 checkpoint(宽松类型),输出 FlowGraphData | null。
* null 表示无任何可绘制数据,由组件显示空态。
*/
import {
extractFlowGraph,
type FlowGraphData,
type FlowGraphEdge,
type FlowGraphNode,
} from "@/roundtable-planning/lib/flow-graph";
import type { MafCheckpoint, MafFactLedger } from "../api/types";
/** 截断长文本用于节点 label(detail 保留全文)。 */
function truncate(text: string, max = 28): string {
const t = text.trim().replace(/\s+/g, " ");
return t.length <= max ? t : `${t.slice(0, max)}…`;
}
/**
* 尝试从 final_result.content 解析 flow-json 结论图。
* 有则优先返回(模型编排的结论图比纯事实更有结构)。
*/
export function extractConclusionFlow(checkpoint: MafCheckpoint | null): FlowGraphData | null {
const content = checkpoint?.final_result?.content;
if (!content || typeof content !== "string") return null;
const graph = extractFlowGraph(content);
return graph && graph.nodes.length > 0 ? graph : null;
}
/**
* 把 confirmed_facts + evidence + tool_results 编织成事实证据图。
*
* 布局:
* start(根) ──► evidence/tool(role/deliverable) ──► fact(task)
*
* 每个事实节点 group 标 confirmed_by(席位),detail 是全文 + evidence_refs。
*/
export function buildFactFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null {
const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger;
const confirmed = ledger?.confirmed_facts ?? [];
if (confirmed.length === 0) return null;
const nodes: FlowGraphNode[] = [];
const edges: FlowGraphEdge[] = [];
const nodeIds = new Set<string>();
const addNode = (n: FlowGraphNode) => {
if (!nodeIds.has(n.id)) {
nodeIds.add(n.id);
nodes.push(n);
}
};
const addEdge = (from: string, to: string, label?: string) => {
if (from === to) return;
edges.push({ from, to, label });
};
// 证据索引:ref → 来源类型,便于给节点配 type。
const evidenceByText = new Map<string, "evidence" | "tool">();
for (const e of ledger?.evidence ?? []) {
const ref = typeof e.ref === "string" ? e.ref : typeof e.text === "string" ? e.text : "";
if (ref) evidenceByText.set(ref, "evidence");
}
for (const tr of ledger?.tool_results ?? []) {
if (tr.status !== "completed") continue; // 只有成功的工具算证据
const name = tr.tool_name ?? "";
const turn = tr.turn_index;
const ref =
name && turn != null ? `tool:${name}:${turn}` : name ? `tool:${name}` : "";
if (ref) evidenceByText.set(ref, "tool");
}
// 根节点
const ROOT_ID = "root";
addNode({ id: ROOT_ID, label: "已确认事实", type: "start" });
for (const fact of confirmed) {
const factId = `fact:${fact.candidate_id}`;
addNode({
id: factId,
label: truncate(fact.text),
type: "task",
detail: fact.text,
group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认",
});
if ((fact.evidence_refs ?? []).length === 0) {
// 无证据引用的事实:直接连根(理论上 Phase-3 校验后不应出现,兜底)。
addEdge(ROOT_ID, factId);
continue;
}
for (const ref of fact.evidence_refs) {
const refStr = String(ref);
const evKind = evidenceByText.get(refStr) ?? "evidence";
const evId = `ev:${refStr}`;
// 证据节点(role=外部证据 / deliverable=工具产出)
addNode({
id: evId,
label: truncate(refStr.replace(/^tool:/, ""), 24),
type: evKind === "tool" ? "deliverable" : "role",
detail: refStr,
group: evKind === "tool" ? "工具证据" : "外部证据",
});
addEdge(ROOT_ID, evId);
addEdge(evId, factId);
}
}
// 边去重(同 from→to 只留一条)
const seen = new Set<string>();
const dedupEdges = edges.filter((e) => {
const k = `${e.from}->${e.to}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
return { title: "事实证据图", nodes, edges: dedupEdges };
}
/**
* 合并两源:结论 flow-json 优先;否则用事实证据图;都没有 → null(空态)。
*/
export function buildMafFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null {
return extractConclusionFlow(checkpoint) ?? buildFactFlowGraph(checkpoint);
}
@@ -0,0 +1,153 @@
/**
* MAF 单 run 工作区(路由 runs/:runId)。
*
* 三栏布局(桌面)/ 纵向堆叠(窄屏):
* 左:事件时间线(MafEventTimeline)—— run live 时 cursor 轮询事件
* 中:席位状态(MafSeatStatusPanel)+ 事实/结论流程图(MafFactFlowPanel)
* 右:审批卡片(MafApprovalCard)—— 仅 pending 审批时显示
*
* 顶部:MafRunHeader(状态徽标 + 操作)。整体由 MafHealthGate 守卫。
*/
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Loader2, ShieldQuestion } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { useMafActions, useMafApprovals } from "../api/approvals";
import { runIsLive, type MafAction } from "../api/types";
import { useMafRunWithEvents } from "../hooks/use-maf-events";
import { foldEventsToTimeline } from "../lib/event-timeline";
import { MafApprovalCard } from "../components/MafApprovalCard";
import { MafEventTimeline } from "../components/MafEventTimeline";
import { MafFactFlowPanel } from "../components/MafFactFlowPanel";
import { MafHealthGate } from "../components/MafHealthGate";
import { MafRunHeader } from "../components/MafRunHeader";
import { MafSeatStatusPanel } from "../components/MafSeatStatusPanel";
export default function MafRunDetailPage() {
const { runId } = useParams<{ runId: string }>();
return (
<MafHealthGate>
{runId ? <RunBody runId={runId} /> : <div className="p-6 text-slate-400">缺少 runId</div>}
</MafHealthGate>
);
}
function RunBody({ runId }: { runId: string }) {
const { run, isLoadingRun, runError, events } = useMafRunWithEvents(runId);
const headerRef = useDesensitizeSubtree<HTMLDivElement>();
const live = runIsLive(run?.status);
const approvalsQuery = useMafApprovals(runId, live);
const actionsQuery = useMafActions(runId, live);
const pendingApprovals = (approvalsQuery.data?.approvals ?? []).filter(
(a) => a.status === "pending",
);
const actionsById = useMemo(() => {
const m = new Map<string, MafAction>();
for (const a of actionsQuery.data?.actions ?? []) m.set(a.id, a);
return m;
}, [actionsQuery.data]);
const timeline = useMemo(() => foldEventsToTimeline(events), [events]);
if (isLoadingRun) {
return (
<div className="flex h-full items-center justify-center text-slate-400">
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> 加载会商…
</div>
);
}
if (runError || !run) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-500">
<p>加载失败:{(runError as Error)?.message ?? "run 不存在或无权访问"}</p>
<Link to="/page/maf">
<Button variant="outline" size="sm">
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> 返回会话列表
</Button>
</Link>
</div>
);
}
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div ref={headerRef}>
<MafRunHeader run={run} />
</div>
<div className="grid flex-1 min-h-0 grid-cols-1 gap-2 overflow-hidden p-2 lg:grid-cols-[1fr_360px_320px]">
{/* 左:时间线 */}
<section className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
<MafEventTimeline entries={timeline} streaming={live} />
</section>
{/* 中:席位 + 流程图 */}
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
<MafSeatStatusPanel
checkpoint={run.maf_checkpoint}
currentRound={typeof run.maf_checkpoint?.current_round === "number"
? run.maf_checkpoint.current_round
: undefined}
/>
<MafFactFlowPanel checkpoint={run.maf_checkpoint} streaming={live} />
{/* final_result 展示 */}
{run.maf_checkpoint?.final_result?.content && (
<div className="rounded-lg border border-slate-200 bg-white p-3">
<h3 className="mb-1.5 text-sm font-semibold text-slate-700">最终结论</h3>
<div className="prose prose-sm max-h-72 max-w-none overflow-auto text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{/* final_result.content 可能含 flow-json 块;这里只展示文字部分(流程图已在上方画布) */}
<pre className="whitespace-pre-wrap break-words font-sans text-xs">
{stripFlowJson(run.maf_checkpoint.final_result.content)}
</pre>
</div>
</div>
)}
</section>
{/* 右:审批 */}
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
<div className="flex items-center gap-1.5 px-1 text-xs font-medium uppercase tracking-wide text-slate-400">
<ShieldQuestion className="h-3.5 w-3.5" />
写工具审批
{pendingApprovals.length > 0 && (
<span className="rounded-full bg-red-100 px-1.5 text-[10px] font-bold text-red-600">
{pendingApprovals.length}
</span>
)}
</div>
{pendingApprovals.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 bg-white/50 p-4 text-center text-xs text-slate-400">
{live ? "暂无待审批的写操作" : "本次会商无待审批项"}
</div>
) : (
pendingApprovals.map((approval) => (
<MafApprovalCard
key={approval.id}
approval={approval}
action={approval.action_id ? actionsById.get(approval.action_id) : undefined}
runId={runId}
/>
))
)}
</section>
</div>
</div>
);
}
/** 把 final_result.content 里的 ```flow-json 块替换成占位提示(流程图已在画布展示)。 */
function stripFlowJson(text: string): string {
const FENCE = "```flow-json";
const start = text.lastIndexOf(FENCE);
if (start < 0) return text;
const after = text.slice(start + FENCE.length);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
return `${text.slice(0, start).trimEnd()}\n\n[结构化流程数据已在上方「事实/结论流程图」画布展示]${tail}`;
}
@@ -0,0 +1,565 @@
/**
* MAF 会商会话列表页(路由 index)。
*
* 左:会话列表(按创建时间倒序);右:选中会话的 run 列表 + 「启动会商」。
* 点击某条 run → 跳 `/page/maf/runs/:runId`。
*
* P0-2:新建弹窗包含 task_text(必填)、模式、经理智能体、席位智能体多选、
* 可选高级项(模型/推理强度/最大轮数/写入策略)。智能体列表复用 @/core/agents。
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Loader2, MessageSquarePlus, Play, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useAgents } from "@/core/agents";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { cn } from "@/lib/utils";
import { useMafSessionActions, useMafSessions, type CreateSessionBody, type ParticipantInput } from "../api/sessions";
import { useMafHealth, useMafRuns, useMafRunActions } from "../api/runs";
import { runIsLive, type MafSessionStatus } from "../api/types";
import { MafHealthGate } from "../components/MafHealthGate";
const STATUS_LABEL: Record<string, string> = {
draft: "草稿",
running: "会商中",
waiting_approval: "等待审批",
waiting_input: "等待输入",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
};
function statusTone(status: string): string {
switch (status) {
case "running":
return "text-blue-600";
case "waiting_approval":
case "waiting_input":
return "text-amber-600";
case "completed":
return "text-emerald-600";
case "failed":
case "cancelled":
return "text-red-500";
default:
return "text-slate-500";
}
}
export default function MafSessionsPage() {
return (
<MafHealthGate>
<SessionsBody />
</MafHealthGate>
);
}
function SessionsBody() {
const sessionsQuery = useMafSessions({ limit: 100 });
const [selectedId, setSelectedId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const headerRef = useDesensitizeSubtree<HTMLDivElement>();
const sessions = sessionsQuery.data?.sessions ?? [];
const active = selectedId
? sessions.find((s) => s.id === selectedId)
: sessions[0];
return (
<div className="flex h-full w-full">
{/* 左:会话列表 */}
<aside className="flex w-72 shrink-0 flex-col border-r border-slate-200 bg-white">
<div ref={headerRef} className="flex items-center justify-between border-b border-slate-100 px-3 py-2.5">
<h2 className="text-sm font-semibold text-slate-700">会话</h2>
<Button size="sm" variant="outline" className="h-7 gap-1" onClick={() => setCreateOpen(true)}>
<MessageSquarePlus className="h-3.5 w-3.5" />
新建
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{sessionsQuery.isLoading && (
<div className="flex items-center justify-center py-8 text-slate-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
</div>
)}
{sessionsQuery.isError && (
<div className="p-3 text-xs text-red-500">
加载失败:{(sessionsQuery.error as Error)?.message ?? "未知错误"}
</div>
)}
{!sessionsQuery.isLoading && sessions.length === 0 && (
<div className="p-4 text-center text-xs text-slate-400">
暂无会话,点击「新建」开始
</div>
)}
<ul>
{sessions.map((s) => (
<li key={s.id}>
<button
type="button"
onClick={() => setSelectedId(s.id)}
className={cn(
"w-full border-b border-slate-50 px-3 py-2.5 text-left transition hover:bg-slate-50",
active?.id === s.id && "bg-blue-50/60",
)}
>
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-700">
{s.title || "未命名会话"}
</span>
<span className={cn("text-[10px]", statusTone(s.status))}>
{STATUS_LABEL[s.status] ?? s.status}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-slate-400">
<Badge variant="outline" className="text-[9px]">{s.mode}</Badge>
<span className="truncate">{s.task_text || "无任务说明"}</span>
</div>
</button>
</li>
))}
</ul>
</div>
</aside>
{/* 右:选中会话的 run 列表 */}
<main className="flex flex-1 flex-col overflow-hidden bg-slate-50/40">
{active ? (
<SessionDetail sessionId={active.id} title={active.title} mode={active.mode} status={active.status} />
) : (
<div className="flex h-full items-center justify-center text-sm text-slate-400">
选择左侧会话查看详情,或新建一个
</div>
)}
</main>
<CreateSessionDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={(id) => setSelectedId(id)} />
</div>
);
}
function SessionDetail({
sessionId,
title,
mode,
status,
}: {
sessionId: string;
title: string;
mode: string;
status: MafSessionStatus;
}) {
const runsQuery = useMafRuns(sessionId);
const actions = useMafRunActions();
const navigate = useNavigate();
const runs = runsQuery.data?.runs ?? [];
// 存在运行中 run 时禁用「启动会商」
const liveRun = runs.find((r) => runIsLive(r.status));
const onStart = () => {
actions.start.mutate(
{ sessionId },
{
onSuccess: (run) => navigate(`/page/maf/runs/${run.id}`),
onError: (e: Error) => toast.error(`启动失败:${e.message}`),
},
);
};
return (
<>
<header className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5">
<div className="min-w-0">
<h1 className="truncate text-base font-semibold text-slate-800">{title || "未命名会话"}</h1>
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
<Badge variant="outline" className="text-[10px]">{mode}</Badge>
<span className={statusTone(status)}>{STATUS_LABEL[status] ?? status}</span>
</div>
</div>
<div className="flex items-center gap-2">
{liveRun && (
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/page/maf/runs/${liveRun.id}`)}
>
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin text-blue-500" />
已有会商进行中
</Button>
)}
<Button size="sm" onClick={onStart} disabled={actions.start.isPending || !!liveRun}>
{actions.start.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Play className="mr-1 h-3.5 w-3.5" />
)}
启动会商
</Button>
</div>
</header>
<div className="flex-1 overflow-y-auto p-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-400">历史 run</h3>
{runsQuery.isLoading && (
<div className="flex items-center py-6 text-slate-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
</div>
)}
{runs.length === 0 && !runsQuery.isLoading && (
<p className="py-6 text-center text-xs text-slate-400">暂无会商记录,点击「启动会商」开始</p>
)}
<ul className="space-y-2">
{runs.map((run) => (
<li key={run.id}>
<button
type="button"
onClick={() => navigate(`/page/maf/runs/${run.id}`)}
className="flex w-full items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-left transition hover:border-blue-300 hover:shadow-sm"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-400">{run.id.slice(0, 12)}…</span>
{runIsLive(run.status) && (
<Loader2 className="h-3 w-3 animate-spin text-blue-500" />
)}
</div>
<div className="mt-0.5 text-[11px] text-slate-400">
{run.started_at ? new Date(run.started_at).toLocaleString() : "未开始"}
</div>
</div>
<Badge variant="outline" className="text-[10px]">{run.status}</Badge>
</button>
</li>
))}
</ul>
</div>
</>
);
}
function CreateSessionDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onCreated: (id: string) => void;
}) {
const health = useMafHealth();
const sessionActions = useMafSessionActions();
const { agents, isLoading: agentsLoading, error: agentsError } = useAgents();
const [title, setTitle] = useState("");
const [taskText, setTaskText] = useState("");
const [mode, setMode] = useState("structured");
const [managerAgentId, setManagerAgentId] = useState("");
const [seatAgentIds, setSeatAgentIds] = useState<string[]>([]);
// 高级项
const [showAdvanced, setShowAdvanced] = useState(false);
const [modelName, setModelName] = useState("");
const [reasoningEffort, setReasoningEffort] = useState("");
const [maxRounds, setMaxRounds] = useState("");
const [writePolicy, setWritePolicy] = useState("");
// 默认 mode 跟随后端 default_mode(在 effect 中设置,避免 render 内 setState)
useEffect(() => {
if (health.data?.default_mode) {
setMode(health.data.default_mode);
}
}, [health.data?.default_mode]);
// P0:切换经理时,自动从已选席位中移除(后端参与者唯一约束:同一 agent 不能同时是 manager 和 seat)
useEffect(() => {
if (managerAgentId) {
setSeatAgentIds((prev) => prev.filter((id) => id !== managerAgentId));
}
}, [managerAgentId]);
const toggleSeat = (agentId: string) => {
// 经理不能同时作为席位
if (agentId === managerAgentId) return;
setSeatAgentIds((prev) =>
prev.includes(agentId)
? prev.filter((id) => id !== agentId)
: [...prev, agentId],
);
};
const onSubmit = () => {
// 前端校验
if (!taskText.trim()) {
toast.error("请填写任务说明");
return;
}
if (!managerAgentId) {
toast.error("请选择经理智能体");
return;
}
if (seatAgentIds.length === 0) {
toast.error("请至少选择一个席位智能体");
return;
}
// 构建 participants
const participants: ParticipantInput[] = [];
// 经理
const managerAgent = agents.find((a) => a.id === managerAgentId);
participants.push({
agent_id: managerAgentId,
display_name: managerAgent?.name ?? "",
role: "manager",
sort_order: 0,
});
// 席位
seatAgentIds.forEach((agentId, idx) => {
const agent = agents.find((a) => a.id === agentId);
participants.push({
agent_id: agentId,
display_name: agent?.name ?? "",
role: "seat",
sort_order: idx + 1,
});
});
const body: CreateSessionBody = {
title: title.trim(),
task_text: taskText.trim(),
mode: mode as "structured" | "group_chat",
manager_agent_id: managerAgentId,
participants,
};
// 高级项(仅非空时传)
if (modelName.trim()) body.model_name = modelName.trim();
if (reasoningEffort.trim()) body.reasoning_effort = reasoningEffort.trim();
if (maxRounds.trim()) {
const rounds = Number(maxRounds);
if (!Number.isInteger(rounds) || rounds < 1 || rounds > 64) {
toast.error("最大轮数必须为 1–64 的整数");
return;
}
body.max_rounds = rounds;
}
if (writePolicy) body.write_policy = writePolicy as CreateSessionBody["write_policy"];
sessionActions.create.mutate(
body,
{
onSuccess: (s) => {
toast.success("会话已创建");
// 重置表单
setTitle("");
setTaskText("");
setManagerAgentId("");
setSeatAgentIds([]);
setModelName("");
setReasoningEffort("");
setMaxRounds("");
setWritePolicy("");
setShowAdvanced(false);
onOpenChange(false);
onCreated(s.id);
},
onError: (e: Error) => toast.error(`创建失败:${e.message}`),
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>新建 MAF 会商会话</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
{/* 标题 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">会商标题</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="例如:某任务多视角研讨" />
</div>
{/* 任务说明(必填) */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
任务说明 <span className="text-red-500">*</span>
</label>
<Textarea
value={taskText}
onChange={(e) => setTaskText(e.target.value)}
placeholder="描述本次会商要解决的问题、背景和目标(必填)"
className="min-h-24"
/>
</div>
{/* 模式 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">会商模式</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2 text-sm"
>
<option value="structured">structured(规划→专家→评审→总结)</option>
<option value="group_chat">group_chat(协调员动态点名)</option>
</select>
</div>
{/* 经理智能体 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
经理智能体 <span className="text-red-500">*</span>
</label>
{agentsLoading ? (
<div className="flex h-9 items-center px-2 text-xs text-slate-400">
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载智能体…
</div>
) : agentsError ? (
<div className="flex h-9 items-center px-2 text-xs text-red-500">
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
</div>
) : (
<select
value={managerAgentId}
onChange={(e) => setManagerAgentId(e.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2 text-sm"
>
<option value="">请选择…</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
)}
</div>
{/* 席位智能体多选 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
席位智能体(至少一个) <span className="text-red-500">*</span>
</label>
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-200 p-2">
{agentsLoading && (
<div className="flex items-center justify-center py-2 text-xs text-slate-400">
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载中…
</div>
)}
{agentsError && (
<div className="py-2 text-center text-xs text-red-500">
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
</div>
)}
{!agentsLoading && !agentsError && agents.length === 0 && (
<div className="py-2 text-center text-xs text-slate-400">暂无可用智能体</div>
)}
{!agentsLoading && !agentsError && agents.map((a) => (
<label key={a.id} className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-slate-50">
<input
type="checkbox"
checked={seatAgentIds.includes(a.id)}
onChange={() => toggleSeat(a.id)}
disabled={a.id === managerAgentId}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
<span className="text-sm text-slate-700">{a.name}</span>
{a.id === managerAgentId && (
<span className="text-[10px] text-slate-400">(已选为经理)</span>
)}
</label>
))}
</div>
{seatAgentIds.length > 0 && (
<div className="mt-1 text-[10px] text-slate-400">
已选 {seatAgentIds.length} 个席位
</div>
)}
</div>
{/* 高级项折叠 */}
<div>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs text-blue-600 hover:underline"
>
{showAdvanced ? "收起高级选项" : "展开高级选项"}
</button>
{showAdvanced && (
<div className="mt-2 space-y-2 rounded-md border border-slate-100 bg-slate-50/50 p-2">
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">模型名称</label>
<Input value={modelName} onChange={(e) => setModelName(e.target.value)} placeholder="留空使用默认" className="h-8 text-xs" />
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">推理强度</label>
<Input value={reasoningEffort} onChange={(e) => setReasoningEffort(e.target.value)} placeholder="low / medium / high" className="h-8 text-xs" />
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">最大轮数</label>
<Input value={maxRounds} onChange={(e) => setMaxRounds(e.target.value)} placeholder="留空使用默认" type="number" min={1} max={64} className="h-8 text-xs" />
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">写入策略</label>
<select
value={writePolicy}
onChange={(e) => setWritePolicy(e.target.value)}
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
>
<option value="">默认</option>
<option value="auto">auto(自动执行)</option>
<option value="confirm_high_risk">confirm_high_risk(高风险确认)</option>
<option value="confirm_all_writes">confirm_all_writes(全部确认)</option>
</select>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={onSubmit} disabled={sessionActions.create.isPending}>
{sessionActions.create.isPending && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 删除会话按钮(预留导出)。
export function DeleteSessionButton({ sessionId }: { sessionId: string }) {
const actions = useMafSessionActions();
return (
<Button
size="sm"
variant="ghost"
className="text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={() => {
actions.remove.mutate(sessionId, {
onSuccess: () => toast.success("已删除"),
onError: (e: Error) => toast.error(`删除失败:${e.message}`),
});
}}
disabled={actions.remove.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
删除
</Button>
);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* MAF 多智能体会商路由树(/page/maf/*)。
*
* 仿 RoundtableRoutes:顶层包一层满高容器,内部 <Routes>:
* index → 会话列表
* runs/:runId → 单 run 工作区(时间线/席位/流程图/审批)
* * → 重定向到 index
*/
import { Navigate, Route, Routes } from "react-router-dom";
import MafRunDetailPage from "@/maf-roundtable/pages/MafRunDetailPage";
import MafSessionsPage from "@/maf-roundtable/pages/MafSessionsPage";
export default function MafRoutes() {
return (
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<MafSessionsPage />} />
<Route path="runs/:runId" element={<MafRunDetailPage />} />
<Route path="*" element={<Navigate to="/page/maf" replace />} />
</Routes>
</div>
);
}
+2
View File
@@ -27,6 +27,7 @@ import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
import RoundtableRoutes from "./RoundtableRoutes";
import NotebookRoutes from "./NotebookRoutes";
import MafRoutes from "./MafRoutes";
import { RoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
function PageLayout() {
@@ -166,6 +167,7 @@ function PageLayout() {
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
<Route path="roundtable/*" element={<RoundtableRoutes />} />
<Route path="notebook/*" element={<NotebookRoutes />} />
<Route path="maf/*" element={<MafRoutes />} />
</Routes>
</main>
</div>