- Backend: action_coordinator, capability_snapshot, event_normalizer, tool_executor updates - Backend: approved_tool_executor with write guard, action leases, migration script - Frontend: maf-roundtable module (API client, event timeline, approval cards, fact flow panel) - Frontend: MafRoutes, MafSessionsPage, MafRunDetailPage - Tests: action_leases, migration_phase5, phase5_entry_e2e, real_write_e2e/http_e2e - Docs: update implementation plan Co-authored-by: Cursor <cursoragent@cursor.com>
122 lines
11 KiB
Markdown
122 lines
11 KiB
Markdown
# 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`):`<Route path="maf/*" element={<MafRoutes/>}/>`(仿 `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<T>/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<MafEvent[]>([]);
|
||
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)` 解析叠加为「结论流程图」。
|
||
|
||
输出喂 `<FlowGraphPanel data={...} streaming={!terminal}/>`(非终态蚂蚁线动)。两源皆空 → 空态「尚无可绘制事实」。
|
||
|
||
## 页面布局(`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` + `<Route path="maf/*" element={<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` 零改动 |