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:
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()}`,
|
||||
"获取会商事件",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user