diff --git a/.zcode/plans/plan-sess_dfc63a2a-7431-46c2-a4ba-63080d16e529.md b/.zcode/plans/plan-sess_dfc63a2a-7431-46c2-a4ba-63080d16e529.md new file mode 100644 index 00000000..e3465baa --- /dev/null +++ b/.zcode/plans/plan-sess_dfc63a2a-7431-46c2-a4ba-63080d16e529.md @@ -0,0 +1,122 @@ +# MAF 多智能体会商 Phase-4 前端实施计划 + +## 决策回顾(已与用户确认) +- **入口**:独立顶级菜单 `/page/maf/*`,新增侧边栏一级项「MAF 会商」 +- **实时更新**:纯前端轮询,`GET /runs/{id}/events?after_seq=N&limit=1000`,间隔 1.8s,复用现有后端,零 SSE、零后端改动 +- **流程图**:要。基于 `confirmed_facts`(来自 `run.maf_checkpoint.fact_ledger`)+ 末轮 `final_result.content` 解析的 `flow-json`,复用 `@xyflow/react` + `flow-graph.ts` + `FlowGraphPanel` +- **审批**:卡片式,仿 `IntentClarificationCard` 的视觉/交互模式(参数摘要 + 批准/拒绝按钮 + 备注 textarea),落到 `POST /approvals/{id}/decision` + +## 关键数据契约(已核实后端源码) +- **后端只读**:16 个端点全部命中,无改动。`GET /health` 返回 `enabled` 决定页面内容(入口仍可见,未启用时显示空态)。 +- **事件流**:单调 `(run_id, seq)`,`GET /events?after_seq=N` 返回 `{events:[...], latest_seq}`;前端推进 `after_seq=latest_seq`。payload 已在后端**写入时脱敏**(`sanitize_payload`),前端直接渲染。 +- **事件分类**(`event_normalizer.py`):`run.started/completed/failed/cancelled`、`agent.turn_started/message.delta/message.completed`、`tool.requested/started/completed/failed`、`skill.started/completed/failed/selected`、`artifact.created`、`action.waiting_approval/approved/rejected`、`workflow.checkpointed`。 +- **事实账本**(`fact_ledger.empty_ledger`):`candidate_facts`/`confirmed_facts`/`evidence`/`tool_results`/`executed_actions`/`pending_actions`/`failed_or_unknown_actions`/`conflicts`。`confirmed_facts` 条目含 `{candidate_id, text, agent_id, confirmed_by, evidence_refs[]}`。 +- **Run 字段**(`run_to_dict`):`id, session_id, status, maf_checkpoint(含 fact_ledger + final_result + seat_outcomes), last_event_seq, started_at, finished_at, error_code, error_message_safe, token_usage, latency_ms`。 +- **终态判定**:`runIsLive(status) = ["pending","running","waiting_approval","cancelling","cancel_failed"].includes(status)`,非 live 即停轮询。 +- **Action/Approval**:`GET /runs/{id}/actions`、`GET /runs/{id}/approvals`;`POST /approvals/{id}/decision` `{decision:"approved"|"rejected"}`。 + +## 复用清单(已定位到文件:行) +- `apiFetch`(`core/api/fetch-client.ts`):内部自动注入 `Authorization`(`getAuthorizationHeaderValue()`)+ `credentials:"include"`,**返回 Response 不解析、不拼 URL**。调用方拼 `${getBackendBaseURL()}/api/maf-roundtable/...`,自行 `res.ok` 检查 + `res.json()`。 +- `getBackendBaseURL`(`core/config/index.ts`):未设时返回 `""`,相对路径同源可用。 +- TanStack Query 模式(`strategy-components/api/task-buttons.ts`):`useQuery`+`refetchOnWindowFocus:false`;`useMutation`+`onSuccess invalidate`;queryKey 用嵌套数组 `["maf-roundtable", runId, "events"]`。 +- 轮询模式(`core/scheduled-tasks/hooks.ts:105-115`):函数式 `refetchInterval: (query) => runIsLive(query.state.data?.status) ? ms : false`。 +- `FlowGraphPanel`(`roundtable-planning/components/FlowGraphPanel.tsx`):props `{data: FlowGraphData, streaming, onClose, ...}`,自动 `useTheme()` 取明暗。`FlowGraphData = {title, nodes:[{id,label,type,detail?,group?}], edges:[{from,to,label?}]}`。`extractFlowGraph(text)`(`flow-graph.ts:263`)容错解析 ```flow-json``` 代码块(含半截 JSON)。 +- `IntentClarificationCard`(`roundtable-planning/components/IntentClarificationCard.tsx`):卡片视觉模板(`rounded-xl border-red-100` + 「需要确认」徽标 + 选项按钮 + textarea + 提交)。 +- 时间线 feed(`roundtable-planning/components/Step1Panel.tsx` + `MessageBubble` + `useAutoScroll` + `ScrollToBottomButton`):数组入、按 seq 序渲染。 +- `useDesensitizeSubtree`(`roundtable-planning/lib/use-desensitize-subtree.ts`):仅挂 header/面板框架;时间线 body 不挂(payload 已脱敏 + hook 自身警告勿用于消息列表)。 +- 路由(`pages/PageRoutes.tsx:143-169`):`}/>`(仿 `RoundtableRoutes.tsx`)。 +- 菜单(`core/page-layout/sidebar-menu.ts`):在 `sidebarMenuItems` 数组加一级项,`icon` 用 lucide 组件(非字符串 key)。新节点经 `use-sidebar-menu.ts` 自动走 static→inject→override→role→desensitize 流水线,无需额外注册。 + +## 文件夹结构(镜像 `roundtable-planning/`,全新 `src/maf-roundtable/`) +``` +frontend-web/src/maf-roundtable/ +├── api/ +│ ├── types.ts # TS 类型(snake_case 直传,后端即 snake_case) +│ ├── client.ts # BASE()=getBackendBaseURL()+"/api/maf-roundtable";apiFetch 封装 + readJson/parseDetail +│ ├── sessions.ts # 会话 CRUD hooks(list/get/create/update/delete) +│ ├── runs.ts # run hooks(get/list/create/cancel/resume/recover)+ useMafHealth +│ ├── events.ts # listEvents(runId, afterSeq) 原始 fetch(供轮询 hook 用) +│ └── approvals.ts # 审批 hooks(list + decide mutation) +├── hooks/ +│ ├── use-maf-run-status.ts # GET /runs/{id} useQuery,函数式 refetchInterval 停终态 +│ └── use-maf-events.ts # ★ cursor 轮询 hook(核心) +├── lib/ +│ ├── event-timeline.ts # 纯函数:events[] → TimelineEntry[](delta 聚合、tool 生命周期折叠、approval 锚点) +│ └── fact-flow-graph.ts # 纯函数:confirmed_facts+evidence → FlowGraphData +├── components/ +│ ├── MafRunHeader.tsx # 状态徽标 + token/延迟 + 取消/恢复/重试 +│ ├── MafEventTimeline.tsx # 时间线 feed(MessageBubble + useAutoScroll) +│ ├── MafSeatStatusPanel.tsx # 席位状态矩阵 + execution_stage 徽标 +│ ├── MafFactFlowPanel.tsx # 包一层 FlowGraphPanel,数据来自 fact-flow-graph +│ ├── MafApprovalCard.tsx # 卡片式审批(批准/拒绝 + 备注) +│ └── MafHealthGate.tsx # health.enabled=false → 空态 +├── pages/ +│ ├── MafSessionsPage.tsx # 会话列表 + 新建 +│ └── MafRunDetailPage.tsx # 单 run 工作区(三栏) +└── index.ts +``` + +## 核心:cursor 轮询 hook(`hooks/use-maf-events.ts`) +不用纯 `useQuery+refetchInterval`:cursor `after_seq` 需每次推进,queryFn 不持有「上次 latest_seq」,塞 queryKey 会抖动。**用自定义 hook**(结合 scheduled-tasks 的 refetchInterval 思路 + roundtable-planning 的 reader-loop 家族): +```ts +export function useMafEvents(runId?: string, active=false) { + const [events, setEvents] = useState([]); + const afterSeqRef = useRef(0); // 避免 setInterval 闭包旧值 + useEffect(() => { + if (!runId || !active) return; + let cancelled = false; + const poll = async () => { + const res = await listEvents(runId, afterSeqRef.current); + if (cancelled || !res.events.length) return; + setEvents(prev => mergeBySeq(prev, res.events)); // seq 去重 + afterSeqRef.current = res.latest_seq; + }; + poll(); // 进入即补拉(after_seq=0 全量历史) + const id = setInterval(poll, 1800); + return () => { cancelled = true; clearInterval(id); }; + }, [runId, active]); + return events; +} +``` +- **补拉历史**:进入页面 `after_seq=0` 立即拉全部已落库事件 → 天然支持断线补拉/刷新恢复。 +- **终态停止**:`active` 由 `useMafRunStatus` 的 `runIsLive(status)` 传入,终态时 effect 清理 interval。 +- **去重**:后端 `(run_id,seq)` + `source_event_id` 双重去重,前端再按 `seq` 做 `mergeBySeq`,幂等。 +- **切换 run**:`runId` 变 → effect 重置(events 清空、afterSeq 归零)。 + +## 流程图数据流(`lib/fact-flow-graph.ts`) +两源按优先级合并 → `FlowGraphData`: +1. **`confirmed_facts`(主)**:每条已确认事实 → `task` 节点(label=text 截断、detail=全文、group=confirmed_by);`evidence_refs` 连到 `evidence`(→ `role`)或 `tool_results`(`tool:{name}:{turn}` → `deliverable`)节点;证据根用 `start`。 +2. **`final_result.content` 的 ```flow-json```(次)**:用现成 `extractFlowGraph(text)` 解析叠加为「结论流程图」。 + +输出喂 ``(非终态蚂蚁线动)。两源皆空 → 空态「尚无可绘制事实」。 + +## 页面布局(`MafRunDetailPage`,三栏 / 窄屏堆叠) +- **左:时间线**(`MafEventTimeline`)—— `event-timeline.ts` 折叠成可读条目(delta 聚合气泡、tool 折叠「调用 X→完成」、action.waiting_approval 插锚点),复用 MessageBubble + useAutoScroll + ScrollToBottomButton。 +- **中上:席位状态**(`MafSeatStatusPanel`)—— `maf_checkpoint.seat_outcomes` 的 agent × status 矩阵 + `execution_stage` 徽标。 +- **中下:事实/结论流程图**(`MafFactFlowPanel`)—— 折叠面板,默认展开。 +- **右:审批卡片**(`MafApprovalCard` 列表)—— 仅 pending 时显示;含工具名、参数摘要(脱敏)、批准/拒绝按钮 + 备注 textarea → `POST /approvals/{id}/decision`,成功后 invalidate `["maf-roundtable","approvals",runId]`,事件轮询自然看到 `action.approved/rejected`。 + +## 路由 + 菜单接入(3 处) +1. **新建** `pages/MafRoutes.tsx`(仿 `RoundtableRoutes.tsx`):`index→MafSessionsPage`、`runs/:runId→MafRunDetailPage`、`*→重定向`。 +2. **改** `pages/PageRoutes.tsx`:加 `import MafRoutes` + `}/>`(插 `notebook` 后)。 +3. **改** `core/page-layout/sidebar-menu.ts`:数组末尾加 `{ id:"maf-center", label:"MAF 会商", icon:Network, children:[{id:"maf-roundtable", label:"会商列表", icon:MessageSquareText, path:"/page/maf"}] }`。**不加 `adminOnly`**(全员可见;`/health.enabled=false` 由 `MafHealthGate` 客户端控制页面空态)。 + +## 实施顺序 +1. `api/types.ts` + `api/client.ts`(地基) +2. `api/{sessions,runs,events,approvals}.ts`(端点 hook) +3. `hooks/{use-maf-run-status,use-maf-events}.ts`(轮询核心) +4. `lib/{event-timeline,fact-flow-graph}.ts`(纯函数) +5. `components/*`(6 个组件) +6. `pages/*`(2 个页面)+ `index.ts` +7. `pages/MafRoutes.tsx` + 接入 `PageRoutes.tsx` + `sidebar-menu.ts` +8. `pnpm typecheck` + 手动核对路由/菜单显隐 + +## 文件清单 +- **新建**(14):`maf-roundtable/api/{types,client,sessions,runs,events,approvals}.ts`、`maf-roundtable/hooks/{use-maf-run-status,use-maf-events}.ts`、`maf-roundtable/lib/{event-timeline,fact-flow-graph}.ts`、`maf-roundtable/components/{MafRunHeader,MafEventTimeline,MafSeatStatusPanel,MafFactFlowPanel,MafApprovalCard,MafHealthGate}.tsx`、`maf-roundtable/pages/{MafSessionsPage,MafRunDetailPage}.tsx`、`maf-roundtable/index.ts`、`pages/MafRoutes.tsx` +- **改动**(2):`pages/PageRoutes.tsx`、`core/page-layout/sidebar-menu.ts` +- **后端**:无改动 + +## 验证 +- `pnpm typecheck`(tsc --noEmit)通过 +- 手动:菜单「MAF 会商」→ 列表 → 新建会话 → 启动 run → 时间线滚动 → 席位状态更新 → 流程图随 confirmed_facts 增长 → 审批可批准 → 终态停轮询 +- 退路:`/health.enabled=false` → 入口可见但页面显示未启用提示;旧 `roundtable-planning` 零改动 \ No newline at end of file diff --git a/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md b/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md index 3202a5cc..40e616c7 100644 --- a/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md +++ b/frontend-web/docs/microsoft-agent-framework-多智能体会商-实施计划.md @@ -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 个可演示里程碑交付: diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 1c93ecec..5b3887d2 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -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({ + + + + + MAF 会商 + + + & { 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(); + 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(); + 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(); + 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 = { + 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((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); + }); +}); diff --git a/frontend-web/src/maf-roundtable/api/approvals.ts b/frontend-web/src/maf-roundtable/api/approvals.ts new file mode 100644 index 00000000..5d83da18 --- /dev/null +++ b/frontend-web/src/maf-roundtable/api/approvals.ts @@ -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 { + return getJson( + `${BASE()}/runs/${runId}/approvals`, + "获取审批列表", + ); +} + +export async function listActions(runId: string): Promise { + return getJson( + `${BASE()}/runs/${runId}/actions`, + "获取动作列表", + ); +} + +export async function decideApproval( + approvalId: string, + body: MafApprovalDecisionRequest, +): Promise { + return sendJson( + `${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 { + const map = new Map(); + 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 { + const map = new Map(); + for (const a of actions ?? []) map.set(a.id, map.get(a.id) ?? a); + return map; +} diff --git a/frontend-web/src/maf-roundtable/api/client.ts b/frontend-web/src/maf-roundtable/api/client.ts new file mode 100644 index 00000000..b8985919 --- /dev/null +++ b/frontend-web/src/maf-roundtable/api/client.ts @@ -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(res: Response, ctx: string): Promise { + 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(url: string, ctx: string): Promise { + const res = await apiFetch(url, { method: "GET" }); + return readJson(res, ctx); +} + +/** 发送 JSON body 的快捷封装。 */ +export async function sendJson( + url: string, + method: "POST" | "PUT" | "DELETE" | "PATCH", + body: unknown, + ctx: string, +): Promise { + const res = await apiFetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return readJson(res, ctx); +} diff --git a/frontend-web/src/maf-roundtable/api/events.ts b/frontend-web/src/maf-roundtable/api/events.ts new file mode 100644 index 00000000..89c9d792 --- /dev/null +++ b/frontend-web/src/maf-roundtable/api/events.ts @@ -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 { + const qs = new URLSearchParams({ + after_seq: String(afterSeq), + limit: String(limit), + }); + return getJson( + `${BASE()}/runs/${runId}/events?${qs.toString()}`, + "获取会商事件", + ); +} diff --git a/frontend-web/src/maf-roundtable/api/runs.ts b/frontend-web/src/maf-roundtable/api/runs.ts new file mode 100644 index 00000000..569a3e54 --- /dev/null +++ b/frontend-web/src/maf-roundtable/api/runs.ts @@ -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 { + return getJson(`${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 { + return getJson(`${BASE()}/runs/${runId}`, "获取会商 run"); +} + +export async function listRuns(sessionId: string): Promise { + return getJson( + `${BASE()}/sessions/${sessionId}/runs`, + "获取 run 列表", + ); +} + +export async function createRun( + sessionId: string, + workflowVersion?: string, +): Promise { + return sendJson( + `${BASE()}/sessions/${sessionId}/runs`, + "POST", + workflowVersion ? { workflow_version: workflowVersion } : {}, + "启动会商 run", + ); +} + +export async function cancelRun(runId: string): Promise { + return sendJson(`${BASE()}/runs/${runId}/cancel`, "POST", {}, "取消会商"); +} + +export async function resumeRun(runId: string): Promise { + return sendJson(`${BASE()}/runs/${runId}/resume`, "POST", {}, "恢复会商"); +} + +export async function recoverRun(runId: string): Promise { + return sendJson(`${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] }); + }, + }), + }; +} diff --git a/frontend-web/src/maf-roundtable/api/sessions.ts b/frontend-web/src/maf-roundtable/api/sessions.ts new file mode 100644 index 00000000..60a3c61e --- /dev/null +++ b/frontend-web/src/maf-roundtable/api/sessions.ts @@ -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 | 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 | 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 | null; + final_result?: Record | null; +} + +// ── 裸 fetch 函数 ────────────────────────────────────────────────────────── + +export async function listSessions(params?: { + status?: string; + mode?: string; + limit?: number; + offset?: number; +}): Promise { + 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( + `${BASE()}/sessions${query ? `?${query}` : ""}`, + "获取会话列表", + ); +} + +export async function getSession(id: string): Promise { + return getJson(`${BASE()}/sessions/${id}`, "获取会话"); +} + +export async function createSession(body: CreateSessionBody): Promise { + return sendJson(`${BASE()}/sessions`, "POST", body, "创建会话"); +} + +export async function updateSession( + id: string, + body: UpdateSessionBody, +): Promise { + return sendJson(`${BASE()}/sessions/${id}`, "PUT", body, "更新会话"); +} + +export async function deleteSession(id: string): Promise { + 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 }), + }; +} diff --git a/frontend-web/src/maf-roundtable/api/types.ts b/frontend-web/src/maf-roundtable/api/types.ts new file mode 100644 index 00000000..c4b08de8 --- /dev/null +++ b/frontend-web/src/maf-roundtable/api/types.ts @@ -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 | 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 | 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 | null; + fact_ledger: Record | null; + final_result: Record | 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 | 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; +} + +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; + 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 | 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 | null; + result: Record | 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 | 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 = 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).has(status); +} diff --git a/frontend-web/src/maf-roundtable/components/MafApprovalCard.tsx b/frontend-web/src/maf-roundtable/components/MafApprovalCard.tsx new file mode 100644 index 00000000..544e92bd --- /dev/null +++ b/frontend-web/src/maf-roundtable/components/MafApprovalCard.tsx @@ -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 = { + 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 = { + 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 ( +
+
+ + + 需要你的确认 + + {action?.id && ( + + #{shortId(approval.action_id ?? action.id)} + + )} +
+ + {/* 标题 */} +
+ {title || "未知操作"} +
+ + {/* 风险等级(R0–R4) */} + {riskLevel && ( +
+ 风险等级: + + {RISK_LABEL[riskLevel] ?? riskLevel} + +
+ )} + + {/* 审批摘要 */} + {approval.summary && ( +
+ {approval.summary} +
+ )} + + {/* 参数(来自 action.arguments,已脱敏) */} + {args && Object.keys(args).length > 0 && ( +
+
+ 参数(已脱敏) +
+
+            {JSON.stringify(args, null, 2)}
+          
+
+ )} + + {/* 审批 payload(仅有值时展示) */} + {approvalPayload && Object.keys(approvalPayload).length > 0 && ( +
+
+ 审批载荷(已脱敏) +
+
+            {JSON.stringify(approvalPayload, null, 2)}
+          
+
+ )} + + {/* 操作按钮 */} +
+ + +
+
+ ); +} diff --git a/frontend-web/src/maf-roundtable/components/MafEventTimeline.tsx b/frontend-web/src/maf-roundtable/components/MafEventTimeline.tsx new file mode 100644 index 00000000..75cfe6d8 --- /dev/null +++ b/frontend-web/src/maf-roundtable/components/MafEventTimeline.tsx @@ -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 = { + message: , + tool: , + skill: , + approval: , + artifact: , + run: , + checkpoint: , + turn: , +}; + +const KIND_TONE: Record = { + 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 ; + if (status === "failed") return ; + if (status === "started" || status === "requested") + return ; + return null; +} + +export function MafEventTimeline({ entries, streaming }: MafEventTimelineProps) { + const scrollRef = useRef(null); + const contentRef = useRef(null); + const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [entries], { + contentRef, + }); + + return ( +
+
+
+ {entries.length === 0 && !streaming && ( +
+ + 暂无事件 +
+ )} + + {entries.map((entry) => ( + + ))} + + {streaming && ( +
+ + 会商进行中… +
+ )} +
+
+ + {!atBottom && ( + + )} +
+ ); +} + +function TimelineEntryCard({ entry }: { entry: TimelineEntry }) { + const isMessage = entry.kind === "message"; + return ( +
+
+ {KIND_ICON[entry.kind]} + {entry.kind === "tool" && toolStatusIcon(entry)} + {entry.title} + {entry.agentId && ( + + {entry.agentId} + + )} + {shortTime(entry.createdAt)} +
+ + {entry.body && ( + isMessage ? ( +
+ {entry.body} +
+ ) : ( +

{entry.body}

+ ) + )} +
+ ); +} diff --git a/frontend-web/src/maf-roundtable/components/MafFactFlowPanel.tsx b/frontend-web/src/maf-roundtable/components/MafFactFlowPanel.tsx new file mode 100644 index 00000000..2c5b9899 --- /dev/null +++ b/frontend-web/src/maf-roundtable/components/MafFactFlowPanel.tsx @@ -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 ( + + +
+ + 事实/结论流程图 + {graph && ( + + ({graph.nodes.length} 节点 / {graph.edges.length} 连线) + + )} +
+ {open ? "收起" : "展开"} +
+ + +
+ {graph ? ( + <> + {/* 内嵌预览:复用 FlowGraphPanel 的横向布局,占满面板高度 */} + + + + ) : ( +
+ {streaming ? "等待已确认事实 / 结论输出…" : "尚无可绘制的事实或结论"} +
+ )} +
+
+ + {/* 全屏弹窗(portal 到 body,仿 Step3SummaryMode 用法) */} + {fullscreen && graph && ( + setFullscreen(false)} + orientation="vertical" + miniMap + /> + )} +
+ ); +} + +/** + * 内嵌预览。FlowGraphPanel 是 portal 全屏组件,不适合直接嵌进小面板; + * 这里用一个轻量的「节点列表」预览代替画布(点击「大图」看完整交互画布)。 + */ +function FlowGraphPreview({ graph, streaming }: { graph: FlowGraphData; streaming: boolean }) { + return ( +
+ {graph.title && ( +
{graph.title}
+ )} +
+
    + {graph.nodes.map((n) => { + const incomers = graph.edges.filter((e) => e.to === n.id).map((e) => e.from); + return ( +
  • +
    + + {n.type} + + {n.label} + {n.group && ( + {n.group} + )} +
    + {incomers.length > 0 && ( +
    ← {incomers.join(", ")}
    + )} +
  • + ); + })} +
+
+ {streaming && ( +
绘制中…
+ )} +
+ ); +} diff --git a/frontend-web/src/maf-roundtable/components/MafHealthGate.tsx b/frontend-web/src/maf-roundtable/components/MafHealthGate.tsx new file mode 100644 index 00000000..bda60ab2 --- /dev/null +++ b/frontend-web/src/maf-roundtable/components/MafHealthGate.tsx @@ -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 ?? ( +
+ + 正在检查 MAF 会商状态… +
+ )} + ); + } + + // 请求异常 → 无法连接,给重试按钮 + if (isError) { + return ( +
+ +
无法连接 MAF 服务
+

+ 请求 /api/maf-roundtable/health 失败,请检查后端服务是否正常运行。 +

+ +
+ ); + } + + // 明确 disabled → 功能未启用 + if (!data?.enabled) { + return ( +
+ +
MAF 会商功能未启用
+

+ 后端未开启多智能体会商功能(maf_roundtable.enabled=false)。 + 请联系管理员在 config.yaml 中启用后重试。 +

+
+ ); + } + + // enabled=true 但 store_available=false → 存储未就绪 + if (!data.store_available) { + return ( +
+ +
持久化存储未就绪
+

+ MAF 会商功能已开启,但持久化存储(数据库)尚未配置或连接失败。 + 写操作将返回 503。请检查数据库配置后重试。 +

+ +
+ ); + } + + 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 ( +
+ {writeGuardNotReady && ( +
+ + {writeGuardMessage} +
+ )} +
{children}
+
+ ); +} diff --git a/frontend-web/src/maf-roundtable/components/MafRunHeader.tsx b/frontend-web/src/maf-roundtable/components/MafRunHeader.tsx new file mode 100644 index 00000000..a40e56b6 --- /dev/null +++ b/frontend-web/src/maf-roundtable/components/MafRunHeader.tsx @@ -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: , tone: "text-slate-500" }; + case "running": + return { label: "进行中", variant: "default", icon: , tone: "text-blue-600" }; + case "waiting_approval": + return { label: "等待审批", variant: "default", icon: , tone: "text-amber-600" }; + case "completed": + return { label: "已完成", variant: "outline", icon: , tone: "text-emerald-600" }; + case "failed": + return { label: "失败", variant: "destructive", icon: , tone: "text-red-600" }; + case "cancelled": + return { label: "已取消", variant: "outline", icon: , tone: "text-slate-500" }; + case "cancelling": + return { label: "取消中", variant: "secondary", icon: , tone: "text-amber-600" }; + case "cancel_failed": + return { label: "取消失败", variant: "destructive", icon: , 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 ( +
+
+ {meta.icon} + {meta.label} +
+ + {run.workflow_version ?? "maf"} + + +
+ {tokenTotal != null && ( + tokens: {tokenTotal.toLocaleString()} + )} + {run.latency_ms != null && ( + {(run.latency_ms / 1000).toFixed(1)}s + )} + {run.last_event_seq > 0 && ( + seq#{run.last_event_seq} + )} +
+ +
+ {run.status === "waiting_approval" && ( + + )} + {run.status === "failed" && ( + + )} + {(live || run.status === "cancel_failed") && ( + + )} +
+ + {run.error_message_safe && ( +
+ 错误:{run.error_message_safe} +
+ )} +
+ ); +} diff --git a/frontend-web/src/maf-roundtable/components/MafSeatStatusPanel.tsx b/frontend-web/src/maf-roundtable/components/MafSeatStatusPanel.tsx new file mode 100644 index 00000000..c4a825e7 --- /dev/null +++ b/frontend-web/src/maf-roundtable/components/MafSeatStatusPanel.tsx @@ -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 = { + legacy: "旧版", + planner: "规划", + expert: "专家分析", + format_repair: "格式修复", + cross_review: "交叉评审", + cross_review_repair: "评审修复", + finalizer: "总结", + group_chat: "群聊", +}; + +const SEAT_STATUS_TONE: Record = { + 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 {status}; +} + +export function MafSeatStatusPanel({ checkpoint, currentRound }: MafSeatStatusPanelProps) { + const outcomes = checkpoint?.seat_outcomes; + const entries: Array<[string, MafSeatOutcome]> = outcomes + ? Object.entries(outcomes) + : []; + + return ( +
+
+

席位状态

+ {typeof currentRound === "number" && ( + 第 {currentRound + 1} 轮 + )} +
+ + {entries.length === 0 ? ( +

尚无席位产出

+ ) : ( +
    + {entries.map(([agentId, outcome]) => ( +
  • + + {agentId} + + {outcome.execution_stage && ( + + {STAGE_LABELS[outcome.execution_stage] ?? outcome.execution_stage} + + )} + + {seatStatusBadge(outcome.status ?? "unknown")} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend-web/src/maf-roundtable/hooks/use-maf-events.ts b/frontend-web/src/maf-roundtable/hooks/use-maf-events.ts new file mode 100644 index 00000000..036ca5d8 --- /dev/null +++ b/frontend-web/src/maf-roundtable/hooks/use-maf-events.ts @@ -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(); + 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(); + 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([]); + 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(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, + }; +} diff --git a/frontend-web/src/maf-roundtable/index.ts b/frontend-web/src/maf-roundtable/index.ts new file mode 100644 index 00000000..3fbae8af --- /dev/null +++ b/frontend-web/src/maf-roundtable/index.ts @@ -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"; diff --git a/frontend-web/src/maf-roundtable/lib/event-timeline.ts b/frontend-web/src/maf-roundtable/lib/event-timeline.ts new file mode 100644 index 00000000..1db9e49e --- /dev/null +++ b/frontend-web/src/maf-roundtable/lib/event-timeline.ts @@ -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 | 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(); + + 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 = { + 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 = { + 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 ""; + } +} diff --git a/frontend-web/src/maf-roundtable/lib/fact-flow-graph.ts b/frontend-web/src/maf-roundtable/lib/fact-flow-graph.ts new file mode 100644 index 00000000..9e1fa067 --- /dev/null +++ b/frontend-web/src/maf-roundtable/lib/fact-flow-graph.ts @@ -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(); + + 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(); + 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(); + 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); +} diff --git a/frontend-web/src/maf-roundtable/pages/MafRunDetailPage.tsx b/frontend-web/src/maf-roundtable/pages/MafRunDetailPage.tsx new file mode 100644 index 00000000..ac884f88 --- /dev/null +++ b/frontend-web/src/maf-roundtable/pages/MafRunDetailPage.tsx @@ -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 ( + + {runId ? :
缺少 runId
} +
+ ); +} + +function RunBody({ runId }: { runId: string }) { + const { run, isLoadingRun, runError, events } = useMafRunWithEvents(runId); + const headerRef = useDesensitizeSubtree(); + + 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(); + 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 ( +
+ 加载会商… +
+ ); + } + if (runError || !run) { + return ( +
+

加载失败:{(runError as Error)?.message ?? "run 不存在或无权访问"}

+ + + +
+ ); + } + + return ( +
+
+ +
+ +
+ {/* 左:时间线 */} +
+ +
+ + {/* 中:席位 + 流程图 */} +
+ + + + {/* final_result 展示 */} + {run.maf_checkpoint?.final_result?.content && ( +
+

最终结论

+
+ {/* final_result.content 可能含 flow-json 块;这里只展示文字部分(流程图已在上方画布) */} +
+                  {stripFlowJson(run.maf_checkpoint.final_result.content)}
+                
+
+
+ )} +
+ + {/* 右:审批 */} +
+
+ + 写工具审批 + {pendingApprovals.length > 0 && ( + + {pendingApprovals.length} + + )} +
+ {pendingApprovals.length === 0 ? ( +
+ {live ? "暂无待审批的写操作" : "本次会商无待审批项"} +
+ ) : ( + pendingApprovals.map((approval) => ( + + )) + )} +
+
+
+ ); +} + +/** 把 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}`; +} diff --git a/frontend-web/src/maf-roundtable/pages/MafSessionsPage.tsx b/frontend-web/src/maf-roundtable/pages/MafSessionsPage.tsx new file mode 100644 index 00000000..288e6fea --- /dev/null +++ b/frontend-web/src/maf-roundtable/pages/MafSessionsPage.tsx @@ -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 = { + 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 ( + + + + ); +} + +function SessionsBody() { + const sessionsQuery = useMafSessions({ limit: 100 }); + const [selectedId, setSelectedId] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const headerRef = useDesensitizeSubtree(); + + const sessions = sessionsQuery.data?.sessions ?? []; + const active = selectedId + ? sessions.find((s) => s.id === selectedId) + : sessions[0]; + + return ( +
+ {/* 左:会话列表 */} + + + {/* 右:选中会话的 run 列表 */} +
+ {active ? ( + + ) : ( +
+ 选择左侧会话查看详情,或新建一个 +
+ )} +
+ + setSelectedId(id)} /> +
+ ); +} + +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 ( + <> +
+
+

{title || "未命名会话"}

+
+ {mode} + {STATUS_LABEL[status] ?? status} +
+
+
+ {liveRun && ( + + )} + +
+
+ +
+

历史 run

+ {runsQuery.isLoading && ( +
+ 加载中… +
+ )} + {runs.length === 0 && !runsQuery.isLoading && ( +

暂无会商记录,点击「启动会商」开始

+ )} +
    + {runs.map((run) => ( +
  • + +
  • + ))} +
+
+ + ); +} + +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([]); + // 高级项 + 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 ( + + + + 新建 MAF 会商会话 + +
+ {/* 标题 */} +
+ + setTitle(e.target.value)} placeholder="例如:某任务多视角研讨" /> +
+ + {/* 任务说明(必填) */} +
+ +