Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f11926db5 |
No files matched your search
@@ -81,9 +81,6 @@ skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
# MAF roundtable tests must be tracked (excepted from the broad tests/* ignore above)
|
||||
!offline-backend-20260512/backend/tests/test_maf_*.py
|
||||
!offline-backend-20260512/backend/tests/maf_test_helpers.py
|
||||
deploy
|
||||
.history
|
||||
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
# 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` 零改动
|
||||
@@ -1,198 +0,0 @@
|
||||
# MAF-R0 阶段 0 交付文档:旧会商交互基线与组件映射
|
||||
|
||||
> 状态:已完成
|
||||
> 更新时间:2026-08-03
|
||||
|
||||
---
|
||||
|
||||
## 1. 实验原型隐藏说明
|
||||
|
||||
### 已完成的操作
|
||||
|
||||
- `/page/maf/*` 路由(`MafSessionsPage` + `MafRunDetailPage`)已从正式菜单移除。
|
||||
- `workspace-nav-chat-list.tsx:199` 保留了墓碑注释:`{/* [MAF-R0] 实验原型已从正式菜单隐藏,仅保留路由供开发诊断:/page/maf */}`
|
||||
- `MafRoutes.tsx` 文件头部标注为 `[MAF-R0 实验原型 — 仅供开发诊断,已从正式菜单移除]`。
|
||||
- 该原型仅可通过手动输入 URL `/page/maf` 访问,不在任何侧边栏、导航菜单或工作区入口中出现。
|
||||
- 停止在该原型上新增业务功能。
|
||||
|
||||
### 原型文件位置
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `src/pages/MafRoutes.tsx` | 路由定义(index → MafSessionsPage, runs/:runId → MafRunDetailPage) |
|
||||
| `src/maf-roundtable/pages/MafSessionsPage.tsx` | 会话列表页(642 行) |
|
||||
| `src/maf-roundtable/pages/MafRunDetailPage.tsx` | 单 run 工作区(195 行) |
|
||||
| `src/maf-roundtable/` | 完整模块(33 文件:api/hooks/components/lib/tests) |
|
||||
| `vitest.maf.config.ts` | MAF 专用测试配置 |
|
||||
| `package.json` → `test:maf` | MAF 测试脚本 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 旧会商 8 类页面状态交互地图
|
||||
|
||||
旧会商入口:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(4376 行,三步合一)。
|
||||
|
||||
### 状态 1:空白 / 新建
|
||||
|
||||
- 触发:点击「新建会商」或首次进入。
|
||||
- 表现:中心区域显示欢迎语 + 业务链选择入口 + 底部输入框。左侧历史列表为空或显示已有草稿。
|
||||
- 关键组件:`RoundtablePlanningPage`(currentStep=1 初始态)、`Step1Panel`(空消息列表 + 输入栏)。
|
||||
- 数据:`useDraftPersistence` 创建新草稿记录。
|
||||
|
||||
### 状态 2:历史切换
|
||||
|
||||
- 触发:点击左侧草稿列表中的某一条。
|
||||
- 表现:加载该草稿的 Step1/Step2/Step3 快照,恢复到对应步骤。
|
||||
- 关键组件:`useRoundtableDrafts`(列表)、`useDraftPersistence`(加载 + hydrate)。
|
||||
- 数据:`GET /api/roundtable-drafts/{id}` 或 `GET /api/roundtable-task-drafts/{id}`。
|
||||
|
||||
### 状态 3:用户提问
|
||||
|
||||
- 触发:用户在 Step1 输入框输入任务文本并点击发送。
|
||||
- 表现:用户气泡立即插入,随后出现 `MessageLoadingPlaceholder`(spinner + 跳动点),等待 SSE 首帧。
|
||||
- 关键组件:`Step1Panel`(输入栏 → 发送)、`MessageBubble`(用户气泡)、`MessageLoadingPlaceholder`。
|
||||
- 数据:`POST /api/intent/init` → `POST /api/intent/stream`(SSE)。
|
||||
|
||||
### 状态 4:意图澄清
|
||||
|
||||
- 触发:总控/意图智能体判断信息不足,返回 `ask_clarification` 帧。
|
||||
- 表现:对话流中嵌入 `IntentClarificationCard`(单选/多选 + 自定义输入),用户选择后提交。
|
||||
- 关键组件:`IntentClarificationCard`(选项卡)、`Step1Panel`(澄清提交逻辑)。
|
||||
- 数据:SSE 帧 `phase=clarification`,用户回答后继续 `POST /api/intent/stream`。
|
||||
|
||||
### 状态 5:总控派活
|
||||
|
||||
- 触发:Step1 完成(INTENT_READY)→ 进入 Step2 → 总控输出派活计划。
|
||||
- 表现:总控气泡显示派活计划(谁做什么),`ParallelSeatStrip` 显示参与席位头像条(running/done/error 徽章)。
|
||||
- 关键组件:`Step2Panel`(派活叙事)、`ParallelSeatStrip`(席位条)、`MessageBubble`(总控气泡)。
|
||||
- 数据:`POST /api/multi-agent/init`(SSE,建立 thread map)→ `POST /api/multi-agent/run/stream`。
|
||||
|
||||
### 状态 6:席位发言
|
||||
|
||||
- 触发:总控派活后,各席位按序/并行执行。
|
||||
- 表现:每个席位的气泡流式出现(`StreamingCursor` 闪烁尾标),工具调用显示为 `MessageStepsCard`(ChainOfThought 折叠卡)。
|
||||
- 关键组件:`MessageBubble`(席位气泡 + avatar)、`StreamingCursor`、`MessageStepsCard`(工具步骤)、`DialogueSpeedMeter`(tok/s)。
|
||||
- 数据:SSE delta 帧按 message id 合并。
|
||||
|
||||
### 状态 7:审批
|
||||
|
||||
- 触发:席位执行外部写操作,WriteGuard 拦截并请求审批。
|
||||
- 表现:对话流中嵌入审批卡(工具名 + 风险等级 + 摘要),用户点击批准/拒绝。
|
||||
- 关键组件:MAF 原型中为 `MafApprovalCard`;旧会商中审批通过 `roundtable-jobs` 的 clarify-resume 机制。
|
||||
- 数据:`POST /api/maf-roundtable/approvals/{id}/decision`(MAF)或 `POST /api/roundtable-jobs/{id}/clarify-resume`(旧)。
|
||||
|
||||
### 状态 8:完成 / 失败恢复
|
||||
|
||||
- 触发:所有席位完成 + 总控收束 → Step3;或 SSE 错误帧 / 网络断开。
|
||||
- 表现(完成):Step3 显示最终报告(Markdown 总结 / HTML 看板 / 大屏),可追问。
|
||||
- 表现(失败):错误提示 + 恢复按钮;后台任务可挂起/恢复。
|
||||
- 关键组件:`Step3Panel` → `HighFidelityReport`(Report/Dashboard/Summary/FileBrowse 模式);失败时 `RoundtableJobNotifier`。
|
||||
- 数据:`useStep3Summary` / `useStep3Report` / `useStep3Dashboard`;失败恢复 via `POST /api/roundtable-jobs/{id}/resume`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 旧组件 → 新 MAF view-model 映射表
|
||||
|
||||
| 旧组件 / hook | 新 MAF 对应 | 映射说明 |
|
||||
|---|---|---|
|
||||
| `RoundtablePlanningPage`(三步壳) | `MafRoundtableConversationPage` | 不再有步骤切换;单一连续时间线。 |
|
||||
| `Step1Panel`(意图问答) | `ConversationTimeline` + `ClarificationCard` | 意图理解变为 `manager.intent` 事件卡。 |
|
||||
| `Step2Panel`(研讨对话流) | `ConversationTimeline` + `SeatMessageBubble` | 席位发言由 SSE `agent.message.*` 驱动。 |
|
||||
| `Step3Panel` / `HighFidelityReport` | `FinalResultCard` | 结论作为时间线中的结果卡。 |
|
||||
| `MessageBubble` | `SeatMessageBubble` / 总控气泡 | 重写为 MAF view model;不复用旧 props。 |
|
||||
| `MessageStepsCard` | `ToolProgressCard` | 工具过程由 `tool.started/progress/completed` 事件驱动。 |
|
||||
| `IntentClarificationCard` | `ClarificationCard` | 由 `clarification.required` 事件触发。 |
|
||||
| `ParallelSeatStrip` | `ParticipantStrip` | 状态改由 `manager.dispatch` + `seat.delivery.submitted` + `manager.review.decision` 驱动。 |
|
||||
| `useStep1Intent` | 删除 | 意图理解完全服务端化。 |
|
||||
| `useStep2Orchestration` | 删除 | 编排完全服务端化;前端只消费 SSE。 |
|
||||
| `useDraftPersistence` | `use-maf-conversations` | 历史改为 conversation 列表。 |
|
||||
| `useRoundtableDrafts` | `use-maf-conversations` | 同上。 |
|
||||
| `useStep3Summary/Report/Dashboard` | 删除 | 结论由服务端生成,前端只渲染结果卡。 |
|
||||
| `useAutoScroll` | 可复用(无业务依赖) | 纯 UI 滚动逻辑。 |
|
||||
| `useDialogueSpeedMetrics` | 可复用(无业务依赖) | 纯 UI 速度指标。 |
|
||||
| `MafApprovalCard`(原型) | `ApprovalMessageCard` | 重写为 conversation 时间线内卡片。 |
|
||||
| `MafEventTimeline`(原型) | 删除 | 不再暴露 run/event 管理后台。 |
|
||||
| `MafSeatStatusPanel`(原型) | `ParticipantStrip` | 合并到右侧席位条。 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 禁止复用的旧业务 hook / API / 状态清单
|
||||
|
||||
以下模块 **严禁** 被新 MAF conversation 页面直接 import 或调用:
|
||||
|
||||
### 4.1 旧会商 API 客户端(`src/roundtable-planning/api/`)
|
||||
|
||||
| 文件 | 禁止原因 |
|
||||
|------|----------|
|
||||
| `multi-agent.ts` | 调用 `/api/multi-agent/*`,包含前端 thread map 和编排逻辑 |
|
||||
| `intent.ts` | 调用 `/api/intent/*`,前端驱动意图流 |
|
||||
| `drafts.ts` | 调用 `/api/roundtable-drafts`,旧草稿状态 |
|
||||
| `task-drafts.ts` | 调用 `/api/roundtable-task-drafts`,旧任务草稿 |
|
||||
| `roundtable-jobs.ts` | 调用 `/api/roundtable-jobs`,旧后台任务 |
|
||||
| `chains.ts` | 调用 `/api/roundtable-chains`,旧业务链 CRUD |
|
||||
| `recommend.ts` | 调用 `/api/recommend/stream`,旧推荐流 |
|
||||
| `flow-extract.ts` | 调用 `/api/flow-extract/*`,旧流程图提取 |
|
||||
| `ingest.ts` | 调用 `/api/flow-extract/ingest-eligibility` |
|
||||
| `dashboard-sessions.ts` | 调用 `/api/dashboard-sessions` |
|
||||
| `diagnostics-report.ts` | 调用 `/api/roundtable-diagnostics/report` |
|
||||
|
||||
### 4.2 旧会商业务 Hook(`src/roundtable-planning/hooks/`)
|
||||
|
||||
| 文件 | 禁止原因 |
|
||||
|------|----------|
|
||||
| `useStep1Intent.ts` | 前端驱动意图状态机 |
|
||||
| `useStep2Orchestration.ts` | 前端编排循环(thread map、派活、轮次控制) |
|
||||
| `useDraftPersistence.ts` | 旧草稿持久化逻辑 |
|
||||
| `useRoundtableDrafts.ts` | 旧草稿列表 |
|
||||
| `useStep3Summary.ts` | 前端驱动总结生成 |
|
||||
| `useStep3Report.ts` | 前端驱动看板生成 |
|
||||
| `useStep3Dashboard.ts` | 前端驱动大屏生成 |
|
||||
|
||||
### 4.3 旧会商业务状态类型
|
||||
|
||||
- `Step1Snapshot`、`Step2Snapshot`、`Step2Dialogue`、`StubAiMessage`、`ParallelSeatView`
|
||||
- `ThreadIdMap`、`ClarificationPayload`、`LeaderDispatch`、`StreamPhase`
|
||||
- `DraftRecord`、`DraftMeta`、`DraftStep1Snapshot`、`DraftStep2Snapshot`、`DraftStep3Snapshot`
|
||||
- `RoundtableJob`、`JobStatus`、`DispatchChainNode`、`StartJobPayload`
|
||||
- `IntentSummary`、`IntentFileRef`、`IntentStreamPhase`
|
||||
|
||||
### 4.4 MAF 原型中禁止继续扩展的模块
|
||||
|
||||
| 文件 | 禁止原因 |
|
||||
|------|----------|
|
||||
| `src/maf-roundtable/pages/MafSessionsPage.tsx` | 实验原型,不作为产品主界面 |
|
||||
| `src/maf-roundtable/pages/MafRunDetailPage.tsx` | 实验原型,暴露 run/session 管理 |
|
||||
| `src/maf-roundtable/hooks/use-maf-events.ts` | 原型轮询 hook |
|
||||
| `src/maf-roundtable/api/sessions.ts` | 原型 session CRUD |
|
||||
| `src/maf-roundtable/api/runs.ts` | 原型 run 管理 |
|
||||
| `src/maf-roundtable/api/events.ts` | 原型事件轮询 |
|
||||
| `src/maf-roundtable/lib/session-form.ts` | 原型表单逻辑 |
|
||||
|
||||
### 4.5 允许参考但必须重写的视觉组件
|
||||
|
||||
以下组件的 **交互模式和信息架构** 可作为参考,但新 MAF 页面必须使用独立实现(MAF view model + tdesign-react),不得直接 import:
|
||||
|
||||
- `MessageBubble.tsx`(气泡布局)
|
||||
- `MessageStepsCard.tsx`(工具步骤折叠)
|
||||
- `IntentClarificationCard.tsx`(澄清选项卡)
|
||||
- `ParallelSeatStrip.tsx`(席位头像条)
|
||||
- `ScrollToBottomButton.tsx`(回到底部按钮)
|
||||
- `Avatars.tsx`(头像 SVG)
|
||||
|
||||
### 4.6 可安全复用的无业务依赖工具
|
||||
|
||||
- `useAutoScroll.ts`(纯滚动逻辑)
|
||||
- `useDialogueSpeedMetrics.ts`(纯速度指标)
|
||||
- `src/lib/use-desensitize-subtree.ts`(DOM 脱敏,已标注多模块共享)
|
||||
- `markdown-components.tsx`(markdown 渲染配置,需确认无旧状态依赖)
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收确认
|
||||
|
||||
- [x] 旧会商 `RoundtablePlanningPage` 无代码行为变化(未修改任何 `src/roundtable-planning/` 文件)。
|
||||
- [x] `/page/maf/*` 原型已从菜单隐藏,仅保留路由供开发诊断。
|
||||
- [x] 8 类页面状态已记录,含触发条件、表现、关键组件和数据流。
|
||||
- [x] 旧组件到新 MAF view-model 的映射表已完成。
|
||||
- [x] 禁止复用的 hook/API/状态清单已明确。
|
||||
- [x] 任何人可根据本文档判断新版是否「像原页面」。
|
||||
@@ -1,645 +0,0 @@
|
||||
# MAF 多智能体会商升级版实施计划(V2:保留原会商体验,替换底层编排)
|
||||
|
||||
> 文档状态:**V2 重写,待按本计划重新实施**
|
||||
> 更新时间:2026-08-02
|
||||
> 本轮决策:原 `/page/maf/*` 的「会话列表 + run 详情 + 前端轮询」原型不再作为目标产品继续扩展。
|
||||
> 产品目标:新 MAF 会商是**原多智能体会商页面的底层升级版**,不是另造一套会商产品。
|
||||
|
||||
---
|
||||
|
||||
## 1. 本次重新确认的产品目标
|
||||
|
||||
用户认可的是现有多智能体会商的视觉语言和交互链路:左侧历史会话、中心对话流、输入框发问、意图理解、总控派活、席位发言、过程卡片、澄清与审批、最终结论。
|
||||
|
||||
本次升级只替换以下“看不见的发动机”:
|
||||
|
||||
- 旧的前端驱动编排、多个前端可见线程和旧会商作业,替换为 MAF Workflow + DeerFlow Agent Adapter;
|
||||
- 浏览器定时轮询,替换为一条可恢复的 SSE 对话事件流;
|
||||
- 用户手动创建/管理多个智能体线程,收敛为服务端按需创建和持久化的内部执行线程;
|
||||
- 保留现有工具、技能、MCP、沙箱、WriteGuard、审批和审计能力,并让它们在原有会商体验中自然出现。
|
||||
|
||||
**不能接受的结果:** 新页面没有对话列表、没有输入框、让用户先创建 session/run、需要用户选择或理解多个内部线程、靠多个定时 GET 拼出实时效果,或把审批/流程图做成脱离聊天主链路的独立后台页。
|
||||
|
||||
### 1.1 必须保留的用户体验
|
||||
|
||||
| 原会商能力 | 升级版要求 |
|
||||
|---|---|
|
||||
| 左侧会商历史、新建会商 | 保留。一个条目就是一个用户可见会商,不暴露 MAF session/run/thread。 |
|
||||
| 中央连续对话流 | 保留。用户消息、总控消息、席位发言、工具进度、澄清、审批、最终报告按时间顺序呈现。 |
|
||||
| 底部对话输入框 | 保留。用户可在首轮发起任务、在澄清时回答、在会商结束后追问/继续会商。 |
|
||||
| 意图理解 | 保留并服务端化。先识别“可直接会商 / 需要澄清 / 只回答问题 / 需要工具或写入”。 |
|
||||
| 总控智能体派活 | 保留。总控是唯一能选择席位、分派任务、收束轮次和生成结论的主体。 |
|
||||
| 业务链、席位与过程可视化 | 保留其信息架构和交互,不复用旧业务状态;数据改由 MAF 事件驱动。 |
|
||||
| 澄清、取消、恢复、审批 | 保留,并成为对话流中的卡片,而不是跳转到 run 管理页。 |
|
||||
|
||||
### 1.2 明确不保留的实现方式
|
||||
|
||||
- 不在浏览器端 `setInterval` 轮询 run、events、actions、approvals;
|
||||
- 不让浏览器创建或维护 seat thread、manager thread、thread map;
|
||||
- 不让用户先填写“经理、席位、模式、最大轮数”才能开始一次正常会商;
|
||||
- 不把 `MafSessionsPage` / `MafRunDetailPage` 当作新产品主界面继续堆功能;
|
||||
- 不调用旧的 `/api/roundtable-*`、`/api/multi-agent/*`、`roundtable_jobs`、旧草稿或旧会商状态;
|
||||
- 不修改旧 `RoundtablePlanningPage` 的业务状态机、历史数据和对外行为。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体架构与边界
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[用户:会商对话页] -->|POST 新建/发送消息| API[MAF Conversation API]
|
||||
API --> C[(MAF Conversation / Message)]
|
||||
API --> D[MAF Dispatcher]
|
||||
D --> W[MAF Workflow]
|
||||
W --> M[总控:意图、派活、收束]
|
||||
M --> S[服务端按需创建席位执行线程]
|
||||
S --> A[DeerFlow Agent Adapter]
|
||||
A --> T[工具 / 技能 / MCP / 沙箱]
|
||||
T --> G[WriteGuard + 审批]
|
||||
W --> E[(持久化会商事件)]
|
||||
E --> H[SSE Hub:重放 + 实时推送]
|
||||
H -->|一条 SSE 流| U
|
||||
```
|
||||
|
||||
### 2.1 前后端职责
|
||||
|
||||
| 层 | 职责 | 禁止事项 |
|
||||
|---|---|---|
|
||||
| 前端 | 呈现会商、发送用户消息、消费 SSE、执行用户明确点击的取消/审批/重试 | 编排席位、决定下一位发言者、创建内部线程、轮询事件。 |
|
||||
| MAF Conversation Service | 会话聚合、消息持久化、运行创建、SSE 重放、所有权校验 | 让浏览器传入 user_id、工具能力快照、目标键或内部幂等键。 |
|
||||
| MAF Workflow | 意图判断、总控派活、席位协作、交叉审查、结论、恢复 | 把模型输出直接当作可写外部系统的授权。 |
|
||||
| DeerFlow Adapter | 为总控/席位提供既有模型、工具、技能、MCP、沙箱和用户上下文 | 从 `packages/harness/deerflow/**` 导入 `app.*`。 |
|
||||
| WriteGuard | 风险识别、审批、目标租约、一次执行、审计 | 将密钥、target_key、idempotency_key 回传给浏览器或模型。 |
|
||||
|
||||
### 2.2 新旧隔离规则
|
||||
|
||||
1. 旧会商继续运行,旧路由、旧 API、旧数据表、旧任务深链全部不变。
|
||||
2. 新页面独立使用 `/api/maf-roundtable/*` 和 `maf_roundtable_*` 表;只允许参考旧页面的布局、文案、交互和通用视觉规范。
|
||||
3. 旧 `roundtable-planning/**` 中的业务 hook、草稿、Job、SSE 消费器和状态类型不能被新页面直接 import。
|
||||
4. 若需要复用旧组件的视觉效果,必须复制/迁移为 `maf-roundtable/legacy-compatible/**` 中的新组件,替换为 MAF view model;或抽到无业务依赖的公共展示组件。旧页面不得因此改变行为。
|
||||
5. 新旧页面都只能使用仓库本地依赖;UI 基础组件统一采用 **`tdesign-react`**,不接远程 UI、CDN 或外部运行时。
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户可见的新版会商流程
|
||||
|
||||
### 3.1 首次进入
|
||||
|
||||
1. 用户点击独立菜单“MAF 会商(升级版)”,进入与旧会商相同层级的页面壳。
|
||||
2. 左侧显示自己的 MAF 会商历史和“新建会商”。新建只创建一个**用户可见会商**,不显示 session/run/thread 表单。
|
||||
3. 中心区域显示欢迎语、业务链/任务上下文入口和对话输入框。
|
||||
4. 用户输入任务后,前端立即插入本地的用户气泡,调用一次 `POST /messages`,然后消费同一会商的 SSE 流。
|
||||
|
||||
### 3.2 一次会商轮次
|
||||
|
||||
1. SSE 推送“正在理解任务”事件;页面以旧会商的过程卡/总控气泡表现。
|
||||
2. 若信息不足,推送 `clarification.required`,页面显示可回复的澄清卡;用户回答后仍通过输入框或卡片提交。
|
||||
3. 若可以执行,总控输出“派活计划”;页面以旧的席位条、派活卡和消息气泡展示。
|
||||
4. 每个席位的可见发言、工具进度、技能过程、交叉审查和总控收束都写入同一条时间线。
|
||||
5. 出现外部写操作时,SSE 推送审批卡。用户批准/拒绝后,后端恢复工作流,SSE 继续推送;前端不手工轮询 run 状态。
|
||||
6. 最终结论、事实账本、执行动作和报告作为对话流中的结果卡呈现;用户可以继续在同一会商输入追问。
|
||||
|
||||
### 3.3 “一个会商”与内部线程的关系
|
||||
|
||||
- 用户只认识 `conversation_id`;左侧历史、URL 和前端状态都以它为主键。
|
||||
- 一个 conversation 可以有多轮用户消息,每轮消息产生一个内部 `maf_run`。
|
||||
- `maf_session`、manager/seat thread、seat run 都是服务端内部执行身份。服务端使用既有 `ExecutionPrincipal` 绑定当前用户,并在真正需要某席位执行时懒创建/复用线程。
|
||||
- 浏览器只收到经过脱敏的消息和事件,不会收到内部 thread id、能力快照、目标键、幂等键、密钥或原始工具结果。
|
||||
|
||||
### 3.4 总控质量闸门:派活不是“发出即完成”
|
||||
|
||||
当前旧会商的问题不是没有派活,而是席位只说一两句话、格式和证据都不合格时,总控仍把它当作完成并直接进入下一步。新版 MAF 必须把总控从“消息转发者”升级为**每项席位交付的验收责任人**。
|
||||
|
||||
每一个由总控发布的席位任务都必须有服务端持久化的 `task_contract`:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `objective` | 本席位要回答的具体问题,不能只写泛泛角色描述。 |
|
||||
| `expected_deliverable` | 交付类型和结构,例如“风险清单 + 依据 + 优先级 + 建议”。 |
|
||||
| `acceptance_criteria` | 由总控给出的可验收标准:覆盖范围、最少关键点、证据/工具依据、格式与约束。 |
|
||||
| `required` | 是否为本阶段必需交付;必需项不能被静默跳过。 |
|
||||
| `max_rework_attempts` | 同一席位最多返工次数,默认 **2**(初稿之外最多两次返工),服务端配置且写入快照。 |
|
||||
| `fallback_policy` | 到达返工上限后的策略:改派、请求用户澄清,或带缺口受控推进。 |
|
||||
|
||||
#### 3.4.1 席位交付与总控验收状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> assigned
|
||||
assigned --> delivering
|
||||
delivering --> submitted
|
||||
submitted --> manager_review
|
||||
manager_review --> accepted: 达到合同标准
|
||||
manager_review --> rework: 不合格且未到返工上限
|
||||
rework --> delivering: 带明确退回意见
|
||||
manager_review --> reassigned: 达到上限且可改派
|
||||
reassigned --> delivering
|
||||
manager_review --> exhausted: 达到上限且无合适改派
|
||||
exhausted --> controlled_gap: 记录缺口、风险与原因
|
||||
accepted --> [*]
|
||||
controlled_gap --> [*]
|
||||
```
|
||||
|
||||
验收规则如下:
|
||||
|
||||
1. 席位先提交结构化 `delivery`,至少包含结论、依据/证据、未解决项、自检结果;不能只输出“已完成”或一两句空泛结论。
|
||||
2. 进入 `manager_review` 前先执行确定性检查:空内容、最小有效长度、必须章节、必填字段、所需证据/工具引用、重复文本、错误 schema。确定性不通过时不允许直接 accepted。
|
||||
3. 总控随后根据 `task_contract` 做结构化审查,决策只能是 `accept`、`return_for_rework`、`reassign`、`ask_user`、`controlled_gap`,并必须写明 `reason` 和针对性修改要求。
|
||||
4. **总控的模型自评不是验收证据。** 席位自评分只作为参考;验收由确定性规则 + 总控审查 + 必要时交叉审查共同决定。
|
||||
5. 对同一席位,默认最多两次返工。每次打回都必须把“缺什么、怎样补、哪些原结论不可用”带入下一次任务上下文。
|
||||
6. 到达上限后绝不把不合格结果伪装成合格:总控应优先改派给一个合适备用席位(默认最多一次改派);仍无法满足时生成 `controlled_gap`,明确标记未覆盖问题、风险和对最终结论的影响,再受控进入下一阶段。
|
||||
7. 一个阶段的所有 `required` 任务必须处于 `accepted` 或 `controlled_gap` 等明确终态,阶段闸门才可通过。总控不能因为“轮数到了”或“已有部分回答”跳过未决必需项。
|
||||
8. 最终报告不得将 `controlled_gap` 的内容写成已验证事实;它必须在结论中的风险/待补信息部分可追溯显示。
|
||||
|
||||
这既满足“打回几次后不要死循环”的要求,也避免“超过次数就把垃圾答案强行推进”的错误行为。
|
||||
|
||||
#### 3.4.2 对话页必须可见的质量反馈
|
||||
|
||||
- 总控派活卡展示任务目标、交付要求和当前第几次尝试;
|
||||
- 席位提交后显示“总控审核中”,而不是立即标为完成;
|
||||
- 打回时以总控消息卡显示具体退回理由和补充要求;
|
||||
- 改派时显示原席位未通过和新席位承接范围,但不羞辱性展示内部评分;
|
||||
- 受控降级时显示“存在未解决缺口”,并在最终结果卡中保留风险提示;
|
||||
- 右侧席位条区分“执行中、待返工、已验收、已改派、存在缺口”,不能只显示“发过言”。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 与 SSE 契约(必须先定,前端不得猜测)
|
||||
|
||||
### 4.1 Conversation API
|
||||
|
||||
以下接口全部位于 `/api/maf-roundtable`,身份只从服务端鉴权上下文读取。
|
||||
|
||||
| 接口 | 用途 | 前端调用时机 |
|
||||
|---|---|---|
|
||||
| `GET /conversations` | 左侧历史列表(分页) | 首次进入、用户显式刷新、创建/删除后失效刷新;**无定时轮询**。 |
|
||||
| `POST /conversations` | 创建空白用户会商 | 点击“新建会商”。只接收 title、任务上下文等安全字段。 |
|
||||
| `GET /conversations/{id}` | 获取会商快照、权限和当前状态 | 进入会商或浏览器刷新时。 |
|
||||
| `GET /conversations/{id}/messages` | 获取历史消息分页 | 进入会商、向上滚动加载历史。 |
|
||||
| `POST /conversations/{id}/messages` | 原子写入用户消息并启动 MAF run | 用户点击发送或提交澄清。返回 `message_id`、`run_id`、`conversation_id`。 |
|
||||
| `POST /conversations/{id}/cancel` | 取消当前活跃 run | 用户显式点击取消。 |
|
||||
| `POST /approvals/{id}/decision` | 批准/拒绝写入动作 | 审批卡点击;成功后由后端恢复 workflow。 |
|
||||
| `POST /conversations/{id}/recover` | 显式恢复异常中断的活跃会商 | 仅在后端判定可恢复时展示。 |
|
||||
|
||||
不再让页面以“创建 session → 创建 run → GET events 循环”的方式驱动会商。原有低层 session/run 接口可以作为内部诊断 API 暂时保留,但从用户页面和导航移除。
|
||||
|
||||
### 4.2 SSE:唯一的实时通道
|
||||
|
||||
```text
|
||||
GET /api/maf-roundtable/conversations/{conversation_id}/stream?after_seq={last_seen_seq}
|
||||
Accept: text/event-stream
|
||||
Authorization: 与现有 fetch 客户端一致
|
||||
```
|
||||
|
||||
- 前端在进入一个 conversation 后只建立**一条** fetch-reader SSE 连接;切换会商、卸载页面、权限失效时 Abort。
|
||||
- 浏览器不能用定时 GET 代替 SSE。连接断开后采用指数退避重连,并携带最后已确认的 `after_seq`;不产生全量重复气泡。
|
||||
- `after_seq` 是 conversation 维度、服务端单调递增的序号,不能复用每个 run 独立的 `seq`。
|
||||
- 首次连接先由 `GET messages` 加载历史,再从该历史的最大 `seq` 订阅 SSE;服务端也必须支持重放,避免“历史请求和订阅之间”丢事件。
|
||||
- SSE 使用 `fetch` + `ReadableStream` 解析,以复用项目鉴权头;响应必须带 `Content-Type: text/event-stream`、`Cache-Control: no-cache`、`X-Accel-Buffering: no`,并每 15 秒发送心跳。
|
||||
- 服务端负责等待和推送。单 worker 可用进程内 conversation event hub;生产多 worker 部署前必须接入 Redis Pub/Sub 或等价分布式广播,不能退回浏览器轮询。
|
||||
|
||||
### 4.3 SSE 事件信封
|
||||
|
||||
所有事件统一为:
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": 42,
|
||||
"event_id": "evt_...",
|
||||
"conversation_id": "conv_...",
|
||||
"run_id": "run_...",
|
||||
"type": "agent.message.delta",
|
||||
"occurred_at": "2026-08-02T12:00:00Z",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
允许的首批 `type`:
|
||||
|
||||
| 类型 | 对话页表现 |
|
||||
|---|---|
|
||||
| `conversation.snapshot` | 首帧状态同步,不重复插入消息。 |
|
||||
| `user.message.created` | 刷新/多标签同步时补齐用户气泡。 |
|
||||
| `manager.intent` | 总控“正在理解/已理解”的过程卡。 |
|
||||
| `clarification.required` / `clarification.resolved` | 澄清卡及其回答状态。 |
|
||||
| `manager.dispatch` | 总控派活卡、席位条状态更新。 |
|
||||
| `seat.delivery.submitted` | 席位提交交付,进入总控审核而非直接完成。 |
|
||||
| `manager.review.decision` | 展示验收、打回、改派或受控降级及其安全摘要。 |
|
||||
| `seat.rework.assigned` / `seat.reassigned` | 更新第几次返工、原/新席位和新的任务要求。 |
|
||||
| `stage.quality_gate` | 阶段质量闸门通过、阻塞或带缺口推进。 |
|
||||
| `agent.message.start` / `delta` / `complete` | 席位或总控流式气泡。 |
|
||||
| `tool.started` / `tool.progress` / `tool.completed` | 现有步骤卡/工具过程,不显示敏感参数。 |
|
||||
| `approval.required` / `approval.decided` | 嵌入对话流的审批卡。 |
|
||||
| `fact.confirmed` / `review.completed` | 事实/交叉审查摘要卡。 |
|
||||
| `run.status` / `run.error` / `run.completed` | 顶部状态、失败重试与最终结果。 |
|
||||
|
||||
事件 `data` 必须先经过现有 MAF 安全规范化和二次展示脱敏;前端不负责猜测工具风险或修复敏感数据。
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端改造计划
|
||||
|
||||
### MAF-R0:冻结错误原型并建立旧会商基线
|
||||
|
||||
**难度:D2(中低)|负责人:前端/测试各 1 人|前置:无**
|
||||
|
||||
- 将现有 `/page/maf/*` 标记为“实验原型”,从正式菜单移除;不得在其上继续新增业务功能。
|
||||
- 记录旧会商的页面状态:空会商、发送后、意图澄清、总控派活、席位流式发言、审批、完成、失败恢复、历史切换。
|
||||
- 为每种状态保存可复现的测试数据与截图基线;截图只作为视觉验收参考,不作为数据契约。
|
||||
- 输出旧组件到新 MAF view-model 的映射清单,禁止直接搬旧 hook。
|
||||
|
||||
**验收:** 旧会商无行为回归;团队对“复制体验、替换编排”有书面映射表。
|
||||
|
||||
### MAF-R1:Conversation 聚合与消息持久化
|
||||
|
||||
**难度:D5(高)|负责人:后端资深 1 人|前置:R0**
|
||||
|
||||
新增/迁移 MAF 专用持久化模型:
|
||||
|
||||
- `maf_roundtable_conversations`:用户可见会商,至少包含 id、owner_user_id、title、task_context、安全状态、active_run_id、created_at、updated_at;
|
||||
- `maf_roundtable_messages`:用户消息、总控/席位消息、澄清/审批/结果卡的稳定历史表示;
|
||||
- `maf_roundtable_conversation_events`:conversation 维度严格递增的 `seq`、event_id、run_id、type、安全 data、created_at;
|
||||
- 现有 `maf_roundtable_sessions/runs/events/seat_runs/actions/approvals` 保留为底层执行与审计记录;conversation 与内部 session/run 显式关联。
|
||||
|
||||
要求:
|
||||
|
||||
1. 发送用户消息、创建/占用 run、写入首个用户消息事件必须在清晰的事务边界内完成;失败不留“消息已显示但未运行”的孤儿状态。
|
||||
2. 消息历史必须可在刷新后重建对话;不能只依赖内存或 SSE 临时帧。
|
||||
3. 所有读写继续 owner-only;管理员诊断只读不扩大写权限。
|
||||
4. 新迁移必须 SQLite/PostgreSQL/MySQL 可重复执行;旧 MAF 原型数据可保留,不强制伪造为新对话历史。
|
||||
|
||||
**验收:** 会话创建、连续发消息、刷新恢复、并发发送冲突、跨用户隔离、迁移幂等都有 API 和存储测试。
|
||||
|
||||
### MAF-R2:服务端 SSE Hub 与断线续传
|
||||
|
||||
**难度:D5(高)|负责人:后端流式/并发经验者 1 人|前置:R1**
|
||||
|
||||
- 新增 conversation stream 路由和 `ConversationEventHub`;Workflow/Dispatcher 的规范化事件落库后再发布,顺序是“先持久化、后通知”。
|
||||
- SSE 连接先按 `after_seq` 重放,再订阅实时事件;重放和订阅切换期间必须有序、不丢帧、不重复。
|
||||
- workflow 终态、审批等待、审批恢复、取消、失败重试都必须通过同一流通知。
|
||||
- 不允许每个 SSE 客户端创建数据库轮询循环。服务端可以阻塞等待 hub 通知,事件重放只在连接/重连时发生。
|
||||
- 写出单 worker 与多 worker 的实现边界;若部署配置多 worker,未接入分布式广播时健康检查必须明确告警并禁止该模式上线。
|
||||
|
||||
**验收:** 测试覆盖慢客户端、断线重连、`after_seq` 重放、同时打开两个标签、切换会商、取消、审批后恢复、进程内 hub 异常和终态自动关流。
|
||||
|
||||
### MAF-R3:总控意图理解与派活语义对齐
|
||||
|
||||
**难度:D5(高)|负责人:MAF/Agent 负责人 1 人|前置:R1,可与 R2 并行**
|
||||
|
||||
在现有 MAF Workflow 上增加用户对话入口状态,而不是让前端预先填 manager/seat/run:
|
||||
|
||||
1. 总控首先进行意图判断:普通问答、需要澄清、单席位任务、并行专家会商、链式业务会商、需要工具/写入。
|
||||
2. 总控产出受 schema 校验的派活计划;只有服务端可将计划转成实际席位执行。
|
||||
3. 每个计划项必须生成并冻结 `task_contract`(目标、交付结构、验收标准、必需性、返工上限和降级策略),席位的发言不等于交付完成。
|
||||
4. 席位完成后先做确定性完整性检查,再由总控做结构化验收;总控可接受、打回同一席位、改派、请求用户补充或记录受控缺口。所有决策、次数和理由持久化到 checkpoint 与审计事件。
|
||||
5. 默认初稿后最多两次同席位返工;到达上限优先改派一次,仍失败才以带风险标记的 `controlled_gap` 结束。严禁自动把不合格交付推进为合格事实。
|
||||
6. 阶段迁移需经过质量闸门:所有必需任务均有 `accepted` 或受控缺口终态;最终总结只能消费已验收事实,缺口必须显式传入风险章节。
|
||||
7. 席位集合来自业务链、可信能力快照和上下文;前端可呈现“建议/已参与席位”,但不能提交任意工具权限或 thread id。
|
||||
8. 总控每轮应吸收已确认事实、验收结果、退回原因和用户追问;弱模型无效派活、重复派活、无收敛时走确定性纠偏/终止策略。
|
||||
9. 已有 structured/group_chat、事实账本、交叉审查、审批 checkpoint、执行阶段身份均继续复用;只重构其输入输出为 conversation event。
|
||||
|
||||
**验收:** 用同一任务对照旧会商的意图澄清、总控派活、席位协作、打回/改派和收束结论;弱模型的短答、空答、错误 schema、缺证据、重复派活、非法席位、超轮次、返工上限和受控降级都有确定性保护测试。
|
||||
|
||||
#### R3 当前实施记录(2026-08-03)
|
||||
|
||||
后端已先完成 R3 的服务端核心骨架,作为 R1/R2 Conversation API/SSE 的后续:
|
||||
|
||||
- `app/maf_roundtable/dispatch_contracts.py` 提供确定性意图分类、服务端业务路线解析、schema 校验的 `MafDispatchPlan` 和弱模型防护;请求、模型输出都不能提供 agent id、thread id、tool metadata 或能力快照。
|
||||
- `MafRoundtableConfig` 增加 `default_manager_agent_id`、`default_seat_agent_ids`、`business_routes` 和 `max_seats_per_run`。仓库配置保持空路线;上线前必须由部署配置填入实际 agent catalog 中的精确 id,未知业务路线失败关闭。
|
||||
- 首条消息在同一事务内创建内部 session、服务端选定的 manager/seat participants 和冻结能力快照;澄清意图进入 `waiting_input`,不创建 run,用户补充信息后才允许重新 claim。
|
||||
- Conversation service 已接入现有 `MafRunDispatcher`/`WorkflowRunner`,持久化 `manager.intent`、`manager.dispatch` 和 run 状态桥接事件;现有结构化工作流的席位集合仍只来自服务端 participants,manager 文本不能扩席或改派非法席位。
|
||||
- V2 `POST /conversations/{id}/messages` 只返回 `message_id`、`conversation_id`、`seq`;`run_id` 仍是持久层/服务端内部关联字段,不返回给浏览器。
|
||||
- 新增 `tests/test_maf_phase3_conversation_dispatch.py`,覆盖未知业务路线、默认路线、澄清、完成状态桥接、重复/越权派发计划等确定性用例。R3 尚未整体验收完成:仍需完成新页面、完整真实 MAF Workflow E2E、质量门禁和与旧会商体验的浏览器对照验收。
|
||||
|
||||
### MAF-R4:将审批和外部写入嵌回对话主链路
|
||||
|
||||
**难度:D4(较高)|负责人:后端安全 + 前端各 1 人|前置:R2、R3**
|
||||
|
||||
- 保留 Phase 5 的 WriteGuard、能力快照、HMAC 目标键、租约、加密参数、一次执行和审计;不得为了 SSE 改弱任何安全边界。
|
||||
- `approval.required` 只携带白名单展示字段和安全摘要。审批决定后由后端自动续跑,前端只等待同一 SSE 流中的新事件。
|
||||
- 拒绝、过期、执行失败、unknown 均形成清晰对话卡和可恢复状态;不得把“未知结果”伪装成成功。
|
||||
- 行为审计可在最终结果区展开查看,但不能替代对话中的关键用户反馈。
|
||||
|
||||
**验收:** HTTP → MAF → ToolNode/outbox → 审批 → SSE → 前端卡片 的端到端测试;确保 target_key、idempotency_key、internal_canary、密钥和原始工具结果从不进入消息/API/SSE。
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端改造计划:复刻体验,替换状态源
|
||||
|
||||
### MAF-R5:新会商页面壳与历史列表
|
||||
|
||||
**难度:D4(较高)|负责人:熟悉旧会商的前端 1 人|前置:R0、R1**
|
||||
|
||||
新建 `src/maf-roundtable/conversation/**`,建议结构:
|
||||
|
||||
```text
|
||||
maf-roundtable/conversation/
|
||||
pages/MafRoundtableConversationPage.tsx
|
||||
components/
|
||||
ConversationSidebar.tsx
|
||||
ConversationTimeline.tsx
|
||||
ConversationComposer.tsx
|
||||
ManagerDispatchCard.tsx
|
||||
SeatMessageBubble.tsx
|
||||
ToolProgressCard.tsx
|
||||
ClarificationCard.tsx
|
||||
ApprovalMessageCard.tsx
|
||||
FinalResultCard.tsx
|
||||
ParticipantStrip.tsx
|
||||
hooks/
|
||||
use-maf-conversations.ts
|
||||
use-maf-conversation-stream.ts
|
||||
use-maf-conversation-reducer.ts
|
||||
api/conversations.ts
|
||||
lib/event-reducer.ts
|
||||
styles/maf-roundtable-legacy-compatible.css
|
||||
```
|
||||
|
||||
页面要求:
|
||||
|
||||
- 左侧 `ConversationSidebar` 的层级、历史列表、新建、激活态、删除/重命名交互对齐旧会商;
|
||||
- 中间 `ConversationTimeline` 使用连续消息流,保留旧会商的用户气泡、总控气泡、席位气泡和时间锚点;
|
||||
- `ConversationComposer` 常驻底部;发送、停止、澄清回答、追问都在该入口完成;
|
||||
- 右侧仅保留旧体验中真正有用的参与席位、当前阶段和业务链摘要,不再摆放 run/session 管理后台;
|
||||
- 使用本地 `tdesign-react` 组件实现按钮、输入、Dialog、Tag、Tabs 等基础交互,同时通过 scoped CSS 对齐旧视觉;
|
||||
- 视觉相似不等于 import 旧业务组件。旧组件仅作参考,数据类型、hook 和 API 必须是 MAF 专用实现。
|
||||
|
||||
**验收:** 不连接后端时,使用固定 MAF 事件样本可完整演示旧会商同等的 8 种关键状态;没有 session/run/thread 表单。
|
||||
|
||||
### MAF-R6:SSE 客户端、事件 reducer 与刷新恢复
|
||||
|
||||
**难度:D5(高)|负责人:前端流式经验者 1 人|前置:R2、R5**
|
||||
|
||||
- `use-maf-conversation-stream` 只维护一个连接、一个 cursor、一个 AbortController;不得含 `setInterval`、`refetchInterval` 或轮询 fallback。
|
||||
- reducer 以 `(conversation_id, seq)` 去重并排序,将原始事件转为稳定 view model;流式 delta 必须按 `message_id` 合并,不能用数组下标。
|
||||
- 切换会商时先 abort 旧流并增加 generation;旧 SSE 帧永远不能污染新会商。
|
||||
- 刷新时先加载消息历史,再以最后 seq 订阅;网络断开显示“正在重连”,重连成功不重复气泡。
|
||||
- 只有历史列表等非实时资源可在用户主动操作后调用 GET;不能靠后台刷新伪装实时性。
|
||||
|
||||
**验收:** 模拟 1 万条历史、跨页加载、慢旧流切换、新流先到、断线重连、重复帧、乱序帧、终态关流和多标签同步;所有案例必须组件级测试。
|
||||
|
||||
### MAF-R7:意图/派活/审批卡的旧体验兼容
|
||||
|
||||
**难度:D4(较高)|负责人:前端业务负责人 1 人|前置:R3、R4、R6**
|
||||
|
||||
- 用旧 `IntentClarificationCard`、`MessageStepsCard`、`ParallelSeatStrip`、`MessageBubble` 的交互作为视觉和信息架构参照,重新实现 MAF 版本。
|
||||
- 总控派活计划必须先显示“将由谁做什么”,席位开始/结束、工具过程和交叉审查要回到原有对话叙事中。
|
||||
- 席位“发言结束”只能显示为“已提交,待总控审核”;只有收到 `manager.review.decision=accept` 才能显示“已验收”。打回次数、改派和缺口必须清楚可见。
|
||||
- 审批卡需要同步防双击锁、二次脱敏、拒绝/通过后的“等待工作流继续”状态;不可另外跳转到 action 审计页。
|
||||
- 最终结论卡要区分“建议动作”“审批中动作”“已执行动作”“失败/未知动作”。
|
||||
- 继续追问时,页面不新建多个用户可见线程;后端根据 conversation 上下文决定是否创建新 run 或继续恢复。
|
||||
|
||||
**验收:** 产品人员可在不看技术术语的情况下完成“发起会商 → 澄清 → 观察派活 → 审批 → 查看结论 → 追问”全链路。
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试、上线与回滚
|
||||
|
||||
### 7.1 必须新增的自动化测试
|
||||
|
||||
| 类别 | 核心用例 |
|
||||
|---|---|
|
||||
| 后端事务 | 发消息/创建 run 原子性、并发发送、跨用户隔离、迁移幂等。 |
|
||||
| SSE 服务端 | 重放、顺序、心跳、取消、断线、审批恢复、多订阅者、终态关流。 |
|
||||
| Workflow | 意图澄清、总控派活、弱模型格式错误、重复派活、最大轮次、恢复。 |
|
||||
| 质量闸门 | 短答/空答、缺少必填章节、无证据、总控打回、两次返工上限、一次改派、受控缺口、阶段阻塞和最终报告不把缺口伪装成事实。 |
|
||||
| 安全 | SSE/API/历史消息不泄露敏感键和值;审批和一次写入仍保持现有 Phase 5 保证。 |
|
||||
| 前端 reducer | delta 合并、去重、乱序、切换代次隔离、重连 cursor。 |
|
||||
| 前端组件 | 输入发送、澄清、派活、审批锁、取消、失败恢复、历史切换、无轮询断言。 |
|
||||
| E2E | 从用户输入到真实 MAF Workflow、ToolNode/outbox、审批、最终结果的 SSE 全链路。 |
|
||||
| 回归 | 旧 `roundtable-planning`、旧 DeerFlow、AI 写作以及既有 MAF Phase 1–5 用例全部通过。 |
|
||||
|
||||
### 7.2 人工视觉验收
|
||||
|
||||
在同一浏览器尺寸下对照旧会商,至少核对:
|
||||
|
||||
1. 空白页和新建会商;
|
||||
2. 左侧历史/当前会商切换;
|
||||
3. 用户输入、流式总控消息、席位消息;
|
||||
4. 意图澄清和总控派活;
|
||||
5. 工具过程、审批、取消、失败恢复;
|
||||
6. 最终结论和连续追问;
|
||||
7. 窄屏滚动、长内容、断线重连和页面刷新。
|
||||
|
||||
### 7.3 灰度与回滚
|
||||
|
||||
- 新入口使用独立 feature flag,例如 `maf_roundtable.conversation_ui_enabled`;旧会商不受该开关影响。
|
||||
- 先仅向内部账号开放,记录连接数、重连率、事件延迟、工作流失败率、审批耗时和前端异常。
|
||||
- 出现问题时关闭新入口即可;不迁移或删除旧会商数据,也不需要回滚旧页面。
|
||||
- 当前错误原型可保留为仅开发者诊断路由,或在新版稳定后删除;它不应继续出现在用户菜单。
|
||||
|
||||
---
|
||||
|
||||
## 8. 分阶段任务分配、难度与交付顺序
|
||||
|
||||
本节是分工的唯一依据。难度定义:D1=很低(局部文案/样式),D2=低(明确边界的小模块),D3=中(跨 2–3 个模块),D4=较高(跨前后端或需回归),D5=高(并发、状态机、安全或 Agent 行为,必须由有经验者负责)。
|
||||
|
||||
### 阶段 0:冻结错误原型,提取旧会商交互基线(R0)
|
||||
|
||||
**难度:D2|建议人员:1 名熟悉旧会商的前端 + 1 名测试|依赖:无**
|
||||
|
||||
具体要做:
|
||||
|
||||
1. 将现有 `/page/maf/*` 的 session/run 详情原型从正式菜单隐藏,标注为开发诊断原型,停止继续加功能。
|
||||
2. 逐一记录旧会商的 8 类页面状态:空白/新建、历史切换、用户提问、意图澄清、总控派活、席位发言、审批、完成/失败恢复。
|
||||
3. 给每种状态保留可复现 mock 数据、页面截图和操作步骤;梳理旧组件到新 MAF view-model 的映射。
|
||||
4. 明确哪些只能参考视觉和交互,哪些旧业务 hook/API/状态禁止复用。
|
||||
|
||||
交付物:旧会商交互地图、截图基线、组件映射表、被隐藏的原型入口说明。
|
||||
|
||||
验收:旧会商没有代码行为变化;任何人可以根据基线判断新版是否“像原页面”。
|
||||
|
||||
### 阶段 1:建立用户可见 Conversation 与消息历史(R1)
|
||||
|
||||
**难度:D5|建议人员:1 名资深后端|依赖:阶段 0**
|
||||
|
||||
具体要做:
|
||||
|
||||
1. 新增 `maf_roundtable_conversations`、`maf_roundtable_messages`、`maf_roundtable_conversation_events`,设计 owner、标题、消息、conversation 全局序号、活跃 run 和安全状态字段。
|
||||
2. 建立 conversation 与现有 MAF session/run/seat-run 的内部关联;用户只得到 conversation id,不能看到内部 thread id。
|
||||
3. 实现会商历史列表、创建空白会商、读取会商快照、读取消息分页、发送用户消息五类 API。
|
||||
4. 发送消息时,用户消息持久化、run 创建/占用和首个事件必须有明确事务边界;并发发送、重复请求、失败回滚都要处理。
|
||||
5. 完成 SQLite/PostgreSQL/MySQL 迁移与 owner-only 权限测试。
|
||||
|
||||
交付物:迁移脚本、repository/service/router、API schema、后端事务与隔离测试。
|
||||
|
||||
验收:刷新后能从消息历史恢复完整会商;跨用户不可读写;失败不会留下“已显示但没有实际运行”的孤儿消息。
|
||||
|
||||
### 阶段 2:服务端 SSE 对话流、事件 Hub 与断线续传(R2)
|
||||
|
||||
**难度:D5|建议人员:1 名熟悉 FastAPI 异步流与并发的后端|依赖:阶段 1**
|
||||
|
||||
具体要做:
|
||||
|
||||
1. 实现 `GET /conversations/{id}/stream?after_seq=`,按 conversation 全局 seq 先重放、后订阅实时事件。
|
||||
2. 新建 `ConversationEventHub`;所有 workflow 事件先安全落库,再按顺序广播。禁止 SSE 客户端通过定时查库等待更新。
|
||||
3. SSE 实现心跳、连接取消、权限校验、终态关流、异常帧和断线后的 cursor 续传。
|
||||
4. 明确多 worker 部署方案:单 worker 可进程内广播;多 worker 必须 Redis Pub/Sub 或等价方案,未满足时不能灰度上线。
|
||||
5. 把 run 状态、总控派活、席位消息 delta、工具过程、审批、质量审核、最终结论全部映射为统一事件信封。
|
||||
|
||||
交付物:SSE router/hub、事件发布适配器、集成测试、部署说明。
|
||||
|
||||
验收:慢连接、断线重连、乱序/重复防护、多标签订阅、审批后续跑、取消和终态均不丢事件;前端不需要轮询任何实时资源。
|
||||
|
||||
### 阶段 3:总控意图理解、业务链选择与服务端派活(R3)
|
||||
|
||||
**难度:D5|建议人员:1 名 MAF/Agent 工程师|依赖:阶段 1;可与阶段 2 并行**
|
||||
|
||||
具体要做:
|
||||
|
||||
1. 将旧会商的意图理解能力迁到 MAF 总控入口:识别直接问答、需澄清、单席位、并行会商、链式业务会商、工具/写入请求。
|
||||
2. 总控根据业务链、用户上下文、可信能力快照选择席位,生成 schema 校验的派活计划;前端只展示,不参与编排。
|
||||
3. Dispatcher 在服务端按需创建/复用 manager 和 seat 的内部执行线程,并由 `ExecutionPrincipal` 绑定当前用户。
|
||||
4. 对弱模型增加确定性保护:非法席位、空派活、重复派活、无效 schema、超轮次、无法收束必须被拒绝、纠偏或终止。
|
||||
5. 保持既有工具、技能、MCP、沙箱、事实账本、交叉审查和 checkpoint 恢复可用。
|
||||
|
||||
交付物:总控状态 schema、workflow executor、派活事件、弱模型测试矩阵。
|
||||
|
||||
验收:用户只输入任务,就能看到“意图理解 → 总控派活 → 席位执行”;浏览器没有 thread map 和派活决策代码。
|
||||
|
||||
### 阶段 4:席位任务合同、总控验收、打回与强制收束(R3-Q)
|
||||
|
||||
**难度:D5|建议人员:1 名 Agent/MAF 工程师 + 1 名后端状态机工程师|依赖:阶段 3**
|
||||
|
||||
具体要做:
|
||||
|
||||
1. 总控发布任务时冻结 `task_contract`:具体目标、交付格式、验收标准、必需性、返工次数、改派与降级策略。
|
||||
2. 为每项席位任务持久化状态机:`assigned → delivering → submitted → manager_review → accepted/rework/reassigned/controlled_gap`。
|
||||
3. 席位提交后先做确定性质量检查:空答、过短、必填章节缺失、格式错、无证据、重复内容等均不能自动完成。
|
||||
4. 总控必须生成结构化验收决定和原因;不合格时打回并带精确补充要求。默认最多两次同席位返工,之后最多一次改派。
|
||||
5. 改派仍失败时只能产生 `controlled_gap`,标记风险、缺失项和最终报告影响;严禁伪装为成功。
|
||||
6. 增加阶段质量闸门:所有必需任务处于 `accepted` 或 `controlled_gap`,才允许进入交叉审查/下一阶段/最终总结。
|
||||
7. 将尝试次数、退回理由、验收结果和缺口写入 checkpoint、审计和 SSE。
|
||||
|
||||
交付物:任务合同模型、持久化表/迁移、质量状态机、总控审查 prompt/schema、事件和测试。
|
||||
|
||||
验收:席位只说一两句话、无依据或内容垃圾时会被明确打回;达到上限不会死循环,也不会被当作合格产出;最终报告可追溯所有缺口。
|
||||
|
||||
### 阶段 5:审批、WriteGuard 与外部动作嵌入对话流(R4)
|
||||
|
||||
**难度:D4|建议人员:1 名安全后端 + 1 名前端|依赖:阶段 2、3、4**
|
||||
|
||||
具体要做:
|
||||
|
||||
1. 保留现有 Phase 5 的可信能力快照、WriteGuard、目标租约、HMAC、加密参数、一次执行与审计,不允许为新页面降低安全性。
|
||||
2. 将工具开始、审批请求、批准/拒绝、执行成功/失败/unknown 变成安全 SSE 事件和对话卡。
|
||||
3. 审批点击只调用明确决策 API;后端负责恢复 checkpoint/workflow,浏览器只等待 SSE。
|
||||
4. 安全展示统一二次脱敏,禁止 target_key、idempotency_key、internal_canary、密钥、凭据和原始工具结果进入 API、消息、SSE 或 DOM。
|
||||
|
||||
交付物:事件映射、审批消息卡契约、端到端 ToolNode/outbox 测试。
|
||||
|
||||
验收:一次真实测试写入能在对话里经历“申请 → 审批 → 继续 → 结果”;重复点击或断线都不会重复外部写入。
|
||||
|
||||
### 阶段 6:复刻旧会商页面壳、历史列表和输入框(R5)
|
||||
|
||||
**难度:D4|建议人员:1 名熟悉旧会商代码的前端|依赖:阶段 0、1**
|
||||
|
||||
具体要做:
|
||||
|
||||
1. 新建 MAF 专用 conversation 页面、路由和独立 feature flag;不修改旧 `RoundtablePlanningPage`。
|
||||
2. 依据阶段 0 的映射,复刻左侧历史会话、新建会商、中心时间线、常驻输入框、参与席位区和业务链摘要的视觉与交互层级。
|
||||
3. 基础组件使用本地 `tdesign-react`,旧页面仅参考;需要的视觉组件在 MAF 目录重写或抽无业务依赖公共组件。
|
||||
4. 接上 Conversation API:创建、历史、切换、消息分页。此阶段不允许先做任何轮询实时逻辑。
|
||||
5. 删除新页面中暴露给用户的 manager/seat/max round/session/run 创建表单。
|
||||
|
||||
交付物:新页面壳、MAF 专用 view model、静态/接口 mock 组件测试、视觉对照截图。
|
||||
|
||||
验收:即使 SSE 未接通,用户也能看到与旧会商同类的历史、对话与输入体验;新旧业务状态零耦合。
|
||||
|
||||
### 阶段 7:前端 SSE 消费、对话 reducer 与过程交互(R6 + R7)
|
||||
|
||||
**难度:D5|建议人员:1 名前端流式工程师 + 1 名前端业务工程师|依赖:阶段 2、4、5、6**
|
||||
|
||||
具体要做:
|
||||
|
||||
1. 实现单连接 `use-maf-conversation-stream`:fetch-reader SSE、cursor、AbortController、指数退避重连和会商切换 generation 隔离。
|
||||
2. 实现 event reducer:按 `conversation_id + seq` 去重排序;按 message_id 合并 delta;刷新后历史与 SSE 无缝衔接。
|
||||
3. 重写旧会商对应的 MAF 版组件:总控意图卡、派活卡、席位条、席位气泡、工具步骤、澄清卡、审批卡、质量审核/打回卡、结果卡。
|
||||
4. 所有卡片在时间线内叙事,不另开 run/session 管理后台;继续追问仍使用同一输入框。
|
||||
5. 页面必须显示待审核、已验收、返工次数、改派、受控缺口和恢复状态,不能把“发过言”显示为“已完成”。
|
||||
6. 代码审查和测试中检查不存在 `setInterval`、`refetchInterval` 或事件轮询 fallback。
|
||||
|
||||
交付物:SSE hook、reducer、对话组件、组件/API mock 测试、无轮询静态检查。
|
||||
|
||||
验收:切换会商、断线重连、审批续跑、慢旧流回包、长会话翻页均不污染、不丢消息、不重复;整个会商过程只依赖 SSE 实时更新。
|
||||
|
||||
### 阶段 8:端到端验收、灰度和回滚演练(R8)
|
||||
|
||||
**难度:D4|建议人员:1 名测试/集成人 + 各阶段负责人配合|依赖:阶段 1–7**
|
||||
|
||||
具体要做:
|
||||
|
||||
1. 跑完后端事务/SSE/Workflow/质量闸门/WriteGuard 测试、前端 reducer/组件测试、旧会商与 DeerFlow 回归。
|
||||
2. 在真实浏览器按阶段 0 截图基线逐项对照,并完成用户任务:发起、澄清、派活、短答打回、两次返工、改派、审批、受控缺口、结论和追问。
|
||||
3. 内部账号灰度,观测 SSE 建连/重连、事件时延、派活/验收/返工次数、workflow 失败率、审批耗时和前端错误。
|
||||
4. 验证关闭 feature flag 后立即回到旧会商入口,旧数据和旧任务不受影响。
|
||||
5. 若需生产外部写入,补齐 UAT 系统、最小权限凭据、核验 API、补偿联系人和生产 `MAF_ACTION_SECRET_KEY`。
|
||||
|
||||
交付物:自动化结果、人工验收记录、灰度指标面板、回滚演练记录、上线清单。
|
||||
|
||||
验收:满足第 9 节全部条件后才允许标记开发完成。
|
||||
|
||||
### 8.1 可并行边界
|
||||
|
||||
- 阶段 2 与阶段 3 可在阶段 1 的 schema/API 定稿后并行,但二者必须共用同一事件信封。
|
||||
- 阶段 6 可在阶段 1 的 API mock 契约定稿后提前开始;不得自行发明事件字段。
|
||||
- 阶段 5 必须等待阶段 2、3、4 的安全事件与状态机定稿。
|
||||
- 阶段 7 必须等待阶段 2、4、6,不能以轮询临时替代 SSE。
|
||||
- 阶段 8 只做集成、验收和上线准备,不补写前面阶段缺失的核心设计。
|
||||
|
||||
建议主顺序:`阶段 0 → 阶段 1 → (阶段 2 + 阶段 3 + 阶段 6) → 阶段 4 → 阶段 5 → 阶段 7 → 阶段 8`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 最终验收定义
|
||||
|
||||
只有同时满足以下条件,才可以说“本次 MAF 会商升级开发完成”:
|
||||
|
||||
- 新页面在视觉和主交互上可被用户认作原多智能体会商的升级版;
|
||||
- 页面具备历史会话、连续对话流、输入框、意图理解、总控派活、席位协作、澄清、审批、结果和追问;
|
||||
- 每个必需席位任务都有任务合同、总控验收、有限返工和改派/受控缺口处理;席位短答或垃圾产出不能自动被当作完成;
|
||||
- 浏览器没有会商事件/run/approval 的定时轮询;实时交互通过可重连 SSE 完成;
|
||||
- 用户不需要创建或管理多个智能体线程,内部线程由服务端按用户身份安全创建和恢复;
|
||||
- 新页面未调用旧会商业务 API/状态,旧会商零回归;
|
||||
- 工具、技能、MCP、沙箱、审批、WriteGuard、一次写入和审计都仍可用;
|
||||
- 自动化测试、SSE 故障测试、真实浏览器人工验收和旧功能回归全部通过;
|
||||
- 真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议及生产 `MAF_ACTION_SECRET_KEY` 已按 Phase 5 前置条件完成。
|
||||
|
||||
在这之前,任何“会话/run 原型页面通过构建或轮询测试”的结果,都只能算技术验证,不算产品升级验收。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 3 实施记录(当前代码基线)
|
||||
|
||||
本轮已补齐阶段 3 的服务端安全路由与真实 MAF Workflow 入口,范围仅针对新 MAF 会商;旧 DeerFlow、旧 roundtable 和 AI 写作行为不变。
|
||||
|
||||
### 配置与入口闸门
|
||||
|
||||
- 仓库默认 `maf_roundtable.enabled: false`。部署环境必须注入真实的 `default_manager_agent_id`、席位 ID 和业务路由后再开启,避免出现“入口显示正常但首条消息必然 503”的空配置。
|
||||
- 健康接口返回非敏感的 `conversation_dispatch_ready` 与 `conversation_dispatch_status`;新会商入口只有在配置路由可用且服务已启用时才开放。
|
||||
- 首条消息使用服务端可信能力快照校验 manager/seat 的可访问性;浏览器、会话请求和模型输出都不能提供或扩大 agent、thread、tool、capability 范围。
|
||||
- 配置模型会清理并校验 manager/seat ID,拒绝 manager 出现在 seat 列表、重复席位、超过 `max_seats_per_run` 的路由和非法业务 code。
|
||||
|
||||
### 受约束的总控派活
|
||||
|
||||
服务端先由业务链与可信 capability snapshot 生成候选席位集合。总控模型只能返回严格 JSON:`intent_type`、`selected_seat_agent_ids`、每席 `task_goal`/`delivery_requirements`、`execution_mode`、`convergence_path` 和 `reason`。Pydantic schema、服务端 allowlist 和顺序校验全部通过后,最终计划才会写入 checkpoint,并产生 `manager.dispatch` 审计/SSE 投影。
|
||||
|
||||
非法席位、重复席位、空任务、错误 schema、席位超限、缺少收敛路径或链式顺序错误,统一回退到服务端安全计划,不会扩大权限或启动越权席位。
|
||||
|
||||
四种结构化拓扑已固定为:
|
||||
|
||||
- `direct_answer`:manager-only Workflow,不创建专家席位任务;
|
||||
- `single_seat` / `tool_write`:一个可信席位;
|
||||
- `parallel_consultation`:至少两个可信席位并行;
|
||||
- `chain_consultation`:按服务端业务链顺序串行。
|
||||
|
||||
### 阶段 3 验证状态
|
||||
|
||||
已覆盖配置校验、候选路由、非法总控计划回退、manager-only 真实 `WorkflowBuilder` 拓扑、线程/执行阶段回归、旧 DeerFlow 回归和会话 API 回归。当前仍不能据此标记整个 MAF 项目或阶段 5 为“验收完成”;真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议和生产 `MAF_ACTION_SECRET_KEY` 仍按阶段 5/8 前置条件保持阻塞。
|
||||
|
||||
### Stage 3 P0 dispatch correction
|
||||
|
||||
- New MAF conversations no longer classify user text with server keyword rules. The server first freezes only the business-route candidate set and trusted, redacted capability summary; `ManagerDispatchExecutor` then returns the strict intent-and-dispatch JSON contract.
|
||||
- The final plan is Pydantic-validated and allowlisted before it is checkpointed. Invalid JSON, illegal intent or seat, missing convergence, manager failure, and timeout all finish on the clarification branch; they do not create expert threads or start expert/tool work.
|
||||
- A conversation initially persists and binds the manager only. After a valid decision, only `selected_seat_agent_ids` are added to the participant store and receive internal threads.
|
||||
- Every non-clarification route, including `group_chat`, starts at the same manager dispatch node. Group speakers are limited to the verified selected seats; static candidate configuration cannot bypass the plan.
|
||||
@@ -11,7 +11,6 @@
|
||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:maf": "vitest run --config vitest.maf.config.ts",
|
||||
"check:chrome109-css": "node scripts/check-chrome109-css.mjs",
|
||||
"check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs"
|
||||
},
|
||||
@@ -107,23 +106,19 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/gsap": "^3.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"lightningcss": "^1.30.2",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-source-identifier": "^1.1.2",
|
||||
"vitest": "^4.1.10"
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M24.036,10l-.012,9.333" transform="translate(-16.016 -6.667)"/><path class="b" d="M10,24h9.333" transform="translate(-6.667 -16)"/></svg>
|
||||
|
Before Width: | Height: | Size: 401 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="36" height="36" viewBox="0 0 36 36"><defs><style>.a,.d{fill:#fff;}.a{opacity:0;}.b{fill:#da2331;}.c{fill:#0a54cf;}.e{fill:#1677ff;}.f{fill:#1e2433;}.g{fill:#10d6ff;}.h{fill:#ff8181;}.i{fill:none;stroke:#10d6ff;stroke-linecap:round;stroke-linejoin:round;}.j{filter:url(#d);}.k{filter:url(#a);}</style><filter id="a"><feOffset dx="4" input="SourceAlpha"/><feGaussianBlur stdDeviation="1" result="b"/><feFlood flood-opacity="0.161" result="c"/><feComposite operator="out" in="SourceGraphic" in2="b"/><feComposite operator="in" in="c"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="d"><feOffset dx="-4" input="SourceAlpha"/><feGaussianBlur stdDeviation="1" result="e"/><feFlood flood-opacity="0.161" result="f"/><feComposite operator="out" in="SourceGraphic" in2="e"/><feComposite operator="in" in="f"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(0 -1)"><rect class="a" width="36" height="36" transform="translate(0 1)"/><path class="b" d="M23.5,14.75a1.5,1.5,0,0,1-1.5-1.5V9.5a1.5,1.5,0,1,1,3,0v3.75A1.5,1.5,0,0,1,23.5,14.75Z" transform="translate(-5.5 -1)"/><g data-type="innerShadowGroup"><path class="c" d="M-2-2H1C3.206-2,5-.532,5,1.273V3.727C5,5.532,3.206,7,1,7H-2Z" transform="translate(28.66 19.541)"/><g class="k" transform="matrix(1, 0, 0, 1, 0, 1)"><path class="d" d="M-2-2H1C3.206-2,5-.532,5,1.273V3.727C5,5.532,3.206,7,1,7H-2Z" transform="translate(28.66 18.54)"/></g></g><g data-type="innerShadowGroup"><path class="c" d="M5-2H2C-.206-2-2-.532-2,1.273V3.727C-2,5.532-.206,7,2,7H5Z" transform="translate(4.34 19.541)"/><g class="j" transform="matrix(1, 0, 0, 1, 0, 1)"><path class="d" d="M5-2H2C-.206-2-2-.532-2,1.273V3.727C-2,5.532-.206,7,2,7H5Z" transform="translate(4.34 18.54)"/></g></g><path class="e" d="M5.5-2H16a7.508,7.508,0,0,1,7.5,7.5V13A7.508,7.508,0,0,1,16,20.5H5.5A7.508,7.508,0,0,1-2,13V5.5A7.508,7.508,0,0,1,5.5-2Z" transform="translate(7.25 13.5)"/><path class="f" d="M5.25,0h9A5.25,5.25,0,0,1,19.5,5.25V7.5a5.25,5.25,0,0,1-5.25,5.25h-9A5.25,5.25,0,0,1,0,7.5V5.25A5.25,5.25,0,0,1,5.25,0Z" transform="translate(8.25 15.25)"/><circle class="g" cx="1.5" cy="1.5" r="1.5" transform="translate(21 19)"/><circle class="g" cx="1.5" cy="1.5" r="1.5" transform="translate(12 19)"/><path class="d" d="M24,10v3.75" transform="translate(-6 -1.5)"/><circle class="h" cx="3" cy="3" r="3" transform="translate(15 4)"/><path class="i" d="M44,28.667a3.367,3.367,0,0,0,1.922.412,2.841,2.841,0,0,0,1.828-.412" transform="translate(-27.875 -5.017)"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#006efe;stroke-width:6px;}.b{stroke-linecap:round;stroke-linejoin:round;}</style></defs><g transform="translate(0 1)"><rect class="a" width="96" height="96" transform="translate(0 -1)"/><path class="b" d="M47,67H38L18,77V67H4V7H84V29" transform="translate(4 8)"/><circle class="c" cx="12" cy="12" r="12" transform="translate(56 45)"/><path class="b" d="M39,32l10,8" transform="translate(39 33)"/><path class="b" d="M12,15H24" transform="translate(12 16)"/><path class="b" d="M12,21H36" transform="translate(12 22)"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 670 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13" height="11" viewBox="0 0 13 11"><defs><style>.a{stroke:rgba(0,0,0,0);stroke-miterlimit:10;fill:url(#a);}</style><linearGradient id="a" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><path class="a" d="M1,10A1,1,0,1,1,1,8H6a1,1,0,0,1,0,2Zm9.8-.482L7.848,7.361A.445.445,0,0,1,7.661,7a.46.46,0,0,1,.187-.366L10.8,4.481a.45.45,0,0,1,.716.365V9.152a.44.44,0,0,1-.131.317.445.445,0,0,1-.322.136A.454.454,0,0,1,10.8,9.518ZM1,6A1,1,0,1,1,1,4H6A1,1,0,0,1,6,6ZM1,2A1,1,0,1,1,1,0H11a1,1,0,1,1,0,2Z" transform="translate(0.5 0.5)"/></svg>
|
||||
|
Before Width: | Height: | Size: 753 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13" height="11" viewBox="0 0 13 11"><defs><style>.a{fill:url(#a);}.b{fill:url(#b);}</style><linearGradient id="a" x1="0.023" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff" stop-opacity="0.302"/><stop offset="1" stop-color="#00c7ff" stop-opacity="0.302"/></linearGradient><linearGradient id="b" x1="0.023" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><path class="a" d="M5.5,0,11,8H0Z" transform="translate(13) rotate(90)"/><path class="b" d="M5.5,0,11,8H0Z" transform="translate(8) rotate(90)"/></svg>
|
||||
|
Before Width: | Height: | Size: 735 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:rgba(255,255,255,0.8);}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M10,1.5A1.5,1.5,0,1,1,11.5,3,1.5,1.5,0,0,1,10,1.5Zm-5,0A1.5,1.5,0,1,1,6.5,3,1.5,1.5,0,0,1,5,1.5Zm-5,0A1.5,1.5,0,1,1,1.5,3,1.5,1.5,0,0,1,0,1.5Z" transform="translate(1.498 6.5)"/></svg>
|
||||
|
Before Width: | Height: | Size: 410 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:rgba(85,166,254,0.6);}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M12.715,13.421,9.588,10.294a5.841,5.841,0,1,1,.707-.707l3.128,3.128a.5.5,0,0,1-.708.706ZM1,5.833A4.833,4.833,0,1,0,5.834,1,4.838,4.838,0,0,0,1,5.833Z" transform="translate(1.334 1.216)"/></svg>
|
||||
|
Before Width: | Height: | Size: 418 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;}</style></defs><path class="a" d="M14.488.053.824,4.256a1.167,1.167,0,0,0-.285,2.1L4.576,8.936a1.167,1.167,0,0,0,1.382-.092L11,4.593A.292.292,0,0,1,11.408,5L7.156,10.043a1.167,1.167,0,0,0-.091,1.382l2.58,4.036a1.168,1.168,0,0,0,2.1-.286l4.2-13.663A1.167,1.167,0,0,0,14.488.053Z" transform="translate(0)"/></svg>
|
||||
|
Before Width: | Height: | Size: 422 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16.131" height="16" viewBox="0 0 16.131 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#97edff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M6,30V34.12H18.361V30" transform="translate(-2.73 -19.994)"/><path class="b" d="M21.181,9.09,18.09,6,15,9.09" transform="translate(-8.64 -4.235)"/><path class="b" d="M23.992,14.241V6" transform="translate(-14.544 -4.235)"/></svg>
|
||||
|
Before Width: | Height: | Size: 504 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#346fb1;stroke-linejoin:round;}.c{stroke-linecap:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M10.667,17.333A6.667,6.667,0,1,0,4,10.667,6.667,6.667,0,0,0,10.667,17.333Z" transform="translate(-2.667 -2.667)"/><path class="c" d="M24.008,12v4l2.826,2.826" transform="translate(-16.005 -8)"/></svg>
|
||||
|
Before Width: | Height: | Size: 473 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(212,120,0,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>
|
||||
|
Before Width: | Height: | Size: 320 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(207,19,34,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>
|
||||
|
Before Width: | Height: | Size: 320 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(29,171,123,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>
|
||||
|
Before Width: | Height: | Size: 321 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(132,85,230,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>
|
||||
|
Before Width: | Height: | Size: 321 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(201,181,0,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>
|
||||
|
Before Width: | Height: | Size: 320 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(69,195,243,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>
|
||||
|
Before Width: | Height: | Size: 321 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#346fb1;stroke-linecap:round;stroke-linejoin:round;}</style></defs><g transform="translate(0 -2)"><rect class="a" width="16" height="16" transform="translate(0 2)"/><path class="b" d="M17.333,8H4V18H9l1.667,1.667L12.333,18h5Z" transform="translate(-2.667 -3.667)"/><circle class="b" cx="1.667" cy="1.667" r="1.667" transform="translate(6.333 6.333)"/><path class="b" d="M21,26.667A2.848,2.848,0,0,0,18,24a2.848,2.848,0,0,0-3,2.667" transform="translate(-10 -14.333)"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 618 B |
@@ -10,6 +10,7 @@ import {
|
||||
Gauge,
|
||||
ListTree,
|
||||
MousePointerClick,
|
||||
FileText,
|
||||
BriefcaseBusiness,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
@@ -236,6 +237,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/notification-templates")}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
通知模板管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
||||
@@ -549,6 +558,14 @@ function LegacyAccordionSidebar({
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/notification-templates")}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
通知模板管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
||||
<BriefcaseBusiness className="size-4" />
|
||||
|
||||
@@ -100,17 +100,15 @@ function Section({
|
||||
icon,
|
||||
title,
|
||||
extra,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("bg-card/60 rounded-xl border p-4", className)}>
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
{icon}
|
||||
@@ -141,15 +139,6 @@ export function AgentDetailPanel({
|
||||
defaultCollapsed,
|
||||
showRecentChats,
|
||||
compactAppearanceActions,
|
||||
avatarSrc,
|
||||
positionName,
|
||||
skillsIcon,
|
||||
skillsTitle,
|
||||
showCommonQuestions = false,
|
||||
panelTitle,
|
||||
embedded,
|
||||
hideStatusBadges = false,
|
||||
editIcon,
|
||||
}: {
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
@@ -161,19 +150,6 @@ export function AgentDetailPanel({
|
||||
showRecentChats?: boolean;
|
||||
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
|
||||
compactAppearanceActions?: ReactNode;
|
||||
avatarSrc?: string;
|
||||
positionName?: string;
|
||||
skillsIcon?: ReactNode;
|
||||
skillsTitle?: string;
|
||||
showCommonQuestions?: boolean;
|
||||
/** Overrides the standard panel heading for an embedded consumer. */
|
||||
panelTitle?: string;
|
||||
/** Renders one fixed panel instead of the responsive collapsible rail. */
|
||||
embedded?: boolean;
|
||||
/** Keeps deployment/status badges out of an embedded product surface. */
|
||||
hideStatusBadges?: boolean;
|
||||
/** Optional icon used by the compact embedded edit action. */
|
||||
editIcon?: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
||||
@@ -496,25 +472,11 @@ export function AgentDetailPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
{embedded ? (
|
||||
<Tooltip content="编辑智能体">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={beginEdit}
|
||||
aria-label="编辑智能体"
|
||||
>
|
||||
{editIcon ?? <SquarePenIcon className="size-3.5" />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={beginEdit}>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{!embedded && canTogglePublish && (
|
||||
<Button size="sm" variant="outline" onClick={beginEdit}>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
{canTogglePublish && (
|
||||
<Tooltip content={agent?.published ? "下架" : "发布"}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -531,19 +493,17 @@ export function AgentDetailPanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!embedded && (
|
||||
<Tooltip content="删除智能体">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
aria-label="删除智能体"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="删除智能体">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
aria-label="删除智能体"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
) : null;
|
||||
@@ -561,11 +521,7 @@ export function AgentDetailPanel({
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
|
||||
{avatarSrc ? (
|
||||
<img src={avatarSrc} alt="" aria-hidden="true" className="size-9" />
|
||||
) : (
|
||||
<BotIcon className="text-primary size-6" />
|
||||
)}
|
||||
<BotIcon className="text-primary size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{editing && draft ? (
|
||||
@@ -585,16 +541,9 @@ export function AgentDetailPanel({
|
||||
{displayName}
|
||||
</div>
|
||||
)}
|
||||
{!editing && positionName && (
|
||||
<div className="position-agent-role text-sm">
|
||||
岗位:{desensitize(positionName)}
|
||||
</div>
|
||||
)}
|
||||
{!hideStatusBadges && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{statusBadges}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{statusBadges}
|
||||
</div>
|
||||
{!editing && agent.owner_username && !agent.builtin && (
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<UserRoundIcon className="size-3 shrink-0" />
|
||||
@@ -697,7 +646,7 @@ export function AgentDetailPanel({
|
||||
|
||||
|
||||
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
||||
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
|
||||
{(editing || questions.length > 0 || canEdit) && (
|
||||
<Section
|
||||
icon={<MessageCircleQuestionIcon className="size-3.5" />}
|
||||
title="常用问题"
|
||||
@@ -838,9 +787,8 @@ export function AgentDetailPanel({
|
||||
|
||||
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
||||
<Section
|
||||
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
|
||||
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
|
||||
className="position-roundtable-skills-section"
|
||||
icon={<SparklesIcon className="size-3.5" />}
|
||||
title={`已启用技能 ${skillNamesShown.length} 个`}
|
||||
extra={
|
||||
editing ? (
|
||||
<Button
|
||||
@@ -882,9 +830,7 @@ export function AgentDetailPanel({
|
||||
const panelChrome = (collapseButton: ReactNode) => (
|
||||
<>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">
|
||||
{panelTitle ?? "智能体信息"}
|
||||
</span>
|
||||
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">智能体信息</span>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{headerActions}
|
||||
{compactAppearanceActions}
|
||||
@@ -898,11 +844,7 @@ export function AgentDetailPanel({
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
||||
{embedded ? (
|
||||
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
|
||||
{panelChrome(null)}
|
||||
</aside>
|
||||
) : collapsed ? (
|
||||
{collapsed ? (
|
||||
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
||||
<div className="flex items-center gap-1">
|
||||
{compactAppearanceActions}
|
||||
@@ -935,34 +877,30 @@ export function AgentDetailPanel({
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{!embedded && (
|
||||
<>
|
||||
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
|
||||
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="打开智能体信息"
|
||||
>
|
||||
<InfoIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b px-4 pb-3 pt-4">
|
||||
<SheetTitle className="flex items-center justify-between text-sm">
|
||||
智能体信息
|
||||
<span className="mr-6">{headerActions}</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)}
|
||||
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
|
||||
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="打开智能体信息"
|
||||
>
|
||||
<InfoIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b px-4 pb-3 pt-4">
|
||||
<SheetTitle className="flex items-center justify-between text-sm">
|
||||
智能体信息
|
||||
<span className="mr-6">{headerActions}</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
|
||||
<SkillPickerSheet
|
||||
|
||||
@@ -196,7 +196,6 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{/* [MAF-R0] 实验原型已从正式菜单隐藏,仅保留路由供开发诊断:/page/maf */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
List,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
MessageSquareText,
|
||||
Network,
|
||||
Radar,
|
||||
Settings,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 区域级脱敏 Hook —— 把某个容器(DOM 子树)里的**所有文本节点**做脱敏,
|
||||
* 用于「左/右侧面板 + 顶部标题」这类需要整片脱敏的区域:静态标题、动态内容、
|
||||
* 嵌套子组件文本一并覆盖,无需逐串包 `desensitize()`(那样易漏、难维护)。
|
||||
*
|
||||
* 用法:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 只读改文本节点(display-only),不碰 `<input>/<textarea>` 的 value、不改 React 状态/回传值;
|
||||
* - `desensitize` 幂等(替换词不再命中词表),MutationObserver 重入安全;改写前先 disconnect 防自触发循环;
|
||||
* - 想在区域内**保留原文**(如内嵌沙箱/消息列表),给该子树根加 `data-no-desensitize` 属性即可跳过。
|
||||
*
|
||||
* 注意:**不要**用它包「消息列表正文」或「沙箱产物预览」——这两处按需求保持原文,
|
||||
* 应作为独立容器、不挂本 Hook(智能体名称的脱敏单独在 MessageBubble 的 senderName 处理)。
|
||||
*
|
||||
* 该 hook 最初位于 `roundtable-planning/lib/use-desensitize-subtree.ts`,因其属于通用脱敏基建
|
||||
* (被 MAF 会商、业务链条等多模块共享)现抽到中性 `@/lib` 位置。旧路径保留 re-export 以零改动兼容。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
const _SKIP_TAGS = new Set(["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "IFRAME", "CODE", "PRE"]);
|
||||
|
||||
export function useDesensitizeSubtree<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = ref.current;
|
||||
if (!root || typeof MutationObserver === "undefined") return;
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
|
||||
const apply = () => {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
if (_SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
||||
// 显式标记 data-no-desensitize 的子树保留原文(如内嵌沙箱)。
|
||||
if (parent.closest("[data-no-desensitize]")) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
const edits: Array<[Text, string]> = [];
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
const textNode = current as Text;
|
||||
const original = textNode.nodeValue ?? "";
|
||||
if (original.trim()) {
|
||||
const masked = desensitize(original);
|
||||
if (masked !== original) edits.push([textNode, masked]);
|
||||
}
|
||||
current = walker.nextNode();
|
||||
}
|
||||
if (edits.length === 0) return;
|
||||
// 改写期间停掉观察,避免自身改动再次触发,改完恢复。
|
||||
observer?.disconnect();
|
||||
for (const [textNode, masked] of edits) textNode.nodeValue = masked;
|
||||
observer?.observe(root, { subtree: true, childList: true, characterData: true });
|
||||
};
|
||||
|
||||
apply();
|
||||
observer = new MutationObserver(() => apply());
|
||||
observer.observe(root, { subtree: true, childList: true, characterData: true });
|
||||
return () => observer?.disconnect();
|
||||
});
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* MafApprovalCard 挂载测试(vitest + jsdom + Testing Library)。
|
||||
*
|
||||
* 覆盖(提交 2):
|
||||
* - 连续双击「批准」mutate 只调用一次(decisionLockRef + disabled 双重锁);
|
||||
* - 连续双击「拒绝」mutate 只调用一次;
|
||||
* - 成功回调后批准/拒绝两个按钮都 disabled;
|
||||
* - 审批参数与 payload 中的敏感键、敏感值均不可见(safeDisplayJson)。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// 同步锁 + mutate 计数:用 vi.hoisted 保证在 vi.mock 工厂里可引用
|
||||
const mocks = vi.hoisted(() => ({
|
||||
decideMutate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../api/approvals", () => ({
|
||||
// MafApprovalCard 只用 useMafApprovalActions
|
||||
useMafApprovalActions: () => ({
|
||||
decide: {
|
||||
mutate: (vars: unknown, opts?: { onSuccess?: () => void }) => {
|
||||
mocks.decideMutate(vars);
|
||||
// 同步触发成功回调,让组件进入 resuming 锁定态
|
||||
opts?.onSuccess?.();
|
||||
},
|
||||
isPending: false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MafApprovalCard } from "../components/MafApprovalCard";
|
||||
import type { MafAction, MafApproval } from "../api/types";
|
||||
|
||||
function makeApproval(overrides: Partial<MafApproval> = {}): MafApproval {
|
||||
return {
|
||||
id: "ap1",
|
||||
session_id: "s1",
|
||||
run_id: "r1",
|
||||
action_id: "act1",
|
||||
requested_by_agent_id: "agent-a",
|
||||
status: "pending",
|
||||
summary: "写入测试文件",
|
||||
payload: { api_key: "secret-val", note: "可见说明" },
|
||||
decided_by: null,
|
||||
decided_at: null,
|
||||
expires_at: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAction(overrides: Partial<MafAction> = {}): MafAction {
|
||||
return {
|
||||
id: "act1",
|
||||
session_id: "s1",
|
||||
run_id: "r1",
|
||||
agent_id: "agent-a",
|
||||
tool_name: "write_file",
|
||||
logical_action_id: null,
|
||||
risk_level: "R3",
|
||||
status: "waiting_approval",
|
||||
arguments: { target_key: "leak-me", path: "/tmp/x", normal: "visible-val" },
|
||||
result: { secret: "must-not-show-result" },
|
||||
external_object_id: null,
|
||||
approval_id: "ap1",
|
||||
attempt_count: 0,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MafApprovalCard", () => {
|
||||
beforeEach(() => {
|
||||
mocks.decideMutate.mockClear();
|
||||
});
|
||||
|
||||
it("连续双击「批准」mutate 只调用一次", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MafApprovalCard approval={makeApproval()} action={makeAction()} runId="r1" />);
|
||||
|
||||
const approve = screen.getByRole("button", { name: "批准" });
|
||||
await user.click(approve);
|
||||
await user.click(approve);
|
||||
|
||||
expect(mocks.decideMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.decideMutate).toHaveBeenCalledWith({ approvalId: "ap1", decision: "approved" });
|
||||
});
|
||||
|
||||
it("连续双击「拒绝」mutate 只调用一次", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MafApprovalCard approval={makeApproval()} action={makeAction()} runId="r1" />);
|
||||
|
||||
const reject = screen.getByRole("button", { name: "拒绝" });
|
||||
await user.click(reject);
|
||||
await user.click(reject);
|
||||
|
||||
expect(mocks.decideMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.decideMutate).toHaveBeenCalledWith({ approvalId: "ap1", decision: "rejected" });
|
||||
});
|
||||
|
||||
it("成功回调后批准与拒绝按钮都 disabled", async () => {
|
||||
// TDesign Button 在 disabled 时会把 <button> 改渲染为带 t-is-disabled 类的 <div>
|
||||
// (见 tdesign-react es/button/Button.js: tag=disabled?'div':...)。
|
||||
// 因此这里断言 TDesign 的 disabled 类,而非原生 disabled 属性。
|
||||
const user = userEvent.setup();
|
||||
render(<MafApprovalCard approval={makeApproval()} action={makeAction()} runId="r1" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "批准" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const approveHost = screen.getByText("批准").closest(".t-button");
|
||||
const rejectHost = screen.getByText("拒绝").closest(".t-button");
|
||||
expect(approveHost?.className).toMatch(/t-is-disabled/);
|
||||
expect(rejectHost?.className).toMatch(/t-is-disabled/);
|
||||
});
|
||||
// 行为兜底:再次可用时也不会重复决策(锁已落下)
|
||||
expect(mocks.decideMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("敏感键与敏感值均不可见,正常字段可见,且不渲染 action.result", () => {
|
||||
render(<MafApprovalCard approval={makeApproval()} action={makeAction()} runId="r1" />);
|
||||
|
||||
const body = document.body.textContent ?? "";
|
||||
|
||||
// 正常字段可见
|
||||
expect(body).toMatch(/visible-val/);
|
||||
expect(body).toMatch(/可见说明/);
|
||||
// 敏感键不出现
|
||||
expect(body).not.toMatch(/target_key|api_key|secret/i);
|
||||
// 敏感值不出现
|
||||
expect(body).not.toMatch(/leak-me|secret-val|must-not-show-result/);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* MafRunHeader 挂载测试(vitest + jsdom + Testing Library)。
|
||||
*
|
||||
* 覆盖(提交 2):
|
||||
* - waiting_approval + 有 pending 审批(canResume=false):没有「恢复」按钮,显示阻断提示;
|
||||
* - 审批加载失败(canResume=false + 相应 hint):没有「恢复」按钮,显示「无法确认审批状态」;
|
||||
* - 审批清零且 run 仍 waiting_approval(canResume=true):出现「恢复」按钮;
|
||||
* - 恢复 mutation pending 时按钮 disabled,不能重复点击。
|
||||
*
|
||||
* 说明:canResume / resumeBlockedHint 由父组件 MafRunDetailPage 用 canManualResume 计算,
|
||||
* 这里直接按真实 prop 喂入,验证 header 的渲染契约。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resumeMutate: vi.fn(),
|
||||
useMafRunActions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/runs", () => ({
|
||||
useMafRunActions: mocks.useMafRunActions,
|
||||
}));
|
||||
|
||||
import { MafRunHeader } from "../components/MafRunHeader";
|
||||
import type { MafRun } from "../api/types";
|
||||
|
||||
function actionsReturn(overrides: Partial<{ resumePending: boolean }> = {}) {
|
||||
return {
|
||||
resume: {
|
||||
mutate: mocks.resumeMutate,
|
||||
isPending: overrides.resumePending ?? false,
|
||||
},
|
||||
cancel: { mutate: vi.fn(), isPending: false },
|
||||
recover: { mutate: vi.fn(), isPending: false },
|
||||
};
|
||||
}
|
||||
|
||||
function makeRun(overrides: Partial<MafRun> = {}): MafRun {
|
||||
return {
|
||||
id: "r1",
|
||||
session_id: "s1",
|
||||
status: "waiting_approval",
|
||||
workflow_version: "maf",
|
||||
maf_checkpoint: null,
|
||||
last_event_seq: 0,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
error_code: null,
|
||||
error_message_safe: null,
|
||||
token_usage: null,
|
||||
latency_ms: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MafRunHeader 恢复交互", () => {
|
||||
beforeEach(() => {
|
||||
mocks.resumeMutate.mockClear();
|
||||
mocks.useMafRunActions.mockReturnValue(actionsReturn());
|
||||
});
|
||||
|
||||
it("waiting_approval + 有 pending 审批:没有「恢复」按钮,显示阻断提示", () => {
|
||||
render(
|
||||
<MafRunHeader
|
||||
run={makeRun()}
|
||||
canResume={false}
|
||||
resumeBlockedHint="请先完成全部审批(剩余 1)"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "恢复" })).toBeNull();
|
||||
expect(screen.getByText(/请先完成全部审批/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("审批查询失败:没有「恢复」按钮,显示「无法确认审批状态」", () => {
|
||||
render(
|
||||
<MafRunHeader
|
||||
run={makeRun()}
|
||||
canResume={false}
|
||||
resumeBlockedHint="无法确认审批状态,请稍后重试"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "恢复" })).toBeNull();
|
||||
expect(screen.getByText(/无法确认审批状态/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("审批清零且 run 仍 waiting_approval:出现「恢复」按钮,点击触发一次 resume", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MafRunHeader run={makeRun()} canResume={true} />);
|
||||
|
||||
const resumeBtn = screen.getByRole("button", { name: "恢复" });
|
||||
await user.click(resumeBtn);
|
||||
expect(mocks.resumeMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.resumeMutate.mock.calls[0][0]).toBe("r1");
|
||||
});
|
||||
|
||||
it("恢复 mutation pending 时按钮 disabled,不能重复点击", async () => {
|
||||
// isPending=true:TDesign 把 disabled Button 渲染成带 t-is-disabled 类的 <div>,
|
||||
// 没有原生 button role,onClick 也被清空,点击不会触发 mutate。
|
||||
mocks.useMafRunActions.mockReturnValue(actionsReturn({ resumePending: true }));
|
||||
const user = userEvent.setup();
|
||||
render(<MafRunHeader run={makeRun()} canResume={true} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "恢复" })).toBeNull();
|
||||
const host = screen.getByText("恢复").closest(".t-button");
|
||||
expect(host?.className).toMatch(/t-is-disabled/);
|
||||
await user.click(screen.getByText("恢复"));
|
||||
await waitFor(() => expect(mocks.resumeMutate).not.toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* CreateSessionDialog(新建会商弹窗)挂载测试(vitest + jsdom + Testing Library)。
|
||||
*
|
||||
* 覆盖(提交 2):
|
||||
* - health 未返回时「创建」禁用、显示「加载后端默认…」;
|
||||
* - health 返回后真实表单显示 default_mode/default_write_policy/default_max_rounds
|
||||
* (通过只读摘要行,值只来自 health,不硬编码);
|
||||
* - 关闭再打开弹窗,表单恢复干净默认值(不保留上次输入);
|
||||
* - 经理切换后,已选席位被真实移除(filterSeatsForManager 在 useEffect 生效)。
|
||||
*
|
||||
* 说明:直接挂载导出的 CreateSessionDialog 并 mock 三个数据 hook,绕开 MafHealthGate
|
||||
* 的门控(gate 与弹窗共用 useMafHealth,无法在门控通过的同时让弹窗看到未就绪)。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useMafHealth: vi.fn(),
|
||||
useAgents: vi.fn(),
|
||||
createMutate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/runs", () => ({ useMafHealth: mocks.useMafHealth }));
|
||||
vi.mock("@/core/agents", () => ({ useAgents: mocks.useAgents }));
|
||||
vi.mock("../api/sessions", () => ({
|
||||
useMafSessionActions: () => ({
|
||||
create: {
|
||||
mutate: (body: unknown, opts?: { onSuccess?: (s: { id: string }) => void }) => {
|
||||
mocks.createMutate(body);
|
||||
opts?.onSuccess?.({ id: "new1" });
|
||||
},
|
||||
isPending: false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { CreateSessionDialog } from "../pages/MafSessionsPage";
|
||||
import type { MafHealthResponse } from "../api/types";
|
||||
|
||||
const AGENTS = [
|
||||
{ id: "a1", name: "智能体一" },
|
||||
{ id: "a2", name: "智能体二" },
|
||||
{ id: "a3", name: "智能体三" },
|
||||
];
|
||||
|
||||
function healthData(overrides: Partial<MafHealthResponse> = {}): MafHealthResponse {
|
||||
return {
|
||||
enabled: true,
|
||||
store_available: true,
|
||||
default_mode: "group_chat",
|
||||
default_write_policy: "confirm_all_writes",
|
||||
default_max_rounds: 12,
|
||||
admin_diagnostic_access: false,
|
||||
write_guard_enabled: true,
|
||||
write_guard_ready: true,
|
||||
action_secret_configured: true,
|
||||
write_guard_status: "ready",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDialog(props: Partial<{ open: boolean }> = {}) {
|
||||
return render(
|
||||
<CreateSessionDialog
|
||||
open={props.open ?? true}
|
||||
onOpenChange={() => {}}
|
||||
onCreated={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CreateSessionDialog", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useMafHealth.mockReturnValue({ data: healthData(), isLoading: false });
|
||||
mocks.useAgents.mockReturnValue({ agents: AGENTS, isLoading: false, error: null });
|
||||
mocks.createMutate.mockClear();
|
||||
});
|
||||
|
||||
it("health 未返回时「创建」禁用且显示「加载后端默认…」", () => {
|
||||
mocks.useMafHealth.mockReturnValue({ data: undefined, isLoading: true });
|
||||
renderDialog();
|
||||
const createBtn = screen.getByText("加载后端默认…");
|
||||
expect(createBtn).toBeTruthy();
|
||||
// TDesign disabled → t-is-disabled 类
|
||||
expect(createBtn.closest(".t-button")?.className).toMatch(/t-is-disabled/);
|
||||
});
|
||||
|
||||
it("health 返回后摘要行真实显示后端默认值(来自 health,不硬编码)", () => {
|
||||
renderDialog();
|
||||
// 折叠态默认摘要行:值来自 health(group_chat / confirm_all_writes / 12)
|
||||
expect(screen.getByText(/当前后端默认/).textContent).toMatch(/group_chat/);
|
||||
expect(screen.getByText(/当前后端默认/).textContent).toMatch(/confirm_all_writes/);
|
||||
expect(screen.getByText(/当前后端默认/).textContent).toMatch(/12/);
|
||||
});
|
||||
|
||||
it("关闭再打开弹窗,表单恢复干净默认值(不保留上次输入)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<CreateSessionDialog open={true} onOpenChange={() => {}} onCreated={() => {}} />,
|
||||
);
|
||||
|
||||
// 填入标题
|
||||
const titleInput = screen.getByPlaceholderText("例如:某任务多视角研讨");
|
||||
await user.type(titleInput, "残留内容");
|
||||
|
||||
// 关闭弹窗
|
||||
rerender(<CreateSessionDialog open={false} onOpenChange={() => {}} onCreated={() => {}} />);
|
||||
// 再打开
|
||||
rerender(<CreateSessionDialog open={true} onOpenChange={() => {}} onCreated={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const reopened = screen.getByPlaceholderText("例如:某任务多视角研讨") as HTMLInputElement;
|
||||
expect(reopened.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("经理切换后,已选席位被真实移除", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
// 先勾选席位「智能体二」(a2):找到对应 checkbox
|
||||
// TCheckbox 渲染 <input type="checkbox">;通过相邻标签文本定位
|
||||
const seatLabel2 = screen.getByText("智能体二");
|
||||
const seatCheck2 = seatLabel2.closest("label")!.querySelector("input[type='checkbox']") as HTMLInputElement;
|
||||
await user.click(seatCheck2);
|
||||
expect(seatCheck2.checked).toBe(true);
|
||||
|
||||
// 切换经理为「智能体二」(a2):useEffect 会把 a2 从席位移除
|
||||
// TDesign Select 的交互在 jsdom 下不稳定,这里通过直接触发经理选择来验证 effect。
|
||||
// 由于 Select 难以稳定驱动,改为断言真实纯函数驱动后的契约:
|
||||
// 经理与席位互斥——勾选经理后该席位被禁用且移除。
|
||||
// 这里用 TInput(经理 Select) 的可访问性不够,改为:再次确认 a2 勾选态可被翻转,
|
||||
// 并断言 filterSeatsForManager 的真实行为已在 node:test 覆盖。
|
||||
// 为提供有意义的挂载断言:取消勾选 a2 后 a2 不在已选列表。
|
||||
await user.click(seatCheck2);
|
||||
expect(seatCheck2.checked).toBe(false);
|
||||
|
||||
// 真实函数行为兜底:经理与席位互斥逻辑(与组件 useEffect 同源)
|
||||
const { filterSeatsForManager } = await import("../lib/session-form");
|
||||
expect(filterSeatsForManager(["a1", "a2", "a3"], "a2")).toEqual(["a1", "a3"]);
|
||||
});
|
||||
});
|
||||
@@ -1,670 +0,0 @@
|
||||
/**
|
||||
* MAF 会商前端回归测试(阶段 6 重做版)。
|
||||
*
|
||||
* 核心原则:**测试只 import 真实纯函数**,绝不在测试文件里复制组件/逻辑。
|
||||
* 这样组件实现坏掉时测试会同步失败,避免「实现坏、测试仍通过」的假绿灯。
|
||||
*
|
||||
* 运行(前端未引入 Vitest,沿用 esbuild + node --test):
|
||||
* pnpm exec esbuild src/maf-roundtable/__tests__/maf-roundtable.test.ts \
|
||||
* --bundle --platform=node --format=esm \
|
||||
* --outfile=%TEMP%\maf-roundtable.test.mjs \
|
||||
* --banner:js="import.meta.env={};"
|
||||
* node --test %TEMP%\maf-roundtable.test.mjs
|
||||
*
|
||||
* 覆盖(按阶段 6 验收清单):
|
||||
* 1. 新建弹窗显示服务端默认值 → createInitialSessionForm / defaultsSummaryLine
|
||||
* 2. 经理切换时席位被真实移除 → filterSeatsForManager / toggleSeat
|
||||
* 3. maxRounds=0/65/小数 无法提交 → validateMaxRounds / buildSessionOverrides
|
||||
* 4. pending 审批双击只发一次决策 → isDecisionButtonDisabled(守卫单一事实源)
|
||||
* 5. 有 pending approval 时不可恢复 → canManualResume
|
||||
* 6. 审批清零且仍 waiting 才允许恢复 → canManualResume
|
||||
* 7. missing_secret health 提示判定 → (MafHealthGate 已上线,这里覆盖风险/动作分类)
|
||||
* 8. 切换 run 时旧请求不污染新 run → createMafEventRequestCoordinator
|
||||
* 9. 终态 run 能补齐历史事件 → runIsLive + foldEventsToTimeline
|
||||
* 10. executed/failed/unknown/expired 动作分类 → classifyActionsByStatus
|
||||
* 11. 风险等级映射 R0–R4 → riskThemeFor(单一事实源)
|
||||
* 12. MAF 目录没有旧会商 import → 边界扫描(fs 读 MAF 目录断言无 roundtable 依赖)
|
||||
* 13. 事件翻页 mergeBySeq 不丢不重 → mergeBySeq(真实导出)
|
||||
* 14. buildMafFlowGraph 纯函数 → flow-json 解析 + 事实图构造
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, test } from "node:test";
|
||||
import { readdirSync, readFileSync, statSync, existsSync, type Dirent } from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
|
||||
// @ts-ignore esbuild 解析 .ts
|
||||
import { foldEventsToTimeline } from "../lib/event-timeline.ts";
|
||||
// @ts-ignore
|
||||
import { runIsLive, type MafEvent } from "../api/types.ts";
|
||||
// @ts-ignore
|
||||
import { createMafEventRequestCoordinator, mergeBySeq } from "../hooks/use-maf-events.ts";
|
||||
// @ts-ignore
|
||||
import { riskThemeFor, riskLabelFor } from "../lib/risk-theme.ts";
|
||||
// @ts-ignore
|
||||
import { buildSessionOverrides, createInitialSessionForm, defaultsSummaryLine, filterSeatsForManager, toggleSeat, validateMaxRounds } from "../lib/session-form.ts";
|
||||
// @ts-ignore
|
||||
import { canManualResume } from "../lib/run-resume.ts";
|
||||
// @ts-ignore
|
||||
import { classifyActionsByStatus, classifyActionStatus } from "../lib/action-audit.ts";
|
||||
// @ts-ignore
|
||||
import { isDecisionButtonDisabled } from "../lib/approval-state.ts";
|
||||
// @ts-ignore
|
||||
import { buildMafFlowGraph, extractMafFlowGraph } from "../lib/maf-flow-graph.ts";
|
||||
// @ts-ignore
|
||||
import { safeDisplayJson } from "../lib/safe-display.ts";
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeEvent(overrides: Partial<MafEvent> & { seq: number }): MafEvent {
|
||||
return {
|
||||
id: `ev-${overrides.seq}`,
|
||||
session_id: "s1",
|
||||
run_id: "r1",
|
||||
event_type: "agent.message.delta",
|
||||
agent_id: "agent-a",
|
||||
turn_index: 0,
|
||||
payload: { text: `msg-${overrides.seq}` },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 1. 新建弹窗显示服务端默认值 ─────────────────────────────────────────────
|
||||
|
||||
describe("createInitialSessionForm:弹窗默认值来自 health", () => {
|
||||
test("health 提供 default_mode/policy/rounds 时全部透传到表单", () => {
|
||||
const form = createInitialSessionForm({
|
||||
default_mode: "group_chat",
|
||||
default_write_policy: "confirm_all_writes",
|
||||
default_max_rounds: 16,
|
||||
});
|
||||
assert.equal(form.mode, "group_chat");
|
||||
assert.equal(form.writePolicy, "confirm_all_writes");
|
||||
assert.equal(form.maxRounds, "16");
|
||||
// 其它字段干净
|
||||
assert.equal(form.title, "");
|
||||
assert.equal(form.taskText, "");
|
||||
assert.deepEqual(form.seatAgentIds, []);
|
||||
});
|
||||
|
||||
test("health 为空时兜底 mode=structured,其余留空", () => {
|
||||
const form = createInitialSessionForm(null);
|
||||
assert.equal(form.mode, "structured");
|
||||
assert.equal(form.writePolicy, "");
|
||||
assert.equal(form.maxRounds, "");
|
||||
});
|
||||
|
||||
test("defaultsSummaryLine 只来自 health,不硬编码", () => {
|
||||
const line = defaultsSummaryLine({
|
||||
default_mode: "group_chat",
|
||||
default_write_policy: "auto",
|
||||
default_max_rounds: 12,
|
||||
});
|
||||
assert.ok(line.includes("group_chat"));
|
||||
assert.ok(line.includes("auto"));
|
||||
assert.ok(line.includes("12"));
|
||||
// health 未就绪时不伪造后端默认值
|
||||
assert.equal(defaultsSummaryLine(null), "正在读取后端默认配置…");
|
||||
assert.equal(defaultsSummaryLine(undefined), "正在读取后端默认配置…");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. 经理切换时席位真实移除 ──────────────────────────────────────────────
|
||||
|
||||
describe("经理与席位互斥(真实纯函数)", () => {
|
||||
test("filterSeatsForManager 移除当前经理", () => {
|
||||
assert.deepEqual(filterSeatsForManager(["a1", "a2", "a3"], "a2"), ["a1", "a3"]);
|
||||
assert.deepEqual(filterSeatsForManager(["a1", "a2"], "aX"), ["a1", "a2"]);
|
||||
// 无经理 → 原样返回
|
||||
assert.deepEqual(filterSeatsForManager(["a1", "a2"], ""), ["a1", "a2"]);
|
||||
});
|
||||
|
||||
test("toggleSeat 拒绝添加当前经理", () => {
|
||||
const seats = ["a2", "a3"];
|
||||
// a1 是经理 → toggle a1 不变
|
||||
assert.deepEqual(toggleSeat(seats, "a1", "a1"), seats);
|
||||
// toggle 非经理 → 切换
|
||||
assert.deepEqual(toggleSeat(["a2", "a3"], "a4", "a1"), ["a2", "a3", "a4"]);
|
||||
assert.deepEqual(toggleSeat(["a2", "a3", "a4"], "a4", "a1"), ["a2", "a3"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. maxRounds 校验 + 提交时拦截 ──────────────────────────────────────────
|
||||
|
||||
describe("maxRounds 校验(真实 validateMaxRounds)", () => {
|
||||
test("空串有效(= 使用默认)", () => {
|
||||
assert.ok(validateMaxRounds("").valid);
|
||||
assert.ok(validateMaxRounds(" ").valid);
|
||||
});
|
||||
test("1–64 整数有效", () => {
|
||||
assert.ok(validateMaxRounds("1").valid);
|
||||
assert.ok(validateMaxRounds("64").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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionOverrides:未改写不传,非法值返回错误", () => {
|
||||
const defaults = {
|
||||
default_mode: "structured",
|
||||
default_write_policy: "confirm_high_risk",
|
||||
default_max_rounds: 8,
|
||||
};
|
||||
|
||||
test("用户未改写 → 不传任何高级字段(让后端决定)", () => {
|
||||
const { overrides, maxRoundsError } = buildSessionOverrides(
|
||||
{ mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "" },
|
||||
defaults,
|
||||
);
|
||||
assert.deepEqual(overrides, {});
|
||||
assert.equal(maxRoundsError, undefined);
|
||||
});
|
||||
|
||||
test("用户改写 mode → 只传 mode", () => {
|
||||
const { overrides } = buildSessionOverrides(
|
||||
{ mode: "group_chat", writePolicy: "confirm_high_risk", maxRounds: "" },
|
||||
defaults,
|
||||
);
|
||||
assert.equal(overrides.mode, "group_chat");
|
||||
assert.equal(overrides.writePolicy, undefined);
|
||||
assert.equal(overrides.maxRounds, undefined);
|
||||
});
|
||||
|
||||
test("maxRounds=0 → 返回错误、不传", () => {
|
||||
const { overrides, maxRoundsError } = buildSessionOverrides(
|
||||
{ mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "0" },
|
||||
defaults,
|
||||
);
|
||||
assert.ok(maxRoundsError);
|
||||
assert.equal(overrides.maxRounds, undefined);
|
||||
});
|
||||
|
||||
test("maxRounds=65 → 返回错误", () => {
|
||||
const { maxRoundsError } = buildSessionOverrides(
|
||||
{ mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "65" },
|
||||
defaults,
|
||||
);
|
||||
assert.ok(maxRoundsError);
|
||||
});
|
||||
|
||||
test("maxRounds=3.5(小数)→ 返回错误", () => {
|
||||
const { maxRoundsError } = buildSessionOverrides(
|
||||
{ mode: "structured", writePolicy: "confirm_high_risk", maxRounds: "3.5" },
|
||||
defaults,
|
||||
);
|
||||
assert.ok(maxRoundsError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. pending 审批双击只发一次决策(守卫单一事实源)─────────────────────────
|
||||
|
||||
describe("审批决策按钮守卫 isDecisionButtonDisabled", () => {
|
||||
test("mutation pending 时禁用(防双击重复决策)", () => {
|
||||
assert.equal(isDecisionButtonDisabled({ pending: true, resuming: false }), true);
|
||||
});
|
||||
test("已批准等待恢复时禁用", () => {
|
||||
assert.equal(isDecisionButtonDisabled({ pending: false, resuming: true }), true);
|
||||
});
|
||||
test("空闲时可点", () => {
|
||||
assert.equal(isDecisionButtonDisabled({ pending: false, resuming: false }), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 5 & 6. canManualResume 恢复前置判定 ─────────────────────────────────────
|
||||
|
||||
describe("canManualResume 恢复前置判定", () => {
|
||||
test("有 pending approval → 不可恢复", () => {
|
||||
assert.equal(
|
||||
canManualResume({
|
||||
runStatus: "waiting_approval",
|
||||
approvalsLoading: false,
|
||||
approvalsSuccess: true,
|
||||
pendingCount: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("审批加载中 → 不可恢复", () => {
|
||||
assert.equal(
|
||||
canManualResume({
|
||||
runStatus: "waiting_approval",
|
||||
approvalsLoading: true,
|
||||
approvalsSuccess: false,
|
||||
pendingCount: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("审批加载失败 → 不可恢复", () => {
|
||||
assert.equal(
|
||||
canManualResume({
|
||||
runStatus: "waiting_approval",
|
||||
approvalsLoading: false,
|
||||
approvalsSuccess: false,
|
||||
pendingCount: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("非 waiting_approval → 不可恢复", () => {
|
||||
assert.equal(
|
||||
canManualResume({
|
||||
runStatus: "running",
|
||||
approvalsLoading: false,
|
||||
approvalsSuccess: true,
|
||||
pendingCount: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("审批清零且仍 waiting_approval 且已加载成功 → 允许恢复(补偿操作)", () => {
|
||||
assert.equal(
|
||||
canManualResume({
|
||||
runStatus: "waiting_approval",
|
||||
approvalsLoading: false,
|
||||
approvalsSuccess: true,
|
||||
pendingCount: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 10. 动作状态分类(executed/failed/unknown/expired 都正确)────────────────
|
||||
|
||||
describe("classifyActionsByStatus 动作分类", () => {
|
||||
function makeAction(id: string, status: string) {
|
||||
return {
|
||||
id,
|
||||
session_id: "s1",
|
||||
run_id: "r1",
|
||||
agent_id: "agent-a",
|
||||
tool_name: "write_tool",
|
||||
logical_action_id: null,
|
||||
risk_level: "R3",
|
||||
status,
|
||||
arguments: { target_key: "secret-should-not-leak" },
|
||||
result: null,
|
||||
external_object_id: null,
|
||||
approval_id: null,
|
||||
attempt_count: 1,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("classifyActionStatus 各状态映射正确", () => {
|
||||
assert.equal(classifyActionStatus("pending"), "waiting");
|
||||
assert.equal(classifyActionStatus("waiting_approval"), "waiting");
|
||||
assert.equal(classifyActionStatus("approved"), "approved");
|
||||
assert.equal(classifyActionStatus("rejected"), "rejected_or_expired");
|
||||
assert.equal(classifyActionStatus("expired"), "rejected_or_expired");
|
||||
assert.equal(classifyActionStatus("executed"), "executed");
|
||||
assert.equal(classifyActionStatus("failed"), "failed");
|
||||
assert.equal(classifyActionStatus("unknown"), "unknown");
|
||||
// 未知 status 落到 unknown(保守)
|
||||
assert.equal(classifyActionStatus("???"), "unknown");
|
||||
assert.equal(classifyActionStatus(null), "unknown");
|
||||
});
|
||||
|
||||
test("executed/failed/unknown/expired 都能正确分到对应桶", () => {
|
||||
const buckets = classifyActionsByStatus([
|
||||
makeAction("a1", "executed"),
|
||||
makeAction("a2", "failed"),
|
||||
makeAction("a3", "unknown"),
|
||||
makeAction("a4", "expired"),
|
||||
makeAction("a5", "pending"),
|
||||
makeAction("a6", "approved"),
|
||||
]);
|
||||
const byKey = new Map(buckets.map((b) => [b.key, b.items.length]));
|
||||
assert.equal(byKey.get("executed"), 1);
|
||||
assert.equal(byKey.get("failed"), 1);
|
||||
assert.equal(byKey.get("unknown"), 1);
|
||||
assert.equal(byKey.get("rejected_or_expired"), 1);
|
||||
assert.equal(byKey.get("waiting"), 1);
|
||||
assert.equal(byKey.get("approved"), 1);
|
||||
});
|
||||
|
||||
test("空输入 → 所有桶为空但结构完整", () => {
|
||||
const buckets = classifyActionsByStatus(null);
|
||||
assert.equal(buckets.length, 6);
|
||||
for (const b of buckets) assert.equal(b.items.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 11. 风险等级映射(真实 riskThemeFor,不再复制 map)──────────────────────
|
||||
|
||||
describe("风险等级映射 riskThemeFor", () => {
|
||||
test("R0/R1 → success(低)", () => {
|
||||
assert.equal(riskThemeFor("R0"), "success");
|
||||
assert.equal(riskThemeFor("R1"), "success");
|
||||
});
|
||||
test("R2 → warning(中)", () => {
|
||||
assert.equal(riskThemeFor("R2"), "warning");
|
||||
});
|
||||
test("R3/R4 → danger(高)", () => {
|
||||
assert.equal(riskThemeFor("R3"), "danger");
|
||||
assert.equal(riskThemeFor("R4"), "danger");
|
||||
});
|
||||
test("未知/null → default(保守,不放过未知风险)", () => {
|
||||
assert.equal(riskThemeFor("R99"), "default");
|
||||
assert.equal(riskThemeFor(null), "default");
|
||||
assert.equal(riskThemeFor(undefined), "default");
|
||||
});
|
||||
test("标签可读且未知原样返回", () => {
|
||||
assert.equal(riskLabelFor("R3"), "R3 · 高");
|
||||
assert.equal(riskLabelFor("R99"), "R99");
|
||||
assert.equal(riskLabelFor(null), "—");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 8 & 9. 事件流代次隔离 + 终态补齐(真实 coordinator + runIsLive)──────────
|
||||
|
||||
describe("事件流代次隔离 createMafEventRequestCoordinator", () => {
|
||||
test("旧请求延迟返回时,终态新请求仍执行且旧事件被丢弃", async () => {
|
||||
const coordinator = createMafEventRequestCoordinator();
|
||||
let resolveOld!: () => void;
|
||||
const oldResponse = new Promise<void>((resolve) => {
|
||||
resolveOld = resolve;
|
||||
});
|
||||
const acceptedEvents: number[] = [];
|
||||
let latestSeq = 0;
|
||||
const acceptPage = (generation: number, page: number[]) => {
|
||||
if (!coordinator.isCurrent(generation)) return;
|
||||
acceptedEvents.push(...page);
|
||||
latestSeq = Math.max(latestSeq, ...page);
|
||||
};
|
||||
|
||||
const oldGeneration = coordinator.nextGeneration();
|
||||
assert.equal(coordinator.start(oldGeneration), true);
|
||||
const oldRequest = (async () => {
|
||||
await oldResponse;
|
||||
acceptPage(oldGeneration, [99]); // 旧 run 的慢响应
|
||||
coordinator.finish(oldGeneration);
|
||||
})();
|
||||
|
||||
// running → completed 触发新代次;终态补拉必须看到完整历史
|
||||
const terminalGeneration = coordinator.nextGeneration();
|
||||
assert.equal(coordinator.start(terminalGeneration), true);
|
||||
acceptPage(terminalGeneration, [1, 2, 3]);
|
||||
coordinator.finish(terminalGeneration);
|
||||
|
||||
resolveOld();
|
||||
await oldRequest;
|
||||
|
||||
assert.deepEqual(acceptedEvents, [1, 2, 3]);
|
||||
assert.equal(latestSeq, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("终态 run 补齐历史", () => {
|
||||
test("runIsLive 对终态返回 false(停止轮询但仍补拉历史)", () => {
|
||||
assert.equal(runIsLive("completed"), false);
|
||||
assert.equal(runIsLive("failed"), false);
|
||||
assert.equal(runIsLive("cancelled"), false);
|
||||
});
|
||||
test("runIsLive 对活跃态返回 true", () => {
|
||||
assert.equal(runIsLive("running"), true);
|
||||
assert.equal(runIsLive("waiting_approval"), true);
|
||||
});
|
||||
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);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 13. 事件翻页 mergeBySeq(真实导出,不再复制)─────────────────────────────
|
||||
|
||||
describe("事件翻页 mergeBySeq(真实导出)", () => {
|
||||
test("两页合并无重复、升序", () => {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
});
|
||||
test("重叠页(网络重传)不产生重复", () => {
|
||||
const page1 = Array.from({ length: 100 }, (_, i) => makeEvent({ seq: i + 1 }));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 14. buildMafFlowGraph 纯函数 ────────────────────────────────────────────
|
||||
|
||||
describe("buildMafFlowGraph 流程图构造", () => {
|
||||
test("extractMafFlowGraph 解析 flow-json 块", () => {
|
||||
const text = '回复\n```flow-json\n{"title":"T","nodes":[{"id":"n1","label":"任务A","type":"task"}]}\n```';
|
||||
const g = extractMafFlowGraph(text);
|
||||
assert.ok(g);
|
||||
assert.equal(g!.nodes.length, 1);
|
||||
assert.equal(g!.nodes[0].label, "任务A");
|
||||
});
|
||||
|
||||
test("无 flow-json → null", () => {
|
||||
assert.equal(extractMafFlowGraph("普通文本无图"), null);
|
||||
});
|
||||
|
||||
test("checkpoint 有 confirmed_facts → 构造事实证据图", () => {
|
||||
const checkpoint = {
|
||||
fact_ledger: {
|
||||
candidate_facts: [],
|
||||
confirmed_facts: [
|
||||
{ candidate_id: "f1", text: "已确认事实一", evidence_refs: [] },
|
||||
],
|
||||
evidence: [],
|
||||
tool_results: [],
|
||||
executed_actions: [],
|
||||
pending_actions: [],
|
||||
failed_or_unknown_actions: [],
|
||||
conflicts: [],
|
||||
proposed_actions: [],
|
||||
},
|
||||
};
|
||||
const g = buildMafFlowGraph(checkpoint as any);
|
||||
assert.ok(g);
|
||||
assert.ok(g!.nodes.length >= 2); // root + fact
|
||||
assert.equal(g!.title, "事实证据图");
|
||||
});
|
||||
|
||||
test("空 checkpoint → null(组件显示空态)", () => {
|
||||
assert.equal(buildMafFlowGraph(null), null);
|
||||
assert.equal(buildMafFlowGraph({}), null);
|
||||
});
|
||||
|
||||
test("超过 200 节点的超大流程图被截断到 200", () => {
|
||||
const nodes = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: `n${i}`,
|
||||
label: `节点${i}`,
|
||||
type: "task",
|
||||
}));
|
||||
const text = "```flow-json\n" + JSON.stringify({ title: "超大图", nodes }) + "\n```";
|
||||
const g = extractMafFlowGraph(text);
|
||||
assert.ok(g);
|
||||
assert.ok(g!.truncated, "应标记 truncated");
|
||||
assert.equal(g!.nodes.length, 200, "最多 200 节点");
|
||||
});
|
||||
|
||||
test("超长 label 和 detail 被截断", () => {
|
||||
const longLabel = "A".repeat(500);
|
||||
const longDetail = "D".repeat(5000);
|
||||
const text =
|
||||
"```flow-json\n" +
|
||||
JSON.stringify({
|
||||
title: "T",
|
||||
nodes: [{ id: "n1", label: longLabel, type: "task", detail: longDetail }],
|
||||
}) +
|
||||
"\n```";
|
||||
const g = extractMafFlowGraph(text);
|
||||
assert.ok(g);
|
||||
assert.equal(g!.nodes[0].label.length, 161); // 160 + …
|
||||
assert.equal(g!.nodes[0].detail!.length, 2001); // 2000 + …
|
||||
});
|
||||
});
|
||||
|
||||
// ── 15. safeDisplayJson 二次脱敏(真实函数)──────────────────────────────────
|
||||
|
||||
describe("safeDisplayJson 统一脱敏", () => {
|
||||
test("敏感键直接删除(不显示成 ***),值与键名均不泄漏", () => {
|
||||
const result = safeDisplayJson({
|
||||
title: "可显示",
|
||||
target_key: "must-not-leak",
|
||||
nested: {
|
||||
token: "must-not-leak",
|
||||
normal: "可显示",
|
||||
},
|
||||
});
|
||||
assert.match(result.text, /可显示/);
|
||||
assert.doesNotMatch(result.text, /target_key|must-not-leak|token/);
|
||||
assert.ok(result.hiddenCount >= 2);
|
||||
assert.equal(result.truncated, false);
|
||||
});
|
||||
|
||||
test("只有敏感字段的对象 → text 为空(调用方应隐藏面板)", () => {
|
||||
const result = safeDisplayJson({ api_key: "k", password: "p" });
|
||||
assert.equal(result.text, "");
|
||||
assert.equal(result.hiddenCount, 2);
|
||||
});
|
||||
|
||||
test("null / 空对象 → text 为空", () => {
|
||||
assert.equal(safeDisplayJson(null).text, "");
|
||||
assert.equal(safeDisplayJson({}).text, "");
|
||||
assert.equal(safeDisplayJson(undefined).text, "");
|
||||
});
|
||||
|
||||
test("数组:递归脱敏 + 上限截断", () => {
|
||||
const big = Array.from({ length: 30 }, (_, i) => ({ id: i, secret: `s${i}` }));
|
||||
const result = safeDisplayJson(big);
|
||||
assert.ok(result.truncated); // 超过 20 项截断
|
||||
// 前 20 项的 secret 被删(hiddenCount=20);后 10 项整体被截断(由 truncated 体现)
|
||||
assert.equal(result.hiddenCount, 20);
|
||||
assert.doesNotMatch(result.text, /secret|s\d/);
|
||||
});
|
||||
|
||||
test("深层对象:超过最大深度 → truncated + 占位", () => {
|
||||
const deep = { a: { b: { c: { d: { e: { f: "too deep" } } } } } };
|
||||
const result = safeDisplayJson(deep);
|
||||
assert.ok(result.truncated);
|
||||
assert.match(result.text, /内容层级过深/);
|
||||
});
|
||||
|
||||
test("超长字符串:截断到 120 字 + …", () => {
|
||||
const long = "x".repeat(500);
|
||||
const result = safeDisplayJson({ note: long });
|
||||
assert.ok(result.truncated);
|
||||
assert.doesNotMatch(result.text, /x{121}/); // 不超过 120
|
||||
assert.match(result.text, /…/);
|
||||
});
|
||||
|
||||
test("单对象字段数上限 30 → 截断 + 计数准确", () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 50; i++) obj[`field${i}`] = i;
|
||||
const result = safeDisplayJson(obj);
|
||||
assert.ok(result.truncated);
|
||||
});
|
||||
|
||||
test("键名忽略大小写匹配", () => {
|
||||
const result = safeDisplayJson({ TOKEN: "t", Authorization: "a", Cookie: "c", safe: "ok" });
|
||||
assert.equal(result.hiddenCount, 3);
|
||||
assert.match(result.text, /ok/);
|
||||
// 敏感键名与值都不应出现
|
||||
assert.doesNotMatch(result.text, /TOKEN|Authorization|Cookie|"(t|a|c)"/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 12. 边界:MAF 目录无旧会商 import ────────────────────────────────────────
|
||||
|
||||
describe("MAF 目录边界:不依赖旧会商", () => {
|
||||
// 扫描 src/maf-roundtable 下所有源文件,断言不含旧会商目录引用。
|
||||
// 根目录用 process.cwd()/src/maf-roundtable(验收命令从 frontend-web 运行),
|
||||
// 不能用 import.meta.url——打包后它指向 TEMP 文件,递归会走到系统受保护目录。
|
||||
const mafRoot = join(process.cwd(), "src", "maf-roundtable");
|
||||
const forbidden = ["roundtable-planning", "@/roundtable", "from \"@/roundtable"];
|
||||
|
||||
function walk(dir: string, out: string[]) {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
// 权限不足 / 不可读目录直接跳过(避免 Windows 上的 EPERM 导致测试 flaky)
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const name = String(entry.name);
|
||||
const p = join(dir, name);
|
||||
let st;
|
||||
try {
|
||||
st = statSync(p);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
if (name === "__tests__") continue; // 测试目录本身允许提及(边界断言)
|
||||
if (name === "node_modules") continue;
|
||||
walk(p, out);
|
||||
} else if (st.isFile()) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkForbidden(filePath: string): string[] {
|
||||
const ext = extname(filePath);
|
||||
if (![".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) return [];
|
||||
const text = readFileSync(filePath, "utf8");
|
||||
const hits: string[] = [];
|
||||
for (const needle of forbidden) {
|
||||
if (text.includes(needle)) hits.push(needle);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
test("src/maf-roundtable 源文件不含 roundtable-planning / @/roundtable", () => {
|
||||
if (!existsSync(mafRoot)) {
|
||||
assert.fail(`MAF 源目录不存在:${mafRoot}(请从 frontend-web 目录运行测试)`);
|
||||
}
|
||||
const files: string[] = [];
|
||||
walk(mafRoot, files);
|
||||
assert.ok(files.length > 0, "应至少扫描到 MAF 源文件");
|
||||
const violations: string[] = [];
|
||||
for (const f of files) {
|
||||
const hits = checkForbidden(f);
|
||||
if (hits.length > 0) {
|
||||
violations.push(`${relative(mafRoot, f)}: ${hits.join(", ")}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
violations,
|
||||
[],
|
||||
`MAF 源文件不得引用旧会商目录,发现:\n${violations.join("\n")}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* useMafEvents 挂载测试(vitest + jsdom + Testing Library)。
|
||||
*
|
||||
* 覆盖(提交 2):慢请求 A 进行中时切到 run B,再 resolve A,断言屏幕上只出现 B 的事件
|
||||
* (代次隔离:旧 run 的慢响应被丢弃,不污染新 run)。
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
const pendingByRun = new Map<string, { resolve: (v: unknown) => void }>();
|
||||
|
||||
vi.mock("../api/events", () => ({
|
||||
listEvents: vi.fn((runId: string) => {
|
||||
let resolveFn!: (v: unknown) => void;
|
||||
const promise = new Promise((res) => {
|
||||
resolveFn = res;
|
||||
});
|
||||
pendingByRun.set(runId, { resolve: resolveFn });
|
||||
return promise;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useMafEvents } from "../hooks/use-maf-events";
|
||||
import type { MafEvent } from "../api/types";
|
||||
|
||||
function makeEv(seq: number, runId: string): MafEvent {
|
||||
return {
|
||||
id: `ev-${runId}-${seq}`,
|
||||
session_id: "s1",
|
||||
run_id: runId,
|
||||
seq,
|
||||
event_type: "agent.message.delta",
|
||||
agent_id: "a",
|
||||
turn_index: 0,
|
||||
payload: { text: `${runId}-${seq}` },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
/** 测试桩:用 hook 渲染当前 run 的事件 seq 列表。 */
|
||||
function Harness({ runId }: { runId: string }) {
|
||||
const { events } = useMafEvents(runId, { enabled: true, polling: false });
|
||||
return (
|
||||
<div data-testid="out">
|
||||
{events.map((e) => (
|
||||
<span key={e.seq}>{e.seq},</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useMafEvents 代次隔离", () => {
|
||||
it("慢请求 A 进行中切到 B,再 resolve A,屏幕只出现 B 的事件", async () => {
|
||||
// 挂载 run A:listEvents("A") 返回挂起的 promise
|
||||
const { rerender } = render(<Harness runId="A" />);
|
||||
expect(pendingByRun.has("A")).toBe(true);
|
||||
|
||||
// 切到 run B:触发新一代次;listEvents("B") 返回可解析的 promise
|
||||
rerender(<Harness runId="B" />);
|
||||
expect(pendingByRun.has("B")).toBe(true);
|
||||
|
||||
// B 的历史先到达:事件 1,2,3
|
||||
pendingByRun.get("B")!.resolve({
|
||||
events: [makeEv(1, "B"), makeEv(2, "B"), makeEv(3, "B")],
|
||||
latest_seq: 3,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const txt = screen.getByTestId("out").textContent ?? "";
|
||||
expect(txt).toContain("1,");
|
||||
expect(txt).toContain("2,");
|
||||
expect(txt).toContain("3,");
|
||||
});
|
||||
|
||||
// 现在 resolve A 的慢响应(事件 99):必须被丢弃,不污染 B 的视图
|
||||
pendingByRun.get("A")!.resolve({
|
||||
events: [makeEv(99, "A")],
|
||||
latest_seq: 99,
|
||||
});
|
||||
|
||||
// 等一个微任务轮次,确保 A 的回调已执行(并被代次守卫丢弃)
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const txt = screen.getByTestId("out").textContent ?? "";
|
||||
expect(txt).not.toContain("99");
|
||||
// B 的三个事件仍然在
|
||||
expect(txt).toContain("1,2,3,");
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* MAF 审批 —— `/runs/{id}/approvals`、`/approvals/{id}/decision`、`/runs/{id}/actions`。
|
||||
*
|
||||
* decide 成功后 invalidate approvals + actions + events(事件轮询会自然看到
|
||||
* action.approved/rejected,但显式 invalidate 让卡片状态即时翻转)。
|
||||
*
|
||||
* P0-4:hook 签名改为 (runId, isLive)。run 运行期间每 2s 刷新一次;终态停止。
|
||||
* 不能以"当前有没有 pending 审批"决定是否刷新——审批通常是进入页面后才由工作流产生的。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { BASE, getJson, sendJson } from "./client";
|
||||
import type {
|
||||
MafAction,
|
||||
MafActionListResponse,
|
||||
MafApproval,
|
||||
MafApprovalDecisionRequest,
|
||||
MafApprovalListResponse,
|
||||
} from "./types";
|
||||
|
||||
const KEY = "maf-roundtable";
|
||||
|
||||
/** live 时审批/动作轮询间隔(ms)。 */
|
||||
const LIVE_POLL_INTERVAL = 2000;
|
||||
|
||||
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function listApprovals(runId: string): Promise<MafApprovalListResponse> {
|
||||
return getJson<MafApprovalListResponse>(
|
||||
`${BASE()}/runs/${runId}/approvals`,
|
||||
"获取审批列表",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listActions(runId: string): Promise<MafActionListResponse> {
|
||||
return getJson<MafActionListResponse>(
|
||||
`${BASE()}/runs/${runId}/actions`,
|
||||
"获取动作列表",
|
||||
);
|
||||
}
|
||||
|
||||
export async function decideApproval(
|
||||
approvalId: string,
|
||||
body: MafApprovalDecisionRequest,
|
||||
): Promise<MafApproval> {
|
||||
return sendJson<MafApproval>(
|
||||
`${BASE()}/approvals/${approvalId}/decision`,
|
||||
"POST",
|
||||
body,
|
||||
"提交审批决策",
|
||||
);
|
||||
}
|
||||
|
||||
// ── React Query hooks ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 审批列表。run live 时每 LIVE_POLL_INTERVAL 刷新;终态停止。
|
||||
* 不能以"当前有没有 pending"决定是否刷新——审批是进入页面后才产生的。
|
||||
*/
|
||||
export function useMafApprovals(runId: string | undefined, isLive: boolean) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "runs", runId, "approvals"],
|
||||
queryFn: () => listApprovals(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: isLive ? LIVE_POLL_INTERVAL : false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 动作列表。run live 时每 LIVE_POLL_INTERVAL 刷新;终态停止。
|
||||
*/
|
||||
export function useMafActions(runId: string | undefined, isLive: boolean) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "runs", runId, "actions"],
|
||||
queryFn: () => listActions(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: isLive ? LIVE_POLL_INTERVAL : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMafApprovalActions(runId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
decide: useMutation({
|
||||
mutationFn: ({
|
||||
approvalId,
|
||||
decision,
|
||||
}: {
|
||||
approvalId: string;
|
||||
decision: "approved" | "rejected";
|
||||
}) => decideApproval(approvalId, { decision }),
|
||||
onSuccess: () => {
|
||||
if (!runId) return;
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId, "approvals"] });
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId, "actions"] });
|
||||
// run 状态会因 resume 改变(approved → running),强制下一次轮询立即生效。
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", runId] });
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 辅助:按 action_id 把 approval 和 action 关联起来 ─────────────────────
|
||||
|
||||
/** 把 pending 审批按 action_id 索引,便于卡片查找对应 action 的工具名/参数。 */
|
||||
export function indexApprovalsByAction(
|
||||
approvals: MafApproval[] | undefined,
|
||||
): Map<string, MafApproval> {
|
||||
const map = new Map<string, MafApproval>();
|
||||
for (const a of approvals ?? []) {
|
||||
if (a.status === "pending" && a.action_id) map.set(a.action_id, a);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 按 id 索引 action,供审批卡片取 tool_name/arguments。 */
|
||||
export function indexActions(
|
||||
actions: MafAction[] | undefined,
|
||||
): Map<string, MafAction> {
|
||||
const map = new Map<string, MafAction>();
|
||||
for (const a of actions ?? []) map.set(a.id, map.get(a.id) ?? a);
|
||||
return map;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* MAF 多智能体会商 API 客户端地基。
|
||||
*
|
||||
* 复用全局 `apiFetch`(自动注入 Authorization + credentials:include),这里只做:
|
||||
* - 拼 URL(`${getBackendBaseURL()}/api/maf-roundtable/...`)
|
||||
* - 统一 `res.ok` 检查 + detail 提取(仿 task-buttons 的 readJson)
|
||||
* - JSON 解析
|
||||
*
|
||||
* 不在此做 snake_case ⇄ camelCase 转换——后端即 snake_case,类型层直传(见 types.ts)。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** MAF roundtable 后端根路径(无尾斜杠)。 */
|
||||
export const BASE = (): string => `${getBackendBaseURL()}/api/maf-roundtable`;
|
||||
|
||||
/**
|
||||
* 检查 Response 并解析 JSON;失败时抛出带后端 `detail` 的 Error。
|
||||
*
|
||||
* - 404 = 不存在或非本人(后端统一返回 404 防止泄漏他人资源存在性)
|
||||
* - 403 = 非所有者写(admin 也不能写别人的)
|
||||
* - 503 = 功能关闭 / store 未就绪
|
||||
*
|
||||
* 这里不区分状态码语义,统一把 detail 透传给上层 toast/错误展示。
|
||||
*/
|
||||
export async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} 失败(HTTP ${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/** GET JSON 的快捷封装。 */
|
||||
export async function getJson<T>(url: string, ctx: string): Promise<T> {
|
||||
const res = await apiFetch(url, { method: "GET" });
|
||||
return readJson<T>(res, ctx);
|
||||
}
|
||||
|
||||
/** 发送 JSON body 的快捷封装。 */
|
||||
export async function sendJson<T>(
|
||||
url: string,
|
||||
method: "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
body: unknown,
|
||||
ctx: string,
|
||||
): Promise<T> {
|
||||
const res = await apiFetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return readJson<T>(res, ctx);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* MAF 事件断线补拉 —— `GET /runs/{id}/events?after_seq=N&limit=...`。
|
||||
*
|
||||
* 这是**裸 fetch 函数**(非 hook):cursor `after_seq` 需在每次拉取后推进,
|
||||
* useQuery 的 queryFn 不持有「上次 latest_seq」,所以轮询逻辑放在
|
||||
* hooks/use-maf-events.ts(自定义 interval hook),这里只负责一次请求。
|
||||
*
|
||||
* 后端契约(events.py list_events):返回 `seq > after_seq` 升序;payload 已脱敏。
|
||||
*/
|
||||
|
||||
import { BASE, getJson } from "./client";
|
||||
import type { MafEventListResponse } from "./types";
|
||||
|
||||
/**
|
||||
* 拉取 `after_seq` 之后的事件(不含 after_seq 本身)。
|
||||
*
|
||||
* @param runId run id
|
||||
* @param afterSeq 上一次拉到的 latest_seq;首次传 0 补拉全部历史
|
||||
* @param limit 单次上限,默认 1000、最大 10000(后端 Query 约束)
|
||||
*/
|
||||
export async function listEvents(
|
||||
runId: string,
|
||||
afterSeq = 0,
|
||||
limit = 1000,
|
||||
): Promise<MafEventListResponse> {
|
||||
const qs = new URLSearchParams({
|
||||
after_seq: String(afterSeq),
|
||||
limit: String(limit),
|
||||
});
|
||||
return getJson<MafEventListResponse>(
|
||||
`${BASE()}/runs/${runId}/events?${qs.toString()}`,
|
||||
"获取会商事件",
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* MAF run 生命周期 + health 探测 —— `/sessions/{id}/runs`、`/runs/{id}`、`/health`。
|
||||
*
|
||||
* 轮询策略:
|
||||
* - `useMafRun` 用函数式 `refetchInterval`:run 仍 live(pending/running/
|
||||
* waiting_approval/cancelling/cancel_failed)时每 1.8s 重拉;终态停。
|
||||
* 镜像 core/scheduled-tasks/hooks.ts:105-115 的模式。
|
||||
* - events 轮询不在此处,见 hooks/use-maf-events.ts(cursor 推进需自定义 hook)。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { BASE, getJson, sendJson } from "./client";
|
||||
import { runIsLive, type MafHealthResponse, type MafRun, type MafRunListResponse } from "./types";
|
||||
|
||||
const KEY = "maf-roundtable";
|
||||
|
||||
/** run 轮询间隔(ms)。1.8s —— 平衡实时感与后端负载。 */
|
||||
export const RUN_POLL_INTERVAL = 1800;
|
||||
|
||||
// ── health ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getMafHealth(): Promise<MafHealthResponse> {
|
||||
return getJson<MafHealthResponse>(`${BASE()}/health`, "获取 MAF 健康状态");
|
||||
}
|
||||
|
||||
/** health 探测:决定入口页是否显示「功能未启用」空态。失败时视为 disabled。 */
|
||||
export function useMafHealth() {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "health"],
|
||||
queryFn: getMafHealth,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
retry: false, // 探测失败不重试,直接走 disabled 空态
|
||||
});
|
||||
}
|
||||
|
||||
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getRun(runId: string): Promise<MafRun> {
|
||||
return getJson<MafRun>(`${BASE()}/runs/${runId}`, "获取会商 run");
|
||||
}
|
||||
|
||||
export async function listRuns(sessionId: string): Promise<MafRunListResponse> {
|
||||
return getJson<MafRunListResponse>(
|
||||
`${BASE()}/sessions/${sessionId}/runs`,
|
||||
"获取 run 列表",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createRun(
|
||||
sessionId: string,
|
||||
workflowVersion?: string,
|
||||
): Promise<MafRun> {
|
||||
return sendJson<MafRun>(
|
||||
`${BASE()}/sessions/${sessionId}/runs`,
|
||||
"POST",
|
||||
workflowVersion ? { workflow_version: workflowVersion } : {},
|
||||
"启动会商 run",
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelRun(runId: string): Promise<MafRun> {
|
||||
return sendJson<MafRun>(`${BASE()}/runs/${runId}/cancel`, "POST", {}, "取消会商");
|
||||
}
|
||||
|
||||
export async function resumeRun(runId: string): Promise<MafRun> {
|
||||
return sendJson<MafRun>(`${BASE()}/runs/${runId}/resume`, "POST", {}, "恢复会商");
|
||||
}
|
||||
|
||||
export async function recoverRun(runId: string): Promise<MafRun> {
|
||||
return sendJson<MafRun>(`${BASE()}/runs/${runId}/recover`, "POST", {}, "重试会商");
|
||||
}
|
||||
|
||||
// ── React Query hooks ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 单个 run —— live 时每 1.8s 轮询,终态停。
|
||||
* 这是事件轮询的「开关」:父组件用 `runIsLive(run.status)` 决定是否拉事件。
|
||||
*/
|
||||
export function useMafRun(runId: string | undefined | null) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "runs", runId],
|
||||
queryFn: () => getRun(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: (query) =>
|
||||
runIsLive(query.state.data?.status) ? RUN_POLL_INTERVAL : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMafRuns(sessionId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "sessions", sessionId, "runs"],
|
||||
queryFn: () => listRuns(sessionId!),
|
||||
enabled: !!sessionId,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMafRunActions() {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
start: useMutation({
|
||||
mutationFn: ({ sessionId, workflowVersion }: { sessionId: string; workflowVersion?: string }) =>
|
||||
createRun(sessionId, workflowVersion),
|
||||
onSuccess: (run) => {
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "sessions", run.session_id, "runs"] });
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
||||
},
|
||||
}),
|
||||
cancel: useMutation({
|
||||
mutationFn: cancelRun,
|
||||
onSuccess: (run) => {
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
||||
},
|
||||
}),
|
||||
resume: useMutation({
|
||||
mutationFn: resumeRun,
|
||||
onSuccess: (run) => {
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
||||
},
|
||||
}),
|
||||
recover: useMutation({
|
||||
mutationFn: recoverRun,
|
||||
onSuccess: (run) => {
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "runs", run.id] });
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* MAF 会话 CRUD —— `/sessions`。
|
||||
*
|
||||
* 镜像 strategy-components/api/task-buttons.ts 的 colocated hook 模式:
|
||||
* queryKey 用嵌套数组;mutation 成功后 invalidate 同前缀。
|
||||
* snake_case 直传(见 types.ts)。
|
||||
*
|
||||
* 唯一依据:offline-backend-20260512/backend/app/maf_roundtable/schemas.py
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { BASE, getJson, sendJson } from "./client";
|
||||
import type {
|
||||
MafSession,
|
||||
MafSessionListResponse,
|
||||
} from "./types";
|
||||
|
||||
const KEY = "maf-roundtable";
|
||||
|
||||
// ── 请求体(对齐 schemas.py CreateSessionRequest / UpdateSessionRequest)──
|
||||
|
||||
export interface ParticipantInput {
|
||||
agent_id: string;
|
||||
display_name?: string;
|
||||
role: "manager" | "seat";
|
||||
sort_order?: number;
|
||||
excluded_tools?: string[];
|
||||
}
|
||||
|
||||
export interface CreateSessionBody {
|
||||
title: string;
|
||||
task_text: string;
|
||||
task_payload?: Record<string, unknown> | null;
|
||||
mode?: "structured" | "group_chat";
|
||||
model_name?: string | null;
|
||||
reasoning_effort?: string | null;
|
||||
manager_agent_id?: string | null;
|
||||
max_rounds?: number;
|
||||
write_policy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
||||
participants: ParticipantInput[];
|
||||
excluded_tools?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateSessionBody {
|
||||
title?: string;
|
||||
task_text?: string;
|
||||
task_payload?: Record<string, unknown> | null;
|
||||
mode?: "structured" | "group_chat";
|
||||
model_name?: string | null;
|
||||
reasoning_effort?: string | null;
|
||||
manager_agent_id?: string | null;
|
||||
max_rounds?: number;
|
||||
write_policy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
||||
participants?: ParticipantInput[];
|
||||
fact_ledger?: Record<string, unknown> | null;
|
||||
final_result?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ── 裸 fetch 函数 ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function listSessions(params?: {
|
||||
status?: string;
|
||||
mode?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MafSessionListResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.status) qs.set("status", params.status);
|
||||
if (params?.mode) qs.set("mode", params.mode);
|
||||
if (params?.limit != null) qs.set("limit", String(params.limit));
|
||||
if (params?.offset != null) qs.set("offset", String(params.offset));
|
||||
const query = qs.toString();
|
||||
return getJson<MafSessionListResponse>(
|
||||
`${BASE()}/sessions${query ? `?${query}` : ""}`,
|
||||
"获取会话列表",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSession(id: string): Promise<MafSession> {
|
||||
return getJson<MafSession>(`${BASE()}/sessions/${id}`, "获取会话");
|
||||
}
|
||||
|
||||
export async function createSession(body: CreateSessionBody): Promise<MafSession> {
|
||||
return sendJson<MafSession>(`${BASE()}/sessions`, "POST", body, "创建会话");
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
id: string,
|
||||
body: UpdateSessionBody,
|
||||
): Promise<MafSession> {
|
||||
return sendJson<MafSession>(`${BASE()}/sessions/${id}`, "PUT", body, "更新会话");
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
await sendJson<{ deleted: boolean }>(
|
||||
`${BASE()}/sessions/${id}`,
|
||||
"DELETE",
|
||||
undefined,
|
||||
"删除会话",
|
||||
);
|
||||
}
|
||||
|
||||
// ── React Query hooks ─────────────────────────────────────────────────────
|
||||
|
||||
export function useMafSessions(params?: {
|
||||
status?: string;
|
||||
mode?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "sessions", params],
|
||||
queryFn: () => listSessions(params),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMafSession(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [KEY, "sessions", id],
|
||||
queryFn: () => getSession(id!),
|
||||
enabled: !!id,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMafSessionActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: [KEY, "sessions"] });
|
||||
return {
|
||||
create: useMutation({ mutationFn: createSession, onSuccess: invalidate }),
|
||||
update: useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: UpdateSessionBody }) =>
|
||||
updateSession(id, body),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({ mutationFn: deleteSession, onSuccess: invalidate }),
|
||||
};
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* MAF 多智能体会商 —— 后端 `/api/maf-roundtable` 的 TypeScript 类型。
|
||||
*
|
||||
* 唯一依据:offline-backend-20260512/backend/app/maf_roundtable/schemas.py。
|
||||
* 后端持久化即 snake_case(见 packages/harness/deerflow/persistence/maf_roundtable),
|
||||
* 这里**直传不转换**:与后端字段一一对应,避免边界处多一层映射。
|
||||
* 所有 `payload` 已在后端写入时脱敏(`sanitize_payload`),前端拿到的就是可渲染的。
|
||||
*/
|
||||
|
||||
// ── health(GET /health)───────────────────────────────────────────────────
|
||||
export interface MafHealthResponse {
|
||||
enabled: boolean;
|
||||
store_available: boolean;
|
||||
default_mode: string;
|
||||
default_write_policy: string;
|
||||
default_max_rounds: number;
|
||||
admin_diagnostic_access: boolean;
|
||||
write_guard_enabled: boolean;
|
||||
write_guard_ready: boolean;
|
||||
action_secret_configured: boolean;
|
||||
write_guard_status:
|
||||
| "disabled"
|
||||
| "provider_unavailable"
|
||||
| "missing_secret"
|
||||
| "ready";
|
||||
}
|
||||
|
||||
// ── session(/sessions)────────────────────────────────────────────────────
|
||||
export type MafSessionStatus =
|
||||
| "draft"
|
||||
| "running"
|
||||
| "waiting_approval"
|
||||
| "waiting_input"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface MafParticipant {
|
||||
id: string;
|
||||
session_id: string;
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
role: "manager" | "seat";
|
||||
sort_order: number;
|
||||
thread_id: string | null;
|
||||
status: string;
|
||||
last_turn_index: number | null;
|
||||
capability_snapshot: Record<string, unknown> | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
task_text: string;
|
||||
task_payload: Record<string, unknown> | null;
|
||||
mode: "structured" | "group_chat";
|
||||
status: MafSessionStatus;
|
||||
model_name: string | null;
|
||||
reasoning_effort: string | null;
|
||||
manager_agent_id: string | null;
|
||||
max_rounds: number;
|
||||
write_policy: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
||||
capability_snapshot: Record<string, unknown> | null;
|
||||
fact_ledger: Record<string, unknown> | null;
|
||||
final_result: Record<string, unknown> | null;
|
||||
participants: MafParticipant[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafSessionListResponse {
|
||||
sessions: MafSession[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── run(/sessions/{id}/runs, /runs/{id})──────────────────────────────────
|
||||
/**
|
||||
* Run 状态机。`runIsLive` 判定为 true 的状态会继续轮询;其余(completed/failed/
|
||||
* cancelled)是终态,停止轮询。cancelling / cancel_failed 是非终态中间态,仍需轮询。
|
||||
*/
|
||||
export type MafRunStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "waiting_approval"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "cancelling"
|
||||
| "cancel_failed";
|
||||
|
||||
export interface MafRun {
|
||||
id: string;
|
||||
session_id: string;
|
||||
status: MafRunStatus;
|
||||
workflow_version: string | null;
|
||||
/**
|
||||
* MAF 工作流检查点。结构见 RoundtableState:含 fact_ledger / final_result /
|
||||
* seat_outcomes / current_round 等。这里用宽松类型——前端只读其中几个已知键。
|
||||
*/
|
||||
maf_checkpoint: MafCheckpoint | null;
|
||||
last_event_seq: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
error_code: string | null;
|
||||
error_message_safe: string | null;
|
||||
token_usage: Record<string, number> | null;
|
||||
latency_ms: number | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafRunListResponse {
|
||||
runs: MafRun[];
|
||||
}
|
||||
|
||||
// ── checkpoint 内部结构(maf_checkpoint,宽松类型)──────────────────────────
|
||||
/** 事实账本(fact_ledger.empty_ledger 的形状)。 */
|
||||
export interface MafFactLedger {
|
||||
candidate_facts: MafCandidateFact[];
|
||||
confirmed_facts: MafConfirmedFact[];
|
||||
evidence: MafEvidence[];
|
||||
tool_results: MafToolResult[];
|
||||
executed_actions: MafLedgerAction[];
|
||||
pending_actions: MafLedgerAction[];
|
||||
failed_or_unknown_actions: MafLedgerAction[];
|
||||
conflicts: MafLedgerConflict[];
|
||||
proposed_actions: MafLedgerAction[];
|
||||
}
|
||||
|
||||
export interface MafCandidateFact {
|
||||
candidate_id: string;
|
||||
text: string;
|
||||
agent_id?: string;
|
||||
source?: string;
|
||||
confirmed?: boolean;
|
||||
}
|
||||
|
||||
export interface MafConfirmedFact {
|
||||
candidate_id: string;
|
||||
text: string;
|
||||
agent_id?: string;
|
||||
source?: string;
|
||||
confirmed_by?: string;
|
||||
evidence_refs: string[];
|
||||
}
|
||||
|
||||
export interface MafEvidence {
|
||||
ref?: string;
|
||||
text?: string;
|
||||
agent_id?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MafToolResult {
|
||||
tool_name: string;
|
||||
status: string; // "completed" | "waiting_approval" | "failed" | "unknown"
|
||||
agent_id?: string;
|
||||
turn_index?: number;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MafLedgerAction {
|
||||
action_id?: string;
|
||||
tool_name?: string;
|
||||
status?: string;
|
||||
agent_id?: string;
|
||||
external_object_id?: string;
|
||||
text?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MafLedgerConflict {
|
||||
text: string;
|
||||
agent_id?: string;
|
||||
}
|
||||
|
||||
/** 席位产出(maf_checkpoint.seat_outcomes 的 value)。 */
|
||||
export interface MafSeatOutcome {
|
||||
status: string; // "completed" | "running" | "failed" | ...
|
||||
execution_stage?: string;
|
||||
agent_id?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MafFinalResult {
|
||||
content: string;
|
||||
agent_id?: string;
|
||||
partial_failure?: boolean;
|
||||
degraded?: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface MafCheckpoint {
|
||||
fact_ledger?: MafFactLedger;
|
||||
seat_outcomes?: Record<string, MafSeatOutcome>;
|
||||
final_result?: MafFinalResult;
|
||||
current_round?: number;
|
||||
seat_agent_ids?: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
// ── event(/runs/{id}/events)──────────────────────────────────────────────
|
||||
/**
|
||||
* 事件分类(见后端 app/maf_roundtable/event_normalizer.py)。
|
||||
* payload 已脱敏,结构因 event_type 而异,这里用 Record 宽松承载。
|
||||
*/
|
||||
export type MafEventType =
|
||||
| "run.started"
|
||||
| "run.completed"
|
||||
| "run.failed"
|
||||
| "run.cancelled"
|
||||
| "agent.turn_started"
|
||||
| "agent.message.delta"
|
||||
| "agent.message.completed"
|
||||
| "tool.requested"
|
||||
| "tool.started"
|
||||
| "tool.completed"
|
||||
| "tool.failed"
|
||||
| "skill.selected"
|
||||
| "skill.started"
|
||||
| "skill.completed"
|
||||
| "skill.failed"
|
||||
| "artifact.created"
|
||||
| "action.waiting_approval"
|
||||
| "action.approved"
|
||||
| "action.rejected"
|
||||
| "workflow.checkpointed"
|
||||
| string; // 兜底:后端可能新增,前端不强断
|
||||
|
||||
export interface MafEvent {
|
||||
id: string;
|
||||
session_id: string;
|
||||
run_id: string;
|
||||
seq: number;
|
||||
event_type: MafEventType;
|
||||
agent_id: string | null;
|
||||
turn_index: number | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafEventListResponse {
|
||||
events: MafEvent[];
|
||||
latest_seq: number;
|
||||
}
|
||||
|
||||
// ── action(/runs/{id}/actions, /actions/{id})─────────────────────────────
|
||||
export type MafActionStatus =
|
||||
| "pending"
|
||||
| "waiting_approval"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "executed"
|
||||
| "failed"
|
||||
| "unknown"
|
||||
| "expired"
|
||||
| string;
|
||||
|
||||
export interface MafAction {
|
||||
id: string;
|
||||
session_id: string;
|
||||
run_id: string | null;
|
||||
agent_id: string | null;
|
||||
tool_name: string;
|
||||
logical_action_id: string | null;
|
||||
risk_level: string;
|
||||
status: MafActionStatus;
|
||||
arguments: Record<string, unknown> | null;
|
||||
result: Record<string, unknown> | null;
|
||||
external_object_id: string | null;
|
||||
approval_id: string | null;
|
||||
attempt_count: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafActionListResponse {
|
||||
actions: MafAction[];
|
||||
}
|
||||
|
||||
// ── approval(/runs/{id}/approvals, /approvals/{id}/decision)──────────────
|
||||
export type MafApprovalStatus = "pending" | "approved" | "rejected" | string;
|
||||
|
||||
export interface MafApproval {
|
||||
id: string;
|
||||
session_id: string;
|
||||
run_id: string | null;
|
||||
action_id: string | null;
|
||||
requested_by_agent_id: string | null;
|
||||
status: MafApprovalStatus;
|
||||
summary: string;
|
||||
payload: Record<string, unknown> | null;
|
||||
decided_by: string | null;
|
||||
decided_at: string | null;
|
||||
expires_at: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface MafApprovalListResponse {
|
||||
approvals: MafApproval[];
|
||||
}
|
||||
|
||||
export interface MafApprovalDecisionRequest {
|
||||
decision: "approved" | "rejected";
|
||||
}
|
||||
|
||||
// ── 终态判定 ───────────────────────────────────────────────────────────────
|
||||
const LIVE_STATUSES: ReadonlySet<MafRunStatus> = new Set([
|
||||
"pending",
|
||||
"running",
|
||||
"waiting_approval",
|
||||
"cancelling",
|
||||
"cancel_failed",
|
||||
]);
|
||||
|
||||
/** run 是否仍可能产生新事件 —— true 时继续轮询。 */
|
||||
export function runIsLive(status: string | null | undefined): boolean {
|
||||
return !!status && (LIVE_STATUSES as Set<string>).has(status);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* MAF 外部动作审计面板(MAF-06-07:可视层改用本地 `tdesign-react`)。
|
||||
*
|
||||
* 数据使用当前页面已正确存在的 useMafActions(runId, live)(轮询 run live 时刷新,
|
||||
* 终态停止)。按真实 action.status 分类展示,分桶语义见 lib/action-audit.ts。
|
||||
*
|
||||
* 安全展示:每项最多展示——工具名称、风险等级、状态、安全 action id、已脱敏参数摘要、
|
||||
* 可安全公开的 external_object_id、开始/结束时间。
|
||||
* 绝不展示原始 result、内部参数、target_key、idempotency_key(参数渲染前先走 safeSummary 脱敏)。
|
||||
*/
|
||||
|
||||
import { AlertTriangle, ClipboardList, Loader2 } from "lucide-react";
|
||||
import { Card as TCard, Tag as TTag } from "tdesign-react";
|
||||
|
||||
import { useMafActions } from "../api/approvals";
|
||||
import type { MafAction } from "../api/types";
|
||||
import { classifyActionsByStatus, type ActionBucket } from "../lib/action-audit";
|
||||
import { riskLabelFor, riskThemeFor } from "../lib/risk-theme";
|
||||
import { safeDisplayJson } from "../lib/safe-display";
|
||||
|
||||
interface MafActionAuditPanelProps {
|
||||
runId: string;
|
||||
/** run 仍 live → 启用轮询;终态停止。 */
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
export function MafActionAuditPanel({ runId, live }: MafActionAuditPanelProps) {
|
||||
const actionsQuery = useMafActions(runId, live);
|
||||
const actions = actionsQuery.data?.actions ?? [];
|
||||
const buckets = classifyActionsByStatus(actions);
|
||||
const total = actions.length;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<ClipboardList className="h-4 w-4 text-slate-500" />
|
||||
外部动作审计
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400">
|
||||
{actionsQuery.isLoading ? "加载中…" : `${total} 条`}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{actionsQuery.isLoading && (
|
||||
<div className="flex items-center justify-center py-4 text-xs text-slate-400">
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载动作…
|
||||
</div>
|
||||
)}
|
||||
{actionsQuery.isError && (
|
||||
<div className="flex items-center gap-1 py-3 text-center text-xs text-red-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
加载失败:{(actionsQuery.error as Error)?.message ?? "未知错误"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!actionsQuery.isLoading && !actionsQuery.isError && total === 0 && (
|
||||
<p className="py-4 text-center text-xs text-slate-400">暂无外部动作记录</p>
|
||||
)}
|
||||
|
||||
{!actionsQuery.isLoading && !actionsQuery.isError && total > 0 && (
|
||||
<div className="space-y-2">
|
||||
{buckets.map((bucket) =>
|
||||
bucket.items.length === 0 ? null : (
|
||||
<BucketBlock key={bucket.key} bucket={bucket} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BucketBlock({ bucket }: { bucket: ActionBucket }) {
|
||||
const theme = bucketTheme(bucket.key);
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<TTag size="small" theme={theme} variant="light">{bucket.items.length}</TTag>
|
||||
<span className="text-xs font-medium text-slate-700">{bucket.label}</span>
|
||||
<span className="text-[10px] text-slate-400">{bucket.hint}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{bucket.items.map((a) => (
|
||||
<ActionRow key={a.id} action={a} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ action }: { action: MafAction }) {
|
||||
return (
|
||||
<TCard bordered className="px-2 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs font-medium text-slate-700">
|
||||
{truncate(action.tool_name || "未知工具", 32)}
|
||||
</span>
|
||||
<TTag size="small" theme={riskThemeFor(action.risk_level)} variant="light-outline">
|
||||
{riskLabelFor(action.risk_level)}
|
||||
</TTag>
|
||||
<TTag size="small" theme="default" variant="outline">{action.status}</TTag>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] text-slate-400">
|
||||
<span title="安全 action id">#{shortId(action.id)}</span>
|
||||
{action.agent_id && <span title="席位">席位:{truncate(action.agent_id, 16)}</span>}
|
||||
{action.external_object_id && (
|
||||
<span title="外部对象 id">对象:{truncate(String(action.external_object_id), 24)}</span>
|
||||
)}
|
||||
{action.started_at && <span>{formatTime(action.started_at)}</span>}
|
||||
{action.finished_at && <span>→ {formatTime(action.finished_at)}</span>}
|
||||
{action.attempt_count > 0 && <span title="尝试次数">尝试 {action.attempt_count}</span>}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const safe = safeDisplayJson(action.arguments);
|
||||
if (!safe.text) return null;
|
||||
return (
|
||||
<div className="mt-1 rounded border border-slate-100 bg-slate-50/70 px-1.5 py-1">
|
||||
<div className="text-[9px] font-medium uppercase tracking-wide text-slate-400">参数(已脱敏)</div>
|
||||
<pre className="mt-0.5 max-h-24 overflow-auto whitespace-pre-wrap break-all text-[11px] text-slate-600">
|
||||
{safe.text}
|
||||
</pre>
|
||||
{(safe.hiddenCount > 0 || safe.truncated) && (
|
||||
<div className="mt-0.5 text-[9px] text-slate-400">
|
||||
{[
|
||||
safe.hiddenCount > 0 ? `已隐藏 ${safe.hiddenCount} 项敏感字段` : "",
|
||||
safe.truncated ? "内容已截断" : "",
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 安全渲染辅助 ───────────────────────────────────────────────────────────
|
||||
|
||||
/** 桶 key → TDesign Tag 主题。 */
|
||||
function bucketTheme(key: ActionBucket["key"]): "default" | "primary" | "warning" | "success" | "danger" {
|
||||
switch (key) {
|
||||
case "waiting":
|
||||
return "warning";
|
||||
case "approved":
|
||||
return "primary";
|
||||
case "executed":
|
||||
return "success";
|
||||
case "failed":
|
||||
case "rejected_or_expired":
|
||||
return "danger";
|
||||
case "unknown":
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function shortId(id: string): string {
|
||||
return id.length > 8 ? `${id.slice(0, 8)}…` : id;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
const t = text.trim().replace(/\s+/g, " ");
|
||||
return t.length <= max ? t : `${t.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* MAF 写工具审批卡片(MAF-06-06:可视层改用本地 `tdesign-react`)。
|
||||
*
|
||||
* 对齐后端 schemas.py:
|
||||
* - 标题:action.tool_name,没有 action 时显示 approval.summary
|
||||
* - 风险:展示 action.risk_level(R0–R4 配色,主题映射来自 lib/risk-theme,单一事实源)
|
||||
* - 审批摘要:展示 approval.summary
|
||||
* - 参数 / 审批 payload:统一走 lib/safe-display 的 safeDisplayJson(敏感键直接删除,
|
||||
* 不显示成 ***;连字段名都不透露)
|
||||
* - 不渲染 action.result(仅展示参数与审批载荷)
|
||||
* - 备注 textarea 已删除(后端 ApprovalDecisionRequest 只接收 decision,不保存备注)
|
||||
*
|
||||
* 重复决策锁定(P1):
|
||||
* - 批准和拒绝都锁卡:onSuccess 后 setResuming(true)(拒绝后后端同样触发恢复流程);
|
||||
* - 除 React state 外,额外用 decisionLockRef 同步锁,避免极快双击时 React 尚未重渲染
|
||||
* 就已发出第二次 mutate;
|
||||
* - mutation pending 期间按钮禁用(isDecisionButtonDisabled 单一事实源)。
|
||||
*
|
||||
* invalidate 使用页面传入的 runId,不依赖 approval.run_id 可选字段。仅 pending 审批渲染。
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Check, Loader2, ShieldCheck, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button as TButton, Tag as TTag } from "tdesign-react";
|
||||
|
||||
import { useMafApprovalActions } from "../api/approvals";
|
||||
import type { MafAction, MafApproval } from "../api/types";
|
||||
import { isDecisionButtonDisabled } from "../lib/approval-state";
|
||||
import { riskLabelFor, riskThemeFor } from "../lib/risk-theme";
|
||||
import { safeDisplayJson } from "../lib/safe-display";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProps) {
|
||||
const actions = useMafApprovalActions(runId);
|
||||
// 决策成功(批准或拒绝)后锁卡,等待后端同步工作流
|
||||
const [resuming, setResuming] = useState(false);
|
||||
// 同步锁:防极快双击在 React 重渲染前就发出第二次 mutate
|
||||
const decisionLockRef = useRef(false);
|
||||
|
||||
const pending = actions.decide.isPending;
|
||||
// 按钮禁用判定走共享纯函数(lib/approval-state,单一事实源)
|
||||
const disabled = isDecisionButtonDisabled({ pending, resuming });
|
||||
|
||||
const onDecide = (decision: "approved" | "rejected") => {
|
||||
if (decisionLockRef.current || disabled) return;
|
||||
decisionLockRef.current = true;
|
||||
actions.decide.mutate(
|
||||
{ approvalId: approval.id, decision },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// 批准和拒绝都锁卡:拒绝后后端同样会推进流程,统一显示「正在同步工作流…」
|
||||
setResuming(true);
|
||||
toast.success(decision === "approved" ? "已批准" : "已拒绝");
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
// 失败时释放锁,允许重试
|
||||
decisionLockRef.current = false;
|
||||
toast.error(`决策失败:${e.message}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// 标题:action.tool_name 优先,没有 action 时用 approval.summary
|
||||
const title = action?.tool_name ?? approval.summary;
|
||||
const riskLevel = action?.risk_level;
|
||||
// 二次脱敏:统一走 safeDisplayJson(敏感键直接删除,绝不显示 ***)
|
||||
const safeArgs = safeDisplayJson(action?.arguments);
|
||||
const safePayload = safeDisplayJson(approval.payload);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-sm">
|
||||
<header className="mb-2 flex items-center gap-1.5">
|
||||
<TTag size="small" theme="danger" variant="light">
|
||||
<ShieldCheck className="mr-0.5 h-3 w-3" />
|
||||
需要你的确认
|
||||
</TTag>
|
||||
{action?.id && (
|
||||
<span className="text-[10px] font-mono text-slate-400">
|
||||
#{shortId(approval.action_id ?? action.id)}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className="mb-1.5 text-sm font-semibold text-slate-800">
|
||||
{title || "未知操作"}
|
||||
</div>
|
||||
|
||||
{/* 风险等级(R0–R4) */}
|
||||
{riskLevel && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-slate-400">风险等级:</span>
|
||||
<TTag size="small" theme={riskThemeFor(riskLevel)} variant="light-outline">
|
||||
{riskLabelFor(riskLevel)}
|
||||
</TTag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 审批摘要 */}
|
||||
{approval.summary && (
|
||||
<div className="mb-2 text-xs text-slate-600">
|
||||
{approval.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参数(来自 action.arguments,二次脱敏;无可展示内容则隐藏) */}
|
||||
{safeArgs.text && (
|
||||
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
参数(已脱敏)
|
||||
</div>
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
|
||||
{safeArgs.text}
|
||||
</pre>
|
||||
<SafeDisplayHint result={safeArgs} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 审批 payload(二次脱敏;无可展示内容则隐藏) */}
|
||||
{safePayload.text && (
|
||||
<div className="mb-2 rounded-md border border-slate-100 bg-slate-50/70 p-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
审批载荷(已脱敏)
|
||||
</div>
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-600">
|
||||
{safePayload.text}
|
||||
</pre>
|
||||
<SafeDisplayHint result={safePayload} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<TButton
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
size="small"
|
||||
loading={pending}
|
||||
disabled={disabled}
|
||||
icon={!pending ? <X className="h-3.5 w-3.5" /> : undefined}
|
||||
onClick={() => onDecide("rejected")}
|
||||
>
|
||||
拒绝
|
||||
</TButton>
|
||||
<TButton
|
||||
theme="success"
|
||||
size="small"
|
||||
loading={pending}
|
||||
disabled={disabled}
|
||||
icon={!pending ? <Check className="h-3.5 w-3.5" /> : undefined}
|
||||
onClick={() => onDecide("approved")}
|
||||
>
|
||||
批准
|
||||
</TButton>
|
||||
</div>
|
||||
|
||||
{(pending || resuming) && (
|
||||
<div className="mt-1 flex items-center justify-center gap-1 text-[10px] text-slate-400">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{resuming ? "正在同步工作流…" : "提交中…"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 脱敏提示:有隐藏或截断时只显示概括计数,不透露具体敏感字段名。 */
|
||||
function SafeDisplayHint({ result }: { result: { hiddenCount: number; truncated: boolean } }) {
|
||||
if (result.hiddenCount === 0 && !result.truncated) return null;
|
||||
const parts: string[] = [];
|
||||
if (result.hiddenCount > 0) parts.push(`已隐藏 ${result.hiddenCount} 项敏感字段`);
|
||||
if (result.truncated) parts.push("内容已截断");
|
||||
return <div className="mt-1 text-[10px] text-slate-400">{parts.join(" · ")}</div>;
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* MAF 事件时间线 —— 把折叠后的 TimelineEntry[] 渲染成可读的对话流。
|
||||
*
|
||||
* MAF-06-05:可视层改用本地 `tdesign-react`(Timeline / Tag)。
|
||||
* 滚动跟随逻辑自含(useMafAutoScroll),MAF 模块不依赖任何旧会商目录的 hook/组件。
|
||||
* 视觉上每条带类型 Tag + 时间 + 内容;消息体走 Markdown,其余走纯文本。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Hammer,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Package,
|
||||
ShieldQuestion,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Tag as TTag, Timeline as TTimeline, TimelineItem as TTimelineItem } from "tdesign-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { shortTime, type TimelineEntry, type TimelineEntryKind } from "../lib/event-timeline";
|
||||
|
||||
interface MafEventTimelineProps {
|
||||
entries: TimelineEntry[];
|
||||
/** 仍在流式(run live)→ 显示底部 spinner;停则不显示。 */
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
const KIND_ICON: Record<TimelineEntryKind, React.ReactElement> = {
|
||||
message: <MessageSquare className="h-3.5 w-3.5" />,
|
||||
tool: <Hammer className="h-3.5 w-3.5" />,
|
||||
skill: <Sparkles className="h-3.5 w-3.5" />,
|
||||
approval: <ShieldQuestion className="h-3.5 w-3.5" />,
|
||||
artifact: <Package className="h-3.5 w-3.5" />,
|
||||
run: <Workflow className="h-3.5 w-3.5" />,
|
||||
checkpoint: <GitBranch className="h-3.5 w-3.5" />,
|
||||
turn: <FileText className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
/** TimelineEntryKind → TDesign TimelineItem dotColor(支持 primary/warning/error/default 或 hex)。 */
|
||||
const KIND_DOT_COLOR: Record<TimelineEntryKind, string> = {
|
||||
message: "primary",
|
||||
tool: "primary",
|
||||
skill: "default",
|
||||
approval: "warning",
|
||||
artifact: "#00a870",
|
||||
run: "default",
|
||||
checkpoint: "default",
|
||||
turn: "default",
|
||||
};
|
||||
|
||||
/** kind → TDesign Tag 主题(用于卡片内类型标签)。 */
|
||||
const KIND_TAG_THEME: Record<TimelineEntryKind, "default" | "primary" | "warning" | "success" | "danger"> = {
|
||||
message: "primary",
|
||||
tool: "primary",
|
||||
skill: "default",
|
||||
approval: "warning",
|
||||
artifact: "success",
|
||||
run: "default",
|
||||
checkpoint: "default",
|
||||
turn: "default",
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<TimelineEntryKind, string> = {
|
||||
message: "消息",
|
||||
tool: "工具",
|
||||
skill: "技能",
|
||||
approval: "审批",
|
||||
artifact: "产物",
|
||||
run: "会商",
|
||||
checkpoint: "检查点",
|
||||
turn: "轮次",
|
||||
};
|
||||
|
||||
function toolStatusIcon(entry: TimelineEntry): React.ReactNode {
|
||||
const status = (entry.meta?.status as string) ?? "";
|
||||
if (status === "completed") return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />;
|
||||
if (status === "failed") return <XCircle className="h-3.5 w-3.5 text-red-600" />;
|
||||
if (status === "started" || status === "requested")
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自含滚动跟随 hook —— MAF 模块内部实现,不依赖旧会商目录。
|
||||
*
|
||||
* 语义:用户在底部时新内容自动滚到底;用户上滚查看历史时不强制下拉,
|
||||
* 只显示「回到底部」按钮。entry 数组变化时若原先在底部则跟随。
|
||||
*/
|
||||
function useMafAutoScroll<T extends HTMLElement>(deps: unknown[]) {
|
||||
const scrollRef = useRef<T | null>(null);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
|
||||
const isNearBottom = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
||||
};
|
||||
|
||||
const onScroll = () => setAtBottom(isNearBottom());
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
setAtBottom(true);
|
||||
};
|
||||
|
||||
// 内容变化时,若原先在底部则跟随滚到底
|
||||
useEffect(() => {
|
||||
if (atBottom) {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return { scrollRef, atBottom, onScroll, scrollToBottom };
|
||||
}
|
||||
|
||||
export function MafEventTimeline({ entries, streaming }: MafEventTimelineProps) {
|
||||
const { scrollRef, atBottom, onScroll, scrollToBottom } = useMafAutoScroll<HTMLDivElement>([entries]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-slate-50/30"
|
||||
>
|
||||
<div className="p-4">
|
||||
{entries.length === 0 && !streaming && (
|
||||
<div className="flex h-full items-center justify-center py-12 text-sm text-slate-400">
|
||||
<AlertTriangle className="mr-1.5 h-4 w-4" />
|
||||
暂无事件
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length > 0 && (
|
||||
<TTimeline layout="vertical" mode="alternate">
|
||||
{entries.map((entry) => (
|
||||
<TimelineEntryItem key={entry.key} entry={entry} />
|
||||
))}
|
||||
</TTimeline>
|
||||
)}
|
||||
|
||||
{streaming && (
|
||||
<div className="flex items-center gap-2 py-2 pl-2 text-xs text-blue-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
会商进行中…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!atBottom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 shadow-md transition hover:bg-slate-50"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
回到底部
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineEntryItem({ entry }: { entry: TimelineEntry }) {
|
||||
const isMessage = entry.kind === "message";
|
||||
return (
|
||||
<TTimelineItem
|
||||
dot={KIND_ICON[entry.kind]}
|
||||
dotColor={KIND_DOT_COLOR[entry.kind]}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 transition",
|
||||
isMessage ? "border-blue-100 bg-blue-50/40" : "border-slate-100 bg-white",
|
||||
)}
|
||||
>
|
||||
<header className="mb-1 flex items-center gap-1.5">
|
||||
<TTag size="small" theme={KIND_TAG_THEME[entry.kind]} variant="light-outline">
|
||||
{KIND_LABEL[entry.kind]}
|
||||
</TTag>
|
||||
{entry.kind === "tool" && toolStatusIcon(entry)}
|
||||
<span className="text-xs font-semibold text-slate-700">{entry.title}</span>
|
||||
{entry.agentId && (
|
||||
<TTag size="small" theme="default" variant="outline">
|
||||
{entry.agentId}
|
||||
</TTag>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-slate-400">{shortTime(entry.createdAt)}</span>
|
||||
</header>
|
||||
|
||||
{entry.body && (
|
||||
isMessage ? (
|
||||
<div className="prose prose-sm max-w-none text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.body}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-slate-600">{entry.body}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TTimelineItem>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* MAF 事实/结论流程图面板(MAF-06-07:可视层改用本地 `tdesign-react`)。
|
||||
*
|
||||
* MAF 自包含:不 import 任何旧会商目录的流程图组件或类型,保证新旧会商页面独立演进。
|
||||
* 数据来自 lib/maf-flow-graph.ts(confirmed_facts 证据图 + final_result 的 flow-json 结论图)。
|
||||
*
|
||||
* TDesign Collapse 做折叠壳,内嵌固定高度的节点列表预览 + 「展开大图」按钮触发
|
||||
* MafFlowGraphDialog(同样是 MAF 自包含组件,用 TDesign Dialog/Card/Tag/Collapse 呈现)。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { Collapse as TCollapse, Tag as TTag } from "tdesign-react";
|
||||
|
||||
import type { MafCheckpoint } from "../api/types";
|
||||
import { buildMafFlowGraph, MAF_FLOW_NODE_TYPE_LABEL, type MafFlowNodeType } from "../lib/maf-flow-graph";
|
||||
import { MafFlowGraphDialog, MafFlowGraphOpenButton } from "./MafFlowGraphDialog";
|
||||
|
||||
/** 节点类型 → TDesign Tag 主题(预览徽章配色)。 */
|
||||
const NODE_TYPE_THEME: Record<MafFlowNodeType, "default" | "primary" | "warning" | "success" | "danger"> = {
|
||||
start: "default",
|
||||
end: "default",
|
||||
stage: "primary",
|
||||
task: "primary",
|
||||
decision: "warning",
|
||||
risk: "danger",
|
||||
deliverable: "success",
|
||||
role: "default",
|
||||
};
|
||||
|
||||
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 = buildMafFlowGraph(checkpoint);
|
||||
|
||||
return (
|
||||
<TCollapse
|
||||
value={open ? ["maf-flow"] : []}
|
||||
onChange={(val) => setOpen(Array.isArray(val) ? val.includes("maf-flow") : val === "maf-flow")}
|
||||
className="rounded-lg border border-slate-200 bg-white"
|
||||
>
|
||||
<TCollapse.Panel
|
||||
value="maf-flow"
|
||||
header={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GitBranch className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-semibold text-slate-700">事实/结论流程图</span>
|
||||
{graph && (
|
||||
<span className="text-xs text-slate-400">
|
||||
({graph.nodes.length} 节点 / {graph.edges.length} 连线)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="relative h-64 border-t border-slate-100">
|
||||
{graph ? (
|
||||
<>
|
||||
<FlowGraphPreview graph={graph} streaming={streaming} />
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<MafFlowGraphOpenButton onClick={() => setFullscreen(true)} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-slate-400">
|
||||
{streaming ? "等待已确认事实 / 结论输出…" : "尚无可绘制的事实或结论"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TCollapse.Panel>
|
||||
|
||||
<MafFlowGraphDialog
|
||||
visible={fullscreen}
|
||||
onClose={() => setFullscreen(false)}
|
||||
checkpoint={checkpoint}
|
||||
streaming={streaming}
|
||||
/>
|
||||
</TCollapse>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 内嵌预览:轻量「节点列表」(点击「大图」看完整结构化视图)。
|
||||
*/
|
||||
function FlowGraphPreview({
|
||||
graph,
|
||||
streaming,
|
||||
}: {
|
||||
graph: ReturnType<typeof buildMafFlowGraph> extends infer T ? Exclude<T, null> : never;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden p-2">
|
||||
{graph.title && (
|
||||
<div className="mb-1 truncate text-xs font-medium text-slate-500">{graph.title}</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ul className="space-y-1">
|
||||
{graph.nodes.map((n) => {
|
||||
const incomers = graph.edges.filter((e) => e.to === n.id).map((e) => e.from);
|
||||
return (
|
||||
<li key={n.id} className="rounded border border-slate-100 bg-slate-50/70 px-2 py-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TTag size="small" theme={NODE_TYPE_THEME[n.type]} variant="light-outline">
|
||||
{n.group?.trim() || MAF_FLOW_NODE_TYPE_LABEL[n.type]}
|
||||
</TTag>
|
||||
<span className="truncate text-xs text-slate-700">{n.label}</span>
|
||||
</div>
|
||||
{incomers.length > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-slate-400">← {incomers.length} 个上游</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{streaming && (
|
||||
<div className="mt-1 text-center text-[10px] text-blue-400">绘制中…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
/**
|
||||
* MAF 事实账本面板(MAF-06-07:可视层改用本地 `tdesign-react`)。
|
||||
*
|
||||
* 数据只读 run.maf_checkpoint.fact_ledger,按 6 个分区展示:
|
||||
* 候选事实(candidate_facts,显式标注「未经交叉核验」,绝不混入已确认)
|
||||
* 已确认事实(confirmed_facts)
|
||||
* 待审批动作(pending_actions)
|
||||
* 已执行动作(executed_actions)
|
||||
* 失败或未知动作(failed_or_unknown_actions)
|
||||
* 冲突(conflicts)
|
||||
*
|
||||
* 安全要求:只展示白名单安全字段(action_id / tool_name / agent_id / external_object_id /
|
||||
* text / status)。绝不展示 checkpoint 里可能包含的原始秘密、内部幂等键、target_key。
|
||||
*
|
||||
* 无数据时显示空态,不伪造数据。
|
||||
*/
|
||||
|
||||
import { AlertTriangle, CheckCircle2, FileQuestion, GitBranch, ListChecks, ShieldAlert } from "lucide-react";
|
||||
import { Collapse as TCollapse, Tag as TTag } from "tdesign-react";
|
||||
|
||||
import type {
|
||||
MafCandidateFact,
|
||||
MafCheckpoint,
|
||||
MafConfirmedFact,
|
||||
MafFactLedger,
|
||||
MafLedgerAction,
|
||||
MafLedgerConflict,
|
||||
} from "../api/types";
|
||||
|
||||
interface MafFactLedgerPanelProps {
|
||||
checkpoint: MafCheckpoint | null;
|
||||
}
|
||||
|
||||
export function MafFactLedgerPanel({ checkpoint }: MafFactLedgerPanelProps) {
|
||||
const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger;
|
||||
|
||||
const candidate = ledger?.candidate_facts ?? [];
|
||||
const confirmed = ledger?.confirmed_facts ?? [];
|
||||
const pending = ledger?.pending_actions ?? [];
|
||||
const executed = ledger?.executed_actions ?? [];
|
||||
const failedOrUnknown = ledger?.failed_or_unknown_actions ?? [];
|
||||
const conflicts = ledger?.conflicts ?? [];
|
||||
|
||||
const total =
|
||||
candidate.length +
|
||||
confirmed.length +
|
||||
pending.length +
|
||||
executed.length +
|
||||
failedOrUnknown.length +
|
||||
conflicts.length;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<ListChecks className="h-4 w-4 text-slate-500" />
|
||||
事实账本
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400">{total} 条</span>
|
||||
</header>
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-slate-400">尚无事实账本数据</p>
|
||||
) : (
|
||||
<TCollapse defaultExpandAll className="rounded border border-slate-100">
|
||||
<LedgerSection
|
||||
value="candidate"
|
||||
icon={<FileQuestion className="h-3.5 w-3.5 text-amber-500" />}
|
||||
title="候选事实"
|
||||
badgeTheme="warning"
|
||||
warn="未经交叉核验"
|
||||
count={candidate.length}
|
||||
>
|
||||
{candidate.map((f) => (
|
||||
<FactItem key={`c-${f.candidate_id}`} fact={f} confirmed={false} />
|
||||
))}
|
||||
</LedgerSection>
|
||||
|
||||
<LedgerSection
|
||||
value="confirmed"
|
||||
icon={<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
||||
title="已确认事实"
|
||||
badgeTheme="success"
|
||||
count={confirmed.length}
|
||||
>
|
||||
{confirmed.map((f) => (
|
||||
<FactItem key={`f-${f.candidate_id}`} fact={f} confirmed />
|
||||
))}
|
||||
</LedgerSection>
|
||||
|
||||
<LedgerSection
|
||||
value="pending"
|
||||
icon={<ShieldAlert className="h-3.5 w-3.5 text-amber-600" />}
|
||||
title="待审批动作"
|
||||
badgeTheme="warning"
|
||||
count={pending.length}
|
||||
>
|
||||
{pending.map((a, i) => (
|
||||
<ActionItem key={`p-${safeKey(a, i)}`} action={a} />
|
||||
))}
|
||||
</LedgerSection>
|
||||
|
||||
<LedgerSection
|
||||
value="executed"
|
||||
icon={<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
||||
title="已执行动作"
|
||||
badgeTheme="success"
|
||||
count={executed.length}
|
||||
>
|
||||
{executed.map((a, i) => (
|
||||
<ActionItem key={`e-${safeKey(a, i)}`} action={a} />
|
||||
))}
|
||||
</LedgerSection>
|
||||
|
||||
<LedgerSection
|
||||
value="failed"
|
||||
icon={<AlertTriangle className="h-3.5 w-3.5 text-red-500" />}
|
||||
title="失败或未知动作"
|
||||
badgeTheme="danger"
|
||||
count={failedOrUnknown.length}
|
||||
>
|
||||
{failedOrUnknown.map((a, i) => (
|
||||
<ActionItem key={`u-${safeKey(a, i)}`} action={a} />
|
||||
))}
|
||||
</LedgerSection>
|
||||
|
||||
<LedgerSection
|
||||
value="conflicts"
|
||||
icon={<GitBranch className="h-3.5 w-3.5 text-red-500" />}
|
||||
title="冲突"
|
||||
badgeTheme="danger"
|
||||
count={conflicts.length}
|
||||
>
|
||||
{conflicts.map((c, i) => (
|
||||
<ConflictItem key={`x-${i}`} conflict={c} />
|
||||
))}
|
||||
</LedgerSection>
|
||||
</TCollapse>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 分区壳 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function LedgerSection({
|
||||
value,
|
||||
icon,
|
||||
title,
|
||||
badgeTheme,
|
||||
warn,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
badgeTheme: "default" | "primary" | "warning" | "success" | "danger";
|
||||
/** 候选事实专用:额外标注「未经交叉核验」。 */
|
||||
warn?: string;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TCollapse.Panel
|
||||
value={value}
|
||||
header={
|
||||
<div className="flex items-center gap-1.5">
|
||||
{icon}
|
||||
<span className="text-sm font-medium text-slate-700">{title}</span>
|
||||
<TTag size="small" theme={badgeTheme} variant="light">{count}</TTag>
|
||||
{warn && count > 0 && (
|
||||
<TTag size="small" theme="warning" variant="light-outline">{warn}</TTag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
</TCollapse.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 条目渲染 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function agentTag(agentId?: string): React.ReactNode {
|
||||
if (!agentId) return null;
|
||||
return (
|
||||
<TTag size="small" theme="default" variant="outline" title={agentId}>
|
||||
{truncate(agentId, 16)}
|
||||
</TTag>
|
||||
);
|
||||
}
|
||||
|
||||
function FactItem({ fact, confirmed }: { fact: MafCandidateFact | MafConfirmedFact; confirmed: boolean }) {
|
||||
return (
|
||||
<div className={`rounded border px-2 py-1.5 ${confirmed ? "border-emerald-100 bg-emerald-50/40" : "border-amber-100 bg-amber-50/40"}`}>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{confirmed ? (
|
||||
<TTag size="small" theme="success" variant="light">已确认</TTag>
|
||||
) : (
|
||||
<TTag size="small" theme="warning" variant="light">候选 · 未经核验</TTag>
|
||||
)}
|
||||
{agentTag(fact.agent_id)}
|
||||
{"confirmed_by" in fact && fact.confirmed_by && (
|
||||
<span className="text-[10px] text-slate-400">确认方:{truncate(fact.confirmed_by, 16)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-xs text-slate-700">{fact.text}</p>
|
||||
{"evidence_refs" in fact && (fact as MafConfirmedFact).evidence_refs?.length > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-slate-400">
|
||||
证据 {fact.evidence_refs.length} 条
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 动作条目:只展示白名单安全字段。
|
||||
* 绝不展示 target_key / idempotency_key / 原始 result / 原始 arguments。
|
||||
*/
|
||||
function ActionItem({ action }: { action: MafLedgerAction }) {
|
||||
return (
|
||||
<div className="rounded border border-slate-100 bg-slate-50/60 px-2 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{action.tool_name && (
|
||||
<span className="text-xs font-medium text-slate-700">{truncate(action.tool_name, 32)}</span>
|
||||
)}
|
||||
{action.status && (
|
||||
<TTag size="small" theme="default" variant="outline">{action.status}</TTag>
|
||||
)}
|
||||
{agentTag(action.agent_id)}
|
||||
</div>
|
||||
{action.text && (
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-[11px] text-slate-500">{action.text}</p>
|
||||
)}
|
||||
{action.external_object_id && (
|
||||
<div className="mt-0.5 text-[10px] text-slate-400">
|
||||
对象:{truncate(String(action.external_object_id), 32)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictItem({ conflict }: { conflict: MafLedgerConflict }) {
|
||||
return (
|
||||
<div className="rounded border border-red-100 bg-red-50/40 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<TTag size="small" theme="danger" variant="light">冲突</TTag>
|
||||
{agentTag(conflict.agent_id)}
|
||||
</div>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-xs text-slate-700">{conflict.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 工具 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
const t = text.trim().replace(/\s+/g, " ");
|
||||
return t.length <= max ? t : `${t.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
/** 动作条目 key:优先用安全 action_id,回退到 index(不依赖任何敏感字段)。 */
|
||||
function safeKey(a: MafLedgerAction, index: number): string {
|
||||
return a.action_id ?? `idx-${index}`;
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* MAF 事实/结论流程图大图对话框(MAF-06-07:可视层改用本地 `tdesign-react`)。
|
||||
*
|
||||
* MAF 自包含组件:不依赖任何旧会商目录的流程图组件或 xyflow 画布。
|
||||
* 用 TDesign Dialog + Card + Tag + Collapse 把 MafFlowGraphData 渲染成结构化节点视图:
|
||||
* 每个节点一张卡,显示类型徽章、label、group、detail 以及上下游连接(可点击跳转)。
|
||||
*
|
||||
* 这不是拖拽画布,而是可读的「节点 + 连线关系」结构视图,足够用于会商事实/结论的审阅,
|
||||
* 且不引入第二套可视化运行时依赖。
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { GitBranch, Maximize2, X } from "lucide-react";
|
||||
import {
|
||||
Button as TButton,
|
||||
Card as TCard,
|
||||
Collapse as TCollapse,
|
||||
Dialog as TDialog,
|
||||
Tag as TTag,
|
||||
} from "tdesign-react";
|
||||
|
||||
import {
|
||||
buildMafFlowGraph,
|
||||
MAF_FLOW_NODE_TYPE_LABEL,
|
||||
type MafFlowGraphData,
|
||||
type MafFlowNodeType,
|
||||
} from "../lib/maf-flow-graph";
|
||||
import type { MafCheckpoint } from "../api/types";
|
||||
|
||||
/** 节点类型 → TDesign Tag 主题(用于类型徽章配色)。 */
|
||||
const NODE_TYPE_THEME: Record<MafFlowNodeType, "default" | "primary" | "warning" | "success" | "danger"> = {
|
||||
start: "default",
|
||||
end: "default",
|
||||
stage: "primary",
|
||||
task: "primary",
|
||||
decision: "warning",
|
||||
risk: "danger",
|
||||
deliverable: "success",
|
||||
role: "default",
|
||||
};
|
||||
|
||||
interface MafFlowGraphDialogProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
checkpoint: MafCheckpoint | null;
|
||||
/** run 仍 live → 显示「绘制中」提示;终态不显示。 */
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
export function MafFlowGraphDialog({ visible, onClose, checkpoint, streaming }: MafFlowGraphDialogProps) {
|
||||
const graph = useMemo(() => buildMafFlowGraph(checkpoint), [checkpoint]);
|
||||
|
||||
return (
|
||||
<TDialog
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GitBranch className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-semibold text-slate-700">事实/结论流程图</span>
|
||||
{graph && (
|
||||
<span className="text-xs text-slate-400">
|
||||
({graph.nodes.length} 节点 / {graph.edges.length} 连线)
|
||||
</span>
|
||||
)}
|
||||
{graph?.truncated && (
|
||||
<TTag size="small" theme="warning" variant="light">已截断</TTag>
|
||||
)}
|
||||
{streaming && (
|
||||
<TTag size="small" theme="primary" variant="light">绘制中</TTag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width="760px"
|
||||
closeOnEscKeydown
|
||||
footer={null}
|
||||
>
|
||||
{graph ? (
|
||||
<FlowGraphBody graph={graph} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 text-xs text-slate-400">
|
||||
{streaming ? "等待已确认事实 / 结论输出…" : "尚无可绘制的事实或结论"}
|
||||
</div>
|
||||
)}
|
||||
</TDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 节点卡片列表 + 上下游连接。 */
|
||||
function FlowGraphBody({ graph }: { graph: MafFlowGraphData }) {
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
|
||||
const incomingByNode = useMemo(() => {
|
||||
const m = new Map<string, { from: string; label?: string }[]>();
|
||||
for (const e of graph.edges) {
|
||||
const list = m.get(e.to) ?? [];
|
||||
list.push({ from: e.from, label: e.label });
|
||||
m.set(e.to, list);
|
||||
}
|
||||
return m;
|
||||
}, [graph.edges]);
|
||||
const outgoingByNode = useMemo(() => {
|
||||
const m = new Map<string, { to: string; label?: string }[]>();
|
||||
for (const e of graph.edges) {
|
||||
const list = m.get(e.from) ?? [];
|
||||
list.push({ to: e.to, label: e.label });
|
||||
m.set(e.from, list);
|
||||
}
|
||||
return m;
|
||||
}, [graph.edges]);
|
||||
|
||||
const labelOf = (id: string) => graph.nodes.find((n) => n.id === id)?.label ?? id;
|
||||
const focused = focusedId ? graph.nodes.find((n) => n.id === focusedId) ?? null : null;
|
||||
|
||||
return (
|
||||
<div className="max-h-[70vh] space-y-2 overflow-y-auto py-1">
|
||||
{graph.title && (
|
||||
<div className="text-xs font-medium text-slate-500">{graph.title}</div>
|
||||
)}
|
||||
|
||||
{graph.truncated && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-[11px] text-amber-700">
|
||||
流程图内容过大,已截断展示(最多 {graph.nodes.length} 节点 / {graph.edges.length} 连线)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graph.nodes.map((n) => {
|
||||
const inc = incomingByNode.get(n.id) ?? [];
|
||||
const out = outgoingByNode.get(n.id) ?? [];
|
||||
const isFocused = focusedId === n.id;
|
||||
const hasRelations = inc.length > 0 || out.length > 0;
|
||||
const relationsId = `relations-${n.id}`;
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`rounded transition ${isFocused ? "ring-1 ring-blue-300" : ""}`}
|
||||
>
|
||||
<TCard bordered>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<TTag size="small" theme={NODE_TYPE_THEME[n.type]} variant="light-outline">
|
||||
{n.group?.trim() || MAF_FLOW_NODE_TYPE_LABEL[n.type]}
|
||||
</TTag>
|
||||
<span className="text-sm font-semibold text-slate-800">{n.label}</span>
|
||||
{inc.length > 0 && (
|
||||
<span className="text-[10px] text-slate-400">← {inc.length}</span>
|
||||
)}
|
||||
{out.length > 0 && (
|
||||
<span className="text-[10px] text-slate-400">→ {out.length}</span>
|
||||
)}
|
||||
{hasRelations && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto inline-flex items-center text-[10px] text-slate-500 hover:text-slate-700"
|
||||
aria-expanded={isFocused}
|
||||
aria-controls={relationsId}
|
||||
onClick={() => setFocusedId(isFocused ? null : n.id)}
|
||||
>
|
||||
{isFocused ? "收起关系" : "展开关系"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(isFocused || n.detail) && n.detail && (
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-xs text-slate-600">
|
||||
{n.detail}
|
||||
</p>
|
||||
)}
|
||||
{isFocused && hasRelations && (
|
||||
<div id={relationsId} className="mt-2 space-y-1 border-t border-slate-100 pt-1.5">
|
||||
{inc.length > 0 && (
|
||||
<div className="text-[11px] text-slate-500">
|
||||
<span className="text-slate-400">上游:</span>
|
||||
{inc.map((e, i) => (
|
||||
<button
|
||||
key={`in-${i}`}
|
||||
type="button"
|
||||
className="mx-0.5 inline-flex items-center gap-0.5 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-600 hover:border-blue-300 hover:text-blue-600"
|
||||
onClick={(ev) => { ev.stopPropagation(); setFocusedId(e.from); }}
|
||||
>
|
||||
{labelOf(e.from)}
|
||||
{e.label && <span className="text-slate-400">·{e.label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{out.length > 0 && (
|
||||
<div className="text-[11px] text-slate-500">
|
||||
<span className="text-slate-400">下游:</span>
|
||||
{out.map((e, i) => (
|
||||
<button
|
||||
key={`out-${i}`}
|
||||
type="button"
|
||||
className="mx-0.5 inline-flex items-center gap-0.5 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-600 hover:border-blue-300 hover:text-blue-600"
|
||||
onClick={(ev) => { ev.stopPropagation(); setFocusedId(e.to); }}
|
||||
>
|
||||
{labelOf(e.to)}
|
||||
{e.label && <span className="text-slate-400">·{e.label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TCard>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{focused && (
|
||||
<div className="sticky bottom-0 flex items-center justify-between rounded border border-slate-200 bg-white/95 px-2 py-1 text-[10px] text-slate-400 backdrop-blur">
|
||||
<span>已选:{focused.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-0.5 text-slate-500 hover:text-slate-700"
|
||||
onClick={() => setFocusedId(null)}
|
||||
>
|
||||
<X className="h-3 w-3" /> 取消选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 「大图」触发按钮的封装(内嵌预览面板里点开弹窗用)。 */
|
||||
export function MafFlowGraphOpenButton({
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TButton
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
icon={<Maximize2 className="h-3.5 w-3.5" />}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
大图
|
||||
</TButton>
|
||||
);
|
||||
}
|
||||
|
||||
/** 类型图例(Collapse 折叠壳里展示节点类型含义)。 */
|
||||
export function MafFlowGraphLegend() {
|
||||
return (
|
||||
<TCollapse className="rounded-lg border border-slate-200 bg-white">
|
||||
<TCollapse.Panel value="legend" header="节点类型说明">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(Object.keys(MAF_FLOW_NODE_TYPE_LABEL) as MafFlowNodeType[]).map((t) => (
|
||||
<TTag key={t} size="small" theme={NODE_TYPE_THEME[t]} variant="light-outline">
|
||||
{MAF_FLOW_NODE_TYPE_LABEL[t]}
|
||||
</TTag>
|
||||
))}
|
||||
</div>
|
||||
</TCollapse.Panel>
|
||||
</TCollapse>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* MAF 功能开关守卫(MAF-06-01:可视层改用本地 `tdesign-react`)。
|
||||
*
|
||||
* 分别处理三种状态:
|
||||
* - enabled=false → 「功能未启用」
|
||||
* - enabled=true && store_available=false → 「功能已开启,但持久化存储未就绪」
|
||||
* - 请求异常 → 「无法连接 MAF 服务」+ 重试按钮
|
||||
*/
|
||||
|
||||
import { DatabaseZap, Loader2, RefreshCw, ShieldAlert, ShieldOff } from "lucide-react";
|
||||
import { Button as TButton } from "tdesign-react";
|
||||
|
||||
import { useMafHealth } from "../api/runs";
|
||||
|
||||
interface MafHealthGateProps {
|
||||
/** enabled 时渲染的子树。 */
|
||||
children: React.ReactNode;
|
||||
/** 探测中时渲染的占位(默认 spinner)。 */
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MafHealthGate({ children, fallback }: MafHealthGateProps) {
|
||||
const { data, isLoading, isError, refetch, isFetching } = useMafHealth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>{fallback ?? (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-400">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
正在检查 MAF 会商状态…
|
||||
</div>
|
||||
)}</>
|
||||
);
|
||||
}
|
||||
|
||||
// 请求异常 → 无法连接,给重试按钮
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<ShieldOff className="h-10 w-10 text-slate-300" />
|
||||
<div className="text-base font-medium text-slate-500">无法连接 MAF 服务</div>
|
||||
<p className="max-w-md text-sm text-slate-400">
|
||||
请求 /api/maf-roundtable/health 失败,请检查后端服务是否正常运行。
|
||||
</p>
|
||||
<TButton
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
loading={isFetching}
|
||||
icon={!isFetching ? <RefreshCw className="h-3.5 w-3.5" /> : undefined}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
重试
|
||||
</TButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 明确 disabled → 功能未启用
|
||||
if (!data?.enabled) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<ShieldOff className="h-10 w-10 text-slate-300" />
|
||||
<div className="text-base font-medium text-slate-500">MAF 会商功能未启用</div>
|
||||
<p className="max-w-md text-sm text-slate-400">
|
||||
后端未开启多智能体会商功能(maf_roundtable.enabled=false)。
|
||||
请联系管理员在 config.yaml 中启用后重试。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// enabled=true 但 store_available=false → 存储未就绪
|
||||
if (!data.store_available) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<DatabaseZap className="h-10 w-10 text-amber-400" />
|
||||
<div className="text-base font-medium text-slate-500">持久化存储未就绪</div>
|
||||
<p className="max-w-md text-sm text-slate-400">
|
||||
MAF 会商功能已开启,但持久化存储(数据库)尚未配置或连接失败。
|
||||
写操作将返回 503。请检查数据库配置后重试。
|
||||
</p>
|
||||
<TButton
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
loading={isFetching}
|
||||
icon={!isFetching ? <RefreshCw className="h-3.5 w-3.5" /> : undefined}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
重新检测
|
||||
</TButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const writeGuardNotReady =
|
||||
data.write_guard_enabled && !data.write_guard_ready;
|
||||
const writeGuardMessage =
|
||||
data.write_guard_status === "missing_secret"
|
||||
? "WriteGuard 未就绪:未注入 MAF_ACTION_SECRET_KEY,外部写入将失败关闭;只读工具和技能仍可用。"
|
||||
: "WriteGuard 未就绪:外部写入将失败关闭,请检查审批桥接配置;只读工具和技能仍可用。";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{writeGuardNotReady && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-800">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
{writeGuardMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* MAF run 顶部状态条:状态 Tag + token/延迟 + 生命周期操作按钮。
|
||||
*
|
||||
* MAF-06-01:可视层改用本地 `tdesign-react`(Tag / Button)。
|
||||
*
|
||||
* 操作:
|
||||
* - live(pending/running/waiting_approval)→ 取消
|
||||
* - waiting_approval → 恢复(resume)**仅在 canResume=true 时显示**
|
||||
* 后端契约:存在 pending approval 时 /resume 返回 409;故 canResume 由父组件按下述规则计算:
|
||||
* 审批已加载成功 && 无 pending 审批 && 仍处于 waiting_approval。
|
||||
* 审批清零后由后端 dispatcher 自动恢复;仅在自动恢复未生效时才允许手动补偿。
|
||||
* - cancel_failed → 重试取消(仍可点取消)
|
||||
* - failed → 重试(recover)
|
||||
*
|
||||
* 取消/恢复/重试按钮均在 mutation pending 时禁用(防重复点击)。
|
||||
*
|
||||
* 状态 Tag 主题按 run.status 区分。token_usage/latency_ms 展示在右侧。
|
||||
*/
|
||||
|
||||
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, Square, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button as TButton, Tag as TTag } from "tdesign-react";
|
||||
|
||||
import { useMafRunActions } from "../api/runs";
|
||||
import { runIsLive, type MafRun, type MafRunStatus } from "../api/types";
|
||||
|
||||
interface MafRunHeaderProps {
|
||||
run: MafRun;
|
||||
/**
|
||||
* 是否允许手动恢复 run。
|
||||
* 规则见 lib/run-resume.ts 的 canManualResume;未提供时默认 false(保守:不显示恢复)。
|
||||
*/
|
||||
canResume?: boolean;
|
||||
/**
|
||||
* 当 canResume=false 且 run 处于 waiting_approval 时,展示给用户的解释文案
|
||||
* (例如「请先完成全部审批」「正在恢复工作流…」)。未提供则用默认兜底。
|
||||
*/
|
||||
resumeBlockedHint?: string;
|
||||
}
|
||||
|
||||
/** run 状态 → 中文 + Tag 主题 + 图标。 */
|
||||
function statusMeta(status: MafRunStatus): {
|
||||
label: string;
|
||||
theme: "default" | "primary" | "success" | "warning" | "danger";
|
||||
icon: React.ReactNode;
|
||||
tone: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return { label: "排队中", theme: "default", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-slate-500" };
|
||||
case "running":
|
||||
return { label: "进行中", theme: "primary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-blue-600" };
|
||||
case "waiting_approval":
|
||||
return { label: "等待审批", theme: "warning", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-amber-600" };
|
||||
case "completed":
|
||||
return { label: "已完成", theme: "success", icon: <CheckCircle2 className="h-3 w-3" />, tone: "text-emerald-600" };
|
||||
case "failed":
|
||||
return { label: "失败", theme: "danger", icon: <XCircle className="h-3 w-3" />, tone: "text-red-600" };
|
||||
case "cancelled":
|
||||
return { label: "已取消", theme: "default", icon: <Square className="h-3 w-3" />, tone: "text-slate-500" };
|
||||
case "cancelling":
|
||||
return { label: "取消中", theme: "warning", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-amber-600" };
|
||||
case "cancel_failed":
|
||||
return { label: "取消失败", theme: "danger", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-red-600" };
|
||||
default:
|
||||
return { label: status, theme: "default", icon: null, tone: "text-slate-500" };
|
||||
}
|
||||
}
|
||||
|
||||
export function MafRunHeader({ run, canResume = false, resumeBlockedHint }: MafRunHeaderProps) {
|
||||
const actions = useMafRunActions();
|
||||
const meta = statusMeta(run.status);
|
||||
const live = runIsLive(run.status);
|
||||
|
||||
const onCancel = () => {
|
||||
actions.cancel.mutate(run.id, {
|
||||
onSuccess: () => toast.success("已发起取消"),
|
||||
onError: (e: Error) => toast.error(`取消失败:${e.message}`),
|
||||
});
|
||||
};
|
||||
const onResume = () => {
|
||||
actions.resume.mutate(run.id, {
|
||||
onSuccess: () => toast.success("已恢复会商"),
|
||||
onError: (e: Error) => toast.error(`恢复失败:${e.message}`),
|
||||
});
|
||||
};
|
||||
const onRecover = () => {
|
||||
actions.recover.mutate(run.id, {
|
||||
onSuccess: () => toast.success("已重试会商"),
|
||||
onError: (e: Error) => toast.error(`重试失败:${e.message}`),
|
||||
});
|
||||
};
|
||||
|
||||
// token 汇总
|
||||
const tokenTotal = run.token_usage
|
||||
? Object.values(run.token_usage).reduce((a, b) => a + (b || 0), 0)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-slate-200 bg-white px-4 py-2.5">
|
||||
<div className={`flex items-center gap-1.5 ${meta.tone}`}>
|
||||
{meta.icon}
|
||||
<span className="text-sm font-semibold">{meta.label}</span>
|
||||
</div>
|
||||
<TTag size="small" theme="default" variant="outline">
|
||||
{run.workflow_version ?? "maf"}
|
||||
</TTag>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
|
||||
{tokenTotal != null && (
|
||||
<span title="累计 token">tokens: {tokenTotal.toLocaleString()}</span>
|
||||
)}
|
||||
{run.latency_ms != null && (
|
||||
<span title="耗时">{(run.latency_ms / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
{run.last_event_seq > 0 && (
|
||||
<span title="最新事件序号">seq#{run.last_event_seq}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{run.status === "waiting_approval" && canResume && (
|
||||
<TButton
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
loading={actions.resume.isPending}
|
||||
disabled={actions.resume.isPending}
|
||||
onClick={onResume}
|
||||
>
|
||||
恢复
|
||||
</TButton>
|
||||
)}
|
||||
{run.status === "waiting_approval" && !canResume && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[11px] text-slate-400"
|
||||
title="审批清零后由后端自动恢复;自动恢复未生效时才会出现手动恢复按钮"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{resumeBlockedHint ?? "等待审批处理或自动恢复…"}
|
||||
</span>
|
||||
)}
|
||||
{run.status === "failed" && (
|
||||
<TButton
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
loading={actions.recover.isPending}
|
||||
disabled={actions.recover.isPending}
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
onClick={onRecover}
|
||||
>
|
||||
重试
|
||||
</TButton>
|
||||
)}
|
||||
{(live || run.status === "cancel_failed") && (
|
||||
<TButton
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
size="small"
|
||||
loading={actions.cancel.isPending}
|
||||
disabled={actions.cancel.isPending}
|
||||
icon={<Square className="h-3.5 w-3.5" />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</TButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{run.error_message_safe && (
|
||||
<div className="w-full text-xs text-red-600">
|
||||
错误:{run.error_message_safe}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* MAF 席位状态矩阵(MAF-06-07:可视层改用本地 `tdesign-react`)。
|
||||
*
|
||||
* 数据来自 `run.maf_checkpoint.seat_outcomes`({agent_id: {status, execution_stage, ...}})。
|
||||
* 每个席位一行:agent_id + status Tag + execution_stage Tag。
|
||||
*
|
||||
* execution_stage 取值(Phase-3 闭合枚举):
|
||||
* legacy / planner / expert / format_repair / cross_review / cross_review_repair /
|
||||
* finalizer / group_chat
|
||||
*/
|
||||
|
||||
import { Tag as TTag } from "tdesign-react";
|
||||
|
||||
import type { MafCheckpoint, MafSeatOutcome } from "../api/types";
|
||||
|
||||
interface MafSeatStatusPanelProps {
|
||||
checkpoint: MafCheckpoint | null;
|
||||
/** 当前轮次(展示用)。 */
|
||||
currentRound?: number;
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
legacy: "旧版",
|
||||
planner: "规划",
|
||||
expert: "专家分析",
|
||||
format_repair: "格式修复",
|
||||
cross_review: "交叉评审",
|
||||
cross_review_repair: "评审修复",
|
||||
finalizer: "总结",
|
||||
group_chat: "群聊",
|
||||
};
|
||||
|
||||
/** 席位状态 → TDesign Tag 主题。 */
|
||||
function seatStatusTheme(status: string): "default" | "primary" | "success" | "danger" | "warning" {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "running":
|
||||
return "primary";
|
||||
case "failed":
|
||||
return "danger";
|
||||
case "cancelled":
|
||||
return "default";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export function MafSeatStatusPanel({ checkpoint, currentRound }: MafSeatStatusPanelProps) {
|
||||
const outcomes = checkpoint?.seat_outcomes;
|
||||
const entries: Array<[string, MafSeatOutcome]> = outcomes
|
||||
? Object.entries(outcomes)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-700">席位状态</h3>
|
||||
{typeof currentRound === "number" && (
|
||||
<span className="text-xs text-slate-400">第 {currentRound + 1} 轮</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-slate-400">尚无席位产出</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{entries.map(([agentId, outcome]) => (
|
||||
<li
|
||||
key={agentId}
|
||||
className="flex items-center gap-2 rounded-md border border-slate-100 bg-slate-50/60 px-2.5 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-slate-700" title={agentId}>
|
||||
{agentId}
|
||||
</span>
|
||||
{outcome.execution_stage && (
|
||||
<TTag size="small" theme="default" variant="light-outline">
|
||||
{STAGE_LABELS[outcome.execution_stage] ?? outcome.execution_stage}
|
||||
</TTag>
|
||||
)}
|
||||
<TTag size="small" theme={seatStatusTheme(outcome.status ?? "unknown")} variant="light">
|
||||
{outcome.status ?? "unknown"}
|
||||
</TTag>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* 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 旧事件,正常不会发生但做幂等保险)。 */
|
||||
export function mergeBySeq(prev: MafEvent[], incoming: MafEvent[]): MafEvent[] {
|
||||
if (incoming.length === 0) return prev;
|
||||
const bySeq = new Map<number, MafEvent>();
|
||||
for (const e of prev) bySeq.set(e.seq, e);
|
||||
for (const e of incoming) bySeq.set(e.seq, e);
|
||||
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-generation gate shared by the hook and its race-condition tests.
|
||||
* Each generation has its own in-flight slot, so a stale request can finish
|
||||
* independently without blocking a newer run's terminal-state backlog pull.
|
||||
*/
|
||||
export interface MafEventRequestCoordinator {
|
||||
readonly currentGeneration: number;
|
||||
nextGeneration: () => number;
|
||||
start: (generation: number) => boolean;
|
||||
finish: (generation: number) => void;
|
||||
isCurrent: (generation: number) => boolean;
|
||||
}
|
||||
|
||||
export function createMafEventRequestCoordinator(): MafEventRequestCoordinator {
|
||||
let currentGeneration = 0;
|
||||
const inFlight = new Set<number>();
|
||||
return {
|
||||
get currentGeneration() {
|
||||
return currentGeneration;
|
||||
},
|
||||
nextGeneration() {
|
||||
currentGeneration += 1;
|
||||
return currentGeneration;
|
||||
},
|
||||
start(generation) {
|
||||
if (inFlight.has(generation)) return false;
|
||||
inFlight.add(generation);
|
||||
return true;
|
||||
},
|
||||
finish(generation) {
|
||||
inFlight.delete(generation);
|
||||
},
|
||||
isCurrent(generation) {
|
||||
return generation === currentGeneration;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseMafEventsResult {
|
||||
/** 按 seq 升序的事件列表(已去重)。 */
|
||||
events: MafEvent[];
|
||||
/** 当前已拉到的最大 seq(供调试/展示)。 */
|
||||
latestSeq: number;
|
||||
/** 手动重置(清空事件、cursor 归零、立即补拉)。 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询某 run 的事件流。
|
||||
*
|
||||
* @param runId run id;undefined/null 时不轮询
|
||||
* @param opts.enabled 是否启用(有 run 即 true,无论终态/live——终态也要补拉历史)
|
||||
* @param opts.polling 是否启动定时器(一般传 `runIsLive(run.status)`)
|
||||
*/
|
||||
export function useMafEvents(
|
||||
runId: string | undefined | null,
|
||||
opts: { enabled: boolean; polling: boolean },
|
||||
): UseMafEventsResult {
|
||||
const { enabled, polling } = opts;
|
||||
const [events, setEvents] = useState<MafEvent[]>([]);
|
||||
const [latestSeq, setLatestSeq] = useState(0);
|
||||
const afterSeqRef = useRef(0);
|
||||
const requestCoordinatorRef = useRef(createMafEventRequestCoordinator());
|
||||
const [resetSignal, setResetSignal] = useState(0);
|
||||
const initializedRef = useRef(false);
|
||||
const lastRunIdRef = useRef<string | undefined | null>(runId);
|
||||
const lastResetSignalRef = useRef(resetSignal);
|
||||
|
||||
const reset = useCallback(() => setResetSignal((n) => n + 1), []);
|
||||
|
||||
// Every runId/enabled/polling/resetSignal change starts a new request
|
||||
// generation. Only run changes and explicit reset clear the accumulated
|
||||
// data; toggling enabled/polling must cancel old work without losing the
|
||||
// already accepted backlog.
|
||||
useEffect(() => {
|
||||
const identityChanged =
|
||||
!initializedRef.current ||
|
||||
lastRunIdRef.current !== runId ||
|
||||
lastResetSignalRef.current !== resetSignal;
|
||||
requestCoordinatorRef.current.nextGeneration();
|
||||
if (identityChanged) {
|
||||
setEvents([]);
|
||||
setLatestSeq(0);
|
||||
afterSeqRef.current = 0;
|
||||
}
|
||||
lastRunIdRef.current = runId;
|
||||
lastResetSignalRef.current = resetSignal;
|
||||
initializedRef.current = true;
|
||||
}, [runId, enabled, polling, resetSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId || !enabled) return;
|
||||
const myGeneration = requestCoordinatorRef.current.currentGeneration;
|
||||
let cancelled = false;
|
||||
|
||||
/**
|
||||
* 从 afterSeqRef.current 开始循环翻页拉取,直到:
|
||||
* - 返回事件数 < PAGE_LIMIT(没有更多了)
|
||||
* - 本页最大 seq <= cursor(无进展,防死循环)
|
||||
* - generation 已变(切换了 run,丢弃结果)
|
||||
*/
|
||||
async function fetchBacklog() {
|
||||
if (!requestCoordinatorRef.current.start(myGeneration)) return;
|
||||
|
||||
try {
|
||||
let cursor = afterSeqRef.current;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (
|
||||
cancelled ||
|
||||
!requestCoordinatorRef.current.isCurrent(myGeneration)
|
||||
) return;
|
||||
const res = await listEvents(runId!, cursor, PAGE_LIMIT);
|
||||
// 响应回来后再次检查代次(网络慢时可能已切换 run)
|
||||
if (
|
||||
cancelled ||
|
||||
!requestCoordinatorRef.current.isCurrent(myGeneration)
|
||||
) return;
|
||||
|
||||
const page = res.events;
|
||||
|
||||
// 本页最大 seq(只推进到实际收到的最大 seq,不跳 latest_seq)
|
||||
let maxSeq = cursor;
|
||||
for (const e of page) {
|
||||
if (e.seq > maxSeq) maxSeq = e.seq;
|
||||
}
|
||||
|
||||
if (page.length > 0) {
|
||||
setEvents((prev) => mergeBySeq(prev, page));
|
||||
}
|
||||
|
||||
// 无进展 → 停
|
||||
if (maxSeq <= cursor) break;
|
||||
|
||||
cursor = maxSeq;
|
||||
afterSeqRef.current = cursor;
|
||||
setLatestSeq(cursor);
|
||||
|
||||
// 不满一页 → 没有更多了
|
||||
if (page.length < PAGE_LIMIT) break;
|
||||
}
|
||||
} catch {
|
||||
// 单次拉取失败(网络抖动/瞬断)静默——下次 interval 重试。
|
||||
} finally {
|
||||
// Never clear a newer generation's in-flight marker from a stale
|
||||
// request. This is what lets a terminal-state refetch start while
|
||||
// the old live poll is still unresolved.
|
||||
requestCoordinatorRef.current.finish(myGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
// 进入即补拉历史(after_seq=0 全量,终态 run 也走这里)
|
||||
void fetchBacklog();
|
||||
|
||||
// polling=true 才启动定时器
|
||||
if (!polling) {
|
||||
return () => { cancelled = true; };
|
||||
}
|
||||
const timer = window.setInterval(() => void fetchBacklog(), POLL_INTERVAL);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [runId, enabled, polling, resetSignal]);
|
||||
|
||||
return { events, latestSeq, reset };
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合 hook:同时跟踪 run 状态 + 轮询事件。
|
||||
*
|
||||
* 用法:`const { run, events } = useMafRunWithEvents(runId)`。
|
||||
* run live 时自动轮询事件;终态时 run 仍按 refetchInterval 停,事件补拉历史后停。
|
||||
*/
|
||||
export function useMafRunWithEvents(runId: string | undefined | null) {
|
||||
const runQuery = useMafRun(runId);
|
||||
const live = runIsLive(runQuery.data?.status);
|
||||
const hasRun = !!runQuery.data;
|
||||
const { events, latestSeq, reset } = useMafEvents(runId, {
|
||||
enabled: hasRun,
|
||||
polling: live,
|
||||
});
|
||||
return {
|
||||
run: runQuery.data,
|
||||
isLoadingRun: runQuery.isLoading,
|
||||
runError: runQuery.error,
|
||||
events,
|
||||
latestSeq,
|
||||
resetEvents: reset,
|
||||
};
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* 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, buildMafFactFlowGraph, extractConclusionFlow } from "./lib/maf-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";
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* 把 MafAction[] 按后端 status 归类成审计面板的可读分桶。
|
||||
*
|
||||
* 对齐后端 schemas.py 的 MafActionStatus 与文档约定的展示语义:
|
||||
* pending / waiting_approval → 等待人工决定
|
||||
* approved → 已批准,等待或正在恢复
|
||||
* rejected / expired → 已拒绝或已过期,不会执行
|
||||
* executed → 已执行
|
||||
* failed → 已确认失败
|
||||
* unknown → 结果未知,禁止自动重试
|
||||
*
|
||||
* 纯函数:输入 actions[],输出有序分桶(稳定排序,便于稳定渲染与单测)。
|
||||
* 未知 status 落到「结果未知」桶(保守,不静默吞掉任何动作)。
|
||||
*/
|
||||
|
||||
import type { MafAction } from "../api/types";
|
||||
|
||||
export type ActionBucketKey =
|
||||
| "waiting"
|
||||
| "approved"
|
||||
| "rejected_or_expired"
|
||||
| "executed"
|
||||
| "failed"
|
||||
| "unknown";
|
||||
|
||||
export interface ActionBucket {
|
||||
key: ActionBucketKey;
|
||||
/** 该桶的人类可读标题。 */
|
||||
label: string;
|
||||
/** 该桶的简短说明(展示在标题下方)。 */
|
||||
hint: string;
|
||||
items: MafAction[];
|
||||
}
|
||||
|
||||
/** 后端 status → 桶 key。未知 status 落到 unknown(保守,不静默吞掉)。 */
|
||||
export function classifyActionStatus(status: string | null | undefined): ActionBucketKey {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
case "waiting_approval":
|
||||
return "waiting";
|
||||
case "approved":
|
||||
return "approved";
|
||||
case "rejected":
|
||||
case "expired":
|
||||
return "rejected_or_expired";
|
||||
case "executed":
|
||||
return "executed";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "unknown":
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/** 桶的展示元信息(顺序即渲染顺序)。 */
|
||||
export const ACTION_BUCKET_META: Record<ActionBucketKey, { label: string; hint: string }> = {
|
||||
waiting: { label: "等待人工决定", hint: "pending / waiting_approval" },
|
||||
approved: { label: "已批准,等待或正在恢复", hint: "approved" },
|
||||
rejected_or_expired: { label: "已拒绝或已过期,不会执行", hint: "rejected / expired" },
|
||||
executed: { label: "已执行", hint: "executed" },
|
||||
failed: { label: "已确认失败", hint: "failed" },
|
||||
unknown: { label: "结果未知,禁止自动重试", hint: "unknown" },
|
||||
};
|
||||
|
||||
/** 桶顺序(渲染顺序)。 */
|
||||
const BUCKET_ORDER: ActionBucketKey[] = [
|
||||
"waiting",
|
||||
"approved",
|
||||
"executed",
|
||||
"failed",
|
||||
"rejected_or_expired",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
/**
|
||||
* 把 actions 按 status 归类成有序分桶。空桶也会返回(便于 UI 显示「0」计数),
|
||||
* 调用方可自行决定是否隐藏空桶。
|
||||
*/
|
||||
export function classifyActionsByStatus(actions: MafAction[] | null | undefined): ActionBucket[] {
|
||||
const byKey = new Map<ActionBucketKey, MafAction[]>();
|
||||
for (const k of BUCKET_ORDER) byKey.set(k, []);
|
||||
for (const a of actions ?? []) {
|
||||
const key = classifyActionStatus(a.status);
|
||||
byKey.get(key)!.push(a);
|
||||
}
|
||||
return BUCKET_ORDER.map((key) => ({
|
||||
key,
|
||||
label: ACTION_BUCKET_META[key].label,
|
||||
hint: ACTION_BUCKET_META[key].hint,
|
||||
items: byKey.get(key)!,
|
||||
}));
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* MAF 审批卡片按钮的禁用判定 —— 纯函数。
|
||||
*
|
||||
* 审批卡片的「批准/拒绝」按钮必须在 mutation 进行中(pending)或已批准等待恢复(resuming)
|
||||
* 时禁用,防止用户双击导致重复决策。把判定抽成纯函数,作为组件与测试的共同事实源:
|
||||
* 组件 import 它设置 disabled,测试 import 它验证守卫,避免测试复制守卫逻辑、
|
||||
* 组件改坏却仍通过。
|
||||
*/
|
||||
|
||||
export interface DecisionButtonState {
|
||||
/** mutation 是否在进行中(useMutation.isPending)。 */
|
||||
pending: boolean;
|
||||
/** 已批准、正在等待后端 dispatcher.resume() 自动恢复期间。 */
|
||||
resuming: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批决策按钮是否应被禁用。
|
||||
*
|
||||
* 规则:pending 或 resuming 任一为 true → 禁用(防重复提交 + 防恢复期间二次点击)。
|
||||
* 这是「pending 审批双击只发一次决策」的唯一事实源。
|
||||
*/
|
||||
export function isDecisionButtonDisabled(state: DecisionButtonState): boolean {
|
||||
return state.pending || state.resuming;
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* 把 MAF 原始事件流(按 seq 升序)折叠成时间线可读条目。
|
||||
*
|
||||
* 后端事件分类见 app/maf_roundtable/event_normalizer.py:
|
||||
* agent.message.delta —— 流式 token 片段,需聚合为一条消息
|
||||
* agent.message.completed —— 一轮完整回复(含 content)
|
||||
* tool.* —— 工具生命周期,折叠为单条「调用 → 结果」
|
||||
* action.waiting_approval —— 审批锚点(右侧审批卡片入口)
|
||||
* run.* / skill.* —— 状态/生命周期条目
|
||||
*
|
||||
* 设计:**纯函数**,无 React、无副作用,便于单测。输入 events[](已去重升序),
|
||||
* 输出 TimelineEntry[](时间线渲染顺序)。
|
||||
*/
|
||||
|
||||
import type { MafEvent } from "../api/types";
|
||||
|
||||
export type TimelineEntryKind =
|
||||
| "message" // 智能体消息(delta 聚合 或 completed)
|
||||
| "tool" // 工具调用生命周期(requested→completed/failed 折叠)
|
||||
| "skill" // 技能生命周期
|
||||
| "approval" // 审批锚点(waiting/approved/rejected)
|
||||
| "artifact" // 产出物
|
||||
| "run" // run 生命周期(started/completed/failed/cancelled)
|
||||
| "checkpoint" // 工作流检查点
|
||||
| "turn"; // 席位轮次开始
|
||||
|
||||
export interface TimelineEntry {
|
||||
/** 用于 React key,全局唯一。 */
|
||||
key: string;
|
||||
kind: TimelineEntryKind;
|
||||
/** 原始 event_type(供组件按类型配色/图标)。 */
|
||||
eventType: string;
|
||||
/** 触发该条目的 agent_id(席位/智能体标识)。 */
|
||||
agentId: string | null;
|
||||
/** 轮次索引(便于分组)。 */
|
||||
turnIndex: number | null;
|
||||
seq: number;
|
||||
createdAt: string | null;
|
||||
/** 主标题(工具名 / 状态文案 / 席位名)。 */
|
||||
title: string;
|
||||
/** 正文(消息内容 / 工具结果摘要 / 错误信息)。 */
|
||||
body?: string;
|
||||
/** 关联的二级信息(action_id/approval_id/tool_call_id 等)。 */
|
||||
meta?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** delta 聚合的分组键:(agent_id, turn_index) —— 同一席位同一轮的 delta 拼成一条。 */
|
||||
function deltaGroupKey(ev: MafEvent): string {
|
||||
return `delta:${ev.agent_id ?? ""}:${ev.turn_index ?? 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 折叠事件流为时间线条目。
|
||||
*
|
||||
* 聚合规则:
|
||||
* 1. `agent.message.delta` 按 (agent,turn) 分组拼接 → 一条 message(取最大 seq 为排序键)。
|
||||
* 若同组有 `agent.message.completed`,则用 completed 的完整 content 覆盖。
|
||||
* 2. `tool.requested`/`tool.started`/`tool.completed`/`tool.failed` 按 tool_call_id 折叠
|
||||
* 为一条 tool 条目(最终状态 + 名称)。
|
||||
* 3. 其余每事件一条。
|
||||
*/
|
||||
export function foldEventsToTimeline(events: MafEvent[]): TimelineEntry[] {
|
||||
const entries: TimelineEntry[] = [];
|
||||
// delta 分组缓冲:groupKey → { texts[], lastSeq, agent, turn, createdAt }
|
||||
const deltaBuf = new Map<
|
||||
string,
|
||||
{ texts: string[]; seq: number; ev: MafEvent; completed?: MafEvent }
|
||||
>();
|
||||
// tool 折叠:tool_call_id → entry(先建占位,后续更新)
|
||||
const toolMap = new Map<string, TimelineEntry>();
|
||||
|
||||
for (const ev of events) {
|
||||
const p = ev.payload ?? {};
|
||||
switch (ev.event_type) {
|
||||
case "agent.message.delta": {
|
||||
const text = typeof p.text === "string" ? p.text : "";
|
||||
if (!text) break;
|
||||
const k = deltaGroupKey(ev);
|
||||
const cur = deltaBuf.get(k);
|
||||
if (cur) {
|
||||
cur.texts.push(text);
|
||||
if (ev.seq > cur.seq) {
|
||||
cur.seq = ev.seq;
|
||||
cur.ev = ev;
|
||||
}
|
||||
} else {
|
||||
deltaBuf.set(k, { texts: [text], seq: ev.seq, ev });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent.message.completed": {
|
||||
const k = deltaGroupKey(ev);
|
||||
const cur = deltaBuf.get(k);
|
||||
const content = typeof p.content === "string" ? p.content : "";
|
||||
if (cur) {
|
||||
cur.completed = ev;
|
||||
if (content) cur.texts = [content]; // completed 的完整内容覆盖 delta 拼接
|
||||
if (ev.seq > cur.seq) cur.seq = ev.seq;
|
||||
} else {
|
||||
deltaBuf.set(k, {
|
||||
texts: content ? [content] : [],
|
||||
seq: ev.seq,
|
||||
ev,
|
||||
completed: ev,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool.requested":
|
||||
case "tool.started":
|
||||
case "tool.completed":
|
||||
case "tool.failed": {
|
||||
const tcId = typeof p.tool_call_id === "string" ? p.tool_call_id : "";
|
||||
const toolName = typeof p.tool_name === "string" ? p.tool_name : "";
|
||||
const mapKey = tcId || `${ev.event_type}:${ev.seq}`;
|
||||
const status =
|
||||
ev.event_type === "tool.failed"
|
||||
? "failed"
|
||||
: ev.event_type === "tool.completed"
|
||||
? "completed"
|
||||
: ev.event_type === "tool.started"
|
||||
? "started"
|
||||
: "requested";
|
||||
const existing = toolMap.get(mapKey);
|
||||
if (existing) {
|
||||
// 更新到最新状态(seq 更大者覆盖)
|
||||
if (ev.seq >= existing.seq) {
|
||||
existing.seq = ev.seq;
|
||||
existing.title = toolName || existing.title;
|
||||
existing.body =
|
||||
typeof p.content_preview === "string"
|
||||
? p.content_preview
|
||||
: existing.body;
|
||||
existing.meta = { ...existing.meta, ...p, status };
|
||||
}
|
||||
} else {
|
||||
toolMap.set(mapKey, {
|
||||
key: `tool:${mapKey}`,
|
||||
kind: "tool",
|
||||
eventType: ev.event_type,
|
||||
agentId: ev.agent_id,
|
||||
turnIndex: ev.turn_index,
|
||||
seq: ev.seq,
|
||||
createdAt: ev.created_at,
|
||||
title: toolName || "工具调用",
|
||||
body: typeof p.content_preview === "string" ? p.content_preview : undefined,
|
||||
meta: { ...p, status },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// 其它事件一一映射(含 approval/run/skill/artifact/checkpoint/turn)。
|
||||
const entry = mapSingleEvent(ev);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delta 缓冲落盘
|
||||
for (const { texts, seq, ev, completed } of deltaBuf.values()) {
|
||||
entries.push({
|
||||
key: `msg:${deltaGroupKey(ev)}`,
|
||||
kind: "message",
|
||||
eventType: completed ? "agent.message.completed" : "agent.message.delta",
|
||||
agentId: ev.agent_id,
|
||||
turnIndex: ev.turn_index,
|
||||
seq,
|
||||
createdAt: ev.created_at,
|
||||
title: ev.agent_id ?? "智能体",
|
||||
body: texts.join("").trim() || undefined,
|
||||
meta: completed?.payload,
|
||||
});
|
||||
}
|
||||
// tool 折叠落盘
|
||||
for (const entry of toolMap.values()) entries.push(entry);
|
||||
|
||||
// 按 seq 升序(聚合条目用其最大 seq,自然排到出现时刻之后)
|
||||
entries.sort((a, b) => a.seq - b.seq);
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 把单个非聚合事件映射成 TimelineEntry。 */
|
||||
function mapSingleEvent(ev: MafEvent): TimelineEntry | null {
|
||||
const p = ev.payload ?? {};
|
||||
const base = {
|
||||
key: `${ev.event_type}:${ev.id}`,
|
||||
agentId: ev.agent_id,
|
||||
turnIndex: ev.turn_index,
|
||||
seq: ev.seq,
|
||||
createdAt: ev.created_at,
|
||||
};
|
||||
switch (ev.event_type) {
|
||||
case "agent.turn_started":
|
||||
return {
|
||||
...base,
|
||||
kind: "turn",
|
||||
eventType: ev.event_type,
|
||||
title: `${ev.agent_id ?? "席位"} 开始第 ${(ev.turn_index ?? 0) + 1} 轮`,
|
||||
meta: p,
|
||||
};
|
||||
case "run.started":
|
||||
return { ...base, kind: "run", eventType: ev.event_type, title: "会商开始", meta: p };
|
||||
case "run.completed":
|
||||
return { ...base, kind: "run", eventType: ev.event_type, title: "会商完成", meta: p };
|
||||
case "run.failed":
|
||||
return {
|
||||
...base,
|
||||
kind: "run",
|
||||
eventType: ev.event_type,
|
||||
title: "会商失败",
|
||||
body: typeof p.message === "string" ? p.message : undefined,
|
||||
meta: p,
|
||||
};
|
||||
case "run.cancelled":
|
||||
return { ...base, kind: "run", eventType: ev.event_type, title: "会商已取消", meta: p };
|
||||
case "skill.selected":
|
||||
case "skill.started":
|
||||
case "skill.completed":
|
||||
case "skill.failed": {
|
||||
const skillName = typeof p.skill_name === "string" ? p.skill_name : "技能";
|
||||
const phase = ev.event_type.split(".")[1];
|
||||
const labelMap: Record<string, string> = {
|
||||
selected: "选择",
|
||||
started: "开始",
|
||||
completed: "完成",
|
||||
failed: "失败",
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
kind: "skill",
|
||||
eventType: ev.event_type,
|
||||
title: `技能「${skillName}」${labelMap[phase] ?? phase}`,
|
||||
body:
|
||||
typeof p.result_preview === "string"
|
||||
? p.result_preview
|
||||
: typeof p.error === "string"
|
||||
? p.error
|
||||
: undefined,
|
||||
meta: p,
|
||||
};
|
||||
}
|
||||
case "artifact.created":
|
||||
return {
|
||||
...base,
|
||||
kind: "artifact",
|
||||
eventType: ev.event_type,
|
||||
title: `产出:${typeof p.title === "string" ? p.title : typeof p.artifact_type === "string" ? p.artifact_type : "产物"}`,
|
||||
meta: p,
|
||||
};
|
||||
case "action.waiting_approval":
|
||||
case "action.approved":
|
||||
case "action.rejected": {
|
||||
const phase = ev.event_type.split(".")[1];
|
||||
const labelMap: Record<string, string> = {
|
||||
waiting_approval: "等待审批",
|
||||
approved: "已批准",
|
||||
rejected: "已拒绝",
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
kind: "approval",
|
||||
eventType: ev.event_type,
|
||||
title: `写工具「${typeof p.tool_name === "string" ? p.tool_name : "未知"}」${labelMap[phase] ?? phase}`,
|
||||
body: typeof p.summary === "string" ? p.summary : undefined,
|
||||
meta: p,
|
||||
};
|
||||
}
|
||||
case "workflow.checkpointed":
|
||||
return {
|
||||
...base,
|
||||
kind: "checkpoint",
|
||||
eventType: ev.event_type,
|
||||
title: "工作流已检查点",
|
||||
body: typeof p.reason === "string" ? p.reason : undefined,
|
||||
meta: p,
|
||||
};
|
||||
default:
|
||||
// 兜底:未知事件类型仍展示,避免遗漏。
|
||||
return {
|
||||
...base,
|
||||
kind: "run",
|
||||
eventType: ev.event_type,
|
||||
title: ev.event_type,
|
||||
meta: p,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 时间线条目的人类可读时间(HH:MM:SS)。null/无效返回空串。 */
|
||||
export function shortTime(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
/**
|
||||
* MAF 事实/结论流程图 —— 纯函数层(MAF 自包含,不依赖任何旧会商目录)。
|
||||
*
|
||||
* 本文件是 MAF 模块独有的图数据层:定义自己的类型、自己解析 flow-json、自己构造
|
||||
* confirmed_facts 证据图。不 import 任何旧会商目录的代码,保证新旧会商页面可独立演进。
|
||||
*
|
||||
* 两个数据源,优先级合并:
|
||||
* 1. `final_result.content` 里的 ```flow-json 块(次源,结论流程图)—— 若 final_result
|
||||
* 含 flow-json,解析后作为「结论流程图」返回(模型精心编排的层级图)。
|
||||
* 2. `confirmed_facts`(主源,事实证据图)—— 每条已确认事实 → task 节点;其
|
||||
* evidence_refs 连到对应证据节点(evidence → role / tool_results → deliverable)。
|
||||
*
|
||||
* 设计:**纯函数**,输入 checkpoint(宽松类型),输出 MafFlowGraphData | null。
|
||||
* null 表示无任何可绘制数据,由组件显示空态。
|
||||
*/
|
||||
|
||||
import type { MafCheckpoint, MafFactLedger } from "../api/types";
|
||||
|
||||
// ── 类型(MAF 独有,不复用旧会商)────────────────────────────────────────────
|
||||
|
||||
export type MafFlowNodeType =
|
||||
| "start"
|
||||
| "end"
|
||||
| "stage"
|
||||
| "task"
|
||||
| "decision"
|
||||
| "risk"
|
||||
| "deliverable"
|
||||
| "role";
|
||||
|
||||
export interface MafFlowGraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: MafFlowNodeType;
|
||||
detail?: string;
|
||||
/** 层级/分组名(如 任务 / 目的 / 行为体),由模型在 flow-json 里给出。 */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface MafFlowGraphEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface MafFlowGraphData {
|
||||
title: string;
|
||||
nodes: MafFlowGraphNode[];
|
||||
edges: MafFlowGraphEdge[];
|
||||
/** 是否因规模上限截断了节点/边/源文本(弱模型超大输出保护)。 */
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/** 节点类型 → 中文标签(图例 / 类型徽章)。 */
|
||||
export const MAF_FLOW_NODE_TYPE_LABEL: Record<MafFlowNodeType, string> = {
|
||||
start: "开始",
|
||||
end: "结束",
|
||||
stage: "阶段",
|
||||
task: "任务",
|
||||
decision: "判断",
|
||||
risk: "风险",
|
||||
deliverable: "交付物",
|
||||
role: "角色",
|
||||
};
|
||||
|
||||
const VALID_TYPES = new Set<string>(Object.keys(MAF_FLOW_NODE_TYPE_LABEL));
|
||||
|
||||
export const MAF_FLOW_FENCE_OPEN = "```flow-json";
|
||||
|
||||
// ── 规模上限(弱模型超大输出保护)─────────────────────────────────────────
|
||||
|
||||
const MAX_FLOW_SOURCE_CHARS = 100_000;
|
||||
const MAX_FLOW_NODES = 200;
|
||||
const MAX_FLOW_EDGES = 400;
|
||||
const MAX_FLOW_LABEL_CHARS = 160;
|
||||
const MAX_FLOW_DETAIL_CHARS = 2_000;
|
||||
const MAX_FLOW_TITLE_CHARS = 160;
|
||||
|
||||
/** 把文本截断到 max 字符(超出时追加 …),用于 node label / detail / graph title。 */
|
||||
function clampText(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return `${text.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
// ── flow-json 解析(容错半截 JSON / 裸换行 / 尾逗号)─────────────────────────
|
||||
|
||||
function asString(v: unknown): string {
|
||||
return typeof v === "string" ? v.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 容错解析单个对象切片:模型输出常带裸换行 / 尾逗号 / 中文标点导致整对象 JSON.parse
|
||||
* 失败、节点被整个丢弃。这里用正则逐字段抽取,最大限度救回节点/边。
|
||||
*/
|
||||
function looseParseObject(slice: string): Record<string, unknown> | null {
|
||||
const pickStr = (...keys: string[]): string | undefined => {
|
||||
for (const k of keys) {
|
||||
const m = new RegExp(`"${k}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(slice);
|
||||
if (m) {
|
||||
const raw = m[1];
|
||||
try {
|
||||
return JSON.parse(`"${raw.replace(/\r?\n/g, "\\n")}"`) as string;
|
||||
} catch {
|
||||
return raw.replace(/\\n/g, "\n").replace(/\\"/g, '"');
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const obj: Record<string, unknown> = {};
|
||||
const id = pickStr("id");
|
||||
if (id) obj.id = id;
|
||||
const label = pickStr("label", "name");
|
||||
if (label) obj.label = label;
|
||||
const type = pickStr("type");
|
||||
if (type) obj.type = type;
|
||||
const group = pickStr("group", "level", "tier", "layer");
|
||||
if (group) obj.group = group;
|
||||
const detail = pickStr("detail", "description");
|
||||
if (detail) obj.detail = detail;
|
||||
const toStr = pickStr("to", "target");
|
||||
if (toStr) obj.to = toStr;
|
||||
const fm = /"(?:from|prev)"\s*:\s*\[([\s\S]*?)\]/.exec(slice);
|
||||
if (fm) {
|
||||
const inner = fm[1];
|
||||
const ids: unknown[] = [];
|
||||
const objRe = /\{[^{}]*?"id"\s*:\s*"([^"]+)"[^{}]*?\}/g;
|
||||
let om: RegExpExecArray | null;
|
||||
while ((om = objRe.exec(inner)) !== null) ids.push({ id: om[1] });
|
||||
if (ids.length === 0) {
|
||||
const sRe = /"([^"]+)"/g;
|
||||
let sm: RegExpExecArray | null;
|
||||
while ((sm = sRe.exec(inner)) !== null) ids.push(sm[1]);
|
||||
}
|
||||
if (ids.length > 0) obj.from = ids;
|
||||
} else {
|
||||
const fromStr = pickStr("from", "source");
|
||||
if (fromStr) obj.from = fromStr;
|
||||
}
|
||||
return Object.keys(obj).length > 0 ? obj : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 平衡括号扫描:从 `key`(如 `"nodes"`)后的 `[` 开始,把已完整闭合的顶层 `{…}`
|
||||
* 逐个解析;跳过字符串内的括号与转义;数组未闭合(流式半截)时收集到哪算哪。
|
||||
*/
|
||||
function collectObjects(text: string, key: string): Record<string, unknown>[] {
|
||||
const keyIdx = text.indexOf(key);
|
||||
if (keyIdx < 0) return [];
|
||||
const arrStart = text.indexOf("[", keyIdx);
|
||||
if (arrStart < 0) return [];
|
||||
const out: Record<string, unknown>[] = [];
|
||||
let depth = 0;
|
||||
let objStart = -1;
|
||||
let inStr = false;
|
||||
let esc = false;
|
||||
for (let i = arrStart + 1; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inStr) {
|
||||
if (esc) esc = false;
|
||||
else if (ch === "\\") esc = true;
|
||||
else if (ch === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inStr = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{") {
|
||||
if (depth === 0) objStart = i;
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0 && objStart >= 0) {
|
||||
const objSlice = text.slice(objStart, i + 1);
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const j = JSON.parse(objSlice) as unknown;
|
||||
if (j && typeof j === "object" && !Array.isArray(j)) {
|
||||
parsed = j as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
parsed = looseParseObject(objSlice);
|
||||
}
|
||||
if (parsed) out.push(parsed);
|
||||
objStart = -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "]" && depth === 0) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toNode(raw: Record<string, unknown>): MafFlowGraphNode | null {
|
||||
const id = asString(raw.id);
|
||||
const labelRaw = asString(raw.label) || asString(raw.name);
|
||||
if (!id || !labelRaw) return null;
|
||||
const rawType = asString(raw.type).toLowerCase();
|
||||
const type = (VALID_TYPES.has(rawType) ? rawType : "task") as MafFlowNodeType;
|
||||
const detailRaw = asString(raw.detail) || asString(raw.description) || undefined;
|
||||
const group =
|
||||
asString(raw.group) || asString(raw.level) || asString(raw.tier) || asString(raw.layer) || undefined;
|
||||
return {
|
||||
id,
|
||||
label: clampText(labelRaw, MAX_FLOW_LABEL_CHARS),
|
||||
type,
|
||||
detail: detailRaw ? clampText(detailRaw, MAX_FLOW_DETAIL_CHARS) : undefined,
|
||||
group,
|
||||
};
|
||||
}
|
||||
|
||||
function edgesFromNode(raw: Record<string, unknown>, nodeId: string): MafFlowGraphEdge[] {
|
||||
const from = raw.from ?? raw.prev;
|
||||
if (!Array.isArray(from)) return [];
|
||||
const out: MafFlowGraphEdge[] = [];
|
||||
for (const entry of from) {
|
||||
if (typeof entry === "string" && entry.trim()) {
|
||||
out.push({ from: entry.trim(), to: nodeId });
|
||||
} else if (entry && typeof entry === "object") {
|
||||
const o = entry as Record<string, unknown>;
|
||||
const fromId = asString(o.id) || asString(o.from);
|
||||
if (!fromId) continue;
|
||||
const label = asString(o.label) || undefined;
|
||||
out.push({ from: fromId, to: nodeId, label });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toEdge(raw: Record<string, unknown>): MafFlowGraphEdge | null {
|
||||
const from = asString(raw.from) || asString(raw.source);
|
||||
const to = asString(raw.to) || asString(raw.target);
|
||||
if (!from || !to) return null;
|
||||
const label = asString(raw.label) || undefined;
|
||||
return { from, to, label };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从(可能流式半截的)回复文本里提取最后一个 ```flow-json 块并解析。
|
||||
* 没有 fence 或一个完整节点都没有 → null。
|
||||
*/
|
||||
export function extractMafFlowGraph(text: string): MafFlowGraphData | null {
|
||||
const start = text.lastIndexOf(MAF_FLOW_FENCE_OPEN);
|
||||
if (start < 0) return null;
|
||||
let body = text.slice(start + MAF_FLOW_FENCE_OPEN.length);
|
||||
const close = body.indexOf("```");
|
||||
if (close >= 0) body = body.slice(0, close);
|
||||
|
||||
let truncated = false;
|
||||
if (body.length > MAX_FLOW_SOURCE_CHARS) {
|
||||
body = body.slice(0, MAX_FLOW_SOURCE_CHARS);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
|
||||
let title = "";
|
||||
if (titleMatch) {
|
||||
try {
|
||||
title = JSON.parse(`"${titleMatch[1]}"`) as string;
|
||||
} catch {
|
||||
title = titleMatch[1];
|
||||
}
|
||||
}
|
||||
if (title.length > MAX_FLOW_TITLE_CHARS) {
|
||||
title = clampText(title, MAX_FLOW_TITLE_CHARS);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const rawNodes = collectObjects(body, '"nodes"');
|
||||
const nodes: MafFlowGraphNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
const edges: MafFlowGraphEdge[] = [];
|
||||
for (const raw of rawNodes) {
|
||||
const node = toNode(raw);
|
||||
if (!node || seen.has(node.id)) continue;
|
||||
seen.add(node.id);
|
||||
nodes.push(node);
|
||||
edges.push(...edgesFromNode(raw, node.id));
|
||||
}
|
||||
for (const raw of collectObjects(body, '"edges"')) {
|
||||
const edge = toEdge(raw);
|
||||
if (edge) edges.push(edge);
|
||||
}
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
if (nodes.length > MAX_FLOW_NODES) {
|
||||
truncated = true;
|
||||
}
|
||||
const cappedNodes = nodes.slice(0, MAX_FLOW_NODES);
|
||||
const cappedNodeIds = new Set(cappedNodes.map((n) => n.id));
|
||||
|
||||
const edgeKeys = new Set<string>();
|
||||
const validEdges = edges.filter((e) => {
|
||||
if (!cappedNodeIds.has(e.from) || !cappedNodeIds.has(e.to) || e.from === e.to) return false;
|
||||
const key = `${e.from}->${e.to}`;
|
||||
if (edgeKeys.has(key)) return false;
|
||||
edgeKeys.add(key);
|
||||
return true;
|
||||
});
|
||||
let cappedEdges = validEdges;
|
||||
if (validEdges.length > MAX_FLOW_EDGES) {
|
||||
cappedEdges = validEdges.slice(0, MAX_FLOW_EDGES);
|
||||
truncated = true;
|
||||
}
|
||||
return { title, nodes: cappedNodes, edges: cappedEdges, truncated };
|
||||
}
|
||||
|
||||
// ── confirmed_facts 证据图 ──────────────────────────────────────────────────
|
||||
|
||||
/** 截断长文本用于节点 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): MafFlowGraphData | null {
|
||||
const content = checkpoint?.final_result?.content;
|
||||
if (!content || typeof content !== "string") return null;
|
||||
const graph = extractMafFlowGraph(content);
|
||||
return graph && graph.nodes.length > 0 ? graph : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 confirmed_facts + evidence + tool_results 编织成事实证据图。
|
||||
*
|
||||
* 布局:start(根) ──► evidence/tool(role/deliverable) ──► fact(task)
|
||||
*/
|
||||
export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlowGraphData | null {
|
||||
const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger;
|
||||
const confirmed = ledger?.confirmed_facts ?? [];
|
||||
if (confirmed.length === 0) return null;
|
||||
|
||||
const nodes: MafFlowGraphNode[] = [];
|
||||
const edges: MafFlowGraphEdge[] = [];
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
const addNode = (n: MafFlowGraphNode) => {
|
||||
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 → 来源类型。
|
||||
const evidenceByText = new Map<string, "evidence" | "tool">();
|
||||
for (const e of ledger?.evidence ?? []) {
|
||||
const ref = typeof e.ref === "string" ? e.ref : typeof e.text === "string" ? e.text : "";
|
||||
if (ref) evidenceByText.set(ref, "evidence");
|
||||
}
|
||||
for (const tr of ledger?.tool_results ?? []) {
|
||||
if (tr.status !== "completed") continue;
|
||||
const name = tr.tool_name ?? "";
|
||||
const turn = tr.turn_index;
|
||||
const ref =
|
||||
name && turn != null ? `tool:${name}:${turn}` : name ? `tool:${name}` : "";
|
||||
if (ref) evidenceByText.set(ref, "tool");
|
||||
}
|
||||
|
||||
const ROOT_ID = "root";
|
||||
addNode({ id: ROOT_ID, label: "已确认事实", type: "start" });
|
||||
|
||||
for (const fact of confirmed) {
|
||||
const factId = `fact:${fact.candidate_id}`;
|
||||
addNode({
|
||||
id: factId,
|
||||
label: truncate(fact.text),
|
||||
type: "task",
|
||||
detail: clampText(fact.text, MAX_FLOW_DETAIL_CHARS),
|
||||
group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认",
|
||||
});
|
||||
|
||||
if ((fact.evidence_refs ?? []).length === 0) {
|
||||
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}`;
|
||||
addNode({
|
||||
id: evId,
|
||||
label: truncate(refStr.replace(/^tool:/, ""), 24),
|
||||
type: evKind === "tool" ? "deliverable" : "role",
|
||||
detail: clampText(refStr, MAX_FLOW_DETAIL_CHARS),
|
||||
group: evKind === "tool" ? "工具证据" : "外部证据",
|
||||
});
|
||||
addEdge(ROOT_ID, evId);
|
||||
addEdge(evId, factId);
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const dedupEdges = edges.filter((e) => {
|
||||
const k = `${e.from}->${e.to}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
|
||||
let truncated = false;
|
||||
let cappedNodes = nodes;
|
||||
let cappedEdges = dedupEdges;
|
||||
if (nodes.length > MAX_FLOW_NODES) {
|
||||
truncated = true;
|
||||
cappedNodes = nodes.slice(0, MAX_FLOW_NODES);
|
||||
const cappedNodeIds = new Set(cappedNodes.map((n) => n.id));
|
||||
cappedEdges = dedupEdges.filter((e) => cappedNodeIds.has(e.from) && cappedNodeIds.has(e.to));
|
||||
}
|
||||
if (cappedEdges.length > MAX_FLOW_EDGES) {
|
||||
truncated = true;
|
||||
cappedEdges = cappedEdges.slice(0, MAX_FLOW_EDGES);
|
||||
}
|
||||
|
||||
return { title: "事实证据图", nodes: cappedNodes, edges: cappedEdges, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并两源:结论 flow-json 优先;否则用事实证据图;都没有 → null(空态)。
|
||||
*/
|
||||
export function buildMafFlowGraph(checkpoint: MafCheckpoint | null): MafFlowGraphData | null {
|
||||
return extractConclusionFlow(checkpoint) ?? buildMafFactFlowGraph(checkpoint);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* 工具风险等级 → TDesign Tag 主题/标签(MAF 审批卡 + 动作审计面板共用)。
|
||||
*
|
||||
* 后端实际使用 R0–R4(见 app/maf_roundtable 的工具元数据分类):
|
||||
* R0/R1 → 低风险(success)
|
||||
* R2 → 中风险(warning)
|
||||
* R3/R4 → 高风险(danger)
|
||||
* 兜底兼容旧的 low/medium/high 字面量;未登记的未知等级落到 default(保守展示)。
|
||||
*
|
||||
* 这是单一事实源:组件和测试都从这里 import,避免测试在文件里复制映射、
|
||||
* 实现坏掉却仍通过。
|
||||
*/
|
||||
|
||||
export type RiskTheme = "success" | "warning" | "danger" | "default";
|
||||
|
||||
const RISK_THEME_MAP: Record<string, RiskTheme> = {
|
||||
R0: "success",
|
||||
R1: "success",
|
||||
R2: "warning",
|
||||
R3: "danger",
|
||||
R4: "danger",
|
||||
// 兜底:后端可能返回旧值
|
||||
low: "success",
|
||||
medium: "warning",
|
||||
high: "danger",
|
||||
};
|
||||
|
||||
const RISK_LABEL_MAP: Record<string, string> = {
|
||||
R0: "R0 · 低",
|
||||
R1: "R1 · 低",
|
||||
R2: "R2 · 中",
|
||||
R3: "R3 · 高",
|
||||
R4: "R4 · 高",
|
||||
low: "low · 低",
|
||||
medium: "medium · 中",
|
||||
high: "high · 高",
|
||||
};
|
||||
|
||||
/** 风险等级 → TDesign Tag 主题;未知等级落到 default(不放过任何未知风险)。 */
|
||||
export function riskThemeFor(level: string | null | undefined): RiskTheme {
|
||||
if (!level) return "default";
|
||||
return RISK_THEME_MAP[level] ?? "default";
|
||||
}
|
||||
|
||||
/** 风险等级 → 人类可读标签;未知等级原样返回(让审阅者看到原始值)。 */
|
||||
export function riskLabelFor(level: string | null | undefined): string {
|
||||
if (!level) return "—";
|
||||
return RISK_LABEL_MAP[level] ?? level;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* MAF run 手动恢复(resume)的前置判定 —— 纯函数。
|
||||
*
|
||||
* 后端契约(app/maf_roundtable):当 run 处于 waiting_approval 且仍存在 pending 审批时,
|
||||
* 调用 POST /runs/{id}/resume 会返回 409。因此前端必须:
|
||||
* 1. 仍有 pending 审批时,禁止手动恢复(让用户先处理审批);
|
||||
* 2. 审批列表尚在加载时,禁止恢复(不知道是否还有 pending);
|
||||
* 3. 审批清零且 run 仍为 waiting_approval 时(说明后端 dispatcher 自动恢复未生效或失败),
|
||||
* 才允许手动恢复作为补偿操作。
|
||||
*
|
||||
* 单一事实源:MafRunHeader 与测试都从这里 import,避免测试复制判定逻辑。
|
||||
*/
|
||||
|
||||
export interface CanManualResumeParams {
|
||||
runStatus: string | null | undefined;
|
||||
/** 审批列表是否仍在加载(首次或轮询中)。 */
|
||||
approvalsLoading: boolean;
|
||||
/** 审批列表是否成功加载(isLoading=false 且无 error)。 */
|
||||
approvalsSuccess: boolean;
|
||||
/** 当前 pending 审批数量。 */
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许手动恢复 run。
|
||||
*
|
||||
* 规则:
|
||||
* - run 必须处于 waiting_approval(其它状态走各自的生命周期按钮);
|
||||
* - 审批必须已成功加载(approvalsSuccess && !approvalsLoading);
|
||||
* - 不存在任何 pending 审批(pendingCount === 0)。
|
||||
*
|
||||
* 满足以上三项 → true(显示「恢复」按钮作为补偿操作)。
|
||||
*/
|
||||
export function canManualResume({
|
||||
runStatus,
|
||||
approvalsLoading,
|
||||
approvalsSuccess,
|
||||
pendingCount,
|
||||
}: CanManualResumeParams): boolean {
|
||||
if (runStatus !== "waiting_approval") return false;
|
||||
if (approvalsLoading) return false;
|
||||
if (!approvalsSuccess) return false;
|
||||
if (pendingCount > 0) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* MAF 安全展示 —— 统一的二次脱敏纯函数(审批卡 + 动作审计面板共用)。
|
||||
*
|
||||
* 设计:把「敏感键删除(不显示成 ***)+ 递归 + 深度/数组/字段/字符串上限」收敛成
|
||||
* 单一事实源。审批卡和动作审计面板都 import safeDisplayJson,禁止各自再实现私有脱敏,
|
||||
* 避免两边规则漂移(一边漏脱敏、另一边不漏)。
|
||||
*
|
||||
* 规则:
|
||||
* - 敏感键直接删除,绝不渲染成 target_key: "***"(连键名都不能透露);
|
||||
* - 键名忽略大小写匹配 SENSITIVE_KEY_RE;
|
||||
* - 递归处理对象/数组,最大深度 4(>4 返回占位);
|
||||
* - 单数组最多 20 项;单对象最多 30 个非敏感字段;
|
||||
* - 单字符串最多 120 字;
|
||||
* - 返回 hiddenCount / truncated,供页面显示「已隐藏 N 项敏感字段」「内容已截断」,
|
||||
* 但绝不透露具体敏感字段名。
|
||||
*/
|
||||
|
||||
const SENSITIVE_KEY_RE =
|
||||
/(secret|token|password|passwd|credential|auth|cookie|target_key|idempotency|api_key|apikey|private_key)/i;
|
||||
|
||||
const MAX_DEPTH = 4;
|
||||
const MAX_ARRAY_ITEMS = 20;
|
||||
const MAX_OBJECT_FIELDS = 30;
|
||||
const MAX_STRING_CHARS = 120;
|
||||
|
||||
export interface SafeDisplayResult {
|
||||
/** 脱敏 + 截断后的 JSON 文本;无可展示内容时为空串(调用方据此隐藏整块面板)。 */
|
||||
text: string;
|
||||
/** 被删除的敏感字段总数(递归累计)。 */
|
||||
hiddenCount: number;
|
||||
/** 是否发生任何截断(深度/数组/字段数/字符串长度)。 */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function isEmptyValue(v: unknown): boolean {
|
||||
if (v == null) return true;
|
||||
if (typeof v === "string") return v.length === 0;
|
||||
if (Array.isArray(v)) return v.length === 0;
|
||||
if (typeof v === "object") return Object.keys(v as Record<string, unknown>).length === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把任意值脱敏 + 截断后序列化成可展示 JSON 文本。
|
||||
*
|
||||
* 全部字段被敏感过滤清空(或输入本身为空)→ text="",调用方应隐藏对应面板。
|
||||
*/
|
||||
export function safeDisplayJson(value: unknown): SafeDisplayResult {
|
||||
let hiddenCount = 0;
|
||||
let truncated = false;
|
||||
|
||||
function sanitize(input: unknown, depth: number): unknown {
|
||||
if (depth > MAX_DEPTH) {
|
||||
truncated = true;
|
||||
return "[内容层级过深]";
|
||||
}
|
||||
|
||||
if (typeof input === "string") {
|
||||
if (input.length > MAX_STRING_CHARS) {
|
||||
truncated = true;
|
||||
return `${input.slice(0, MAX_STRING_CHARS)}…`;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
if (input.length > MAX_ARRAY_ITEMS) truncated = true;
|
||||
return input.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitize(item, depth + 1));
|
||||
}
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
const result: Record<string, unknown> = {};
|
||||
let fieldCount = 0;
|
||||
for (const [key, item] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (SENSITIVE_KEY_RE.test(key)) {
|
||||
hiddenCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (fieldCount >= MAX_OBJECT_FIELDS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
result[key] = sanitize(item, depth + 1);
|
||||
fieldCount += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
const cleaned = sanitize(value, 0);
|
||||
|
||||
let text: string;
|
||||
if (isEmptyValue(cleaned)) {
|
||||
text = "";
|
||||
} else {
|
||||
try {
|
||||
text = JSON.stringify(cleaned, null, 2);
|
||||
} catch {
|
||||
text = "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
return { text, hiddenCount, truncated };
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* MAF 会商创建表单的纯逻辑层。
|
||||
*
|
||||
* 把「从 health 默认值初始化表单」「校验最大轮数」「经理互斥过滤席位」「构建只含改写项的
|
||||
* 请求体」等逻辑抽成纯函数,作为组件与测试的共同事实源。组件不得在内部复制这些逻辑,
|
||||
* 测试必须 import 这里的真实函数,避免「实现坏掉、测试仍通过」。
|
||||
*/
|
||||
|
||||
export interface HealthDefaults {
|
||||
default_mode?: string;
|
||||
default_write_policy?: string;
|
||||
default_max_rounds?: number;
|
||||
}
|
||||
|
||||
export interface SessionFormState {
|
||||
title: string;
|
||||
taskText: string;
|
||||
mode: string;
|
||||
managerAgentId: string;
|
||||
seatAgentIds: string[];
|
||||
modelName: string;
|
||||
reasoningEffort: string;
|
||||
/** 文本输入,空串表示「使用默认」。 */
|
||||
maxRounds: string;
|
||||
writePolicy: string;
|
||||
showAdvanced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 health 返回的后端默认值创建一份干净的初始表单。
|
||||
* - mode 默认取 health.default_mode(兜底 structured);
|
||||
* - writePolicy 默认取 health.default_write_policy(空串表示使用默认);
|
||||
* - maxRounds 默认取 health.default_max_rounds 的字符串(空串表示使用默认)。
|
||||
*
|
||||
* 注意:mode 即便来自 health 也只用作 UI 初始展示;提交时是否真的传回后端,
|
||||
* 由 buildSessionOverrides 按「是否被改写」决定(见下)。
|
||||
*/
|
||||
export function createInitialSessionForm(
|
||||
health: HealthDefaults | null | undefined,
|
||||
): SessionFormState {
|
||||
return {
|
||||
title: "",
|
||||
taskText: "",
|
||||
mode: health?.default_mode ?? "structured",
|
||||
managerAgentId: "",
|
||||
seatAgentIds: [],
|
||||
modelName: "",
|
||||
reasoningEffort: "",
|
||||
maxRounds:
|
||||
typeof health?.default_max_rounds === "number"
|
||||
? String(health.default_max_rounds)
|
||||
: "",
|
||||
writePolicy: health?.default_write_policy ?? "",
|
||||
showAdvanced: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MaxRoundsValidation {
|
||||
valid: boolean;
|
||||
value?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验最大轮数输入。空串视为有效(= 使用后端默认)。必须是 1–64 的整数。
|
||||
*/
|
||||
export function validateMaxRounds(input: string): MaxRoundsValidation {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { valid: true };
|
||||
const n = Number(trimmed);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 64) {
|
||||
return { valid: false, error: "最大轮数必须为 1–64 的整数" };
|
||||
}
|
||||
return { valid: true, value: n };
|
||||
}
|
||||
|
||||
/**
|
||||
* 经理与席位互斥(后端参与者唯一约束):切换经理时,从已选席位中移除该 agent。
|
||||
*/
|
||||
export function filterSeatsForManager(seats: string[], managerId: string): string[] {
|
||||
if (!managerId) return seats;
|
||||
return seats.filter((id) => id !== managerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换某个席位的选择(toggle)。若该 agent 已是经理,则拒绝(返回原数组)。
|
||||
*/
|
||||
export function toggleSeat(
|
||||
seats: string[],
|
||||
agentId: string,
|
||||
managerAgentId: string,
|
||||
): string[] {
|
||||
if (agentId === managerAgentId) return seats;
|
||||
return seats.includes(agentId)
|
||||
? seats.filter((id) => id !== agentId)
|
||||
: [...seats, agentId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示一行只读的后端默认摘要(高级项折叠时用)。值只来自 health,绝不硬编码。
|
||||
*/
|
||||
export function defaultsSummaryLine(health: HealthDefaults | null | undefined): string {
|
||||
if (!health) return "正在读取后端默认配置…";
|
||||
return `当前后端默认:${health.default_mode} | ${health.default_write_policy} | 最大 ${health.default_max_rounds} 轮`;
|
||||
}
|
||||
|
||||
export interface SessionOverrides {
|
||||
mode?: "structured" | "group_chat";
|
||||
writePolicy?: "auto" | "confirm_high_risk" | "confirm_all_writes";
|
||||
maxRounds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只产出「用户相对后端默认确实改写了」的字段。
|
||||
*
|
||||
* 设计:用户没改写 → 不传 → 后端决定(符合「最好不传对应字段」)。
|
||||
* 模式必须是 structured/group_chat 之一才算有效改写;写入策略必须是合法枚举值;
|
||||
* 最大轮数走 validateMaxRounds 校验。
|
||||
*/
|
||||
export function buildSessionOverrides(
|
||||
form: Pick<SessionFormState, "mode" | "writePolicy" | "maxRounds">,
|
||||
defaults: HealthDefaults | null | undefined,
|
||||
): { overrides: SessionOverrides; maxRoundsError?: string } {
|
||||
const result: SessionOverrides = {};
|
||||
let maxRoundsError: string | undefined;
|
||||
|
||||
// 模式:仅当与后端默认不同且合法时传
|
||||
if (form.mode !== (defaults?.default_mode ?? "structured")) {
|
||||
if (form.mode === "structured" || form.mode === "group_chat") {
|
||||
result.mode = form.mode;
|
||||
}
|
||||
}
|
||||
|
||||
// 写入策略:仅当与后端默认不同且合法时传
|
||||
const policyDefault = defaults?.default_write_policy ?? "";
|
||||
if (form.writePolicy !== policyDefault) {
|
||||
if (
|
||||
form.writePolicy === "auto" ||
|
||||
form.writePolicy === "confirm_high_risk" ||
|
||||
form.writePolicy === "confirm_all_writes"
|
||||
) {
|
||||
result.writePolicy = form.writePolicy;
|
||||
}
|
||||
// 空串 = 使用默认,不传
|
||||
}
|
||||
|
||||
// 最大轮数:空串不传;非空走校验
|
||||
const roundsValidation = validateMaxRounds(form.maxRounds);
|
||||
if (form.maxRounds.trim()) {
|
||||
if (!roundsValidation.valid) {
|
||||
maxRoundsError = roundsValidation.error;
|
||||
} else if (roundsValidation.value !== undefined) {
|
||||
const defaultRounds =
|
||||
typeof defaults?.default_max_rounds === "number"
|
||||
? defaults.default_max_rounds
|
||||
: null;
|
||||
if (defaultRounds === null || roundsValidation.value !== defaultRounds) {
|
||||
result.maxRounds = roundsValidation.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { overrides: result, maxRoundsError };
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* MAF 单 run 工作区(路由 runs/:runId)。
|
||||
*
|
||||
* MAF-06-01/07/08/09:可视层改用本地 `tdesign-react`。
|
||||
*
|
||||
* 三栏布局(桌面)/ 纵向堆叠(窄屏):
|
||||
* 左:事件时间线(MafEventTimeline)—— run live 时 cursor 轮询事件
|
||||
* 中:席位状态(MafSeatStatusPanel)+ 事实/结论流程图(MafFactFlowPanel)+ 最终结论
|
||||
* 右:审批卡片(MafApprovalCard)—— 仅 pending 审批时显示
|
||||
*
|
||||
* 顶部:MafRunHeader(状态 Tag + 操作)。整体由 MafHealthGate 守卫。
|
||||
* 刷新/切页/跨页返回时从数据库事件恢复,不依赖 React 内存保存运行状态(MAF-06-09)。
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Loader2, ShieldQuestion } from "lucide-react";
|
||||
import { Button as TButton, Tag as TTag } from "tdesign-react";
|
||||
|
||||
import { useDesensitizeSubtree } from "@/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 { canManualResume } from "../lib/run-resume";
|
||||
import { foldEventsToTimeline } from "../lib/event-timeline";
|
||||
import { MafActionAuditPanel } from "../components/MafActionAuditPanel";
|
||||
import { MafApprovalCard } from "../components/MafApprovalCard";
|
||||
import { MafEventTimeline } from "../components/MafEventTimeline";
|
||||
import { MafFactFlowPanel } from "../components/MafFactFlowPanel";
|
||||
import { MafFactLedgerPanel } from "../components/MafFactLedgerPanel";
|
||||
import { MafHealthGate } from "../components/MafHealthGate";
|
||||
import { MafRunHeader } from "../components/MafRunHeader";
|
||||
import { MafSeatStatusPanel } from "../components/MafSeatStatusPanel";
|
||||
|
||||
export default function MafRunDetailPage() {
|
||||
const { runId } = useParams<{ runId: string }>();
|
||||
return (
|
||||
<MafHealthGate>
|
||||
{runId ? <RunBody runId={runId} /> : <div className="p-6 text-slate-400">缺少 runId</div>}
|
||||
</MafHealthGate>
|
||||
);
|
||||
}
|
||||
|
||||
function RunBody({ runId }: { runId: string }) {
|
||||
const { run, isLoadingRun, runError, events } = useMafRunWithEvents(runId);
|
||||
const headerRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
const live = runIsLive(run?.status);
|
||||
const approvalsQuery = useMafApprovals(runId, live);
|
||||
const actionsQuery = useMafActions(runId, live);
|
||||
const pendingApprovals = (approvalsQuery.data?.approvals ?? []).filter(
|
||||
(a) => a.status === "pending",
|
||||
);
|
||||
// 手动恢复前置判定(见 lib/run-resume.ts):
|
||||
// run 处于 waiting_approval && 审批已加载成功 && 无 pending 审批时才允许手动恢复。
|
||||
// 其余情况(有 pending、加载中、加载失败)一律禁止,避免 /resume 撞后端 409。
|
||||
const allowManualResume = canManualResume({
|
||||
runStatus: run?.status,
|
||||
approvalsLoading: approvalsQuery.isLoading,
|
||||
approvalsSuccess: approvalsQuery.isSuccess,
|
||||
pendingCount: pendingApprovals.length,
|
||||
});
|
||||
// 阻断恢复时的提示文案(让用户知道为什么不能点)
|
||||
const resumeBlockedHint = approvalsQuery.isError
|
||||
? "无法确认审批状态,请稍后重试"
|
||||
: pendingApprovals.length > 0
|
||||
? `请先完成全部审批(剩余 ${pendingApprovals.length})`
|
||||
: approvalsQuery.isLoading
|
||||
? "审批加载中…"
|
||||
: "正在等待自动恢复…";
|
||||
const actionsById = useMemo(() => {
|
||||
const m = new Map<string, MafAction>();
|
||||
for (const a of actionsQuery.data?.actions ?? []) m.set(a.id, a);
|
||||
return m;
|
||||
}, [actionsQuery.data]);
|
||||
|
||||
const timeline = useMemo(() => foldEventsToTimeline(events), [events]);
|
||||
|
||||
if (isLoadingRun) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-slate-400">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> 加载会商…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (runError || !run) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-500">
|
||||
<p>加载失败:{(runError as Error)?.message ?? "run 不存在或无权访问"}</p>
|
||||
<Link to="/page/maf">
|
||||
<TButton theme="default" variant="outline" size="small" icon={<ArrowLeft className="h-3.5 w-3.5" />}>
|
||||
返回会话列表
|
||||
</TButton>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const finalResult = run.maf_checkpoint?.final_result;
|
||||
const showFinalResult = !!finalResult?.content;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div ref={headerRef}>
|
||||
<MafRunHeader
|
||||
run={run}
|
||||
canResume={allowManualResume}
|
||||
resumeBlockedHint={resumeBlockedHint}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 min-h-0 grid-cols-1 gap-2 overflow-hidden p-2 lg:grid-cols-[1fr_360px_320px]">
|
||||
{/* 左:时间线 */}
|
||||
<section className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
<MafEventTimeline entries={timeline} streaming={live} />
|
||||
</section>
|
||||
|
||||
{/* 中:席位 + 流程图 + 最终结论 */}
|
||||
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
|
||||
<MafSeatStatusPanel
|
||||
checkpoint={run.maf_checkpoint}
|
||||
currentRound={typeof run.maf_checkpoint?.current_round === "number"
|
||||
? run.maf_checkpoint.current_round
|
||||
: undefined}
|
||||
/>
|
||||
<MafFactFlowPanel checkpoint={run.maf_checkpoint} streaming={live} />
|
||||
<MafFactLedgerPanel checkpoint={run.maf_checkpoint} />
|
||||
|
||||
{/* 最终结论(MAF-06-07:区分建议动作与已执行动作) */}
|
||||
{showFinalResult && (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<h3 className="text-sm font-semibold text-slate-700">最终结论</h3>
|
||||
{finalResult?.degraded && (
|
||||
<TTag size="small" theme="warning" variant="light-outline">降级输出</TTag>
|
||||
)}
|
||||
{finalResult?.partial_failure && (
|
||||
<TTag size="small" theme="danger" variant="light-outline">部分失败</TTag>
|
||||
)}
|
||||
</div>
|
||||
{/* final_result.content 可能含 flow-json 块;这里只展示文字部分(流程图已在上方画布) */}
|
||||
<div className="prose prose-sm max-h-72 max-w-none overflow-auto text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-xs">
|
||||
{stripFlowJson(finalResult!.content!)}
|
||||
</pre>
|
||||
</div>
|
||||
{finalResult?.note && (
|
||||
<div className="mt-2 text-[11px] text-slate-400">{finalResult.note}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 右:审批 */}
|
||||
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
|
||||
<div className="flex items-center gap-1.5 px-1 text-xs font-medium uppercase tracking-wide text-slate-400">
|
||||
<ShieldQuestion className="h-3.5 w-3.5" />
|
||||
写工具审批
|
||||
{pendingApprovals.length > 0 && (
|
||||
<TTag size="small" theme="danger" variant="light">{pendingApprovals.length}</TTag>
|
||||
)}
|
||||
</div>
|
||||
{pendingApprovals.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 bg-white/50 p-4 text-center text-xs text-slate-400">
|
||||
{live ? "暂无待审批的写操作" : "本次会商无待审批项"}
|
||||
</div>
|
||||
) : (
|
||||
pendingApprovals.map((approval) => (
|
||||
<MafApprovalCard
|
||||
key={approval.id}
|
||||
approval={approval}
|
||||
action={approval.action_id ? actionsById.get(approval.action_id) : undefined}
|
||||
runId={runId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<MafActionAuditPanel runId={runId} live={live} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 把 final_result.content 里的 ```flow-json 块替换成占位提示(流程图已在画布展示)。 */
|
||||
function stripFlowJson(text: string): string {
|
||||
const FENCE = "```flow-json";
|
||||
const start = text.lastIndexOf(FENCE);
|
||||
if (start < 0) return text;
|
||||
const after = text.slice(start + FENCE.length);
|
||||
const close = after.indexOf("```");
|
||||
const tail = close >= 0 ? after.slice(close + 3) : "";
|
||||
return `${text.slice(0, start).trimEnd()}\n\n[结构化流程数据已在上方「事实/结论流程图」画布展示]${tail}`;
|
||||
}
|
||||
@@ -1,642 +0,0 @@
|
||||
/**
|
||||
* MAF 会商会话列表页(路由 index)。
|
||||
*
|
||||
* 左:会话列表(按创建时间倒序);右:选中会话的 run 列表 + 「启动会商」。
|
||||
* 点击某条 run → 跳 `/page/maf/runs/:runId`。
|
||||
*
|
||||
* 可视 UI 统一使用本地 `tdesign-react`(MAF-06-01/02/03)。
|
||||
* 新建弹窗包含 task_text(必填)、模式、经理智能体、席位智能体多选、
|
||||
* 可选高级项(模型/推理强度/最大轮数/写入策略)。智能体列表复用 @/core/agents。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Loader2, MessageSquarePlus, Play, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Button as TButton,
|
||||
Checkbox as TCheckbox,
|
||||
Dialog as TDialog,
|
||||
Input as TInput,
|
||||
Select as TSelect,
|
||||
Tag as TTag,
|
||||
Textarea as TTextarea,
|
||||
} from "tdesign-react";
|
||||
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { useDesensitizeSubtree } from "@/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 {
|
||||
buildSessionOverrides,
|
||||
createInitialSessionForm,
|
||||
defaultsSummaryLine,
|
||||
filterSeatsForManager,
|
||||
toggleSeat,
|
||||
validateMaxRounds,
|
||||
type SessionFormState,
|
||||
} from "../lib/session-form";
|
||||
import { MafHealthGate } from "../components/MafHealthGate";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
running: "会商中",
|
||||
waiting_approval: "等待审批",
|
||||
waiting_input: "等待输入",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
/** session 状态 → TDesign Tag 主题。 */
|
||||
function statusTagTheme(status: string): "default" | "primary" | "warning" | "success" | "danger" {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "primary";
|
||||
case "waiting_approval":
|
||||
case "waiting_input":
|
||||
return "warning";
|
||||
case "completed":
|
||||
return "success";
|
||||
case "failed":
|
||||
case "cancelled":
|
||||
return "danger";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export default function MafSessionsPage() {
|
||||
return (
|
||||
<MafHealthGate>
|
||||
<SessionsBody />
|
||||
</MafHealthGate>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsBody() {
|
||||
const sessionsQuery = useMafSessions({ limit: 100 });
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const headerRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
const sessions = sessionsQuery.data?.sessions ?? [];
|
||||
const active = selectedId
|
||||
? sessions.find((s) => s.id === selectedId)
|
||||
: sessions[0];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* 左:会话列表 */}
|
||||
<aside className="flex w-72 shrink-0 flex-col border-r border-slate-200 bg-white">
|
||||
<div ref={headerRef} className="flex items-center justify-between border-b border-slate-100 px-3 py-2.5">
|
||||
<h2 className="text-sm font-semibold text-slate-700">会话</h2>
|
||||
<TButton
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
icon={<MessageSquarePlus className="h-3.5 w-3.5" />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
新建
|
||||
</TButton>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessionsQuery.isLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-slate-400">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
|
||||
</div>
|
||||
)}
|
||||
{sessionsQuery.isError && (
|
||||
<div className="p-3 text-xs text-red-500">
|
||||
加载失败:{(sessionsQuery.error as Error)?.message ?? "未知错误"}
|
||||
</div>
|
||||
)}
|
||||
{!sessionsQuery.isLoading && sessions.length === 0 && (
|
||||
<div className="p-4 text-center text-xs text-slate-400">
|
||||
暂无会话,点击「新建」开始
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{sessions.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
className={cn(
|
||||
"w-full border-b border-slate-50 px-3 py-2.5 text-left transition hover:bg-slate-50",
|
||||
active?.id === s.id && "bg-blue-50/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-700">
|
||||
{s.title || "未命名会话"}
|
||||
</span>
|
||||
<TTag size="small" theme={statusTagTheme(s.status)} variant="light-outline">
|
||||
{STATUS_LABEL[s.status] ?? s.status}
|
||||
</TTag>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||
<TTag size="small" theme="default" variant="outline">{s.mode}</TTag>
|
||||
<span className="truncate">{s.task_text || "无任务说明"}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 右:选中会话的 run 列表 */}
|
||||
<main className="flex flex-1 flex-col overflow-hidden bg-slate-50/40">
|
||||
{active ? (
|
||||
<SessionDetail sessionId={active.id} title={active.title} mode={active.mode} status={active.status} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
||||
选择左侧会话查看详情,或新建一个
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<CreateSessionDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={(id) => setSelectedId(id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDetail({
|
||||
sessionId,
|
||||
title,
|
||||
mode,
|
||||
status,
|
||||
}: {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
mode: string;
|
||||
status: MafSessionStatus;
|
||||
}) {
|
||||
const runsQuery = useMafRuns(sessionId);
|
||||
const actions = useMafRunActions();
|
||||
const navigate = useNavigate();
|
||||
const runs = runsQuery.data?.runs ?? [];
|
||||
|
||||
// 存在运行中 run 时禁用「启动会商」
|
||||
const liveRun = runs.find((r) => runIsLive(r.status));
|
||||
|
||||
const onStart = () => {
|
||||
actions.start.mutate(
|
||||
{ sessionId },
|
||||
{
|
||||
onSuccess: (run) => navigate(`/page/maf/runs/${run.id}`),
|
||||
onError: (e: Error) => toast.error(`启动失败:${e.message}`),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-semibold text-slate-800">{title || "未命名会话"}</h1>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
|
||||
<TTag size="small" theme="default" variant="outline">{mode}</TTag>
|
||||
<TTag size="small" theme={statusTagTheme(status)} variant="light-outline">
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
</TTag>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{liveRun && (
|
||||
<TButton
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
icon={<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />}
|
||||
onClick={() => navigate(`/page/maf/runs/${liveRun.id}`)}
|
||||
>
|
||||
已有会商进行中
|
||||
</TButton>
|
||||
)}
|
||||
<TButton
|
||||
theme="primary"
|
||||
size="small"
|
||||
loading={actions.start.isPending}
|
||||
disabled={!!liveRun}
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
onClick={onStart}
|
||||
>
|
||||
启动会商
|
||||
</TButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-400">历史 run</h3>
|
||||
{runsQuery.isLoading && (
|
||||
<div className="flex items-center py-6 text-slate-400">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
|
||||
</div>
|
||||
)}
|
||||
{runs.length === 0 && !runsQuery.isLoading && (
|
||||
<p className="py-6 text-center text-xs text-slate-400">暂无会商记录,点击「启动会商」开始</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/page/maf/runs/${run.id}`)}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-left transition hover:border-blue-300 hover:shadow-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-slate-400">{run.id.slice(0, 12)}…</span>
|
||||
{runIsLive(run.status) && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-400">
|
||||
{run.started_at ? new Date(run.started_at).toLocaleString() : "未开始"}
|
||||
</div>
|
||||
</div>
|
||||
<TTag size="small" theme="default" variant="outline">{run.status}</TTag>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export 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();
|
||||
|
||||
// 表单状态:单一对象,由 createInitialSessionForm 根据 health 默认值初始化。
|
||||
const [form, setForm] = useState<SessionFormState>(() =>
|
||||
createInitialSessionForm(health.data),
|
||||
);
|
||||
const [maxRoundsError, setMaxRoundsError] = useState<string>("");
|
||||
|
||||
// 仅在弹窗「由关闭变打开」时重置表单(干净表单 + 从 health 重新取默认值)。
|
||||
// 不在 health.data 变化时重置——避免打开中途 health 刷新把用户已填内容冲掉。
|
||||
const prevOpenRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (open && !prevOpenRef.current) {
|
||||
setForm(createInitialSessionForm(health.data));
|
||||
setMaxRoundsError("");
|
||||
}
|
||||
prevOpenRef.current = open;
|
||||
}, [open, health.data]);
|
||||
|
||||
// 切换经理时,自动从已选席位中移除(后端参与者唯一约束)
|
||||
useEffect(() => {
|
||||
setForm((prev) => {
|
||||
const nextSeats = filterSeatsForManager(
|
||||
prev.seatAgentIds,
|
||||
prev.managerAgentId,
|
||||
);
|
||||
|
||||
return nextSeats.length === prev.seatAgentIds.length
|
||||
? prev
|
||||
: { ...prev, seatAgentIds: nextSeats };
|
||||
});
|
||||
}, [form.managerAgentId]);
|
||||
|
||||
const onToggleSeat = (agentId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
seatAgentIds: toggleSeat(prev.seatAgentIds, agentId, prev.managerAgentId),
|
||||
}));
|
||||
};
|
||||
|
||||
const patch = (p: Partial<SessionFormState>) => setForm((prev) => ({ ...prev, ...p }));
|
||||
|
||||
const healthDefaults = health.data;
|
||||
const healthReady = !!healthDefaults;
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!healthReady) {
|
||||
toast.error("后端默认配置尚未就绪,请稍后重试");
|
||||
return;
|
||||
}
|
||||
if (!form.taskText.trim()) {
|
||||
toast.error("请填写任务说明");
|
||||
return;
|
||||
}
|
||||
if (!form.managerAgentId) {
|
||||
toast.error("请选择经理智能体");
|
||||
return;
|
||||
}
|
||||
if (form.seatAgentIds.length === 0) {
|
||||
toast.error("请至少选择一个席位智能体");
|
||||
return;
|
||||
}
|
||||
|
||||
// 只产出「相对后端默认确实改写了」的高级字段;未改写 → 不传 → 后端决定。
|
||||
const { overrides, maxRoundsError: roundsErr } = buildSessionOverrides(form, healthDefaults);
|
||||
if (roundsErr) {
|
||||
setMaxRoundsError(roundsErr);
|
||||
toast.error(roundsErr);
|
||||
return;
|
||||
}
|
||||
setMaxRoundsError("");
|
||||
|
||||
// 构建 participants
|
||||
const participants: ParticipantInput[] = [];
|
||||
const managerAgent = agents.find((a) => a.id === form.managerAgentId);
|
||||
participants.push({
|
||||
agent_id: form.managerAgentId,
|
||||
display_name: managerAgent?.name ?? "",
|
||||
role: "manager",
|
||||
sort_order: 0,
|
||||
});
|
||||
form.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: form.title.trim(),
|
||||
task_text: form.taskText.trim(),
|
||||
manager_agent_id: form.managerAgentId,
|
||||
participants,
|
||||
};
|
||||
// 仅传被改写的模式 / 写入策略 / 最大轮数(来自 buildSessionOverrides)
|
||||
if (overrides.mode) body.mode = overrides.mode;
|
||||
if (overrides.writePolicy) body.write_policy = overrides.writePolicy;
|
||||
if (overrides.maxRounds != null) body.max_rounds = overrides.maxRounds;
|
||||
// 模型名 / 推理强度:非空才传
|
||||
if (form.modelName.trim()) body.model_name = form.modelName.trim();
|
||||
if (form.reasoningEffort.trim()) body.reasoning_effort = form.reasoningEffort.trim();
|
||||
|
||||
sessionActions.create.mutate(body, {
|
||||
onSuccess: (s) => {
|
||||
toast.success("会话已创建");
|
||||
// 关闭弹窗;下次打开会由 open-effect 重新重置表单
|
||||
onOpenChange(false);
|
||||
onCreated(s.id);
|
||||
},
|
||||
onError: (e: Error) => toast.error(`创建失败:${e.message}`),
|
||||
});
|
||||
};
|
||||
|
||||
const agentOptions = agents.map((a) => ({ label: a.name, value: a.id }));
|
||||
// 实时校验最大轮数(展示错误,不阻塞输入)
|
||||
const liveMaxRoundsError = (() => {
|
||||
if (!form.maxRounds.trim()) return "";
|
||||
const v = validateMaxRounds(form.maxRounds);
|
||||
return v.valid ? "" : v.error ?? "";
|
||||
})();
|
||||
|
||||
return (
|
||||
<TDialog
|
||||
visible={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
header="新建 MAF 会商会话"
|
||||
width="560px"
|
||||
closeOnEscKeydown
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<TButton theme="default" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</TButton>
|
||||
<TButton
|
||||
theme="primary"
|
||||
loading={sessionActions.create.isPending}
|
||||
disabled={!healthReady}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{healthReady ? "创建" : "加载后端默认…"}
|
||||
</TButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 py-2">
|
||||
{/* health 尚未就绪:禁用创建并提示 */}
|
||||
{!healthReady && (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
正在获取后端默认配置,创建按钮暂不可点…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标题 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">会商标题</label>
|
||||
<TInput
|
||||
value={form.title}
|
||||
onChange={(val: string) => patch({ title: val })}
|
||||
placeholder="例如:某任务多视角研讨"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务说明(必填) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">
|
||||
任务说明 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<TTextarea
|
||||
value={form.taskText}
|
||||
onChange={(val: string) => patch({ taskText: val })}
|
||||
placeholder="描述本次会商要解决的问题、背景和目标(必填)"
|
||||
autosize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模式 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">会商模式</label>
|
||||
<TSelect
|
||||
value={form.mode}
|
||||
onChange={(val) => patch({ mode: String(val) })}
|
||||
options={[
|
||||
{ label: "structured(规划→专家→评审→总结)", value: "structured" },
|
||||
{ label: "group_chat(协调员动态点名)", value: "group_chat" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 经理智能体 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">
|
||||
经理智能体 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
{agentsLoading ? (
|
||||
<div className="flex h-9 items-center px-2 text-xs text-slate-400">
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载智能体…
|
||||
</div>
|
||||
) : agentsError ? (
|
||||
<div className="flex h-9 items-center px-2 text-xs text-red-500">
|
||||
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
|
||||
</div>
|
||||
) : (
|
||||
<TSelect
|
||||
value={form.managerAgentId}
|
||||
onChange={(val) => patch({ managerAgentId: String(val) })}
|
||||
options={agentOptions}
|
||||
placeholder="请选择…"
|
||||
filterable
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 席位智能体多选 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">
|
||||
席位智能体(至少一个) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-200 p-2">
|
||||
{agentsLoading && (
|
||||
<div className="flex items-center justify-center py-2 text-xs text-slate-400">
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载中…
|
||||
</div>
|
||||
)}
|
||||
{agentsError && (
|
||||
<div className="py-2 text-center text-xs text-red-500">
|
||||
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
|
||||
</div>
|
||||
)}
|
||||
{!agentsLoading && !agentsError && agents.length === 0 && (
|
||||
<div className="py-2 text-center text-xs text-slate-400">暂无可用智能体</div>
|
||||
)}
|
||||
{!agentsLoading && !agentsError && agents.map((a) => (
|
||||
<label key={a.id} className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-slate-50">
|
||||
<TCheckbox
|
||||
checked={form.seatAgentIds.includes(a.id)}
|
||||
onChange={() => onToggleSeat(a.id)}
|
||||
disabled={a.id === form.managerAgentId}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">{a.name}</span>
|
||||
{a.id === form.managerAgentId && (
|
||||
<span className="text-[10px] text-slate-400">(已选为经理)</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{form.seatAgentIds.length > 0 && (
|
||||
<div className="mt-1 text-[10px] text-slate-400">
|
||||
已选 {form.seatAgentIds.length} 个席位
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 高级项折叠 */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patch({ showAdvanced: !form.showAdvanced })}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{form.showAdvanced ? "收起高级选项" : "展开高级选项"}
|
||||
</button>
|
||||
{/* 高级项未展开时,显示一行只读后端默认摘要(值只来自 health,绝不硬编码) */}
|
||||
{!form.showAdvanced && (
|
||||
<div className="mt-1 text-[10px] text-slate-400">
|
||||
{defaultsSummaryLine(healthDefaults)}
|
||||
</div>
|
||||
)}
|
||||
{form.showAdvanced && (
|
||||
<div className="mt-2 space-y-2 rounded-md border border-slate-100 bg-slate-50/50 p-2">
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[11px] text-slate-500">模型名称</label>
|
||||
<TInput
|
||||
value={form.modelName}
|
||||
onChange={(val: string) => patch({ modelName: val })}
|
||||
placeholder="留空使用默认"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[11px] text-slate-500">推理强度</label>
|
||||
<TSelect
|
||||
value={form.reasoningEffort}
|
||||
onChange={(val) => patch({ reasoningEffort: String(val) })}
|
||||
size="small"
|
||||
options={[
|
||||
{ label: "留空使用默认", value: "" },
|
||||
{ label: "low", value: "low" },
|
||||
{ label: "medium", value: "medium" },
|
||||
{ label: "high", value: "high" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[11px] text-slate-500">最大轮数(1–64)</label>
|
||||
<TInput
|
||||
value={form.maxRounds}
|
||||
onChange={(val: string) => patch({ maxRounds: val })}
|
||||
placeholder="留空使用默认"
|
||||
type="number"
|
||||
size="small"
|
||||
status={liveMaxRoundsError ? "warning" : "default"}
|
||||
/>
|
||||
{(liveMaxRoundsError || maxRoundsError) && (
|
||||
<div className="mt-0.5 text-[10px] text-red-500">
|
||||
{liveMaxRoundsError || maxRoundsError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[11px] text-slate-500">写入策略</label>
|
||||
<TSelect
|
||||
value={form.writePolicy}
|
||||
onChange={(val) => patch({ writePolicy: String(val) })}
|
||||
size="small"
|
||||
options={[
|
||||
{ label: "默认", value: "" },
|
||||
{ label: "auto(自动执行)", value: "auto" },
|
||||
{ label: "confirm_high_risk(高风险确认)", value: "confirm_high_risk" },
|
||||
{ label: "confirm_all_writes(全部确认)", value: "confirm_all_writes" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 删除会话按钮(预留导出)。
|
||||
export function DeleteSessionButton({ sessionId }: { sessionId: string }) {
|
||||
const actions = useMafSessionActions();
|
||||
return (
|
||||
<TButton
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
size="small"
|
||||
loading={actions.remove.isPending}
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
onClick={() => {
|
||||
actions.remove.mutate(sessionId, {
|
||||
onSuccess: () => toast.success("已删除"),
|
||||
onError: (e: Error) => toast.error(`删除失败:${e.message}`),
|
||||
});
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</TButton>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* [MAF-R0 实验原型 — 仅供开发诊断,已从正式菜单移除]
|
||||
*
|
||||
* MAF 多智能体会商路由树(/page/maf/*)。
|
||||
* 本原型不再新增业务功能。新版会商将使用 /api/maf-roundtable/* + conversation 页面。
|
||||
*
|
||||
* 仿 RoundtableRoutes:顶层包一层满高容器,内部 <Routes>:
|
||||
* index → 会话列表
|
||||
* runs/:runId → 单 run 工作区(时间线/席位/流程图/审批)
|
||||
* * → 重定向到 index
|
||||
*/
|
||||
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import MafRunDetailPage from "@/maf-roundtable/pages/MafRunDetailPage";
|
||||
import MafSessionsPage from "@/maf-roundtable/pages/MafSessionsPage";
|
||||
|
||||
export default function MafRoutes() {
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<MafSessionsPage />} />
|
||||
<Route path="runs/:runId" element={<MafRunDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/page/maf" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通知模板管理(仅 admin):创建/维护消息通知模板,支持变量校验与实时预览、
|
||||
* 乐观锁冲突对比(重新加载 / 覆盖保存)。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
FileText,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
NOTIFICATION_TEMPLATE_VARIABLES,
|
||||
NotificationTemplateConflictError,
|
||||
previewNotificationTemplate,
|
||||
useNotificationTemplateActions,
|
||||
useNotificationTemplates,
|
||||
type NotificationTemplate,
|
||||
type NotificationTemplateInput,
|
||||
type PreviewResult,
|
||||
} from "@/strategy-components/api/notification-templates";
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
titleTemplate: string;
|
||||
contentTemplate: string;
|
||||
tagsText: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface ConflictState {
|
||||
server: NotificationTemplate;
|
||||
mine: FormState;
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: "",
|
||||
titleTemplate: "",
|
||||
contentTemplate: "",
|
||||
tagsText: "",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
function formFromTemplate(t: NotificationTemplate): FormState {
|
||||
return {
|
||||
name: t.name,
|
||||
titleTemplate: t.titleTemplate,
|
||||
contentTemplate: t.contentTemplate,
|
||||
tagsText: (t.tags ?? []).join(", "),
|
||||
enabled: t.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTags(text: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of text.split(/[,,]/)) {
|
||||
const tag = part.trim();
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
out.push(tag);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toInput(form: FormState): NotificationTemplateInput {
|
||||
return {
|
||||
name: form.name,
|
||||
titleTemplate: form.titleTemplate,
|
||||
contentTemplate: form.contentTemplate,
|
||||
tags: parseTags(form.tagsText),
|
||||
enabled: form.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_PREVIEW_VARS: Record<string, string> = {
|
||||
userName: "张三",
|
||||
taskName: "示例任务",
|
||||
taskId: "T-001",
|
||||
dueDate: "2026-08-10",
|
||||
};
|
||||
|
||||
export default function NotificationTemplatesPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const actions = useNotificationTemplateActions();
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [keywordApplied, setKeywordApplied] = useState("");
|
||||
const [enabledFilter, setEnabledFilter] = useState<"all" | "true" | "false">("all");
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
keyword: keywordApplied || undefined,
|
||||
enabled:
|
||||
enabledFilter === "all" ? null : enabledFilter === "true" ? true : false,
|
||||
tag: tagFilter.trim() || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
[keywordApplied, enabledFilter, tagFilter, page, pageSize],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||
useNotificationTemplates(listParams);
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<NotificationTemplate | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState<ConflictState | null>(null);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<NotificationTemplate | null>(null);
|
||||
|
||||
const [previewVars, setPreviewVars] = useState(DEFAULT_PREVIEW_VARS);
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
const saving =
|
||||
actions.create.isPending || actions.update.isPending || actions.duplicate.isPending;
|
||||
|
||||
// 实时预览(编辑弹窗打开时)
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
setPreviewLoading(true);
|
||||
void previewNotificationTemplate({
|
||||
titleTemplate: form.titleTemplate,
|
||||
contentTemplate: form.contentTemplate,
|
||||
variables: previewVars,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setPreview(r);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPreview(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
}, 280);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [dialogOpen, form.titleTemplate, form.contentTemplate, previewVars]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setSaveError(null);
|
||||
setConflict(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (t: NotificationTemplate) => {
|
||||
setEditing(t);
|
||||
setForm(formFromTemplate(t));
|
||||
setSaveError(null);
|
||||
setConflict(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const applySearch = () => {
|
||||
setPage(1);
|
||||
setKeywordApplied(keyword.trim());
|
||||
};
|
||||
|
||||
const handleSave = async (opts?: { forceVersion?: number }) => {
|
||||
setSaveError(null);
|
||||
const input = toInput(form);
|
||||
try {
|
||||
if (editing) {
|
||||
const version = opts?.forceVersion ?? editing.version;
|
||||
const updated = await actions.update.mutateAsync({
|
||||
id: editing.id,
|
||||
input: { ...input, expectedVersion: version },
|
||||
});
|
||||
setEditing(updated);
|
||||
setConflict(null);
|
||||
toast.success("模板已保存");
|
||||
setDialogOpen(false);
|
||||
} else {
|
||||
await actions.create.mutateAsync(input);
|
||||
toast.success("模板已创建");
|
||||
setDialogOpen(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof NotificationTemplateConflictError) {
|
||||
setConflict({ server: e.server, mine: form });
|
||||
setSaveError("版本冲突:服务器上的模板已被他人更新,请选择重新加载或覆盖保存。");
|
||||
return;
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
setSaveError(msg);
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReloadFromServer = () => {
|
||||
if (!conflict) return;
|
||||
setEditing(conflict.server);
|
||||
setForm(formFromTemplate(conflict.server));
|
||||
setConflict(null);
|
||||
setSaveError(null);
|
||||
toast.info("已加载服务器最新版本");
|
||||
};
|
||||
|
||||
const handleForceOverwrite = async () => {
|
||||
if (!conflict) return;
|
||||
await handleSave({ forceVersion: conflict.server.version });
|
||||
};
|
||||
|
||||
const handleToggle = async (t: NotificationTemplate) => {
|
||||
try {
|
||||
await actions.update.mutateAsync({
|
||||
id: t.id,
|
||||
input: {
|
||||
name: t.name,
|
||||
titleTemplate: t.titleTemplate,
|
||||
contentTemplate: t.contentTemplate,
|
||||
tags: t.tags,
|
||||
enabled: !t.enabled,
|
||||
expectedVersion: t.version,
|
||||
},
|
||||
});
|
||||
toast.success(t.enabled ? "已停用" : "已启用");
|
||||
} catch (e) {
|
||||
if (e instanceof NotificationTemplateConflictError) {
|
||||
toast.error("版本冲突,请刷新列表后重试");
|
||||
void refetch();
|
||||
return;
|
||||
}
|
||||
toast.error(e instanceof Error ? e.message : "启停失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (t: NotificationTemplate) => {
|
||||
try {
|
||||
await actions.duplicate.mutateAsync(t.id);
|
||||
toast.success("已复制");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await actions.remove.mutateAsync(deleteTarget.id);
|
||||
toast.success("已删除");
|
||||
setDeleteTarget(null);
|
||||
if (items.length <= 1 && page > 1) setPage((p) => p - 1);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const insertVariable = (name: string, field: "titleTemplate" | "contentTemplate") => {
|
||||
const token = `{{${name}}}`;
|
||||
setForm((f) => ({ ...f, [field]: `${f[field]}${token}` }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-4 p-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
|
||||
<FileText className="size-5" />
|
||||
通知模板管理
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
维护消息通知的标题/正文模板。支持变量:
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<code key={v} className="mx-1 rounded bg-muted px-1.5 py-0.5 text-xs">
|
||||
{`{{${v}}}`}
|
||||
</code>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
新建模板
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">关键字</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder="搜索名称 / 标题 / 正文"
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
/>
|
||||
<Button variant="secondary" onClick={applySearch}>
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">启用状态</label>
|
||||
<Select
|
||||
value={enabledFilter}
|
||||
onValueChange={(v) => {
|
||||
setEnabledFilter(v as "all" | "true" | "false");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="true">已启用</SelectItem>
|
||||
<SelectItem value="false">已停用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">标签</label>
|
||||
<Input
|
||||
value={tagFilter}
|
||||
placeholder="精确匹配标签"
|
||||
onChange={(e) => {
|
||||
setTagFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-12 text-center">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
{error instanceof Error && /403|仅限管理员|Forbidden/i.test(error.message)
|
||||
? "无权限访问通知模板管理"
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "加载失败"}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-20 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载中…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
|
||||
<FileText className="size-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">暂无通知模板</p>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
创建第一个模板
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("overflow-hidden rounded-lg border", isFetching && "opacity-70")}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">名称</th>
|
||||
<th className="px-3 py-2 font-medium">标题模板</th>
|
||||
<th className="px-3 py-2 font-medium">标签</th>
|
||||
<th className="px-3 py-2 font-medium">版本</th>
|
||||
<th className="px-3 py-2 font-medium">启用</th>
|
||||
<th className="px-3 py-2 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((t) => (
|
||||
<tr key={t.id} className="border-t">
|
||||
<td className="px-3 py-2.5 font-medium">{t.name}</td>
|
||||
<td className="max-w-[240px] truncate px-3 py-2.5 text-muted-foreground">
|
||||
{t.titleTemplate}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.tags.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : (
|
||||
t.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-muted px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground">v{t.version}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Switch checked={t.enabled} onCheckedChange={() => void handleToggle(t)} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEdit(t)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void handleDuplicate(t)}
|
||||
title="复制"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteTarget(t)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
共 {total} 条 · 第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建 / 编辑 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[92vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑通知模板" : "新建通知模板"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">名称</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
maxLength={50}
|
||||
placeholder="2~50 个字符"
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">标题模板</label>
|
||||
<Input
|
||||
value={form.titleTemplate}
|
||||
maxLength={120}
|
||||
placeholder="如:任务 {{taskName}} 即将到期"
|
||||
onChange={(e) => setForm((f) => ({ ...f, titleTemplate: e.target.value }))}
|
||||
/>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
|
||||
onClick={() => insertVariable(v, "titleTemplate")}
|
||||
>
|
||||
{`{{${v}}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">正文模板</label>
|
||||
<Textarea
|
||||
value={form.contentTemplate}
|
||||
rows={7}
|
||||
maxLength={5000}
|
||||
placeholder="支持 {{userName}} / {{taskName}} / {{taskId}} / {{dueDate}}"
|
||||
onChange={(e) => setForm((f) => ({ ...f, contentTemplate: e.target.value }))}
|
||||
/>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
|
||||
onClick={() => insertVariable(v, "contentTemplate")}
|
||||
>
|
||||
{`{{${v}}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
标签(逗号分隔,最多 5 个)
|
||||
</label>
|
||||
<Input
|
||||
value={form.tagsText}
|
||||
placeholder="提醒, 任务"
|
||||
onChange={(e) => setForm((f) => ({ ...f, tagsText: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||
/>
|
||||
<span className="text-sm">启用</span>
|
||||
{editing && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
当前编辑版本 v{editing.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
|
||||
<div className="text-sm font-medium">实时预览</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
|
||||
<div key={v}>
|
||||
<label className="mb-0.5 block text-[11px] text-muted-foreground">{v}</label>
|
||||
<Input
|
||||
value={previewVars[v] ?? ""}
|
||||
onChange={(e) =>
|
||||
setPreviewVars((prev) => ({ ...prev, [v]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded border bg-background p-3 text-sm">
|
||||
{previewLoading && !preview ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
预览中…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 font-medium">{preview?.title || "(标题预览)"}</div>
|
||||
<div className="whitespace-pre-wrap text-muted-foreground">
|
||||
{preview?.content || "(正文预览)"}
|
||||
</div>
|
||||
{preview && preview.missingVariables.length > 0 && (
|
||||
<p className="mt-2 text-xs text-amber-600">
|
||||
缺少变量:{preview.missingVariables.join(", ")}(保留原占位符)
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && (
|
||||
<div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="size-4" />
|
||||
版本冲突 — 请选择如何处理
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded border bg-background p-2 text-xs">
|
||||
<div className="mb-1 font-medium">服务器最新版本 (v{conflict.server.version})</div>
|
||||
<div className="text-muted-foreground">名称:{conflict.server.name}</div>
|
||||
<div className="text-muted-foreground">标题:{conflict.server.titleTemplate}</div>
|
||||
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
|
||||
{conflict.server.contentTemplate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border bg-background p-2 text-xs">
|
||||
<div className="mb-1 font-medium">我的编辑内容</div>
|
||||
<div className="text-muted-foreground">名称:{conflict.mine.name}</div>
|
||||
<div className="text-muted-foreground">标题:{conflict.mine.titleTemplate}</div>
|
||||
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
|
||||
{conflict.mine.contentTemplate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleReloadFromServer}>
|
||||
重新加载
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={saving} onClick={() => void handleForceOverwrite()}>
|
||||
覆盖保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={saving || !!conflict} onClick={() => void handleSave()}>
|
||||
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确定删除模板「{deleteTarget?.name}」?此操作不可恢复。
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={actions.remove.isPending}
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
{actions.remove.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import Header from "@/strategy-components/Header";
|
||||
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||
import RoundtableRoutes from "./RoundtableRoutes";
|
||||
import NotebookRoutes from "./NotebookRoutes";
|
||||
import MafRoutes from "./MafRoutes";
|
||||
import { RoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
|
||||
|
||||
function PageLayout() {
|
||||
@@ -167,7 +166,6 @@ function PageLayout() {
|
||||
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||
<Route path="roundtable/*" element={<RoundtableRoutes />} />
|
||||
<Route path="notebook/*" element={<NotebookRoutes />} />
|
||||
<Route path="maf/*" element={<MafRoutes />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,7 @@ import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
import NotificationTemplatesPage from "./NotificationTemplatesPage";
|
||||
import PositionRolesPage from "./PositionRolesPage";
|
||||
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
@@ -438,6 +439,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/notification-templates"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<NotificationTemplatesPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/position-roles"
|
||||
element={
|
||||
|
||||
@@ -1,30 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, type ComponentProps } from "react";
|
||||
import { useEffect, useRef, type ComponentProps } from "react";
|
||||
|
||||
import {
|
||||
usePromptInputController,
|
||||
type PromptInputMessage,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import type { InputBox } from "@/components/workspace/input-box";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentQuestion } from "@/core/agents";
|
||||
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type InputBoxProps = ComponentProps<typeof InputBox>;
|
||||
type PositionAgentInputProps = Pick<
|
||||
InputBoxProps,
|
||||
| "className"
|
||||
| "assistantId"
|
||||
| "status"
|
||||
| "disabled"
|
||||
| "context"
|
||||
| "onContextChange"
|
||||
| "onSubmit"
|
||||
| "onStop"
|
||||
| "isNewThread"
|
||||
| "threadId"
|
||||
| "hideAdminRecommendedQuestions"
|
||||
> & {
|
||||
commonQuestions?: (string | AgentQuestion)[] | null;
|
||||
type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
|
||||
/** Stable node/builtin key used to restore the matching SQL-backed draft. */
|
||||
draftKey?: string;
|
||||
draftValue?: string;
|
||||
@@ -38,36 +18,18 @@ type PositionAgentInputProps = Pick<
|
||||
* 只改这个组件即可,不会影响普通对话、工作区或其他业务页面。
|
||||
*/
|
||||
export function PositionAgentInput({
|
||||
context,
|
||||
disabled = false,
|
||||
status = "ready",
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
commonQuestions,
|
||||
className,
|
||||
draftKey = "",
|
||||
draftValue = "",
|
||||
onDraftChange,
|
||||
...props
|
||||
}: PositionAgentInputProps) {
|
||||
const { textInput, attachments } = usePromptInputController();
|
||||
const { models } = useModels();
|
||||
const { textInput } = usePromptInputController();
|
||||
const activeDraftKeyRef = useRef("");
|
||||
const restoringValueRef = useRef<string | null>(null);
|
||||
const textInputRef = useRef(textInput);
|
||||
textInputRef.current = textInput;
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() => models.map((model) => ({ label: model.display_name ?? model.name, value: model.name })),
|
||||
[models],
|
||||
);
|
||||
const attachedFiles = useMemo(
|
||||
() =>
|
||||
attachments.files
|
||||
.map((attachment) => attachment.file)
|
||||
.filter((file): file is File => file instanceof File),
|
||||
[attachments.files],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
activeDraftKeyRef.current = draftKey;
|
||||
if (textInputRef.current.value !== draftValue) {
|
||||
@@ -89,60 +51,13 @@ export function PositionAgentInput({
|
||||
onDraftChange?.(textInput.value);
|
||||
}, [draftKey, onDraftChange, textInput.value]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (status === "streaming") {
|
||||
onStop?.();
|
||||
return;
|
||||
}
|
||||
const text = textInput.value.trim();
|
||||
if (!text && attachedFiles.length === 0) return;
|
||||
const message: PromptInputMessage = {
|
||||
text: textInput.value,
|
||||
files: attachments.files,
|
||||
};
|
||||
void onSubmit?.(message);
|
||||
textInput.clear();
|
||||
attachments.clear();
|
||||
};
|
||||
|
||||
const handleModelChange = (modelName: string) => {
|
||||
const model = models.find((candidate) => candidate.name === modelName);
|
||||
if (!model) return;
|
||||
onContextChange?.({ ...context, model_name: model.name });
|
||||
};
|
||||
const selectedModel =
|
||||
typeof context.model_name === "string"
|
||||
? context.model_name
|
||||
: modelOptions[0]?.value;
|
||||
|
||||
// Keep the page-specific class on the wrapper rather than adding a variant
|
||||
// to InputBox. Node chat and built-in chat can evolve together while every
|
||||
// other workspace keeps the global composer untouched.
|
||||
return (
|
||||
<PositionIntentInput
|
||||
value={textInput.value}
|
||||
onValueChange={textInput.setInput}
|
||||
attachedFiles={attachedFiles}
|
||||
onAddFiles={attachments.add}
|
||||
onRemoveFile={(index) => {
|
||||
const attachment = attachments.files[index];
|
||||
if (attachment) attachments.remove(attachment.id);
|
||||
}}
|
||||
charactersLeft={textInput.value.length}
|
||||
isSending={status === "streaming"}
|
||||
canSend={!disabled && (Boolean(textInput.value.trim()) || attachedFiles.length > 0)}
|
||||
pendingClarification={false}
|
||||
onSend={handleSend}
|
||||
onStop={() => onStop?.()}
|
||||
onFollowUp={() => undefined}
|
||||
onSkip={() => undefined}
|
||||
onEnterRoundtable={() => undefined}
|
||||
enterButtonLabel=""
|
||||
intentReady={null}
|
||||
hideEnterRoundtable
|
||||
onManualSave={() => undefined}
|
||||
onTriggerToast={() => undefined}
|
||||
modelOptions={modelOptions}
|
||||
selectedModel={selectedModel}
|
||||
onSelectedModelChange={handleModelChange}
|
||||
commonQuestions={commonQuestions}
|
||||
<InputBox
|
||||
{...props}
|
||||
className={cn("position-roundtable-agent-input w-full", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -716,7 +716,7 @@ export function PositionBuiltinAgentChat({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="position-roundtable-agent-composer-bar position-roundtable-node-composer shrink-0">
|
||||
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
|
||||
<PositionAgentInput
|
||||
className="w-full"
|
||||
isNewThread={!boundThreadId}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
FileText,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Repeat,
|
||||
Save,
|
||||
Send,
|
||||
Smile,
|
||||
Sparkles,
|
||||
X,
|
||||
@@ -11,11 +14,7 @@ import {
|
||||
import { Select as TSelect } from "tdesign-react";
|
||||
|
||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||
import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents";
|
||||
import type { Step1InputAreaProps } from "@/roundtable-planning/components/Step1Panel";
|
||||
import moreTaskIcon from "@/assets/position-roundtable/more-task.svg";
|
||||
import uploadFileIcon from "@/assets/position-roundtable/upload-file.svg";
|
||||
import sendMessageIcon from "@/assets/position-roundtable/send-message.svg";
|
||||
|
||||
interface PositionIntentModelOption {
|
||||
label: string;
|
||||
@@ -26,7 +25,6 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
|
||||
modelOptions?: PositionIntentModelOption[];
|
||||
selectedModel?: string;
|
||||
onSelectedModelChange?: (model: string) => void;
|
||||
commonQuestions?: (string | AgentQuestion)[] | null;
|
||||
}
|
||||
|
||||
/** Keep attachment metadata presentation local to this position-only composer. */
|
||||
@@ -66,25 +64,40 @@ export function PositionIntentInput({
|
||||
modelOptions = [],
|
||||
selectedModel,
|
||||
onSelectedModelChange,
|
||||
commonQuestions,
|
||||
}: PositionIntentInputProps) {
|
||||
// Files are owned by Step1Panel. This ref only opens the native picker and
|
||||
// forwards its selection through onAddFiles, so replacement layouts do not
|
||||
// accidentally create a second attachment state.
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showMoreQuestions, setShowMoreQuestions] = useState(false);
|
||||
const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions);
|
||||
const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2);
|
||||
const extraCommonQuestions = normalizedCommonQuestions.slice(2);
|
||||
|
||||
const pickCommonQuestion = (question: AgentQuestion) => {
|
||||
onValueChange(question.prompt);
|
||||
setShowMoreQuestions(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="position-roundtable-intent-input position-roundtable-intent-composer shrink-0 border-t border-slate-200 bg-[#f7faff] px-4 py-3 dark:border-[#2b8ffe]/35 dark:bg-[#071a38]">
|
||||
<div className="position-roundtable-intent-composer-frame relative overflow-hidden rounded-[4px] border border-slate-300 bg-white shadow-[0_2px_10px_rgba(15,23,42,0.08)] transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/45 dark:border-[#2b8ffe]/55 dark:bg-[#0d2855] dark:shadow-none dark:focus-within:border-[#56baff] dark:focus-within:ring-[#2b8ffe]/25">
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="position-roundtable-intent-attachments mx-3 mb-2 flex flex-wrap gap-1.5 border-b border-slate-100 pb-2 pt-2.5 dark:border-[#2b8ffe]/25">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-[4px] border border-slate-200 bg-slate-50 py-1 pl-2 pr-1 text-[11px] text-slate-600 dark:border-[#2b8ffe]/30 dark:bg-[#102f63] dark:text-[#c4dcff]"
|
||||
>
|
||||
<FileText className="size-3 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveFile(index)}
|
||||
className="shrink-0 rounded-[3px] p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600 dark:text-[#8aaee0] dark:hover:bg-[#21447c] dark:hover:text-white"
|
||||
title="移除"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AutoGrowTextarea
|
||||
id="input-text-area"
|
||||
value={value}
|
||||
@@ -131,8 +144,7 @@ export function PositionIntentInput({
|
||||
title="上传参考文件(智能体将读取文件内容辅助理解任务)"
|
||||
className="rounded-[3px] p-1.5 transition hover:bg-white hover:text-primary dark:hover:bg-[#16386e] dark:hover:text-[#73c8ff]"
|
||||
>
|
||||
<img src={uploadFileIcon} alt="" aria-hidden="true" />
|
||||
<span>上传附件</span>
|
||||
<Paperclip className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-slate-400">{charactersLeft}/500</span>
|
||||
@@ -142,79 +154,8 @@ export function PositionIntentInput({
|
||||
Parent pages can still hide optional actions by their stable ids
|
||||
without changing this component's layout or global input styles.
|
||||
*/}
|
||||
<div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 px-3 py-2.5 dark:border-[#2b8ffe]/25">
|
||||
<div className="position-roundtable-intent-common-slot">
|
||||
{visibleCommonQuestions.length > 0 && (
|
||||
<div className="position-roundtable-intent-common-questions">
|
||||
{visibleCommonQuestions.map((question, index) => (
|
||||
<button
|
||||
key={`${question.prompt}-${index}`}
|
||||
type="button"
|
||||
className="position-roundtable-intent-common-question"
|
||||
title={question.prompt}
|
||||
onClick={() => pickCommonQuestion(question)}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
<span>{question.title}</span>
|
||||
</button>
|
||||
))}
|
||||
{extraCommonQuestions.length > 0 && (
|
||||
<div className="position-roundtable-intent-common-more">
|
||||
<button
|
||||
type="button"
|
||||
className="position-roundtable-intent-common-more-button"
|
||||
aria-label="更多常用问题"
|
||||
aria-expanded={showMoreQuestions}
|
||||
title="更多常用问题"
|
||||
onClick={() => setShowMoreQuestions((visible) => !visible)}
|
||||
>
|
||||
<img src={moreTaskIcon} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
{showMoreQuestions && (
|
||||
<div className="position-roundtable-intent-common-more-menu">
|
||||
{extraCommonQuestions.map((question, index) => (
|
||||
<button
|
||||
key={`${question.prompt}-${index}`}
|
||||
type="button"
|
||||
title={question.prompt}
|
||||
onClick={() => pickCommonQuestion(question)}
|
||||
>
|
||||
{question.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="position-roundtable-intent-file-cards flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
className="inline-flex max-w-[220px] shrink-0 items-center gap-1.5 rounded-[4px] border border-[#2b8ffe]/45 bg-[#0b3168] py-1 pl-2 pr-1 text-[11px] text-[#c4dcff]"
|
||||
>
|
||||
<FileText className="size-3 shrink-0 text-[#73c8ff]" />
|
||||
<span className="truncate font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#8aaee0]">{formatFileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveFile(index)}
|
||||
className="shrink-0 cursor-pointer rounded-[3px] p-0.5 text-[#8aaee0] transition hover:bg-[#21447c] hover:text-white"
|
||||
title="移除"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
|
||||
<div className="position-roundtable-intent-upload-wrap flex shrink-0 items-center">
|
||||
<div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 bg-white px-3 py-2.5 dark:border-[#2b8ffe]/25 dark:bg-[#0b234c]">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -228,28 +169,28 @@ export function PositionIntentInput({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传附件"
|
||||
aria-label="上传附件"
|
||||
className="position-roundtable-intent-upload-btn flex items-center justify-center rounded-[24px] border-0"
|
||||
title="上传参考文件"
|
||||
aria-label="上传参考文件"
|
||||
className="position-roundtable-intent-upload-btn flex size-10 items-center justify-center rounded-[4px] border border-slate-200 bg-white text-slate-500 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b] dark:hover:text-[#73c8ff]"
|
||||
>
|
||||
<img src={uploadFileIcon} alt="" aria-hidden="true" />
|
||||
<span>上传附件</span>
|
||||
<Paperclip className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
|
||||
{modelOptions.length > 0 && (
|
||||
<div
|
||||
className="position-roundtable-intent-model-switch flex h-8 shrink-0 items-center gap-1.5 rounded-[20px] border border-[#2b8ffe]/55 bg-[#0b3168] px-2.5 text-xs font-bold text-[#dceaff]"
|
||||
title="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
|
||||
className="position-roundtable-intent-model-switch flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white pl-2 text-xs font-bold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7]"
|
||||
title="切换意图理解模型"
|
||||
>
|
||||
<Sparkles className="size-3.5 shrink-0 text-[#73c8ff]" />
|
||||
<Sparkles className="size-3.5 shrink-0 text-primary dark:text-[#73c8ff]" />
|
||||
<TSelect
|
||||
size="small"
|
||||
aria-label="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
|
||||
aria-label="切换意图理解模型"
|
||||
value={selectedModel}
|
||||
options={modelOptions}
|
||||
disabled={isSending}
|
||||
onChange={(value) => onSelectedModelChange?.(String(value))}
|
||||
className="w-[150px] shrink-0 [&_.t-input]:!h-7 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!text-[#dceaff] [&_.t-input]:!shadow-none"
|
||||
className="w-[150px] shrink-0 [&_.t-input]:!h-8 [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!shadow-none dark:[&_.t-input]:!text-[#dceaff]"
|
||||
popupProps={{ overlayClassName: "z-[80]" }}
|
||||
/>
|
||||
</div>
|
||||
@@ -257,20 +198,26 @@ export function PositionIntentInput({
|
||||
<button
|
||||
id="send-message-btn"
|
||||
type="button"
|
||||
onClick={isSending ? onStop : onSend}
|
||||
disabled={isSending ? false : !canSend}
|
||||
aria-label={isSending ? "暂停生成" : "发送"}
|
||||
title={isSending ? "暂停生成" : "发送"}
|
||||
className={`position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]${isSending ? " position-roundtable-intent-send-btn--stopping" : ""}`}
|
||||
onClick={onSend}
|
||||
disabled={!canSend}
|
||||
className="position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]"
|
||||
>
|
||||
{isSending ? (
|
||||
<Pause className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<img src={sendMessageIcon} alt="" aria-hidden="true" />
|
||||
)}
|
||||
<span className="sr-only">{isSending ? "暂停生成" : "发送"}</span>
|
||||
{isSending ? <Loader2 className="size-3 animate-spin" /> : <Send className="size-3" />}
|
||||
{isSending ? "发送中" : "发送"}
|
||||
</button>
|
||||
|
||||
{isSending && (
|
||||
<button
|
||||
id="pause-intent-btn"
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-amber-200/40 bg-amber-100 px-3 text-xs font-bold text-amber-800 shadow-sm transition active:scale-[0.98] hover:bg-amber-200 dark:border-amber-300/30 dark:bg-amber-400/15 dark:text-amber-200 dark:hover:bg-amber-400/25"
|
||||
>
|
||||
<Pause className="size-3" />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="suggest-followup-btn"
|
||||
type="button"
|
||||
|
||||
@@ -811,7 +811,7 @@ export function PositionNodeChat({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="position-roundtable-agent-composer-bar position-roundtable-node-composer shrink-0">
|
||||
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
|
||||
<PositionAgentInput
|
||||
className="w-full"
|
||||
isNewThread={!boundThreadId}
|
||||
@@ -825,7 +825,6 @@ export function PositionNodeChat({
|
||||
onDraftChange={setInputDraft}
|
||||
hideAdminRecommendedQuestions
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
commonQuestions={agent?.common_questions}
|
||||
onSubmit={(message) => void handleSubmit(message)}
|
||||
onStop={() => void thread.stop()}
|
||||
/>
|
||||
|
||||
@@ -7,9 +7,6 @@ import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type { ClarificationPayload };
|
||||
|
||||
/** Stable built-in agent used by the multi-agent roundtable Step 1 flow. */
|
||||
export const INTENT_AGENT_ID = "roundtable-intent";
|
||||
|
||||
/**
|
||||
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
|
||||
* built-in agent and no per-call coordinator: one /init creates a thread
|
||||
|
||||
@@ -103,12 +103,6 @@ export interface Step1PanelProps {
|
||||
*/
|
||||
headerLeftSlot?: React.ReactNode;
|
||||
headerRightSlot?: React.ReactNode;
|
||||
/** Optional side content rendered below the shared header, beside the dialogue area. */
|
||||
rightSidebar?: React.ReactNode;
|
||||
/** Hide the shared question-count badge when a workspace supplies its own header actions. */
|
||||
hideQuestionCount?: boolean;
|
||||
/** Optional empty state rendered in the centre of an otherwise blank dialogue list. */
|
||||
emptyState?: React.ReactNode;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
intentThreadId: string | null;
|
||||
intentReady: object | null;
|
||||
@@ -152,9 +146,6 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
const {
|
||||
headerLeftSlot,
|
||||
headerRightSlot,
|
||||
rightSidebar,
|
||||
hideQuestionCount = false,
|
||||
emptyState,
|
||||
rootClassName,
|
||||
interactiveMessages,
|
||||
intentThreadId,
|
||||
@@ -239,7 +230,6 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
|
||||
// 顶部标题栏整片脱敏(消息列表正文在下方,不在此容器内,保持原文)。
|
||||
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
const showEmptyState = interactiveMessages.length === 0 && Boolean(emptyState);
|
||||
|
||||
return (
|
||||
<div className={`flex-1 flex flex-col min-h-0 ${rootClassName ?? ""}`}>
|
||||
@@ -254,45 +244,34 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!hideQuestionCount && (
|
||||
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
|
||||
</span>
|
||||
{headerRightSlot}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={rightSidebar ? "flex min-h-0 flex-1" : "contents"}>
|
||||
<div className={rightSidebar ? "flex min-w-0 flex-1 flex-col" : "contents"}>
|
||||
{/* Chat list */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col font-normal">
|
||||
<div
|
||||
ref={step1ScrollRef}
|
||||
// overflow-x-hidden:消息列表只竖向滚(见 Step2Panel 同处注释),避免误触横向拖动抢滑块。
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white"
|
||||
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white"
|
||||
>
|
||||
<div
|
||||
ref={step1ContentRef}
|
||||
className={showEmptyState ? "flex min-h-full flex-1 p-6" : "space-y-4 p-6"}
|
||||
>
|
||||
{showEmptyState ? (
|
||||
<div className="flex min-h-full w-full flex-1 items-center justify-center">{emptyState}</div>
|
||||
) : (
|
||||
interactiveMessages.map((msg) => (
|
||||
<Step1Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
|
||||
intentReady={intentReady}
|
||||
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
|
||||
intentClarificationSelections={intentClarificationSelections}
|
||||
setIntentClarificationSelections={setIntentClarificationSelections}
|
||||
isIntentSending={isIntentSending}
|
||||
submitIntentClarifications={submitIntentClarifications}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={step1ContentRef} className="space-y-4 p-6">
|
||||
{interactiveMessages.map((msg) => (
|
||||
<Step1Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
|
||||
intentReady={intentReady}
|
||||
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
|
||||
intentClarificationSelections={intentClarificationSelections}
|
||||
setIntentClarificationSelections={setIntentClarificationSelections}
|
||||
isIntentSending={isIntentSending}
|
||||
submitIntentClarifications={submitIntentClarifications}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
|
||||
@@ -474,9 +453,6 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rightSidebar}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,8 +94,6 @@ export interface UseStep1IntentOptions {
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
/** Optional task-aware welcome bubble text before the first user turn. */
|
||||
initialWelcomeText?: string;
|
||||
/** Keep the dialogue visually empty while its intent thread is initialising. */
|
||||
showInitialWelcome?: boolean;
|
||||
/** 主页面统一的 toast 入口。 */
|
||||
triggerToast: (msg: string) => void;
|
||||
}
|
||||
@@ -106,7 +104,6 @@ export interface UseStep1IntentReturn {
|
||||
intentReady: IntentSummary | null;
|
||||
intentInitError: string | null;
|
||||
intentInitErrorDetail: IntentErrorDetail | null;
|
||||
isIntentInitializing: boolean;
|
||||
isIntentSending: boolean;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
/** Per-question selections, keyed by `clarificationKey(msgId, clarId, index)`. */
|
||||
@@ -175,21 +172,13 @@ function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
|
||||
}
|
||||
|
||||
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
|
||||
const {
|
||||
currentStep,
|
||||
selectedModel,
|
||||
taskContext,
|
||||
initialWelcomeText,
|
||||
showInitialWelcome = true,
|
||||
triggerToast,
|
||||
} = options;
|
||||
const { currentStep, selectedModel, taskContext, initialWelcomeText, triggerToast } = options;
|
||||
|
||||
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
const [intentInitError, setIntentInitError] = useState<string | null>(null);
|
||||
const [intentInitErrorDetail, setIntentInitErrorDetail] =
|
||||
useState<IntentErrorDetail | null>(null);
|
||||
const [isIntentInitializing, setIsIntentInitializing] = useState<boolean>(false);
|
||||
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
|
||||
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] =
|
||||
useState<number | null>(null);
|
||||
@@ -241,23 +230,18 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
setIntentThreadId(null);
|
||||
setIsIntentInitializing(true);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setTimelineEntries([]);
|
||||
setInteractiveMessages(
|
||||
showInitialWelcome
|
||||
? [
|
||||
{
|
||||
id: 1,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
|
||||
time: nowTime(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
setInteractiveMessages([
|
||||
{
|
||||
id: 1,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
|
||||
time: nowTime(),
|
||||
},
|
||||
]);
|
||||
(async () => {
|
||||
try {
|
||||
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
|
||||
@@ -268,7 +252,6 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
);
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
setIntentThreadId(res.thread_id);
|
||||
setIsIntentInitializing(false);
|
||||
setTimelineEntries([
|
||||
{
|
||||
id: 1,
|
||||
@@ -279,7 +262,6 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
]);
|
||||
} catch (err) {
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
setIsIntentInitializing(false);
|
||||
console.error("init intent failed:", err);
|
||||
if (err instanceof IntentInitError) {
|
||||
setIntentInitError(err.detail?.message || err.message);
|
||||
@@ -290,14 +272,13 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [showInitialWelcome]);
|
||||
}, []);
|
||||
|
||||
const resetIntent = useCallback(() => {
|
||||
stopIntentStream();
|
||||
step1InitRef.current = false;
|
||||
step1InitGenRef.current += 1;
|
||||
setIntentThreadId(null);
|
||||
setIsIntentInitializing(false);
|
||||
setIntentReady(null);
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
@@ -673,7 +654,6 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
// 或继续显示旧会话的澄清选择。
|
||||
stopIntentStream();
|
||||
setIsIntentSending(false);
|
||||
setIsIntentInitializing(false);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setIntentThreadId(snapshot.intentThreadId);
|
||||
@@ -710,7 +690,6 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
intentReady,
|
||||
intentInitError,
|
||||
intentInitErrorDetail,
|
||||
isIntentInitializing,
|
||||
isIntentSending,
|
||||
pendingIntentClarificationMsgId,
|
||||
intentClarificationSelections,
|
||||
|
||||
@@ -1,9 +1,67 @@
|
||||
/**
|
||||
* 区域级脱敏 Hook —— re-export shim。
|
||||
* 区域级脱敏 Hook —— 把某个容器(DOM 子树)里的**所有文本节点**做脱敏,
|
||||
* 用于「左/右侧面板 + 顶部标题」这类需要整片脱敏的区域:静态标题、动态内容、
|
||||
* 嵌套子组件文本一并覆盖,无需逐串包 `desensitize()`(那样易漏、难维护)。
|
||||
*
|
||||
* 实现已抽到中性共享位置 `@/lib/use-desensitize-subtree`(MAF 会商、业务链条等多模块共享)。
|
||||
* 本文件保留导出,旧 roundtable-planning 消费者零改动、行为完全一致。
|
||||
* 用法:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
|
||||
*
|
||||
* 用法不变:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
|
||||
* 设计要点:
|
||||
* - 只读改文本节点(display-only),不碰 `<input>/<textarea>` 的 value、不改 React 状态/回传值;
|
||||
* - `desensitize` 幂等(替换词不再命中词表),MutationObserver 重入安全;改写前先 disconnect 防自触发循环;
|
||||
* - 想在区域内**保留原文**(如内嵌沙箱/消息列表),给该子树根加 `data-no-desensitize` 属性即可跳过。
|
||||
*
|
||||
* 注意:**不要**用它包「消息列表正文」或「沙箱产物预览」——这两处按需求保持原文,
|
||||
* 应作为独立容器、不挂本 Hook(智能体名称的脱敏单独在 MessageBubble 的 senderName 处理)。
|
||||
*/
|
||||
export { useDesensitizeSubtree } from "@/lib/use-desensitize-subtree";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
const _SKIP_TAGS = new Set(["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "IFRAME", "CODE", "PRE"]);
|
||||
|
||||
export function useDesensitizeSubtree<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = ref.current;
|
||||
if (!root || typeof MutationObserver === "undefined") return;
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
|
||||
const apply = () => {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
if (_SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
||||
// 显式标记 data-no-desensitize 的子树保留原文(如内嵌沙箱)。
|
||||
if (parent.closest("[data-no-desensitize]")) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
const edits: Array<[Text, string]> = [];
|
||||
let current = walker.nextNode();
|
||||
while (current) {
|
||||
const textNode = current as Text;
|
||||
const original = textNode.nodeValue ?? "";
|
||||
if (original.trim()) {
|
||||
const masked = desensitize(original);
|
||||
if (masked !== original) edits.push([textNode, masked]);
|
||||
}
|
||||
current = walker.nextNode();
|
||||
}
|
||||
if (edits.length === 0) return;
|
||||
// 改写期间停掉观察,避免自身改动再次触发,改完恢复。
|
||||
observer?.disconnect();
|
||||
for (const [textNode, masked] of edits) textNode.nodeValue = masked;
|
||||
observer?.observe(root, { subtree: true, childList: true, characterData: true });
|
||||
};
|
||||
|
||||
apply();
|
||||
observer = new MutationObserver(() => apply());
|
||||
observer.observe(root, { subtree: true, childList: true, characterData: true });
|
||||
return () => observer?.disconnect();
|
||||
});
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* 通知模板管理 API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/notification-templates`(见 app/gateway/routers/notification_templates.py)。
|
||||
* 全局共享;读/写均仅 admin。snake_case ⇄ camelCase 在此边界收敛。
|
||||
*/
|
||||
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
export const NOTIFICATION_TEMPLATE_VARIABLES = [
|
||||
"userName",
|
||||
"taskName",
|
||||
"taskId",
|
||||
"dueDate",
|
||||
] as const;
|
||||
|
||||
export type NotificationTemplateVariable =
|
||||
(typeof NOTIFICATION_TEMPLATE_VARIABLES)[number];
|
||||
|
||||
export interface NotificationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
titleTemplate: string;
|
||||
contentTemplate: string;
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
version: number;
|
||||
createdBy: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationTemplateInput {
|
||||
name: string;
|
||||
titleTemplate: string;
|
||||
contentTemplate: string;
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationTemplateUpdateInput extends NotificationTemplateInput {
|
||||
expectedVersion: number;
|
||||
}
|
||||
|
||||
export interface ListNotificationTemplatesParams {
|
||||
keyword?: string;
|
||||
enabled?: boolean | null;
|
||||
tag?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface NotificationTemplateListResult {
|
||||
items: NotificationTemplate[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
title: string;
|
||||
content: string;
|
||||
missingVariables: string[];
|
||||
}
|
||||
|
||||
/** 409 乐观锁冲突:携带服务端最新完整记录。 */
|
||||
export class NotificationTemplateConflictError extends Error {
|
||||
readonly server: NotificationTemplate;
|
||||
|
||||
constructor(server: NotificationTemplate, message = "版本冲突") {
|
||||
super(message);
|
||||
this.name = "NotificationTemplateConflictError";
|
||||
this.server = server;
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/notification-templates`;
|
||||
|
||||
interface ServerTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
title_template: string;
|
||||
content_template: string;
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
version: number;
|
||||
created_by?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
function toTemplate(s: ServerTemplate): NotificationTemplate {
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
titleTemplate: s.title_template,
|
||||
contentTemplate: s.content_template,
|
||||
tags: s.tags ?? [],
|
||||
enabled: s.enabled ?? true,
|
||||
version: s.version ?? 1,
|
||||
createdBy: s.created_by ?? null,
|
||||
createdAt: s.created_at ?? null,
|
||||
updatedAt: s.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toBody(input: NotificationTemplateInput & { expectedVersion?: number }) {
|
||||
const body: Record<string, unknown> = {
|
||||
name: input.name,
|
||||
title_template: input.titleTemplate,
|
||||
content_template: input.contentTemplate,
|
||||
tags: input.tags,
|
||||
enabled: input.enabled,
|
||||
};
|
||||
if (input.expectedVersion !== undefined) {
|
||||
body.expected_version = input.expectedVersion;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function detailMessage(data: unknown, fallback: string): string {
|
||||
if (!data || typeof data !== "object" || !("detail" in data)) return fallback;
|
||||
const detail = (data as { detail: unknown }).detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (detail && typeof detail === "object" && "message" in detail) {
|
||||
const msg = (detail as { message: unknown }).message;
|
||||
if (typeof msg === "string") return msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (res.status === 204) return undefined as T;
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
if (!res.ok) {
|
||||
if (res.status === 409 && data && typeof data === "object" && "detail" in data) {
|
||||
const detail = (data as { detail: unknown }).detail;
|
||||
if (detail && typeof detail === "object" && "server" in detail) {
|
||||
const server = (detail as { server: ServerTemplate }).server;
|
||||
if (server && typeof server === "object" && "id" in server) {
|
||||
throw new NotificationTemplateConflictError(
|
||||
toTemplate(server),
|
||||
detailMessage(data, "版本冲突"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(detailMessage(data, `${ctx} failed (${res.status})`));
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function listNotificationTemplates(
|
||||
params: ListNotificationTemplatesParams = {},
|
||||
): Promise<NotificationTemplateListResult> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.keyword) qs.set("keyword", params.keyword);
|
||||
if (params.enabled === true || params.enabled === false) {
|
||||
qs.set("enabled", String(params.enabled));
|
||||
}
|
||||
if (params.tag) qs.set("tag", params.tag);
|
||||
if (params.page) qs.set("page", String(params.page));
|
||||
if (params.pageSize) qs.set("page_size", String(params.pageSize));
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
|
||||
const data = await readJson<{
|
||||
items: ServerTemplate[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}>(res, "list notification templates");
|
||||
const result: NotificationTemplateListResult = {
|
||||
items: (data.items ?? []).map(toTemplate),
|
||||
total: data.total ?? 0,
|
||||
page: data.page ?? 1,
|
||||
pageSize: data.page_size ?? params.pageSize ?? 20,
|
||||
};
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "CM助手-全量-通知模板-列表",
|
||||
url: `${BASE()}${query ? `?${query}` : ""}`,
|
||||
reqParam: params,
|
||||
returnParam: { total: result.total, page: result.page },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getNotificationTemplate(id: string): Promise<NotificationTemplate> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
|
||||
const tpl = toTemplate(await readJson<ServerTemplate>(res, "get notification template"));
|
||||
writeYLog({
|
||||
optType: OptType.view,
|
||||
optDetail: "CM助手-全量-通知模板-查看",
|
||||
url: `${BASE()}/${encodeURIComponent(id)}`,
|
||||
reqParam: { id },
|
||||
returnParam: tpl,
|
||||
});
|
||||
return tpl;
|
||||
}
|
||||
|
||||
export async function createNotificationTemplate(
|
||||
input: NotificationTemplateInput,
|
||||
): Promise<NotificationTemplate> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toBody(input)),
|
||||
});
|
||||
const tpl = toTemplate(await readJson<ServerTemplate>(res, "create notification template"));
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "CM助手-全量-通知模板-创建",
|
||||
url: BASE(),
|
||||
reqParam: input,
|
||||
returnParam: tpl,
|
||||
});
|
||||
return tpl;
|
||||
}
|
||||
|
||||
export async function updateNotificationTemplate(
|
||||
id: string,
|
||||
input: NotificationTemplateUpdateInput,
|
||||
): Promise<NotificationTemplate> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toBody(input)),
|
||||
});
|
||||
const tpl = toTemplate(await readJson<ServerTemplate>(res, "update notification template"));
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "CM助手-全量-通知模板-修改",
|
||||
url: `${BASE()}/${encodeURIComponent(id)}`,
|
||||
reqParam: { id, input },
|
||||
returnParam: tpl,
|
||||
});
|
||||
return tpl;
|
||||
}
|
||||
|
||||
export async function deleteNotificationTemplate(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
await readJson<void>(res, "delete notification template");
|
||||
writeYLog({
|
||||
optType: OptType.delete,
|
||||
optDetail: "CM助手-全量-通知模板-删除",
|
||||
url: `${BASE()}/${encodeURIComponent(id)}`,
|
||||
reqParam: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function duplicateNotificationTemplate(
|
||||
id: string,
|
||||
): Promise<NotificationTemplate> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}/duplicate`, {
|
||||
method: "POST",
|
||||
});
|
||||
const tpl = toTemplate(await readJson<ServerTemplate>(res, "duplicate notification template"));
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "CM助手-全量-通知模板-复制",
|
||||
url: `${BASE()}/${encodeURIComponent(id)}/duplicate`,
|
||||
reqParam: { id },
|
||||
returnParam: tpl,
|
||||
});
|
||||
return tpl;
|
||||
}
|
||||
|
||||
export async function previewNotificationTemplate(input: {
|
||||
titleTemplate: string;
|
||||
contentTemplate: string;
|
||||
variables: Record<string, string>;
|
||||
}): Promise<PreviewResult> {
|
||||
const res = await apiFetch(`${BASE()}/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title_template: input.titleTemplate,
|
||||
content_template: input.contentTemplate,
|
||||
variables: input.variables,
|
||||
}),
|
||||
});
|
||||
const data = await readJson<{
|
||||
title: string;
|
||||
content: string;
|
||||
missing_variables: string[];
|
||||
}>(res, "preview notification template");
|
||||
return {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
missingVariables: data.missing_variables ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const KEY = "notification-templates";
|
||||
|
||||
export function useNotificationTemplates(params: ListNotificationTemplatesParams) {
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
KEY,
|
||||
"list",
|
||||
params.keyword ?? "",
|
||||
params.enabled === true ? "1" : params.enabled === false ? "0" : "",
|
||||
params.tag ?? "",
|
||||
params.page ?? 1,
|
||||
params.pageSize ?? 20,
|
||||
],
|
||||
queryFn: () => listNotificationTemplates(params),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotificationTemplateActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: [KEY] });
|
||||
return {
|
||||
create: useMutation({ mutationFn: createNotificationTemplate, onSuccess: invalidate }),
|
||||
update: useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: NotificationTemplateUpdateInput }) =>
|
||||
updateNotificationTemplate(id, input),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({ mutationFn: deleteNotificationTemplate, onSuccess: invalidate }),
|
||||
duplicate: useMutation({
|
||||
mutationFn: duplicateNotificationTemplate,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -746,7 +746,7 @@
|
||||
|
||||
/* 岗位会商左侧总面板:仅收窄滚动条宽度,沿用全局既有的滑块颜色、圆角和轨道样式。 */
|
||||
.position-roundtable-sidebar-scrollbar::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Vitest 配置 —— 仅跑 MAF 模块的真实挂载测试(jsdom + Testing Library)。
|
||||
*
|
||||
* 设计:
|
||||
* - environment=jsdom 让 React 组件可挂载;
|
||||
* - 只 include `*.test.tsx`(组件挂载测试),现有 `*.test.ts`(node:test 纯函数)
|
||||
* 保持原来的 esbuild + node --test 路径,互不干扰;
|
||||
* - 复用项目 tsconfig 的 `@/*` → `./src/*` 别名;
|
||||
* - 不引入 CDN / 在线服务,所有依赖本地安装并锁入 lockfile。
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(here, "src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
// 只跑 MAF 的组件挂载测试(.test.tsx);纯函数 .test.ts 仍走 esbuild+node --test
|
||||
include: ["src/maf-roundtable/**/*.test.tsx"],
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
},
|
||||
});
|
||||
@@ -301,7 +301,6 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 |
|
||||
| **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 |
|
||||
| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, append_auth, auth_token_param, auth_name_param, enabled, sort_order}`. **Two independent, coexisting login-append mechanisms for `url` buttons** — both resolved frontend-side: (1) `login_params` (JSON column `login_params_json`, `PortableJSON`, migration `20260625_01`) = a list of `{key, source, custom_value}` resolved at click time (`source` = a `userInfo` field or `other`→literal `custom_value`) and appended to the target URL — mirrors the light-app login params; empty = nothing appended. (2) `append_auth` + `auth_token_param` + `auth_name_param` (columns, migration `20260625_02`, default off) — appends `?{auth_token_param}={token1}&{auth_name_param}={username}` so the target system can identify the current user; gated on **token login** — the frontend only stashes `token1` (the `?authToken=`) + the raw `getTokenInfo` username (`login.token1`/`login.username`) when login went through `/login/token`, so a non-token-login session appends neither. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. |
|
||||
| **MAF Roundtable (实验)** (`/api/maf-roundtable`) | Microsoft Agent Framework 多智能体会商实验 — an **independent, feature-flag-gated** sibling of the legacy multi-agent roundtable (`/api/roundtable-*` / `/api/multi-agent/*`). Uses MAF's Agent / Workflow / Group Chat orchestration while reusing the existing DeerFlow Agent Runtime for each seat's actual execution (tools / skills / MCP / sandbox / external writes). **Phase 1 (shipped)** = persistence skeleton + API contracts: six isolated tables (`maf_roundtable_sessions` / `_participants` / `_runs` / `_events` / `_actions` / `_approvals`) with **no FK to the legacy `roundtable_*` tables** (full isolation — see `tests/test_maf_roundtable_isolation.py::test_maf_tables_isolated_from_legacy_roundtable`); session CRUD + participants + frozen `capability_snapshot`; run lifecycle (create / get / list / cancel); **monotonic per-run event seq** (`(run_id, seq)` unique, `SELECT MAX(seq)+1` with `IntegrityError` retry so concurrent appends from parallel seats produce a dense 1..N seq set — `tests/test_maf_roundtable_api.py::test_concurrent_appends_produce_dense_seq_set`); event 断线补拉 (`GET /runs/{id}/events?after_seq=N`); action idempotency-key uniqueness; approval decision transition + "already decided" guard. **P1/P2 hardening (shipped)**: (1) **atomic single-transaction run launch** — `store.claim_and_create_pending_run` opens one `AsyncSession` + `begin()`: conditional `UPDATE sessions SET status='running' WHERE id=:id AND user_id=:owner AND status NOT IN (active…)` then INSERT a `pending` run (`started_at=None` — pending = queued, not yet handed to the executor); `rowcount!=1` → no run, caller maps to 404/403/409; any flush/commit failure rolls the whole txn back (no residual `starting`, no orphan run). **Atomic cancel** — `store.cancel_run_atomically` SELECTs run JOIN session `with_for_update` in one txn: active run → run+session `cancelled` (+`finished_at`); already-`cancelled` run → repairs a still-active session (fixes historical half-commits); `completed`/`failed` run → run untouched, session repaired only if still active. Concurrent callers are serialised by the DB (not a Python lock). Until a real MAF executor exists the run stays `pending`. (2) **read/write permission split** — `admin_diagnostic_access` widens only the *read* scope (`_read_scope_user`); every *write* is owner-checked via `_write_scope_user` regardless of admin → `403 MafForbiddenError`. (3) **atomic approval decision** — conditional `UPDATE … WHERE status='pending'`; `rowcount==1` wins, already-decided → 409. (4) **config-driven create defaults + shared enums** — `MafMode` / `MafWritePolicy` / `MafParticipantRole` live in `maf_roundtable_config.py` (single source); API schemas reuse them; `default_max_rounds` has `ge=1, le=64`; omitted create fields fall back to config. (5) **no public mock event write** — `POST /runs/{id}/events` removed; events are written only via internal `service.append_event` (future Workflow executor). (6) **PUT rejects explicit null** on non-nullable fields (`title`/`task_text`/`mode`/`max_rounds`/`write_policy`/`participants` → 422); clearable fields (`task_payload`/`model_name`/…) keep null = clear. **User isolation (MAF-01-07)**: every read/write is owner-scoped by `sessions.user_id`; a non-owner gets `404` (not `403`, so existence is not leaked); admins bypass the owner filter when `maf_roundtable.admin_diagnostic_access` is on. **Feature flag (MAF-01-04)**: `config.yaml → maf_roundtable.enabled` (default **false**, deployment-level); when off, write paths return `503 Service Disabled` and the **MAF SDK is never imported** (lazy import inside `app.maf_roundtable` only when a run is actually started — Phase 2+), reads stay open for admin history review. **Run gating**: a session in `running` / `waiting_*` rejects config edits; `capability_snapshot` is frozen at creation and never mutated on update. **Lifespan (MAF-01-03)**: store always wired (`app.state.maf_roundtable_store`), service only constructed when enabled (`app.state.maf_roundtable_service`); init failures are logged but never abort Gateway startup (experimental / opt-in). Boundary: `deerflow.persistence.maf_roundtable` (harness) holds only pure data models + repos (no `app.*` / no MAF SDK); `app.maf_roundtable` (app) is the **only** place the MAF SDK is imported. Config: `deerflow.config.maf_roundtable_config.MafRoundtableConfig` (`enabled` / `default_mode: MafMode` / `default_write_policy: MafWritePolicy` / `default_max_rounds` ge=1 le=64 / `admin_diagnostic_access`). Router `app/gateway/routers/maf_roundtable.py`; service `app/maf_roundtable/service.py`; schemas `app/maf_roundtable/schemas.py`; persistence `packages/harness/deerflow/persistence/maf_roundtable/` (`model.py` = 6 ORM rows, `sessions.py` / `participants.py` / `runs.py` / `events.py` / `actions.py` / `approvals.py` = repos, `store.py` = facade + `claim_and_create_pending_runLine truncated
|
||||
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy import select
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationEventRow, MafRoundtableConversationRow
|
||||
from app.maf_roundtable.conversation_service import MafConversationService
|
||||
|
||||
class FakeMafConfig:
|
||||
enabled = True
|
||||
default_mode = 'structured'
|
||||
default_model_name = 'test-model'
|
||||
default_reasoning_effort = None
|
||||
default_manager_agent_id = 'mgr-agent'
|
||||
default_max_rounds = 8
|
||||
default_write_policy = 'confirm_high_risk'
|
||||
admin_diagnostic_access = True
|
||||
|
||||
async def main():
|
||||
engine = create_async_engine('sqlite+aiosqlite://')
|
||||
async with engine.begin() as conn:
|
||||
import deerflow.persistence.maf_roundtable.model
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
store = make_maf_roundtable_store(sf)
|
||||
svc = MafConversationService(store, maf_config=FakeMafConfig())
|
||||
conv = await svc.create_conversation(user_id='u1', title='Test')
|
||||
|
||||
# Monkey-patch _publish_conversation_event to trace calls
|
||||
orig = svc._publish_conversation_event
|
||||
async def traced_publish(*args, **kwargs):
|
||||
print(f' _publish_conversation_event called: event_type={kwargs.get("event_type")}')
|
||||
result = await orig(*args, **kwargs)
|
||||
print(f' _publish_conversation_event returned: {result}')
|
||||
return result
|
||||
svc._publish_conversation_event = traced_publish
|
||||
|
||||
try:
|
||||
result = await svc.send_message(conversation_id=conv['id'], user_id='u1', content='Hello')
|
||||
print(f'send_message result: {result}')
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f'send_message ERROR: {type(e).__name__}: {e}')
|
||||
traceback.print_exc()
|
||||
|
||||
async with sf() as session:
|
||||
events = (await session.execute(select(MafRoundtableConversationEventRow))).scalars().all()
|
||||
for e in events:
|
||||
print(f' event: seq={e.seq} type={e.event_type}')
|
||||
conv_row = (await session.execute(
|
||||
select(MafRoundtableConversationRow).where(MafRoundtableConversationRow.id == conv['id'])
|
||||
)).scalars().first()
|
||||
print(f'last_stream_seq={conv_row.last_stream_seq}')
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,7 +1,4 @@
|
||||
# ruff: noqa: E402
|
||||
# load_dotenv() must run before application imports because they read environment
|
||||
# configuration during import-time initialisation.
|
||||
import asyncio
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
@@ -9,9 +6,8 @@ import sys
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
from time import perf_counter
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
@@ -24,84 +20,83 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
# origins, and "Failed to fetch" in the browser.
|
||||
load_dotenv()
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled # noqa: E402
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from app.gateway.config import get_gateway_config
|
||||
from app.gateway.csrf_middleware import CSRFMiddleware
|
||||
from app.gateway.deps import langgraph_runtime
|
||||
from app.gateway.routers import (
|
||||
admin_active_runs,
|
||||
admin_users,
|
||||
agents,
|
||||
ai_writing,
|
||||
artifacts,
|
||||
assistants_compat,
|
||||
auth,
|
||||
browser_context,
|
||||
business_mapping,
|
||||
channels,
|
||||
checkpoint_migration,
|
||||
dashboard_sessions,
|
||||
feedback,
|
||||
flow_extract,
|
||||
html_page_favorites,
|
||||
intent,
|
||||
knowledge,
|
||||
knowledge_ingest,
|
||||
light_apps,
|
||||
llm_metrics,
|
||||
maf_conversations,
|
||||
maf_roundtable,
|
||||
mcp,
|
||||
memory,
|
||||
menu_overrides,
|
||||
models,
|
||||
multi_agent,
|
||||
notifications,
|
||||
open_3qfx,
|
||||
open_chat,
|
||||
parallel_agent_tasks,
|
||||
parallel_agents,
|
||||
position_roles,
|
||||
position_roundtable,
|
||||
positions,
|
||||
public_config,
|
||||
public_embed,
|
||||
public_leaderboard,
|
||||
public_scheduled_tasks,
|
||||
recommend,
|
||||
recommended_questions,
|
||||
roundtable_chains,
|
||||
roundtable_diagnostics,
|
||||
roundtable_draft_shares,
|
||||
roundtable_drafts,
|
||||
roundtable_jobs,
|
||||
roundtable_task_drafts,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
sensitive_words,
|
||||
skill_addresses,
|
||||
skills,
|
||||
suggestions,
|
||||
system_settings,
|
||||
tags,
|
||||
task_buttons,
|
||||
task_reports,
|
||||
taskcop_tasks,
|
||||
thread_runs,
|
||||
thread_shares,
|
||||
threads,
|
||||
tool_metrics,
|
||||
uploads,
|
||||
user_preferences,
|
||||
user_prompts,
|
||||
weknora_sources,
|
||||
writing,
|
||||
)
|
||||
from deerflow.config import app_config as deerflow_app_config
|
||||
from deerflow.config.app_config import apply_logging_level
|
||||
from deerflow.runtime.scheduler import ScheduledTaskService, set_scheduled_task_service
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled # noqa: E402
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from app.gateway.config import get_gateway_config
|
||||
from app.gateway.csrf_middleware import CSRFMiddleware
|
||||
from app.gateway.deps import langgraph_runtime
|
||||
from app.gateway.routers import (
|
||||
ai_writing,
|
||||
admin_active_runs,
|
||||
admin_users,
|
||||
agents,
|
||||
artifacts,
|
||||
assistants_compat,
|
||||
auth,
|
||||
browser_context,
|
||||
business_mapping,
|
||||
channels,
|
||||
checkpoint_migration,
|
||||
feedback,
|
||||
dashboard_sessions,
|
||||
html_page_favorites,
|
||||
intent,
|
||||
knowledge,
|
||||
knowledge_ingest,
|
||||
light_apps,
|
||||
llm_metrics,
|
||||
mcp,
|
||||
memory,
|
||||
menu_overrides,
|
||||
models,
|
||||
multi_agent,
|
||||
notifications,
|
||||
notification_templates,
|
||||
open_3qfx,
|
||||
open_chat,
|
||||
parallel_agent_tasks,
|
||||
parallel_agents,
|
||||
position_roundtable,
|
||||
positions,
|
||||
position_roles,
|
||||
public_config,
|
||||
public_embed,
|
||||
public_leaderboard,
|
||||
public_scheduled_tasks,
|
||||
recommend,
|
||||
flow_extract,
|
||||
recommended_questions,
|
||||
roundtable_chains,
|
||||
roundtable_diagnostics,
|
||||
roundtable_draft_shares,
|
||||
roundtable_drafts,
|
||||
roundtable_jobs,
|
||||
roundtable_task_drafts,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
sensitive_words,
|
||||
skill_addresses,
|
||||
skills,
|
||||
suggestions,
|
||||
system_settings,
|
||||
tags,
|
||||
task_buttons,
|
||||
taskcop_tasks,
|
||||
task_reports,
|
||||
thread_runs,
|
||||
tool_metrics,
|
||||
thread_shares,
|
||||
threads,
|
||||
uploads,
|
||||
user_preferences,
|
||||
user_prompts,
|
||||
weknora_sources,
|
||||
writing,
|
||||
)
|
||||
from deerflow.config import app_config as deerflow_app_config
|
||||
from deerflow.config.app_config import apply_logging_level
|
||||
from deerflow.runtime.scheduler import ScheduledTaskService, set_scheduled_task_service
|
||||
|
||||
AppConfig = deerflow_app_config.AppConfig
|
||||
get_app_config = deerflow_app_config.get_app_config
|
||||
|
||||
@@ -337,10 +332,10 @@ async def _run_db_migrations() -> None:
|
||||
deadline = asyncio.get_running_loop().time() + timeout_s
|
||||
while result_queue.empty():
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise TimeoutError
|
||||
raise asyncio.TimeoutError
|
||||
await asyncio.sleep(0.2)
|
||||
result = result_queue.get_nowait()
|
||||
except TimeoutError:
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"Database migration did not finish within %.0fs; continuing startup on create_all schema. "
|
||||
"Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
|
||||
@@ -963,93 +958,8 @@ def _warn_if_multi_worker() -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def _scan_maf_approval_expiry(app: Any) -> list[str]:
|
||||
"""Expire due MAF approvals once, then resume affected workflows.
|
||||
|
||||
The store owns the conditional state transition and critical audit event.
|
||||
Only the worker that wins that CAS receives an affected run, so repeated
|
||||
scans cannot resume the same expiry twice.
|
||||
"""
|
||||
|
||||
service = getattr(app.state, "maf_roundtable_service", None)
|
||||
if service is None or not getattr(service, "enabled", False):
|
||||
return []
|
||||
|
||||
expired = await service.store.expire_due_approvals()
|
||||
run_ids = list(dict.fromkeys(
|
||||
str(item["run_id"])
|
||||
for item in expired
|
||||
if item.get("run_id")
|
||||
))
|
||||
if not run_ids:
|
||||
return []
|
||||
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher(app)
|
||||
resumed: list[str] = []
|
||||
for run_id in run_ids:
|
||||
run = await service.store.runs.get_run(run_id)
|
||||
if run is None:
|
||||
continue
|
||||
session = await service.store.sessions.get_session(run["session_id"], user_id=None)
|
||||
if session is None:
|
||||
continue
|
||||
# Dispatcher.resume is a no-op for a terminal/non-waiting run; keeping
|
||||
# this call centralized preserves the restart and run-level CAS path.
|
||||
await dispatcher.resume(run_id, user_id=str(session.get("user_id") or "default"))
|
||||
resumed.append(run_id)
|
||||
return resumed
|
||||
|
||||
|
||||
async def _maf_approval_scanner_loop(app: Any) -> None:
|
||||
"""Continuously fail closed expired MAF approvals until shutdown."""
|
||||
|
||||
service = getattr(app.state, "maf_roundtable_service", None)
|
||||
config = getattr(service, "config", None)
|
||||
interval = max(5, int(getattr(config, "approval_scan_interval_seconds", 30) or 30))
|
||||
while True:
|
||||
try:
|
||||
await _scan_maf_approval_expiry(app)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("MAF approval expiry scan failed (non-fatal)")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
def _start_maf_approval_scanner(app: Any) -> asyncio.Task[None] | None:
|
||||
"""Start the MAF-only approval scanner when the feature is enabled."""
|
||||
|
||||
service = getattr(app.state, "maf_roundtable_service", None)
|
||||
if service is None or not getattr(service, "enabled", False):
|
||||
return None
|
||||
task = asyncio.create_task(_maf_approval_scanner_loop(app))
|
||||
app.state.maf_approval_scanner_task = task
|
||||
logger.info(
|
||||
"MAF approval expiry scanner started (interval=%ss)",
|
||||
getattr(service.config, "approval_scan_interval_seconds", 30),
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
async def _stop_maf_approval_scanner(app: Any) -> None:
|
||||
"""Cancel and await the MAF approval scanner during Gateway shutdown."""
|
||||
|
||||
task = getattr(app.state, "maf_approval_scanner_task", None)
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("MAF approval expiry scanner shutdown error")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
|
||||
# ?????? worker ?? ?? ?? AI ?? / ? agent ???????
|
||||
@@ -1213,19 +1123,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
# Concurrency (system-pressure) sampler — lightweight, singleton-locked,
|
||||
# runtime-toggleable via system_settings.concurrency_monitor.enabled.
|
||||
try:
|
||||
app.state.concurrency_sampler_task = asyncio.create_task(_concurrency_sampler_loop(app))
|
||||
except Exception:
|
||||
logger.exception("Concurrency sampler failed to start (non-fatal)")
|
||||
|
||||
# MAF-only approval expiry scanner. Legacy DeerFlow, old roundtable
|
||||
# and AI-writing paths never enter this loop.
|
||||
try:
|
||||
_start_maf_approval_scanner(app)
|
||||
except Exception:
|
||||
logger.exception("MAF approval expiry scanner failed to start (non-fatal)")
|
||||
|
||||
# Start IM channel service if any channels are configured
|
||||
try:
|
||||
app.state.concurrency_sampler_task = asyncio.create_task(_concurrency_sampler_loop(app))
|
||||
except Exception:
|
||||
logger.exception("Concurrency sampler failed to start (non-fatal)")
|
||||
|
||||
# Start IM channel service if any channels are configured
|
||||
try:
|
||||
from app.channels.service import start_channel_service
|
||||
|
||||
@@ -1284,18 +1187,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logger.exception("Leaderboard snapshot scheduler shutdown error")
|
||||
|
||||
concurrency_sampler_task = getattr(app.state, "concurrency_sampler_task", None)
|
||||
if concurrency_sampler_task is not None:
|
||||
concurrency_sampler_task.cancel()
|
||||
try:
|
||||
await concurrency_sampler_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Concurrency sampler shutdown error")
|
||||
|
||||
await _stop_maf_approval_scanner(app)
|
||||
|
||||
# ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
|
||||
if concurrency_sampler_task is not None:
|
||||
concurrency_sampler_task.cancel()
|
||||
try:
|
||||
await concurrency_sampler_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Concurrency sampler shutdown error")
|
||||
|
||||
# ?? 7 ????AI ?? graph ???? checkpointer / in-flight task?
|
||||
# thread ??? LangGraph Server ? checkpointer ???lifespan ???
|
||||
# ``langgraph_runtime()`` ? contextmanager ???????????????
|
||||
|
||||
@@ -1608,13 +1509,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
|
||||
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
|
||||
app.include_router(task_buttons.router)
|
||||
|
||||
# Microsoft Agent Framework 多智能体会商实验 (independent of the legacy
|
||||
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
|
||||
app.include_router(maf_roundtable.router)
|
||||
|
||||
# MAF roundtable V2 Conversation API (user-visible layer) at /api/maf-roundtable/conversations
|
||||
app.include_router(maf_conversations.router)
|
||||
app.include_router(notification_templates.router)
|
||||
|
||||
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
|
||||
app.include_router(taskcop_tasks.router)
|
||||
|
||||
@@ -12,7 +12,7 @@ import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, TypeVar, cast
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from langgraph.types import Checkpointer
|
||||
@@ -39,67 +39,6 @@ if TYPE_CHECKING:
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def get_gateway_worker_topology() -> dict[str, Any]:
|
||||
"""Detect the current worker count and source.
|
||||
|
||||
Returns ``{"worker_count": int|None, "source": str, "is_multi_worker": bool}``.
|
||||
Sources checked in order: ``WEB_CONCURRENCY`` env var, ``/proc`` scan (Linux).
|
||||
"""
|
||||
import os as _os
|
||||
|
||||
wc = _os.environ.get("WEB_CONCURRENCY", "").strip()
|
||||
if wc and wc.isdigit():
|
||||
count = int(wc)
|
||||
return {"worker_count": count, "source": "WEB_CONCURRENCY", "is_multi_worker": count > 1}
|
||||
|
||||
if not _os.name == "posix":
|
||||
return {"worker_count": None, "source": "unknown", "is_multi_worker": False}
|
||||
|
||||
try:
|
||||
from pathlib import Path as _Path
|
||||
|
||||
peers = 0
|
||||
proc = _Path("/proc")
|
||||
if proc.exists():
|
||||
for entry in proc.iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
cmdline = (entry / "cmdline").read_bytes().replace(b"\x00", b" ").decode("utf-8", "ignore")
|
||||
except OSError:
|
||||
continue
|
||||
if "uvicorn" in cmdline and "app.gateway.app" in cmdline:
|
||||
peers += 1
|
||||
if peers > 0:
|
||||
return {"worker_count": peers, "source": "process_scan", "is_multi_worker": peers > 1}
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
return {"worker_count": None, "source": "unknown", "is_multi_worker": False}
|
||||
|
||||
|
||||
async def _background_reconcile_loop(service: Any, *, interval: int) -> None:
|
||||
"""Periodically reconcile active conversation runs."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
await service.reconcile_active_conversations()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[maf-roundtable] background reconcile failed", exc_info=True)
|
||||
|
||||
|
||||
async def _startup_reconcile(service: Any) -> None:
|
||||
"""One-shot reconcile of all active runs at gateway startup."""
|
||||
try:
|
||||
written = await service.reconcile_active_conversations()
|
||||
if written:
|
||||
logger.info("[maf-roundtable] startup reconcile wrote %d conversation events", written)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[maf-roundtable] startup reconcile failed", exc_info=True)
|
||||
|
||||
|
||||
def resolve_roundtable_gateway_mode(raw: str | None) -> str:
|
||||
"""解析 ``ROUNDTABLE_JOB_GATEWAY``:**默认 ``inprocess``(真模型后台挂起)**。
|
||||
|
||||
@@ -358,6 +297,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.task_button_store = make_task_button_store(sf)
|
||||
|
||||
from deerflow.persistence.notification_templates import make_notification_template_store
|
||||
|
||||
app.state.notification_template_store = make_notification_template_store(sf)
|
||||
|
||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||
|
||||
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
||||
@@ -370,161 +313,6 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
|
||||
|
||||
# ── MAF roundtable experiment (Microsoft Agent Framework 多智能体会商) ──
|
||||
# Independent, feature-flag-gated sibling of the legacy roundtable. Six
|
||||
# isolated tables (no FK to roundtable_*). The store is always wired
|
||||
# (so reads work for admin history review) but the **service** is only
|
||||
# constructed when ``maf_roundtable.enabled`` is on — that is the gate
|
||||
# the router checks; when off, write paths return 503 and the MAF SDK
|
||||
# is never imported. Initialization failures are logged but never
|
||||
# abort Gateway startup (the feature is experimental / opt-in).
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
|
||||
app.state.maf_roundtable_store = make_maf_roundtable_store(sf)
|
||||
maf_enabled = False
|
||||
maf_dispatch_ready = False
|
||||
maf_dispatch_status = "configuration_unavailable"
|
||||
try:
|
||||
from app.maf_roundtable import MafRoundtableService
|
||||
|
||||
maf_cfg = getattr(config, "maf_roundtable", None)
|
||||
maf_enabled = bool(getattr(maf_cfg, "enabled", False)) if maf_cfg is not None else False
|
||||
if maf_cfg is not None:
|
||||
from app.maf_roundtable.dispatch_contracts import MafDispatchCatalog
|
||||
|
||||
maf_dispatch_ready, maf_dispatch_status = MafDispatchCatalog(maf_cfg).readiness()
|
||||
if maf_enabled and not maf_dispatch_ready:
|
||||
logger.error(
|
||||
"[maf-roundtable] enabled but no trusted manager route is configured; "
|
||||
"forcing the conversation entry OFF (%s)",
|
||||
maf_dispatch_status,
|
||||
)
|
||||
maf_enabled = False
|
||||
maf_admin_diag = (
|
||||
bool(getattr(maf_cfg, "admin_diagnostic_access", True)) if maf_cfg is not None else True
|
||||
)
|
||||
# P0-4: when MAF is switched on, its full safety stack must be in
|
||||
# place (write_guard mounted + provider complete + bridge resolvable
|
||||
# + MAF_ACTION_SECRET_KEY set). If any prerequisite is missing the
|
||||
# feature is forced OFF (clearly logged) — never "MAF on, approval
|
||||
# guard not mounted". Reads stay open for admin history review.
|
||||
if maf_enabled:
|
||||
from app.maf_roundtable.service import (
|
||||
MafConfigurationError,
|
||||
validate_maf_prerequisites,
|
||||
)
|
||||
|
||||
try:
|
||||
validate_maf_prerequisites()
|
||||
except MafConfigurationError as cfg_exc:
|
||||
logger.error(
|
||||
"[maf-roundtable] enabled but prerequisites unmet — "
|
||||
"forcing the feature OFF: %s",
|
||||
cfg_exc,
|
||||
)
|
||||
maf_enabled = False
|
||||
maf_dispatch_ready = False
|
||||
if app.state.maf_roundtable_store is not None:
|
||||
app.state.maf_roundtable_service = MafRoundtableService(
|
||||
app.state.maf_roundtable_store,
|
||||
enabled=maf_enabled,
|
||||
admin_diagnostic_access=maf_admin_diag,
|
||||
config=maf_cfg,
|
||||
agent_store=getattr(app.state, "agent_store", None),
|
||||
app_config=config,
|
||||
)
|
||||
# Wire the WriteGuard bridge so the harness middleware can reach
|
||||
# the service when intercepting external-write tool calls.
|
||||
if app.state.maf_roundtable_service is not None:
|
||||
from app.maf_roundtable.write_guard_bridge import register_write_guard_service
|
||||
|
||||
register_write_guard_service(app.state.maf_roundtable_service)
|
||||
else:
|
||||
app.state.maf_roundtable_service = None
|
||||
except Exception: # noqa: BLE001 — experimental feature must never abort startup
|
||||
logger.exception("[maf-roundtable] service initialization failed; feature stays off")
|
||||
app.state.maf_roundtable_service = None
|
||||
|
||||
# ── MAF roundtable V2 Conversation service + SSE event hub ──
|
||||
# P0-2: multi-worker guard — memory hub cannot broadcast across
|
||||
# workers. When detected, disable the conversation SSE write path
|
||||
# (reads stay open for admin history review).
|
||||
_topology = get_gateway_worker_topology()
|
||||
_maf_cfg = getattr(config, "maf_roundtable", None)
|
||||
_conv_backend = getattr(_maf_cfg, "conversation_event_backend", "memory")
|
||||
_require_single = getattr(_maf_cfg, "require_single_worker_for_conversation_sse", True)
|
||||
_stream_blocked = (
|
||||
_conv_backend == "memory"
|
||||
and _topology["is_multi_worker"]
|
||||
and _require_single
|
||||
)
|
||||
if _stream_blocked:
|
||||
logger.error(
|
||||
"[maf-roundtable] conversation_event_backend=memory with %d workers "
|
||||
"(%s) — SSE stream disabled. Set WEB_CONCURRENCY=1 or deploy Redis "
|
||||
"Pub/Sub before enabling multi-worker conversation SSE.",
|
||||
_topology["worker_count"],
|
||||
_topology["source"],
|
||||
)
|
||||
|
||||
try:
|
||||
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
|
||||
|
||||
if _stream_blocked:
|
||||
app.state.maf_conversation_event_hub = None
|
||||
else:
|
||||
app.state.maf_conversation_event_hub = ConversationEventHub()
|
||||
|
||||
if app.state.maf_roundtable_store is not None:
|
||||
from app.maf_roundtable.conversation_service import MafConversationService
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
|
||||
app.state.maf_conversation_service = MafConversationService(
|
||||
app.state.maf_roundtable_store,
|
||||
maf_config=_maf_cfg,
|
||||
event_hub=app.state.maf_conversation_event_hub,
|
||||
agent_store=getattr(app.state, "agent_store", None),
|
||||
app_config=config,
|
||||
dispatcher=get_dispatcher(app),
|
||||
enabled=maf_enabled,
|
||||
)
|
||||
else:
|
||||
app.state.maf_conversation_service = None
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[maf-roundtable] conversation service init failed")
|
||||
app.state.maf_conversation_service = None
|
||||
app.state.maf_conversation_event_hub = None
|
||||
|
||||
app.state.maf_conversation_dispatch_ready = bool(maf_enabled and maf_dispatch_ready)
|
||||
app.state.maf_conversation_dispatch_status = maf_dispatch_status
|
||||
app.state.maf_conversation_stream_ready = not _stream_blocked and app.state.maf_conversation_event_hub is not None
|
||||
app.state.maf_conversation_stream_status = (
|
||||
"multi_worker_memory_hub_unsupported" if _stream_blocked
|
||||
else "ok" if app.state.maf_conversation_event_hub is not None
|
||||
else "hub_not_initialized"
|
||||
)
|
||||
app.state.maf_conversation_event_backend = _conv_backend
|
||||
|
||||
# ── Background reconcile task for conversation event projection ──
|
||||
_reconcile_task: asyncio.Task | None = None
|
||||
if (
|
||||
app.state.maf_conversation_service is not None
|
||||
and maf_enabled
|
||||
and not _stream_blocked
|
||||
):
|
||||
_reconcile_interval = getattr(_maf_cfg, "background_reconcile_interval_seconds", 10)
|
||||
_reconcile_task = asyncio.create_task(
|
||||
_background_reconcile_loop(
|
||||
app.state.maf_conversation_service,
|
||||
interval=max(5, int(_reconcile_interval)),
|
||||
)
|
||||
)
|
||||
app.state.maf_conversation_reconcile_task = _reconcile_task
|
||||
logger.info("[maf-roundtable] background reconcile started (interval=%ss)", _reconcile_interval)
|
||||
|
||||
# Startup reconcile: scan active/recent runs once
|
||||
asyncio.create_task(_startup_reconcile(app.state.maf_conversation_service))
|
||||
|
||||
from deerflow.persistence.roundtable_jobs import (
|
||||
make_roundtable_job_store,
|
||||
)
|
||||
@@ -614,23 +402,6 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reconcile_task = getattr(app.state, "maf_conversation_reconcile_task", None)
|
||||
if reconcile_task is not None:
|
||||
reconcile_task.cancel()
|
||||
try:
|
||||
await reconcile_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[maf-roundtable] reconcile task shutdown error", exc_info=True)
|
||||
|
||||
hub = getattr(app.state, "maf_conversation_event_hub", None)
|
||||
if hub is not None:
|
||||
try:
|
||||
await hub.aclose()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[maf-roundtable] hub aclose failed", exc_info=True)
|
||||
|
||||
from deerflow.persistence.notifications import set_notification_store
|
||||
|
||||
set_notification_store(None)
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
"""HTTP router for MAF roundtable Conversation API at ``/api/maf-roundtable/conversations``.
|
||||
|
||||
Phase 1 (R1) scope:
|
||||
- POST /conversations — create blank user conversation
|
||||
- GET /conversations — list current user's history
|
||||
- GET /conversations/{id} — public conversation DTO
|
||||
- GET /conversations/{id}/messages — paginated message history
|
||||
- POST /conversations/{id}/messages — atomic send + run start
|
||||
|
||||
Phase 2 (R2) adds:
|
||||
- GET /conversations/{id}/stream — SSE real-time event stream
|
||||
|
||||
User isolation: owner-only reads/writes. Admin diagnostic access is read-only.
|
||||
Internal fields (owner_user_id, internal_session_id, active_run_id, thread ids)
|
||||
are never returned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
|
||||
from app.maf_roundtable.conversation_schemas import (
|
||||
ConversationListResponse,
|
||||
ConversationResponse,
|
||||
CreateConversationRequest,
|
||||
MessageListResponse,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
)
|
||||
from app.maf_roundtable.conversation_service import (
|
||||
ConversationConflictError,
|
||||
ConversationDisabledError,
|
||||
ConversationNoSessionConfigError,
|
||||
ConversationNotFoundError,
|
||||
ConversationServiceError,
|
||||
MafConversationService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/maf-roundtable", tags=["maf-roundtable-conversations"])
|
||||
|
||||
_SSE_HEADERS = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
|
||||
|
||||
def _get_conversation_service(request: Request) -> MafConversationService:
|
||||
svc = getattr(request.app.state, "maf_conversation_service", None)
|
||||
if svc is None:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MAF conversation service not available")
|
||||
return svc
|
||||
|
||||
|
||||
def _get_event_hub(request: Request) -> ConversationEventHub | None:
|
||||
return getattr(request.app.state, "maf_conversation_event_hub", None)
|
||||
|
||||
|
||||
def _user_ctx(request: Request) -> tuple[str, bool]:
|
||||
"""Match the main MAF router's authenticated-user convention.
|
||||
|
||||
``AuthMiddleware`` stamps the resolved account on ``request.state.user``;
|
||||
it does not create ``user_id`` / ``is_admin`` attributes. Reading those
|
||||
nonexistent attributes collapses every authenticated caller into the
|
||||
fallback account and breaks owner isolation.
|
||||
"""
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is None:
|
||||
return "default", False
|
||||
return str(user.id), getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
# ── REST endpoints (Phase 1) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_stream_ready(request: Request) -> None:
|
||||
stream_ready = getattr(request.app.state, "maf_conversation_stream_ready", True)
|
||||
if not stream_ready:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=getattr(request.app.state, "maf_conversation_stream_status", "stream_not_ready"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/conversations", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_conversation(body: CreateConversationRequest, request: Request):
|
||||
_check_stream_ready(request)
|
||||
svc = _get_conversation_service(request)
|
||||
user_id, _ = _user_ctx(request)
|
||||
try:
|
||||
return await svc.create_conversation(
|
||||
user_id=user_id,
|
||||
title=body.title.strip(),
|
||||
task_context=body.task_context,
|
||||
)
|
||||
except ConversationDisabledError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="MAF roundtable feature is disabled",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=ConversationListResponse)
|
||||
async def list_conversations(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
svc = _get_conversation_service(request)
|
||||
user_id, _ = _user_ctx(request)
|
||||
conversations, total = await svc.list_conversations(user_id=user_id, limit=limit, offset=offset)
|
||||
return {"conversations": conversations, "total": total}
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}", response_model=ConversationResponse)
|
||||
async def get_conversation(conversation_id: str, request: Request):
|
||||
svc = _get_conversation_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
return await svc.get_conversation(conversation_id=conversation_id, user_id=user_id, is_admin=is_admin)
|
||||
except ConversationNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}/messages", response_model=MessageListResponse)
|
||||
async def list_messages(
|
||||
conversation_id: str,
|
||||
request: Request,
|
||||
before_seq: int | None = Query(default=None, ge=1),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
):
|
||||
svc = _get_conversation_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
messages, last_stream_seq = await svc.list_messages(
|
||||
conversation_id=conversation_id, user_id=user_id, is_admin=is_admin,
|
||||
before_seq=before_seq, limit=limit,
|
||||
)
|
||||
return {"messages": messages, "last_stream_seq": last_stream_seq}
|
||||
except ConversationNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/messages", response_model=SendMessageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def send_message(conversation_id: str, body: SendMessageRequest, request: Request):
|
||||
_check_stream_ready(request)
|
||||
svc = _get_conversation_service(request)
|
||||
user_id, _ = _user_ctx(request)
|
||||
try:
|
||||
return await svc.send_message(conversation_id=conversation_id, user_id=user_id, content=body.content)
|
||||
except ConversationNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
except ConversationConflictError:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A run is already active")
|
||||
except ConversationNoSessionConfigError:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="No trusted session configuration")
|
||||
except ConversationDisabledError:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="MAF roundtable feature is disabled")
|
||||
except ConversationServiceError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message)
|
||||
|
||||
|
||||
# ── SSE endpoint (Phase 2 / R2) ───────────────────────────────────────────
|
||||
|
||||
|
||||
# Event types that mark the end of a run's lifecycle. When the SSE stream
|
||||
# encounters any of these in order, it sends the event and then closes the
|
||||
# connection so the client doesn't sit idle receiving heartbeats.
|
||||
_TERMINAL_EVENT_TYPES = frozenset({
|
||||
"run.completed",
|
||||
"run.error",
|
||||
"run.cancelled",
|
||||
})
|
||||
|
||||
|
||||
def _format_sse_event(event: dict[str, Any]) -> str:
|
||||
"""Format one conversation event as an SSE frame per §4.3 contract.
|
||||
|
||||
``event:`` field carries the event type; ``data:`` carries the full
|
||||
envelope; ``id:`` carries the event_id for Last-Event-ID reconnection.
|
||||
"""
|
||||
event_type = event.get("type", "message")
|
||||
event_id = event.get("event_id")
|
||||
payload = json.dumps(event, default=str, ensure_ascii=False)
|
||||
parts = [f"event: {event_type}", f"data: {payload}"]
|
||||
if event_id:
|
||||
parts.append(f"id: {event_id}")
|
||||
parts.append("")
|
||||
parts.append("")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _format_heartbeat() -> str:
|
||||
"""SSE comment frame — keeps the connection alive without producing a
|
||||
client-visible event."""
|
||||
return ": heartbeat\n\n"
|
||||
|
||||
|
||||
def _is_terminal(event: dict[str, Any]) -> bool:
|
||||
return event.get("type") in _TERMINAL_EVENT_TYPES
|
||||
|
||||
|
||||
def build_conversation_event_generator(
|
||||
*,
|
||||
service: MafConversationService,
|
||||
hub: ConversationEventHub,
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
after_seq: int = 0,
|
||||
) -> Any:
|
||||
"""Extracted async generator for SSE streaming — testable without HTTP.
|
||||
|
||||
The ordering is:
|
||||
|
||||
1. **Reconcile** any missed projections for the active run (P0-1 auto).
|
||||
2. **Open subscription** (register before any DB query — P0-1 fix).
|
||||
3. **Replay** from DB: ``list_events(after_seq)``.
|
||||
4. **Stabilise**: re-query while the hub generation advanced during replay.
|
||||
5. **Snapshot** marker.
|
||||
6. **Real-time**: ``wait_for_change`` → query DB → yield events.
|
||||
|
||||
On terminal events the stream sends the event and closes (P0-2 fix).
|
||||
Heartbeat every ``hub.heartbeat_interval`` seconds.
|
||||
"""
|
||||
store = service._store # noqa: SLF001
|
||||
|
||||
async def _gen():
|
||||
try:
|
||||
active_run_id = await _get_active_run_id(store, conversation_id)
|
||||
if active_run_id:
|
||||
try:
|
||||
await service.reconcile_conversation_events(run_id=active_run_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("SSE connect reconcile failed conv=%s", conversation_id, exc_info=True)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("SSE connect reconcile lookup failed conv=%s", conversation_id, exc_info=True)
|
||||
|
||||
sub = hub.open_subscription(conversation_id)
|
||||
last_seq = after_seq
|
||||
try:
|
||||
try:
|
||||
replay_events = await store.conversation_events.list_events(
|
||||
conversation_id, after_seq=last_seq,
|
||||
)
|
||||
for evt in replay_events:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
yield _format_sse_event(evt)
|
||||
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
|
||||
if _is_terminal(evt):
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("SSE replay error conv=%s", conversation_id)
|
||||
yield _format_sse_event({
|
||||
"seq": last_seq,
|
||||
"event_id": f"replay_error_{last_seq}",
|
||||
"conversation_id": conversation_id,
|
||||
"type": "run.error",
|
||||
"occurred_at": datetime.now(UTC).isoformat(),
|
||||
"data": {"error": "replay_failed"},
|
||||
})
|
||||
return
|
||||
|
||||
while sub.has_pending:
|
||||
sub.mark_seen()
|
||||
late_events = await store.conversation_events.list_events(
|
||||
conversation_id, after_seq=last_seq,
|
||||
)
|
||||
if not late_events:
|
||||
break
|
||||
for evt in late_events:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
yield _format_sse_event(evt)
|
||||
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
|
||||
if _is_terminal(evt):
|
||||
return
|
||||
|
||||
snapshot = {
|
||||
"seq": last_seq,
|
||||
"event_id": f"snapshot_{conversation_id}_{last_seq}",
|
||||
"conversation_id": conversation_id,
|
||||
"type": "conversation.snapshot",
|
||||
"occurred_at": datetime.now(UTC).isoformat(),
|
||||
"data": {"last_stream_seq": last_seq},
|
||||
}
|
||||
if not await request.is_disconnected():
|
||||
yield _format_sse_event(snapshot)
|
||||
|
||||
heartbeat_interval = hub.heartbeat_interval
|
||||
while True:
|
||||
if hub.is_closed:
|
||||
return
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
|
||||
changed = await sub.wait_for_change(timeout=heartbeat_interval)
|
||||
if not changed:
|
||||
if hub.is_closed:
|
||||
return
|
||||
yield _format_heartbeat()
|
||||
continue
|
||||
|
||||
new_events = await store.conversation_events.list_events(
|
||||
conversation_id, after_seq=last_seq,
|
||||
)
|
||||
for evt in new_events:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
yield _format_sse_event(evt)
|
||||
last_seq = max(last_seq, int(evt.get("seq", last_seq)))
|
||||
if _is_terminal(evt):
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("SSE stream cancelled conv=%s", conversation_id)
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("SSE stream error conv=%s", conversation_id)
|
||||
return
|
||||
finally:
|
||||
sub.close()
|
||||
|
||||
return _gen()
|
||||
|
||||
|
||||
async def _get_active_run_id(store: Any, conversation_id: str) -> str | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from deerflow.persistence.maf_roundtable.model import MafRoundtableConversationRow
|
||||
async with store.session_factory() as db:
|
||||
row = (
|
||||
await db.execute(
|
||||
select(MafRoundtableConversationRow.active_run_id).where(
|
||||
MafRoundtableConversationRow.id == conversation_id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
return row[0] if row and row[0] else None
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}/stream")
|
||||
async def stream_conversation(
|
||||
conversation_id: str,
|
||||
request: Request,
|
||||
after_seq: int = Query(default=0, ge=0),
|
||||
):
|
||||
"""SSE stream for one conversation (§4.2)."""
|
||||
svc = _get_conversation_service(request)
|
||||
hub = _get_event_hub(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
|
||||
if hub is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="SSE event hub not configured",
|
||||
)
|
||||
|
||||
stream_ready = getattr(request.app.state, "maf_conversation_stream_ready", True)
|
||||
if not stream_ready:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=getattr(request.app.state, "maf_conversation_stream_status", "stream_not_ready"),
|
||||
)
|
||||
|
||||
try:
|
||||
await svc.get_conversation(
|
||||
conversation_id=conversation_id, user_id=user_id, is_admin=is_admin,
|
||||
)
|
||||
except ConversationNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
|
||||
return StreamingResponse(
|
||||
build_conversation_event_generator(
|
||||
service=svc,
|
||||
hub=hub,
|
||||
request=request,
|
||||
conversation_id=conversation_id,
|
||||
after_seq=after_seq,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers=_SSE_HEADERS,
|
||||
)
|
||||
@@ -1,477 +0,0 @@
|
||||
"""HTTP router for the MAF roundtable experiment at ``/api/maf-roundtable``.
|
||||
|
||||
Phase 1 ships:
|
||||
|
||||
- ``GET /health`` — feature-flag probe (open read; the frontend reads
|
||||
``enabled`` to decide whether to show the entry).
|
||||
- Session CRUD: ``POST /sessions``, ``GET /sessions``, ``GET /sessions/{id}``,
|
||||
``PUT /sessions/{id}``, ``DELETE /sessions/{id}``.
|
||||
- Run lifecycle: ``POST /sessions/{id}/runs``, ``GET /runs/{id}``,
|
||||
``GET /sessions/{id}/runs``, ``POST /runs/{id}/cancel``,
|
||||
``POST /runs/{id}/resume``, ``POST /runs/{id}/recover``.
|
||||
- Event stream: ``GET /runs/{id}/events?after_seq=N`` (断线补拉) — **read-only**;
|
||||
there is no public ``POST /runs/{id}/events``. Real workflow events are
|
||||
written only by the backend executor via ``service.append_event``.
|
||||
- Actions: ``GET /runs/{id}/actions``, ``GET /actions/{id}``.
|
||||
- Approvals: ``GET /runs/{id}/approvals``, ``POST /approvals/{id}/decision``.
|
||||
|
||||
User isolation (MAF-01-07): every read / write is scoped to the caller's
|
||||
``user_id``; admins may *read* cross-user when ``admin_diagnostic_access`` is
|
||||
on, but **writes** remain owner-only (admin write of another user's resource
|
||||
→ 403). A non-admin user cannot read or mutate another user's session / run /
|
||||
action / approval — the store returns ``None`` for out-of-scope rows and the
|
||||
router surfaces that as ``404`` (not ``403``, so the existence of another
|
||||
user's session is not leaked).
|
||||
|
||||
Feature flag (MAF-01-04): when ``maf_roundtable.enabled`` is off, write paths
|
||||
return ``503 Service Disabled``; reads stay open so admins can review history.
|
||||
The MAF SDK is **not** imported by this router — Phase 2+ imports it lazily
|
||||
inside ``app.maf_roundtable.service`` only when a run is actually started.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
|
||||
from app.maf_roundtable import (
|
||||
ActionListResponse,
|
||||
ActionResponse,
|
||||
ApprovalDecisionRequest,
|
||||
ApprovalListResponse,
|
||||
ApprovalResponse,
|
||||
CreateRunRequest,
|
||||
CreateSessionRequest,
|
||||
EventListResponse,
|
||||
EventResponse,
|
||||
HealthResponse,
|
||||
MafConflictError,
|
||||
MafDisabledError,
|
||||
MafForbiddenError,
|
||||
MafNotFoundError,
|
||||
RunListResponse,
|
||||
RunResponse,
|
||||
SessionListResponse,
|
||||
SessionResponse,
|
||||
UpdateSessionRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/maf-roundtable", tags=["maf-roundtable"])
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_service(request: Request):
|
||||
"""Resolve the MAF service from app.state. Returns None when the feature
|
||||
is fully off (no store wired) — the router then 503s on writes.
|
||||
"""
|
||||
return getattr(request.app.state, "maf_roundtable_service", None)
|
||||
|
||||
|
||||
def _user_ctx(request: Request) -> tuple[str | None, bool]:
|
||||
"""Return ``(user_id, is_admin)`` for the current request.
|
||||
|
||||
When auth is disabled (no user stamped on request.state), the caller is
|
||||
treated as the ``"default"`` user (same convention as
|
||||
``get_effective_user_id``) and non-admin — so a no-auth deployment stays
|
||||
owner-scoped to ``"default"`` rather than accidentally bypassing the
|
||||
owner filter.
|
||||
"""
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is None:
|
||||
return "default", False
|
||||
return str(user.id), getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
def _map_service_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, MafDisabledError):
|
||||
return HTTPException(status_code=503, detail="MAF roundtable feature is disabled")
|
||||
if isinstance(exc, MafForbiddenError):
|
||||
return HTTPException(status_code=403, detail=f"not the owner of {exc.what}")
|
||||
if isinstance(exc, MafNotFoundError):
|
||||
return HTTPException(status_code=404, detail=f"{exc.what} not found")
|
||||
if isinstance(exc, MafConflictError):
|
||||
return HTTPException(status_code=409, detail=str(exc))
|
||||
return HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
def _require_service(request: Request):
|
||||
svc = _get_service(request)
|
||||
if svc is None:
|
||||
raise HTTPException(status_code=503, detail="MAF roundtable store not available")
|
||||
return svc
|
||||
|
||||
|
||||
# ── health ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def get_health(request: Request) -> HealthResponse:
|
||||
"""Feature-flag probe. Open read — the frontend reads ``enabled`` to
|
||||
decide whether to show the MAF entry. Does not require the store.
|
||||
"""
|
||||
from app.maf_roundtable.service import get_maf_write_guard_readiness
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
app_config = get_app_config()
|
||||
cfg = app_config.maf_roundtable
|
||||
svc = _get_service(request)
|
||||
store_available = svc is not None and svc.store is not None
|
||||
from app.maf_roundtable.dispatch_contracts import MafDispatchCatalog
|
||||
|
||||
dispatch_ready, dispatch_status = MafDispatchCatalog(cfg).readiness()
|
||||
readiness = get_maf_write_guard_readiness(app_config=app_config)
|
||||
stream_ready = bool(getattr(request.app.state, "maf_conversation_stream_ready", False))
|
||||
stream_status = str(getattr(request.app.state, "maf_conversation_stream_status", "unknown"))
|
||||
conv_backend = str(getattr(request.app.state, "maf_conversation_event_backend", "memory"))
|
||||
return HealthResponse(
|
||||
enabled=bool(cfg.enabled and dispatch_ready and svc is not None and svc.enabled),
|
||||
conversation_dispatch_ready=bool(cfg.enabled and dispatch_ready and svc is not None and svc.enabled),
|
||||
conversation_dispatch_status=dispatch_status,
|
||||
conversation_stream_ready=stream_ready,
|
||||
conversation_stream_status=stream_status,
|
||||
conversation_event_backend=conv_backend,
|
||||
store_available=store_available,
|
||||
default_mode=cfg.default_mode,
|
||||
default_write_policy=cfg.default_write_policy,
|
||||
default_max_rounds=cfg.default_max_rounds,
|
||||
admin_diagnostic_access=cfg.admin_diagnostic_access,
|
||||
write_guard_enabled=readiness["enabled"],
|
||||
write_guard_ready=readiness["ready"],
|
||||
action_secret_configured=readiness["secret_configured"],
|
||||
write_guard_status=readiness["status"],
|
||||
sse_hub_type="in_memory",
|
||||
sse_multi_worker=False,
|
||||
sse_warning="Single-worker only. Multi-worker requires Redis Pub/Sub.",
|
||||
)
|
||||
|
||||
|
||||
# ── sessions ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=SessionResponse)
|
||||
async def create_session(request: Request, body: CreateSessionRequest) -> SessionResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
session = await svc.create_session(user_id=user_id, body=body.model_dump(), is_admin=is_admin)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return SessionResponse(**session)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=SessionListResponse)
|
||||
async def list_sessions(
|
||||
request: Request,
|
||||
status: str | None = Query(default=None),
|
||||
mode: str | None = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> SessionListResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
rows, total = await svc.list_sessions(
|
||||
user_id=user_id, is_admin=is_admin, status=status, mode=mode, limit=limit, offset=offset
|
||||
)
|
||||
return SessionListResponse(sessions=[SessionResponse(**r) for r in rows], total=total)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=SessionResponse)
|
||||
async def get_session(request: Request, session_id: str) -> SessionResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
session = await svc.get_session(session_id=session_id, user_id=user_id, is_admin=is_admin)
|
||||
except MafNotFoundError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return SessionResponse(**session)
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}", response_model=SessionResponse)
|
||||
async def update_session(request: Request, session_id: str, body: UpdateSessionRequest) -> SessionResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
session = await svc.update_session(
|
||||
session_id=session_id, user_id=user_id, fields=body.model_dump(exclude_unset=True), is_admin=is_admin
|
||||
)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return SessionResponse(**session)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(request: Request, session_id: str) -> dict[str, Any]:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
await svc.delete_session(session_id=session_id, user_id=user_id, is_admin=is_admin)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return {"deleted": True, "id": session_id}
|
||||
|
||||
|
||||
# ── runs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/runs", response_model=RunResponse)
|
||||
async def create_run(request: Request, session_id: str, body: CreateRunRequest) -> RunResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
|
||||
# Check MAF SDK availability BEFORE creating the run — so the user gets a
|
||||
# clean 503 rather than a run that will immediately fail.
|
||||
from app.maf_roundtable.dispatcher import MafSdkNotAvailableError, get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher(request.app)
|
||||
try:
|
||||
dispatcher.check_sdk_available()
|
||||
except MafSdkNotAvailableError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
run = await svc.create_run(
|
||||
session_id=session_id, user_id=user_id, workflow_version=body.workflow_version, is_admin=is_admin
|
||||
)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
|
||||
# Launch the workflow in the background. The run is already persisted as
|
||||
# "pending"; the dispatcher transitions it to "running" then completes/fails it.
|
||||
# Auto-dispatch can be disabled (e.g. in tests) via app.state.maf_auto_dispatch=False.
|
||||
if getattr(request.app.state, "maf_auto_dispatch", True):
|
||||
session = await svc.get_session(session_id=session_id, user_id=user_id, is_admin=is_admin)
|
||||
await dispatcher.dispatch(session, run, user_id=user_id or "default")
|
||||
|
||||
return RunResponse(**run)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/runs", response_model=RunListResponse)
|
||||
async def list_runs(request: Request, session_id: str) -> RunListResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
runs = await svc.list_runs(session_id=session_id, user_id=user_id, is_admin=is_admin)
|
||||
except MafNotFoundError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return RunListResponse(runs=[RunResponse(**r) for r in runs])
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=RunResponse)
|
||||
async def get_run(request: Request, run_id: str) -> RunResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
run = await svc.get_run(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
except MafNotFoundError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return RunResponse(**run)
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/cancel", response_model=RunResponse)
|
||||
async def cancel_run(request: Request, run_id: str) -> RunResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
|
||||
# Phase 1: atomically set run+session to cancelling (not yet cancelled).
|
||||
try:
|
||||
await svc.start_cancel(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
|
||||
# Phase 2: cascade cancel to the background task + DeerFlow sub-runs.
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher(request.app)
|
||||
result = await dispatcher.cancel(run_id)
|
||||
|
||||
# Phase 3: finalize based on the structured cancel result.
|
||||
# - all_cancelled → finalize to ``cancelled`` (terminal).
|
||||
# - some sub-runs failed/still active → mark ``cancel_failed`` (non-terminal)
|
||||
# so the failure is visible and retryable. A failed cancel must NEVER
|
||||
# masquerade as cancelled.
|
||||
try:
|
||||
if result.get("all_cancelled"):
|
||||
final_run = await svc.finalize_cancel(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
else:
|
||||
failed = result.get("failed_run_ids") or []
|
||||
still_active = result.get("still_active_run_ids") or []
|
||||
detail = []
|
||||
if failed:
|
||||
detail.append(f"{len(failed)} sub-run(s) failed to cancel")
|
||||
if still_active:
|
||||
detail.append(f"{len(still_active)} sub-run(s) had no DeerFlow run id")
|
||||
reason = "; ".join(detail) or "cancel cascade incomplete"
|
||||
final_run = await svc.mark_cancel_failed(
|
||||
run_id=run_id, user_id=user_id, is_admin=is_admin, reason=reason
|
||||
)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
|
||||
return RunResponse(**final_run)
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/resume", response_model=RunResponse)
|
||||
async def resume_run(request: Request, run_id: str) -> RunResponse:
|
||||
"""Resume after approvals are decided (owner-only).
|
||||
|
||||
If the run is not ``waiting_approval`` or still has pending approvals,
|
||||
returns 409.
|
||||
"""
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
run = await svc.get_run(run_id=run_id, user_id=user_id, is_admin=False)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
|
||||
if run.get("status") != "waiting_approval":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"run {run_id} is not waiting_approval (status={run.get('status')})",
|
||||
)
|
||||
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher(request.app)
|
||||
try:
|
||||
updated = await dispatcher.resume(run_id, user_id=user_id or "default")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"resume failed: {type(exc).__name__}",
|
||||
) from exc
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="run not found")
|
||||
return RunResponse(**updated)
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/recover", response_model=RunResponse)
|
||||
async def recover_run(request: Request, run_id: str) -> RunResponse:
|
||||
"""Graph-level recover after process restart (owner-only).
|
||||
|
||||
Re-enters the workflow from checkpoint; completed_nodes are skipped.
|
||||
Does not silently scan all runs at startup.
|
||||
"""
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
run = await svc.get_run(run_id=run_id, user_id=user_id, is_admin=False)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
|
||||
if run.get("status") not in ("running", "waiting_approval"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"run {run_id} cannot be recovered (status={run.get('status')})",
|
||||
)
|
||||
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher(request.app)
|
||||
try:
|
||||
updated = await dispatcher.recover(run_id, user_id=user_id or "default")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"recover failed: {type(exc).__name__}",
|
||||
) from exc
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="run not found")
|
||||
return RunResponse(**updated)
|
||||
|
||||
|
||||
# ── events ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/events", response_model=EventListResponse)
|
||||
async def list_events(
|
||||
request: Request,
|
||||
run_id: str,
|
||||
after_seq: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=1000, ge=1, le=10000),
|
||||
) -> EventListResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
events, latest = await svc.list_events(
|
||||
run_id=run_id, user_id=user_id, after_seq=after_seq, limit=limit, is_admin=is_admin
|
||||
)
|
||||
except MafNotFoundError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return EventListResponse(events=[EventResponse(**e) for e in events], latest_seq=latest)
|
||||
|
||||
|
||||
# ── actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/actions", response_model=ActionListResponse)
|
||||
async def list_actions(request: Request, run_id: str) -> ActionListResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
actions = await svc.list_actions(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
except MafNotFoundError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return ActionListResponse(actions=[ActionResponse(**a) for a in actions])
|
||||
|
||||
|
||||
@router.get("/actions/{action_id}", response_model=ActionResponse)
|
||||
async def get_action(request: Request, action_id: str) -> ActionResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
action = await svc.get_action(action_id=action_id, user_id=user_id, is_admin=is_admin)
|
||||
except MafNotFoundError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return ActionResponse(**action)
|
||||
|
||||
|
||||
# ── approvals ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/approvals", response_model=ApprovalListResponse)
|
||||
async def list_approvals(request: Request, run_id: str) -> ApprovalListResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
approvals = await svc.list_approvals(run_id=run_id, user_id=user_id, is_admin=is_admin)
|
||||
except MafNotFoundError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
return ApprovalListResponse(approvals=[ApprovalResponse(**a) for a in approvals])
|
||||
|
||||
|
||||
@router.post("/approvals/{approval_id}/decision", response_model=ApprovalResponse)
|
||||
async def decide_approval(request: Request, approval_id: str, body: ApprovalDecisionRequest) -> ApprovalResponse:
|
||||
svc = _require_service(request)
|
||||
user_id, is_admin = _user_ctx(request)
|
||||
try:
|
||||
approval = await svc.decide_approval(
|
||||
approval_id=approval_id, decision=body.decision, user_id=user_id, is_admin=is_admin
|
||||
)
|
||||
except (MafDisabledError, MafForbiddenError, MafNotFoundError, MafConflictError) as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
|
||||
# P1: after a successful approval decision, resume the workflow from its
|
||||
# checkpoint. Only the owner can resume (decide_approval already enforced
|
||||
# owner-only write). The resume loads the checkpoint, restores
|
||||
# RoundtableState.from_dict(), and executes the approved action (at-most-once).
|
||||
run_id = approval.get("run_id")
|
||||
if run_id and body.decision in ("approved", "rejected"):
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
|
||||
dispatcher = get_dispatcher(request.app)
|
||||
try:
|
||||
await dispatcher.resume(run_id, user_id=user_id or "default")
|
||||
except Exception:
|
||||
# Resume failure should not fail the approval response itself —
|
||||
# the approval is already persisted. Log for observability.
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception(
|
||||
"MAF resume failed for run %s after approval %s", run_id, approval_id
|
||||
)
|
||||
|
||||
return ApprovalResponse(**approval)
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Notification template management API (通知模板管理).
|
||||
|
||||
Global / shared templates for admin-authored notification messages. Every
|
||||
endpoint (including reads) requires ``system_role == "admin"``.
|
||||
|
||||
Routes (prefix ``/api/notification-templates``):
|
||||
GET "" list (keyword / enabled / tag / page / page_size)
|
||||
POST "" create
|
||||
GET "/{id}" read one
|
||||
PUT "/{id}" update (optimistic lock via expected_version)
|
||||
DELETE "/{id}" delete
|
||||
POST "/{id}/duplicate" duplicate
|
||||
POST "/preview" render title/content with variables
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_current_user, get_optional_user_from_request
|
||||
from deerflow.persistence.notification_templates import (
|
||||
DuplicateNotificationTemplateNameError,
|
||||
NotificationTemplateStore,
|
||||
TemplateVariableError,
|
||||
VersionConflictError,
|
||||
preview_templates,
|
||||
validate_templates,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/notification-templates", tags=["notification-templates"])
|
||||
|
||||
|
||||
# ── schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class NotificationTemplateResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
title_template: str
|
||||
content_template: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
enabled: bool = True
|
||||
version: int = 1
|
||||
created_by: str | None = None
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
|
||||
|
||||
class NotificationTemplateListResponse(BaseModel):
|
||||
items: list[NotificationTemplateResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class NotificationTemplateCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=80)
|
||||
title_template: str = Field(..., min_length=1, max_length=120)
|
||||
content_template: str = Field(..., min_length=1, max_length=5000)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class NotificationTemplateUpdateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=80)
|
||||
title_template: str = Field(..., min_length=1, max_length=120)
|
||||
content_template: str = Field(..., min_length=1, max_length=5000)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
enabled: bool = True
|
||||
expected_version: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
title_template: str = Field(..., min_length=0, max_length=120)
|
||||
content_template: str = Field(..., min_length=0, max_length=5000)
|
||||
variables: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PreviewResponse(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
missing_variables: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_store(request: Request) -> NotificationTemplateStore:
|
||||
store = getattr(request.app.state, "notification_template_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Notification template store not available")
|
||||
return store
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> None:
|
||||
"""Reject non-admins. Lets the call through when auth is disabled."""
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return
|
||||
if getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="通知模板管理仅限管理员")
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
cleaned = (name or "").strip()
|
||||
if not (2 <= len(cleaned) <= 50):
|
||||
raise HTTPException(status_code=422, detail="名称去除首尾空格后长度须为 2~50")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _normalize_tags(tags: list[str] | None) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for raw in tags or []:
|
||||
tag = (raw or "").strip()
|
||||
if not tag:
|
||||
continue
|
||||
if not (1 <= len(tag) <= 20):
|
||||
raise HTTPException(status_code=422, detail="单个标签长度须为 1~20")
|
||||
if tag in seen:
|
||||
continue
|
||||
seen.add(tag)
|
||||
out.append(tag)
|
||||
if len(out) > 5:
|
||||
raise HTTPException(status_code=422, detail="标签最多 5 个")
|
||||
return out
|
||||
|
||||
|
||||
def _validate_lengths(title: str, content: str) -> None:
|
||||
if not (1 <= len(title) <= 120):
|
||||
raise HTTPException(status_code=422, detail="标题模板长度须为 1~120")
|
||||
if not (1 <= len(content) <= 5000):
|
||||
raise HTTPException(status_code=422, detail="正文模板长度须为 1~5000")
|
||||
|
||||
|
||||
def _validate_variables(title: str, content: str) -> None:
|
||||
try:
|
||||
validate_templates(title, content)
|
||||
except TemplateVariableError as exc:
|
||||
raise HTTPException(status_code=422, detail=exc.message) from exc
|
||||
|
||||
|
||||
def _to_response(row: dict) -> NotificationTemplateResponse:
|
||||
return NotificationTemplateResponse(**row)
|
||||
|
||||
|
||||
async def _unique_duplicate_name(store: NotificationTemplateStore, original: str) -> str:
|
||||
base = f"{original} - 副本"
|
||||
if not await store.name_exists(base):
|
||||
return base
|
||||
n = 2
|
||||
while True:
|
||||
candidate = f"{original} - 副本 {n}"
|
||||
if not await store.name_exists(candidate):
|
||||
return candidate
|
||||
n += 1
|
||||
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationTemplateListResponse)
|
||||
async def list_notification_templates(
|
||||
request: Request,
|
||||
keyword: str | None = Query(default=None),
|
||||
enabled: bool | None = Query(default=None),
|
||||
tag: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> NotificationTemplateListResponse:
|
||||
await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
items, total = await store.list_templates(
|
||||
keyword=keyword,
|
||||
enabled=enabled,
|
||||
tag=tag,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return NotificationTemplateListResponse(
|
||||
items=[_to_response(r) for r in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/preview", response_model=PreviewResponse)
|
||||
async def preview_notification_template(request: Request, body: PreviewRequest) -> PreviewResponse:
|
||||
await _require_admin(request)
|
||||
result = preview_templates(body.title_template, body.content_template, body.variables)
|
||||
return PreviewResponse(**result) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.post("", response_model=NotificationTemplateResponse, status_code=201)
|
||||
async def create_notification_template(
|
||||
request: Request,
|
||||
body: NotificationTemplateCreateRequest,
|
||||
) -> NotificationTemplateResponse:
|
||||
await _require_admin(request)
|
||||
name = _normalize_name(body.name)
|
||||
tags = _normalize_tags(body.tags)
|
||||
_validate_lengths(body.title_template, body.content_template)
|
||||
_validate_variables(body.title_template, body.content_template)
|
||||
store = _get_store(request)
|
||||
user_id = await get_current_user(request)
|
||||
try:
|
||||
row = await store.create_template(
|
||||
{
|
||||
"name": name,
|
||||
"title_template": body.title_template,
|
||||
"content_template": body.content_template,
|
||||
"tags": tags,
|
||||
"enabled": body.enabled,
|
||||
"created_by": user_id,
|
||||
}
|
||||
)
|
||||
except DuplicateNotificationTemplateNameError:
|
||||
raise HTTPException(status_code=409, detail="模板名称已存在")
|
||||
return _to_response(row)
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=NotificationTemplateResponse)
|
||||
async def get_notification_template(request: Request, template_id: str) -> NotificationTemplateResponse:
|
||||
await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
row = await store.get_template(template_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return _to_response(row)
|
||||
|
||||
|
||||
@router.put("/{template_id}", response_model=NotificationTemplateResponse)
|
||||
async def update_notification_template(
|
||||
request: Request,
|
||||
template_id: str,
|
||||
body: NotificationTemplateUpdateRequest,
|
||||
) -> NotificationTemplateResponse:
|
||||
await _require_admin(request)
|
||||
name = _normalize_name(body.name)
|
||||
tags = _normalize_tags(body.tags)
|
||||
_validate_lengths(body.title_template, body.content_template)
|
||||
_validate_variables(body.title_template, body.content_template)
|
||||
store = _get_store(request)
|
||||
try:
|
||||
row = await store.update_template(
|
||||
template_id,
|
||||
{
|
||||
"name": name,
|
||||
"title_template": body.title_template,
|
||||
"content_template": body.content_template,
|
||||
"tags": tags,
|
||||
"enabled": body.enabled,
|
||||
},
|
||||
expected_version=body.expected_version,
|
||||
)
|
||||
except VersionConflictError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"message": "版本冲突,请重新加载或覆盖保存", "server": exc.server},
|
||||
) from exc
|
||||
except DuplicateNotificationTemplateNameError:
|
||||
raise HTTPException(status_code=409, detail="模板名称已存在")
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return _to_response(row)
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=204)
|
||||
async def delete_notification_template(request: Request, template_id: str) -> Response:
|
||||
await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
deleted = await store.delete_template(template_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/{template_id}/duplicate", response_model=NotificationTemplateResponse, status_code=201)
|
||||
async def duplicate_notification_template(
|
||||
request: Request,
|
||||
template_id: str,
|
||||
) -> NotificationTemplateResponse:
|
||||
await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
source = await store.get_template(template_id)
|
||||
if source is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
new_name = await _unique_duplicate_name(store, source["name"])
|
||||
user_id = await get_current_user(request)
|
||||
try:
|
||||
row = await store.create_template(
|
||||
{
|
||||
"name": new_name,
|
||||
"title_template": source["title_template"],
|
||||
"content_template": source["content_template"],
|
||||
"tags": list(source.get("tags") or []),
|
||||
"enabled": source.get("enabled", True),
|
||||
"created_by": user_id,
|
||||
}
|
||||
)
|
||||
except DuplicateNotificationTemplateNameError:
|
||||
raise HTTPException(status_code=409, detail="模板名称已存在")
|
||||
return _to_response(row)
|
||||
@@ -359,50 +359,6 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# These fields activate or authorize MAF WriteGuard behavior. They may only
|
||||
# cross the Gateway boundary on the typed, server-created request from
|
||||
# ``DeerFlowRuntimeClient``. In particular, accepting them from a public
|
||||
# ``body.config`` would let a caller forge an active MAF run or a post-approval
|
||||
# passthrough.
|
||||
_MAF_PRIVATE_CONTEXT_KEYS: frozenset[str] = frozenset(
|
||||
{
|
||||
"maf_run_id",
|
||||
"maf_session_id",
|
||||
"maf_user_id",
|
||||
"turn_index",
|
||||
"write_policy",
|
||||
"capability_snapshot",
|
||||
"maf_approved_tool",
|
||||
"maf_approved_action_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_internal_maf_request(request: Any) -> bool:
|
||||
state = getattr(request, "state", None)
|
||||
return bool(getattr(state, "maf_internal", False))
|
||||
|
||||
|
||||
def _merge_internal_maf_context(config: dict[str, Any], context: Mapping[str, Any], request: Any) -> None:
|
||||
"""Forward server-generated MAF context, never public request fields."""
|
||||
if not _is_internal_maf_request(request):
|
||||
for container_name in ("context", "configurable"):
|
||||
container = config.get(container_name)
|
||||
if isinstance(container, dict):
|
||||
for key in _MAF_PRIVATE_CONTEXT_KEYS:
|
||||
container.pop(key, None)
|
||||
return
|
||||
|
||||
configurable = config.setdefault("configurable", {})
|
||||
runtime_context = config.setdefault("context", {})
|
||||
for key in _MAF_PRIVATE_CONTEXT_KEYS:
|
||||
if key not in context:
|
||||
continue
|
||||
if isinstance(configurable, dict):
|
||||
configurable.setdefault(key, context[key])
|
||||
if isinstance(runtime_context, dict):
|
||||
runtime_context.setdefault(key, context[key])
|
||||
|
||||
|
||||
def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, Any] | None) -> None:
|
||||
"""Merge whitelisted keys from ``body.context`` into both ``config['configurable']``
|
||||
@@ -1066,7 +1022,6 @@ async def start_run(
|
||||
if value is not None and top_level_key not in body_context:
|
||||
body_context[top_level_key] = value
|
||||
merge_run_context_overrides(config, body_context or None)
|
||||
_merge_internal_maf_context(config, body_context, request)
|
||||
|
||||
stream_modes = normalize_stream_modes(body.stream_mode)
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Microsoft Agent Framework (MAF) multi-agent roundtable experiment.
|
||||
|
||||
An independent, feature-flag-gated sibling of the legacy multi-agent
|
||||
roundtable (``/api/roundtable-*`` / ``/api/multi-agent/*``). It uses MAF's
|
||||
Agent / Workflow / Group Chat orchestration while reusing the existing
|
||||
DeerFlow Agent Runtime for each seat's actual execution (tools / skills /
|
||||
MCP / sandbox / external writes). Phase 1 ships the persistence skeleton +
|
||||
API contracts; the MAF SDK is loaded lazily inside this package only when
|
||||
the feature flag is on and a run is actually started (Phase 2+).
|
||||
|
||||
Boundary: this package is the **only** place the MAF SDK is imported. It may
|
||||
import ``deerflow.*`` (harness) freely but must not be imported by
|
||||
``deerflow.*``. The harness-layer persistence (``deerflow.persistence.maf_roundtable``)
|
||||
holds only pure data models and never imports the MAF SDK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.maf_roundtable.schemas import ( # noqa: F401 — re-exported for the router
|
||||
ActionListResponse,
|
||||
ActionResponse,
|
||||
ApprovalDecisionRequest,
|
||||
ApprovalListResponse,
|
||||
ApprovalResponse,
|
||||
CreateRunRequest,
|
||||
CreateSessionRequest,
|
||||
EventListResponse,
|
||||
EventResponse,
|
||||
HealthResponse,
|
||||
ParticipantInput,
|
||||
ParticipantResponse,
|
||||
RunListResponse,
|
||||
RunResponse,
|
||||
SessionListResponse,
|
||||
SessionResponse,
|
||||
UpdateSessionRequest,
|
||||
)
|
||||
from app.maf_roundtable.service import ( # noqa: F401
|
||||
MafConflictError,
|
||||
MafDisabledError,
|
||||
MafForbiddenError,
|
||||
MafNotFoundError,
|
||||
MafRoundtableService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ActionListResponse",
|
||||
"ActionResponse",
|
||||
"ApprovalDecisionRequest",
|
||||
"ApprovalListResponse",
|
||||
"ApprovalResponse",
|
||||
"CreateRunRequest",
|
||||
"CreateSessionRequest",
|
||||
"EventListResponse",
|
||||
"EventResponse",
|
||||
"HealthResponse",
|
||||
"MafConflictError",
|
||||
"MafDisabledError",
|
||||
"MafForbiddenError",
|
||||
"MafNotFoundError",
|
||||
"MafRoundtableService",
|
||||
"ParticipantInput",
|
||||
"ParticipantResponse",
|
||||
"RunListResponse",
|
||||
"RunResponse",
|
||||
"SessionListResponse",
|
||||
"SessionResponse",
|
||||
"UpdateSessionRequest",
|
||||
]
|
||||
@@ -1,487 +0,0 @@
|
||||
"""Action Coordinator for the MAF roundtable experiment (Phase 2 P1).
|
||||
|
||||
Intercepts external-write tool calls **before** they reach the real external
|
||||
system. The current problem (pre-fix) was that seats executed DeerFlow tools
|
||||
first and the ApprovalGate only inspected ``tool_calls`` afterwards — too
|
||||
late. The ActionCoordinator fixes this by:
|
||||
|
||||
1. **Classification**: reads per-tool risk metadata from the frozen
|
||||
capability snapshot (``side_effect: read|write``, ``risk_level: R0–R4``).
|
||||
Tools without metadata use a **safe default** (treated as write/R3 →
|
||||
requires approval). Classification never relies on name-prefix heuristics
|
||||
like ``create_`` / ``delete_``.
|
||||
|
||||
2. **Pre-execution interception (Phase-2)**: write tools are NO LONGER
|
||||
excluded from the seat run — the model sees the full tool schema and can
|
||||
propose writes. A harness-level ``WriteGuardMiddleware`` intercepts each
|
||||
write tool call at ``wrap_tool_call``/``awrap_tool_call`` time (activated
|
||||
per-run by a ``maf_run_id`` flag in the run context) and records a pending
|
||||
action before the real tool ever runs. The AI's proposed call is turned
|
||||
into an ``approval_required`` ToolMessage instead of the tool's real result.
|
||||
|
||||
3. **Post-turn capture**: after the seat turn, the seat executor reads the
|
||||
run's ``tool_calls`` to surface the pending approval in workflow state so
|
||||
the ApprovalGate can pause. Action creation itself happens inside the
|
||||
WriteGuard interceptor (single transaction — see point 5).
|
||||
|
||||
For each intercepted write tool call, the coordinator creates — in a
|
||||
**single logical transaction** (``store.create_pending_action_bundle``):
|
||||
- an ``action`` row with idempotency key ``run_id:agent_id:turn_index:tool_call_id``
|
||||
- the **sanitized** arguments (for audit / API visibility)
|
||||
- the **encrypted** actual arguments (server-only, for restart recovery)
|
||||
- an ``approval`` row (pending)
|
||||
- an ``action.waiting_approval`` critical audit event
|
||||
|
||||
If the critical audit event write fails, the action stays **unexecuted**
|
||||
— the external system is never called.
|
||||
|
||||
4. **Post-approval execution**: after the user approves:
|
||||
- Only an ``approved`` ``action_id`` may re-enter the tool executor.
|
||||
- Before execution, the action is atomically occupied (``approved → running``).
|
||||
- A second entry for the same action returns the existing result or is
|
||||
rejected — **never** a second write.
|
||||
- Success → ``succeeded``, failure → ``failed``, network-unknown →
|
||||
``unknown`` (``unknown`` prohibits auto-retry).
|
||||
|
||||
Encryption: uses ``cryptography.fernet.Fernet`` with a key derived from the
|
||||
``MAF_ACTION_SECRET_KEY`` env var. There is **no default key** — the var is
|
||||
required whenever the MAF feature is enabled (validated at startup). The
|
||||
ciphertext is opaque to the store and is **never** returned from any API,
|
||||
event, log, or frontend query.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.sanitize import sanitize, sanitize_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Risk classification ───────────────────────────────────────────────────
|
||||
|
||||
# Tools whose side_effect is explicitly "read" or "skill" are never intercepted.
|
||||
# Everything else (including "unknown" / missing metadata) is treated as a
|
||||
# potential write and subject to the write policy.
|
||||
_SAFE_SIDE_EFFECTS = frozenset({"read", "skill"})
|
||||
|
||||
# Default risk level for tools without metadata — safe default requires approval.
|
||||
_DEFAULT_RISK_LEVEL = "R3"
|
||||
_DEFAULT_SIDE_EFFECT = "write"
|
||||
|
||||
|
||||
def classify_tool(
|
||||
tool_name: str,
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
agent_id: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Classify a tool's side-effect and risk level from the capability snapshot.
|
||||
|
||||
Returns ``(side_effect, risk_level)``. Tools without explicit metadata
|
||||
use a **safe default**: ``("write", "R3")`` — meaning they are treated as
|
||||
high-risk writes requiring approval. This is intentional: we never guess
|
||||
risk from the tool name.
|
||||
"""
|
||||
if not capability_snapshot:
|
||||
return (_DEFAULT_SIDE_EFFECT, _DEFAULT_RISK_LEVEL)
|
||||
|
||||
tools = capability_snapshot.get("tools")
|
||||
if isinstance(tools, list):
|
||||
for t in tools:
|
||||
if isinstance(t, dict) and t.get("name") == tool_name:
|
||||
side = t.get("side_effect") or _DEFAULT_SIDE_EFFECT
|
||||
risk = t.get("risk_level") or _DEFAULT_RISK_LEVEL
|
||||
return (side, risk)
|
||||
|
||||
agents = capability_snapshot.get("agents")
|
||||
if isinstance(agents, list) and agent_id:
|
||||
for a in agents:
|
||||
if isinstance(a, dict) and a.get("agent_id") == agent_id:
|
||||
agent_tools = a.get("tools")
|
||||
if isinstance(agent_tools, list):
|
||||
for t in agent_tools:
|
||||
if isinstance(t, dict) and t.get("name") == tool_name:
|
||||
side = t.get("side_effect") or _DEFAULT_SIDE_EFFECT
|
||||
risk = t.get("risk_level") or _DEFAULT_RISK_LEVEL
|
||||
return (side, risk)
|
||||
|
||||
return (_DEFAULT_SIDE_EFFECT, _DEFAULT_RISK_LEVEL)
|
||||
|
||||
|
||||
def tool_needs_approval(
|
||||
tool_name: str,
|
||||
write_policy: str,
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
agent_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Determine whether a tool call needs approval before execution.
|
||||
|
||||
- ``auto``: no approval needed (but the tool is still classified for audit).
|
||||
- ``confirm_high_risk``: approval needed for R3+ tools.
|
||||
- ``confirm_all_writes``: approval needed for any write tool.
|
||||
"""
|
||||
if write_policy == "auto":
|
||||
return False
|
||||
|
||||
side_effect, risk_level = classify_tool(tool_name, capability_snapshot, agent_id)
|
||||
if side_effect in _SAFE_SIDE_EFFECTS:
|
||||
return False
|
||||
|
||||
if write_policy == "confirm_all_writes":
|
||||
return True
|
||||
|
||||
# confirm_high_risk: R3 and above
|
||||
risk_order = {"R0": 0, "R1": 1, "R2": 2, "R3": 3, "R4": 4}
|
||||
risk_val = risk_order.get(risk_level, 3)
|
||||
return risk_val >= 3
|
||||
|
||||
|
||||
def derive_target_key(
|
||||
tool_name: str,
|
||||
raw_args: Any,
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
agent_id: str | None = None,
|
||||
) -> str:
|
||||
"""Return a service-derived, opaque target lease key for a write action.
|
||||
|
||||
The model and browser never get a target-key input. Only the frozen,
|
||||
server-built capability metadata chooses the parameter fields used to
|
||||
identify a business object. Only stable scalar values are accepted;
|
||||
structured or otherwise unstable values conservatively collapse to the
|
||||
unidentified target. The canonical value is protected with a
|
||||
domain-separated HMAC keyed by ``MAF_ACTION_SECRET_KEY`` before it is
|
||||
persisted, so a database reader cannot brute-force ordinary object IDs
|
||||
with a public hash.
|
||||
|
||||
An unclassified/missing identifier deliberately shares one conservative
|
||||
key across writes (rather than guessing that different requests are safe
|
||||
to parallelize). Such tools are already write/R3 by default and require
|
||||
normal approval under the default policy.
|
||||
"""
|
||||
metadata: dict[str, Any] | None = None
|
||||
snapshot_tools = (capability_snapshot or {}).get("tools")
|
||||
if isinstance(snapshot_tools, list):
|
||||
metadata = next(
|
||||
(item for item in snapshot_tools if isinstance(item, dict) and item.get("name") == tool_name),
|
||||
None,
|
||||
)
|
||||
if metadata is None and agent_id:
|
||||
for agent in (capability_snapshot or {}).get("agents") or []:
|
||||
if not isinstance(agent, dict) or agent.get("agent_id") != agent_id:
|
||||
continue
|
||||
for item in agent.get("tools") or []:
|
||||
if isinstance(item, dict) and item.get("name") == tool_name:
|
||||
metadata = item
|
||||
break
|
||||
if metadata is not None:
|
||||
break
|
||||
|
||||
fields = metadata.get("target_key_fields") if isinstance(metadata, dict) else None
|
||||
namespace = metadata.get("target_key_namespace") if isinstance(metadata, dict) else None
|
||||
if not isinstance(fields, list) or not fields or not isinstance(raw_args, dict):
|
||||
canonical = {"scope": "unidentified"}
|
||||
else:
|
||||
valid_fields = [
|
||||
field for field in fields
|
||||
if isinstance(field, str) and field != "target_key" and field in raw_args
|
||||
]
|
||||
if len(valid_fields) != len(fields) or any(
|
||||
not _is_stable_scalar(raw_args[field]) for field in valid_fields
|
||||
):
|
||||
canonical = {"scope": "unidentified"}
|
||||
else:
|
||||
canonical = {
|
||||
"scope": "identified",
|
||||
"tool": tool_name,
|
||||
"namespace": namespace if isinstance(namespace, str) and namespace else "default",
|
||||
"target": {field: raw_args[field] for field in sorted(valid_fields)},
|
||||
}
|
||||
|
||||
encoded = json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
raw_secret = os.getenv("MAF_ACTION_SECRET_KEY", "")
|
||||
if not raw_secret.strip():
|
||||
raise MafActionSecretKeyError(
|
||||
"MAF_ACTION_SECRET_KEY is required before deriving a real-write target key"
|
||||
)
|
||||
domain = "maf-target-key:v1"
|
||||
digest = hmac.new(
|
||||
raw_secret.encode("utf-8"),
|
||||
domain.encode("ascii") + b"\x00" + encoded.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"maf-target:v1:{digest}"
|
||||
|
||||
|
||||
def _is_stable_scalar(value: Any) -> bool:
|
||||
"""Whether a configured target field can safely identify one target."""
|
||||
|
||||
if isinstance(value, str):
|
||||
return bool(value.strip())
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, (bool, int)):
|
||||
return True
|
||||
return isinstance(value, float) and isfinite(value)
|
||||
|
||||
|
||||
# ── Encryption ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MafActionSecretKeyError(RuntimeError):
|
||||
"""Raised when ``MAF_ACTION_SECRET_KEY`` is missing or empty.
|
||||
|
||||
There is no default key: the env var is required whenever the MAF feature
|
||||
is enabled (validated at startup). Using a baked-in default would let a
|
||||
deployment silently encrypt all action params with a publicly-known key.
|
||||
"""
|
||||
|
||||
|
||||
class ActionSecretUnavailableError(RuntimeError):
|
||||
"""The approved action's parameters cannot be produced — execution MUST fail.
|
||||
|
||||
Raised (instead of returning ``None``) whenever the server-only secret is
|
||||
missing, cannot be decrypted, or does not decrypt to a JSON object. Callers
|
||||
must treat this as a hard execution failure: record the action ``failed``,
|
||||
fail the parent run, and NEVER invoke the tool — they must NOT coerce the
|
||||
failure into empty params (``params or {}``) and execute anyway.
|
||||
|
||||
Carries a machine-readable ``reason`` (``missing`` / ``decrypt_failed`` /
|
||||
``invalid_type``) and a ``detail`` that is always safe to log/persist — it
|
||||
never contains the ciphertext, the key, or raw exception bodies.
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str, reason: str, detail: str) -> None:
|
||||
self.action_id = action_id
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
super().__init__(f"action secret unavailable for {action_id}: {reason} — {detail}")
|
||||
|
||||
|
||||
def _get_fernet_key() -> bytes:
|
||||
"""Derive a Fernet key from the ``MAF_ACTION_SECRET_KEY`` env var.
|
||||
|
||||
Raises ``MafActionSecretKeyError`` if the var is missing or empty — there
|
||||
is deliberately **no default key**. Callers must ensure the var is set
|
||||
before the MAF feature is used (the service validates this at startup).
|
||||
"""
|
||||
raw = os.getenv("MAF_ACTION_SECRET_KEY", "")
|
||||
if not raw or not raw.strip():
|
||||
raise MafActionSecretKeyError(
|
||||
"MAF_ACTION_SECRET_KEY environment variable is not set. "
|
||||
"It is required to encrypt external-write action parameters. "
|
||||
"Set it (e.g. a 32+ char random string) before enabling "
|
||||
"maf_roundtable.enabled."
|
||||
)
|
||||
digest = hashlib.sha256(raw.encode()).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
def encrypt_params(params: dict[str, Any]) -> str:
|
||||
"""Encrypt tool-call arguments for server-only storage."""
|
||||
import json
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
plaintext = json.dumps(params, ensure_ascii=False).encode()
|
||||
return Fernet(_get_fernet_key()).encrypt(plaintext).decode()
|
||||
|
||||
|
||||
def decrypt_params(ciphertext: str) -> dict[str, Any]:
|
||||
"""Decrypt tool-call arguments at execution time (server-only)."""
|
||||
import json
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
plaintext = Fernet(_get_fernet_key()).decrypt(ciphertext.encode()).decode()
|
||||
return json.loads(plaintext)
|
||||
|
||||
|
||||
# ── Action Coordinator ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ActionCoordinator:
|
||||
"""Manages the intercept-approve-execute lifecycle for external writes.
|
||||
|
||||
Used by the DeerFlowAgentAdapter and WorkflowRunner to:
|
||||
- Determine which tools to exclude before a seat turn.
|
||||
- Capture intercepted write tool calls after a seat turn.
|
||||
- Create actions + approvals + audit events in one logical transaction.
|
||||
- Atomically occupy an approved action for execution (at-most-once).
|
||||
"""
|
||||
|
||||
def __init__(self, service: Any, *, user_id: str = "") -> None:
|
||||
self._service = service
|
||||
self._store = service.store
|
||||
self._user_id = user_id
|
||||
|
||||
def _approval_expires_at(self) -> datetime:
|
||||
seconds = int(getattr(self._service.config, "approval_timeout_seconds", 3600) or 3600)
|
||||
return datetime.now(UTC) + timedelta(seconds=seconds)
|
||||
|
||||
# ── Post-turn: capture intercepted write tool calls ──────────────────
|
||||
|
||||
async def intercept_write_tool_calls(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
session_id: str,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
tool_calls: list[dict[str, Any]],
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
write_policy: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Process the AI's tool_calls and create actions+approvals for writes.
|
||||
|
||||
For each tool call that needs approval, create the action + encrypted
|
||||
params + approval + audit event in a **single transaction**
|
||||
(``store.create_pending_action_bundle``). If any step fails, the whole
|
||||
bundle rolls back — the DB never holds a half-finished action.
|
||||
|
||||
Idempotency: ``idempotency_key`` is DB-unique. A concurrent duplicate
|
||||
submission re-reads and returns the already-stored action instead of
|
||||
raising, so a retry never fails the run.
|
||||
|
||||
Returns a list of created (or pre-existing) action dicts.
|
||||
"""
|
||||
created: list[dict[str, Any]] = []
|
||||
|
||||
for tc in tool_calls:
|
||||
tool_name = tc.get("name", "")
|
||||
tool_call_id = tc.get("id", "")
|
||||
raw_args = tc.get("args") or {}
|
||||
|
||||
if not tool_needs_approval(tool_name, write_policy, capability_snapshot, agent_id):
|
||||
continue
|
||||
|
||||
_side_effect, risk_level = classify_tool(tool_name, capability_snapshot, agent_id)
|
||||
idempotency_key = f"{run_id}:{agent_id}:{turn_index}:{tool_call_id or tool_name}"
|
||||
target_key = derive_target_key(tool_name, raw_args, capability_snapshot, agent_id)
|
||||
|
||||
# Sanitize args for audit visibility (never expose raw args).
|
||||
sanitized_args = sanitize(raw_args)
|
||||
if not isinstance(sanitized_args, dict):
|
||||
sanitized_args = {"value": str(sanitized_args)}
|
||||
|
||||
# Encrypt actual args for server-only recovery.
|
||||
encrypted_args = encrypt_params(
|
||||
raw_args if isinstance(raw_args, dict) else {"value": str(raw_args)}
|
||||
)
|
||||
|
||||
# Single-transaction bundle: action + secret + approval + event.
|
||||
# The store retries internally (up to 8 attempts) on (run_id, seq)
|
||||
# collisions, re-computing MAX(seq)+1 each time.
|
||||
action = await self._store.create_pending_action_bundle(
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
agent_id=agent_id,
|
||||
tool_name=tool_name,
|
||||
logical_action_id=tool_call_id or None,
|
||||
idempotency_key=idempotency_key,
|
||||
risk_level=risk_level,
|
||||
target_key=target_key,
|
||||
turn_index=turn_index,
|
||||
arguments_sanitized=sanitized_args,
|
||||
arguments_encrypted=encrypted_args,
|
||||
approval_summary=f"外部写操作需要审批: {tool_name}(第{turn_index}轮)",
|
||||
approval_payload={
|
||||
"tool_name": tool_name,
|
||||
"risk_level": risk_level,
|
||||
"turn_index": turn_index,
|
||||
"arguments_redacted": sanitized_args,
|
||||
},
|
||||
audit_event_agent_id=agent_id,
|
||||
audit_event_turn_index=turn_index,
|
||||
approval_expires_at=self._approval_expires_at(),
|
||||
)
|
||||
|
||||
created.append(action)
|
||||
|
||||
return created
|
||||
|
||||
# ── Post-approval: atomic occupy + execution ─────────────────────────
|
||||
|
||||
async def occupy_for_execution(self, action_id: str) -> str:
|
||||
"""Atomically transition an action from ``approved`` to ``running``.
|
||||
|
||||
Returns the effective claim status (never a blanket bool):
|
||||
``"claimed"`` (won — execute), ``"in_progress"`` (another executor is
|
||||
running it), or a terminal status ``succeeded`` / ``failed`` /
|
||||
``unknown`` / ``rejected`` (already finished elsewhere). Raises
|
||||
``MafActionStateMachineError`` when the action was never properly
|
||||
approved. This guarantees at-most-once execution under concurrent
|
||||
``resume()`` / retries / restart.
|
||||
"""
|
||||
lease_seconds = int(getattr(self._service.config, "target_lease_seconds", 900) or 900)
|
||||
return await self._store.claim_action_for_execution(
|
||||
action_id, lease_seconds=lease_seconds
|
||||
)
|
||||
|
||||
async def record_result(
|
||||
self,
|
||||
action_id: str,
|
||||
*,
|
||||
status: str,
|
||||
result: dict[str, Any] | None = None,
|
||||
external_object_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record the outcome of an action execution.
|
||||
|
||||
``status`` must be ``succeeded``, ``failed``, or ``unknown``.
|
||||
``unknown`` prohibits auto-retry — the action stays in ``unknown``
|
||||
until a human reconciles it.
|
||||
|
||||
State-machine constrained: only a ``running`` action may transition
|
||||
to a terminal status. A concurrent caller that lost the race (or a
|
||||
replay after restart) finds the row already terminal and its UPDATE
|
||||
is a no-op — it must NOT overwrite the real result.
|
||||
"""
|
||||
sanitized_result = sanitize_payload(result) if result else None
|
||||
await self._store.record_action_terminal_result(
|
||||
action_id,
|
||||
status=status,
|
||||
result=sanitized_result,
|
||||
external_object_id=external_object_id,
|
||||
)
|
||||
|
||||
async def get_approved_action_params(self, action_id: str) -> dict[str, Any]:
|
||||
"""Decrypt and return the actual execution parameters for an approved action.
|
||||
|
||||
Server-only: called only by the tool executor at execution time. The
|
||||
decrypted params never reach API, events, logs, or frontend.
|
||||
|
||||
Raises ``ActionSecretUnavailableError`` (never returns ``None``) when
|
||||
the params cannot be produced — missing secret row, undecryptable
|
||||
ciphertext, or a non-object plaintext. The caller MUST treat this as a
|
||||
hard failure (record ``failed``, fail the parent run, never execute the
|
||||
tool); it must NOT fall back to empty params.
|
||||
"""
|
||||
ciphertext = await self._store.action_secrets.get_by_action(action_id)
|
||||
if ciphertext is None:
|
||||
raise ActionSecretUnavailableError(
|
||||
action_id, "missing", "no encrypted action secret is stored for this action"
|
||||
)
|
||||
try:
|
||||
params = decrypt_params(ciphertext)
|
||||
except Exception as exc:
|
||||
# Log a sanitized reason only — never the ciphertext, key, or raw body.
|
||||
logger.error(
|
||||
"Failed to decrypt action params for %s: %s",
|
||||
action_id, sanitize(str(exc)),
|
||||
)
|
||||
raise ActionSecretUnavailableError(
|
||||
action_id, "decrypt_failed", "encrypted action params could not be decrypted"
|
||||
) from exc
|
||||
if not isinstance(params, dict):
|
||||
raise ActionSecretUnavailableError(
|
||||
action_id, "invalid_type", "decrypted action params are not a JSON object"
|
||||
)
|
||||
return params
|
||||
@@ -1,177 +0,0 @@
|
||||
"""Build server-trusted, frozen MAF capability snapshots.
|
||||
|
||||
Clients must not supply tool metadata or full snapshots — only participant
|
||||
``agent_id`` and optional per-seat ``excluded_tools`` (subtractive only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.tools.tool_metadata import resolve_tool_metadata
|
||||
from deerflow.tools.tools import get_available_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CapabilitySnapshotError(Exception):
|
||||
"""Invalid participant / tool selection when building a trusted snapshot."""
|
||||
|
||||
|
||||
class AgentNotAccessibleError(CapabilitySnapshotError):
|
||||
def __init__(self, agent_id: str) -> None:
|
||||
super().__init__(f"agent not accessible: {agent_id}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
class InvalidExcludedToolsError(CapabilitySnapshotError):
|
||||
def __init__(self, agent_id: str, tools: list[str]) -> None:
|
||||
super().__init__(f"invalid excluded_tools for {agent_id}: {tools}")
|
||||
self.agent_id = agent_id
|
||||
self.tools = tools
|
||||
|
||||
|
||||
async def _assert_user_may_use_agent(
|
||||
*,
|
||||
agent_store: Any | None,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
) -> None:
|
||||
"""Ensure the user may run this agent (visible in catalog)."""
|
||||
if agent_store is not None:
|
||||
visible = await agent_store.get_visible(agent_id, user_id)
|
||||
if visible is None:
|
||||
raise AgentNotAccessibleError(agent_id)
|
||||
return
|
||||
cfg = load_agent_config(agent_id)
|
||||
if cfg is None:
|
||||
raise AgentNotAccessibleError(agent_id)
|
||||
|
||||
|
||||
def _config_extra_for_tool(app_config: AppConfig, tool_name: str) -> dict[str, Any] | None:
|
||||
for tc in app_config.tools:
|
||||
if tc.name == tool_name:
|
||||
extra = dict(getattr(tc, "model_extra", None) or {})
|
||||
return extra or None
|
||||
return None
|
||||
|
||||
|
||||
def _seat_tools_for_agent(
|
||||
*,
|
||||
app_config: AppConfig,
|
||||
agent_id: str,
|
||||
session_model_name: str | None,
|
||||
session_reasoning_effort: str | None,
|
||||
requested_excluded: list[str],
|
||||
) -> tuple[list[str], list[str], list[dict[str, Any]]]:
|
||||
"""Resolve allowed names, effective excluded, and tool metadata for one seat."""
|
||||
agent_config = load_agent_config(agent_id)
|
||||
tool_groups = getattr(agent_config, "tool_groups", None) if agent_config else None
|
||||
model_name = (
|
||||
getattr(agent_config, "model", None) if agent_config else None
|
||||
) or session_model_name
|
||||
|
||||
available = get_available_tools(
|
||||
groups=tool_groups,
|
||||
model_name=model_name,
|
||||
excluded_tools=[],
|
||||
app_config=app_config,
|
||||
)
|
||||
allowed_set = {t.name for t in available}
|
||||
excluded_set = set(requested_excluded or [])
|
||||
unknown_excluded = excluded_set - allowed_set
|
||||
if unknown_excluded:
|
||||
raise InvalidExcludedToolsError(agent_id, sorted(unknown_excluded))
|
||||
effective_excluded = sorted(excluded_set & allowed_set)
|
||||
effective_allowed = sorted(allowed_set - set(effective_excluded))
|
||||
|
||||
tools_meta: list[dict[str, Any]] = []
|
||||
maf_tool_metadata = getattr(
|
||||
getattr(app_config, "maf_roundtable", None), "tool_metadata", None
|
||||
) or {}
|
||||
for name in effective_allowed:
|
||||
extra = _config_extra_for_tool(app_config, name)
|
||||
tools_meta.append(
|
||||
resolve_tool_metadata(
|
||||
name,
|
||||
config_extra=extra,
|
||||
maf_metadata=maf_tool_metadata,
|
||||
)
|
||||
)
|
||||
|
||||
return effective_allowed, effective_excluded, tools_meta
|
||||
|
||||
|
||||
async def build_trusted_capability_snapshot(
|
||||
*,
|
||||
user_id: str,
|
||||
participants: list[dict[str, Any]],
|
||||
app_config: AppConfig,
|
||||
agent_store: Any | None = None,
|
||||
session_model_name: str | None = None,
|
||||
session_reasoning_effort: str | None = None,
|
||||
session_excluded_tools: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the frozen snapshot stored on the session and used by WriteGuard."""
|
||||
agents_out: list[dict[str, Any]] = []
|
||||
flat_tools: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for p in participants:
|
||||
agent_id = str(p.get("agent_id") or "")
|
||||
if not agent_id:
|
||||
continue
|
||||
await _assert_user_may_use_agent(
|
||||
agent_store=agent_store, user_id=user_id, agent_id=agent_id
|
||||
)
|
||||
agent_config = load_agent_config(agent_id)
|
||||
seat_excluded = list(p.get("excluded_tools") or [])
|
||||
if session_excluded_tools:
|
||||
seat_excluded = sorted(set(seat_excluded) | set(session_excluded_tools))
|
||||
|
||||
allowed, excluded, tools_meta = _seat_tools_for_agent(
|
||||
app_config=app_config,
|
||||
agent_id=agent_id,
|
||||
session_model_name=session_model_name,
|
||||
session_reasoning_effort=session_reasoning_effort,
|
||||
requested_excluded=seat_excluded,
|
||||
)
|
||||
for meta in tools_meta:
|
||||
flat_tools[meta["tool_name"]] = meta
|
||||
|
||||
agents_out.append(
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"role": p.get("role", "seat"),
|
||||
"model_name": getattr(agent_config, "model", None) if agent_config else session_model_name,
|
||||
"reasoning_effort": session_reasoning_effort,
|
||||
"thinking_enabled": True,
|
||||
"subagent_enabled": bool(getattr(agent_config, "subagent_enabled", False))
|
||||
if agent_config
|
||||
else False,
|
||||
"tool_groups": list(getattr(agent_config, "tool_groups", None) or [])
|
||||
if agent_config
|
||||
else None,
|
||||
"allowed_tool_names": allowed,
|
||||
"excluded_tools": excluded,
|
||||
"tools": tools_meta,
|
||||
"memory_recall_disabled": None,
|
||||
"memory_injection_enabled": None,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"agents": agents_out,
|
||||
"tools": list(flat_tools.values()),
|
||||
}
|
||||
|
||||
|
||||
def seat_snapshot_for_agent(full_snapshot: dict[str, Any], agent_id: str) -> dict[str, Any]:
|
||||
"""Per-participant slice persisted on ``maf_roundtable_participants``."""
|
||||
for a in full_snapshot.get("agents") or []:
|
||||
if isinstance(a, dict) and a.get("agent_id") == agent_id:
|
||||
return dict(a)
|
||||
return {}
|
||||
@@ -1,205 +0,0 @@
|
||||
"""Build bounded, traceable seat input from MAF roundtable context.
|
||||
|
||||
Converts the shared roundtable state (task, previous turns, fact ledger,
|
||||
stage instructions) into a single human message for a seat's DeerFlow thread.
|
||||
|
||||
Design constraints (MAF-02-06):
|
||||
- Bounded: total output STRICTLY never exceeds ``max_context_chars``.
|
||||
- Trimmable: each section is independently truncatable.
|
||||
- Traceable: includes turn references so the seat can cite prior work.
|
||||
- Never injects credentials, tokens, or raw binary content.
|
||||
- All content is sanitized before inclusion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.sanitize import sanitize
|
||||
|
||||
_DEFAULT_MAX_CONTEXT_CHARS = 12000
|
||||
_DEFAULT_MAX_TURN_CHARS = 2000
|
||||
_DEFAULT_MAX_FACT_CHARS = 3000
|
||||
|
||||
|
||||
def build_seat_input(
|
||||
*,
|
||||
task_text: str,
|
||||
stage_instruction: str,
|
||||
role_description: str,
|
||||
prior_turns: list[dict[str, Any]],
|
||||
fact_ledger: dict[str, Any] | None = None,
|
||||
max_context_chars: int = _DEFAULT_MAX_CONTEXT_CHARS,
|
||||
max_turn_chars: int = _DEFAULT_MAX_TURN_CHARS,
|
||||
max_fact_chars: int = _DEFAULT_MAX_FACT_CHARS,
|
||||
) -> str:
|
||||
"""Assemble the human message for a seat's DeerFlow run.
|
||||
|
||||
The total output length is STRICTLY <= max_context_chars. Truncation
|
||||
happens per-section first, then a final hard cap ensures no overflow.
|
||||
"""
|
||||
sections: list[str] = []
|
||||
|
||||
sections.append(f"【任务】\n{task_text}")
|
||||
|
||||
if role_description:
|
||||
sections.append(f"【你的角色】\n{role_description}")
|
||||
|
||||
if stage_instruction:
|
||||
sections.append(f"【本阶段要求】\n{stage_instruction}")
|
||||
|
||||
if prior_turns:
|
||||
turns_text = _format_prior_turns(prior_turns, max_turn_chars)
|
||||
if turns_text:
|
||||
sections.append(f"【已有讨论】\n{turns_text}")
|
||||
|
||||
if fact_ledger:
|
||||
sanitized_ledger = sanitize(fact_ledger)
|
||||
if isinstance(sanitized_ledger, dict):
|
||||
facts_text = _format_fact_ledger(sanitized_ledger, max_fact_chars)
|
||||
if facts_text:
|
||||
sections.append(f"【共享事实账本】\n{facts_text}")
|
||||
|
||||
result = "\n\n".join(sections)
|
||||
|
||||
if len(result) > max_context_chars:
|
||||
result = result[:max_context_chars]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def build_manager_synthesis_input(
|
||||
*,
|
||||
task_text: str,
|
||||
seat_results: list[dict[str, Any]],
|
||||
fact_ledger: dict[str, Any] | None = None,
|
||||
max_context_chars: int = _DEFAULT_MAX_CONTEXT_CHARS,
|
||||
) -> str:
|
||||
"""Build the manager's final synthesis prompt."""
|
||||
sections: list[str] = []
|
||||
sections.append(f"【原始任务】\n{task_text}")
|
||||
sections.append("【各席位分析结果】")
|
||||
|
||||
for r in seat_results:
|
||||
agent_id = r.get("agent_id", "unknown")
|
||||
content = str(r.get("content", ""))
|
||||
if len(content) > 3000:
|
||||
content = content[:3000] + "..."
|
||||
sections.append(f"--- {agent_id} ---\n{content}")
|
||||
|
||||
if fact_ledger:
|
||||
sanitized_ledger = sanitize(fact_ledger)
|
||||
if isinstance(sanitized_ledger, dict):
|
||||
facts_text = _format_fact_ledger(sanitized_ledger, _DEFAULT_MAX_FACT_CHARS)
|
||||
if facts_text:
|
||||
sections.append(f"【共享事实账本】\n{facts_text}")
|
||||
|
||||
sections.append(
|
||||
"【综合要求】\n"
|
||||
"请基于以上各席位分析和事实账本,生成最终结论。要求:\n"
|
||||
"1. 明确标注共识点和分歧点\n"
|
||||
"2. 区分「建议动作」和「已实际执行的动作」\n"
|
||||
"3. 引用具体证据来源\n"
|
||||
"4. 列出失败或状态不确定的动作"
|
||||
)
|
||||
|
||||
result = "\n\n".join(sections)
|
||||
if len(result) > max_context_chars:
|
||||
result = result[:max_context_chars]
|
||||
return result
|
||||
|
||||
|
||||
# ── internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _format_prior_turns(turns: list[dict[str, Any]], max_per_turn: int) -> str:
|
||||
lines: list[str] = []
|
||||
for t in turns:
|
||||
agent_id = t.get("agent_id", "unknown")
|
||||
turn_idx = t.get("turn_index", "?")
|
||||
summary = str(t.get("summary", ""))
|
||||
if len(summary) > max_per_turn:
|
||||
summary = summary[:max_per_turn] + "..."
|
||||
lines.append(f"[轮次{turn_idx}] {agent_id}: {summary}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_fact_ledger(ledger: dict[str, Any], max_chars: int) -> str:
|
||||
parts: list[str] = []
|
||||
|
||||
candidates = ledger.get("candidate_facts")
|
||||
if candidates and isinstance(candidates, list):
|
||||
# Phase-3: candidate facts are UNVERIFIED seat claims — label them
|
||||
# explicitly so downstream seats / the finalizer never treat them as
|
||||
# established conclusions.
|
||||
parts.append("未经核验的席位主张(不能作为结论):")
|
||||
for c in candidates[:20]:
|
||||
if isinstance(c, dict):
|
||||
parts.append(f" - {c.get('text', c)}")
|
||||
else:
|
||||
parts.append(f" - {c}")
|
||||
|
||||
facts = ledger.get("confirmed_facts")
|
||||
if facts and isinstance(facts, list):
|
||||
parts.append("已确认事实(经交叉审查核验):")
|
||||
for f in facts[:20]:
|
||||
if isinstance(f, dict):
|
||||
parts.append(f" - {f.get('text', f)}")
|
||||
else:
|
||||
parts.append(f" - {f}")
|
||||
|
||||
evidence = ledger.get("evidence")
|
||||
if evidence and isinstance(evidence, list):
|
||||
parts.append("证据引用:")
|
||||
for e in evidence[:10]:
|
||||
if isinstance(e, dict):
|
||||
parts.append(f" - {e.get('ref') or e.get('text') or e}")
|
||||
else:
|
||||
parts.append(f" - {e}")
|
||||
|
||||
tool_results = ledger.get("tool_results")
|
||||
if tool_results and isinstance(tool_results, list):
|
||||
parts.append("工具执行结果:")
|
||||
for tr in tool_results[:10]:
|
||||
if isinstance(tr, dict):
|
||||
parts.append(f" - {tr.get('tool_name', '?')}: {tr.get('status', '?')}")
|
||||
else:
|
||||
parts.append(f" - {tr}")
|
||||
|
||||
proposed = ledger.get("proposed_actions")
|
||||
if proposed and isinstance(proposed, list):
|
||||
parts.append("建议动作(未执行):")
|
||||
for p in proposed[:8]:
|
||||
parts.append(f" - {p}")
|
||||
|
||||
executed = ledger.get("executed_actions")
|
||||
if executed and isinstance(executed, list):
|
||||
parts.append("已执行外部写入:")
|
||||
for e in executed[:8]:
|
||||
if isinstance(e, dict):
|
||||
parts.append(f" - {e.get('tool_name', '?')} ({e.get('action_id', '')})")
|
||||
else:
|
||||
parts.append(f" - {e}")
|
||||
|
||||
conflicts = ledger.get("conflicts")
|
||||
if conflicts and isinstance(conflicts, list):
|
||||
parts.append("未解决冲突:")
|
||||
for c in conflicts[:5]:
|
||||
parts.append(f" - {c}")
|
||||
|
||||
pending = ledger.get("pending_actions")
|
||||
if pending and isinstance(pending, list):
|
||||
parts.append("等待确认动作:")
|
||||
for p in pending[:5]:
|
||||
parts.append(f" - {p}")
|
||||
|
||||
failed = ledger.get("failed_or_unknown_actions")
|
||||
if failed and isinstance(failed, list):
|
||||
parts.append("失败或状态不确定的动作:")
|
||||
for f in failed[:5]:
|
||||
parts.append(f" - {f}")
|
||||
|
||||
result = "\n".join(parts)
|
||||
if len(result) > max_chars:
|
||||
result = result[:max_chars]
|
||||
return result
|
||||
@@ -1,280 +0,0 @@
|
||||
"""Conversation-scoped in-process event hub for MAF roundtable SSE streaming.
|
||||
|
||||
This hub mediates between the writers (store/service/workflow executor that
|
||||
persist conversation events to the DB) and the SSE subscribers (the HTTP
|
||||
stream endpoint). It is deliberately **notification-only**: the database is
|
||||
the authoritative event store, and this hub merely signals "new events may be
|
||||
available" so subscribers can re-query. This avoids duplicating the event log
|
||||
in memory and guarantees that a reconnecting client can always replay from the
|
||||
DB cursor (``after_seq``).
|
||||
|
||||
Writer contract (§4.2 "先持久化、后通知"):
|
||||
1. Persist events to ``maf_roundtable_conversation_events`` (via
|
||||
``MafRoundtableStore.append_conversation_item``) inside a transaction.
|
||||
2. **After commit**, call ``await hub.publish(conversation_id)``.
|
||||
|
||||
Subscriber contract (P0-1 race-free):
|
||||
1. ``sub = hub.open_subscription(conversation_id)`` — register **before**
|
||||
any DB query so publishes during replay are captured.
|
||||
2. Replay from DB: ``list_events(after_seq=last_seq)``.
|
||||
3. **Stabilise**: if ``sub.current_generation > sub.seen_generation``,
|
||||
re-query DB and repeat until stable (events arrived during replay).
|
||||
4. Send snapshot.
|
||||
5. ``await sub.wait_for_change(timeout=heartbeat)`` → on True, query DB
|
||||
for new events; on False, send heartbeat.
|
||||
|
||||
This eliminates the ``signal.clear()`` race: generation is monotonic and
|
||||
never cleared, so a publish that happens between the DB read and the
|
||||
``wait()`` is always detected on the next stabilisation check.
|
||||
|
||||
Multi-worker limitation:
|
||||
Single-process, in-memory. Multi-worker requires Redis Pub/Sub.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_HEARTBEAT_INTERVAL = 15.0
|
||||
_MAX_SUBSCRIBERS_PER_CONVERSATION = 64
|
||||
|
||||
|
||||
class _ConvState:
|
||||
"""Per-conversation notification state."""
|
||||
|
||||
__slots__ = ("generation", "condition", "subscriber_count")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.generation: int = 0
|
||||
self.condition: asyncio.Condition = asyncio.Condition()
|
||||
self.subscriber_count: int = 0
|
||||
|
||||
|
||||
class Subscription:
|
||||
"""Explicit subscription handle — race-free notification reader.
|
||||
|
||||
Created via :meth:`ConversationEventHub.open_subscription`. The caller
|
||||
owns its lifecycle: call :meth:`close` when the SSE connection ends.
|
||||
|
||||
Key invariant: ``seen_generation`` is monotonic — it is never reset.
|
||||
A publish that races ahead of ``wait_for_change`` simply increments
|
||||
``current_generation``, and the next ``wait_for_change`` returns
|
||||
immediately (no lost wakeup).
|
||||
"""
|
||||
|
||||
__slots__ = ("_hub", "_conv_id", "_seen_gen", "_closed")
|
||||
|
||||
def __init__(self, hub: ConversationEventHub, conversation_id: str, gen_at_open: int) -> None:
|
||||
self._hub = hub
|
||||
self._conv_id = conversation_id
|
||||
self._seen_gen = gen_at_open
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def conversation_id(self) -> str:
|
||||
return self._conv_id
|
||||
|
||||
@property
|
||||
def current_generation(self) -> int:
|
||||
"""Latest generation known to the hub for this conversation."""
|
||||
return self._hub._get_generation(self._conv_id)
|
||||
|
||||
@property
|
||||
def seen_generation(self) -> int:
|
||||
"""Generation as of the last successful stabilisation / wait."""
|
||||
return self._seen_gen
|
||||
|
||||
@property
|
||||
def has_pending(self) -> bool:
|
||||
"""True when the hub generation is ahead of what we've seen."""
|
||||
return self.current_generation > self._seen_gen
|
||||
|
||||
def mark_seen(self) -> None:
|
||||
"""Advance ``seen_generation`` to the hub's current generation."""
|
||||
self._seen_gen = self.current_generation
|
||||
|
||||
def mark_seen_if_higher(self, gen: int) -> None:
|
||||
if gen > self._seen_gen:
|
||||
self._seen_gen = gen
|
||||
|
||||
async def wait_for_change(self, *, timeout: float) -> bool:
|
||||
"""Block until ``current_generation > seen_generation`` or timeout.
|
||||
|
||||
Returns ``True`` if the generation advanced (new events *might* be
|
||||
available — caller must query DB to confirm). Returns ``False`` on
|
||||
timeout (caller should send a heartbeat).
|
||||
"""
|
||||
state = self._hub._convs.get(self._conv_id)
|
||||
if state is None:
|
||||
return False
|
||||
async with state.condition:
|
||||
if state.generation > self._seen_gen:
|
||||
self._seen_gen = state.generation
|
||||
return True
|
||||
try:
|
||||
await asyncio.wait_for(state.condition.wait(), timeout=timeout)
|
||||
except TimeoutError:
|
||||
return False
|
||||
if state.generation > self._seen_gen:
|
||||
self._seen_gen = state.generation
|
||||
return True
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the subscription. Safe to call multiple times."""
|
||||
if not self._closed:
|
||||
self._closed = True
|
||||
self._hub._release_subscription(self._conv_id)
|
||||
|
||||
def __enter__(self) -> Subscription:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class ConversationEventHub:
|
||||
"""In-process notification hub keyed by ``conversation_id``.
|
||||
|
||||
Not thread-safe; intended for a single asyncio event loop (one worker).
|
||||
"""
|
||||
|
||||
def __init__(self, *, heartbeat_interval: float = _DEFAULT_HEARTBEAT_INTERVAL) -> None:
|
||||
self._heartbeat_interval = heartbeat_interval
|
||||
self._convs: dict[str, _ConvState] = {}
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def heartbeat_interval(self) -> float:
|
||||
return self._heartbeat_interval
|
||||
|
||||
@property
|
||||
def total_subscribers(self) -> int:
|
||||
return sum(s.subscriber_count for s in self._convs.values())
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
def conversation_subscriber_count(self, conversation_id: str) -> int:
|
||||
state = self._convs.get(conversation_id)
|
||||
return state.subscriber_count if state else 0
|
||||
|
||||
def _get_generation(self, conversation_id: str) -> int:
|
||||
state = self._convs.get(conversation_id)
|
||||
return state.generation if state else 0
|
||||
|
||||
def open_subscription(self, conversation_id: str) -> Subscription:
|
||||
"""Register a new subscriber **before** any DB replay.
|
||||
|
||||
Returns a :class:`Subscription` whose ``seen_generation`` is the hub's
|
||||
current generation at registration time. Any ``publish()`` after this
|
||||
call will be detectable via ``sub.has_pending``.
|
||||
"""
|
||||
if self._closed:
|
||||
raise RuntimeError("ConversationEventHub is shut down")
|
||||
state = self._convs.get(conversation_id)
|
||||
if state is None:
|
||||
state = _ConvState()
|
||||
self._convs[conversation_id] = state
|
||||
if state.subscriber_count >= _MAX_SUBSCRIBERS_PER_CONVERSATION:
|
||||
raise RuntimeError(
|
||||
f"Too many SSE subscribers for conversation {conversation_id} "
|
||||
f"(max {_MAX_SUBSCRIBERS_PER_CONVERSATION})"
|
||||
)
|
||||
state.subscriber_count += 1
|
||||
logger.debug(
|
||||
"SSE subscribe conv=%s gen=%d (subs=%d)",
|
||||
conversation_id, state.generation, state.subscriber_count,
|
||||
)
|
||||
return Subscription(self, conversation_id, state.generation)
|
||||
|
||||
def _release_subscription(self, conversation_id: str) -> None:
|
||||
state = self._convs.get(conversation_id)
|
||||
if state is None:
|
||||
return
|
||||
state.subscriber_count -= 1
|
||||
logger.debug(
|
||||
"SSE unsubscribe conv=%s (subs=%d)",
|
||||
conversation_id, max(0, state.subscriber_count),
|
||||
)
|
||||
if state.subscriber_count <= 0:
|
||||
self._convs.pop(conversation_id, None)
|
||||
|
||||
async def publish(self, conversation_id: str) -> None:
|
||||
"""Signal that new events have been persisted for a conversation.
|
||||
|
||||
Must be called **after** the events are committed to the DB, never
|
||||
before. Atomically increments the per-conversation generation counter
|
||||
and wakes all waiting subscribers. Safe to call when no subscribers
|
||||
are active (the next subscriber's stabilisation check will see the
|
||||
incremented generation).
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
state = self._convs.get(conversation_id)
|
||||
if state is None:
|
||||
# No active subscribers — nothing to wake. The generation is not
|
||||
# tracked for conversations with zero subscribers; a future
|
||||
# open_subscription starts fresh at 0, which is correct because
|
||||
# there was nobody to notify.
|
||||
return
|
||||
async with state.condition:
|
||||
state.generation += 1
|
||||
state.condition.notify_all()
|
||||
|
||||
async def wait_for_change(
|
||||
self, conversation_id: str, seen_gen: int, *, timeout: float
|
||||
) -> int:
|
||||
"""Convenience: wait until generation > ``seen_gen``.
|
||||
|
||||
Returns the new generation (> ``seen_gen``) on change, or ``seen_gen``
|
||||
unchanged on timeout. Prefer using a :class:`Subscription` for the
|
||||
full lifecycle.
|
||||
"""
|
||||
state = self._convs.get(conversation_id)
|
||||
if state is None:
|
||||
return seen_gen
|
||||
async with state.condition:
|
||||
if state.generation > seen_gen:
|
||||
return state.generation
|
||||
try:
|
||||
await asyncio.wait_for(state.condition.wait(), timeout=timeout)
|
||||
except TimeoutError:
|
||||
return seen_gen
|
||||
return state.generation
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Async shutdown that immediately wakes all subscribers.
|
||||
|
||||
Acquires each Condition lock, bumps generation, and calls
|
||||
``notify_all()`` so every waiter returns within milliseconds —
|
||||
not after the next heartbeat timeout.
|
||||
"""
|
||||
self._closed = True
|
||||
for state in list(self._convs.values()):
|
||||
async with state.condition:
|
||||
state.generation += 1
|
||||
state.condition.notify_all()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Deprecated sync stub — bumps generation but cannot wake waiters
|
||||
immediately. Callers should use ``await hub.aclose()`` instead."""
|
||||
self._closed = True
|
||||
for state in self._convs.values():
|
||||
state.generation += 1
|
||||
|
||||
|
||||
async def hub_aclose(hub: ConversationEventHub) -> None:
|
||||
"""Async shutdown that immediately wakes all subscribers."""
|
||||
await hub.aclose()
|
||||
|
||||
|
||||
__all__ = ["ConversationEventHub", "Subscription", "hub_aclose"]
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Pydantic schemas for the MAF roundtable Conversation API (V2).
|
||||
|
||||
These are the public wire contracts for ``/api/maf-roundtable/conversations``.
|
||||
Internal fields (owner_user_id, internal_session_id, active_run_id, thread ids,
|
||||
capability snapshots, idempotency keys) are NEVER exposed here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
_TASK_CONTEXT_SAFE_KEYS = frozenset({"business_code", "task_id", "task_name", "chain_id", "chain_name"})
|
||||
|
||||
|
||||
class _StrictRequestModel(BaseModel):
|
||||
"""Reject unknown request members instead of silently ignoring spoofed input."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class CreateConversationRequest(_StrictRequestModel):
|
||||
title: str = Field(default="", max_length=255)
|
||||
task_context: dict[str, Any] | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _reject_internal_fields(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
forbidden = {"user_id", "owner_user_id", "internal_session_id", "run_id",
|
||||
"thread_id", "capability_snapshot", "id"}
|
||||
found = forbidden & set(values.keys())
|
||||
if found:
|
||||
raise ValueError(f"Forbidden fields: {sorted(found)}")
|
||||
return values
|
||||
|
||||
@field_validator("task_context")
|
||||
@classmethod
|
||||
def _validate_task_context(cls, value: dict[str, Any] | None) -> dict[str, str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
unexpected = set(value) - _TASK_CONTEXT_SAFE_KEYS
|
||||
if unexpected:
|
||||
raise ValueError(f"Unsupported task_context fields: {sorted(unexpected)}")
|
||||
cleaned: dict[str, str] = {}
|
||||
for key, raw in value.items():
|
||||
if not isinstance(raw, (str, int)) or isinstance(raw, bool):
|
||||
raise ValueError(f"task_context.{key} must be a string or integer")
|
||||
rendered = str(raw).strip()
|
||||
if not rendered:
|
||||
raise ValueError(f"task_context.{key} cannot be blank")
|
||||
if len(rendered) > 255:
|
||||
raise ValueError(f"task_context.{key} exceeds 255 characters")
|
||||
cleaned[key] = rendered
|
||||
return cleaned
|
||||
|
||||
|
||||
class SendMessageRequest(_StrictRequestModel):
|
||||
content: str = Field(min_length=1, max_length=20000)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _reject_internal_fields(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
forbidden = {"user_id", "owner_user_id", "internal_session_id", "run_id",
|
||||
"thread_id", "capability_snapshot", "idempotency_key"}
|
||||
found = forbidden & set(values.keys())
|
||||
if found:
|
||||
raise ValueError(f"Forbidden fields: {sorted(found)}")
|
||||
return values
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _strip_content(self) -> SendMessageRequest:
|
||||
stripped = self.content.strip()
|
||||
if not stripped:
|
||||
raise ValueError("content cannot be empty or whitespace-only")
|
||||
self.content = stripped
|
||||
return self
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
task_context: dict[str, Any] | None = None
|
||||
safe_status: str
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
last_stream_seq: int = 0
|
||||
|
||||
|
||||
class ConversationListResponse(BaseModel):
|
||||
conversations: list[ConversationResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: str
|
||||
conversation_id: str
|
||||
seq: int
|
||||
message_kind: str
|
||||
role: str
|
||||
agent_id: str | None = None
|
||||
message_ref: str | None = None
|
||||
content: str
|
||||
metadata: dict[str, Any] | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class MessageListResponse(BaseModel):
|
||||
messages: list[MessageResponse]
|
||||
last_stream_seq: int
|
||||
|
||||
|
||||
class SendMessageResponse(BaseModel):
|
||||
message_id: str
|
||||
conversation_id: str
|
||||
seq: int
|
||||
@@ -1,793 +0,0 @@
|
||||
"""Conversation service for the MAF roundtable V2 (App layer).
|
||||
|
||||
Orchestrates the harness-layer store with business rules that belong to the
|
||||
application layer: session blueprint construction, ownership enforcement,
|
||||
public DTO projection, and fail-closed behaviour when configuration is missing.
|
||||
|
||||
Blueprint generation lives here (not in the harness) because it depends on
|
||||
deployment config (``MafRoundtableConfig``) which is an app-layer concern.
|
||||
The harness ``MafRoundtableStore`` never imports ``app.*``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.maf_roundtable.dispatch_contracts import (
|
||||
DispatchConfigurationError,
|
||||
MafDispatchCandidateSet,
|
||||
MafDispatchCatalog,
|
||||
)
|
||||
from deerflow.persistence.maf_roundtable.conversations import (
|
||||
conversation_public_to_dict,
|
||||
)
|
||||
from deerflow.persistence.maf_roundtable.messages import message_public_to_dict
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableConversationRow,
|
||||
MafRoundtableMessageRow,
|
||||
MafRoundtableRunRow,
|
||||
)
|
||||
from deerflow.persistence.maf_roundtable.store import MafRoundtableStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.maf_roundtable.conversation_event_hub import ConversationEventHub
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationServiceError(Exception):
|
||||
def __init__(self, code: str, message: str, status_code: int = 400):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ConversationNotFoundError(ConversationServiceError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("not_found", "Conversation not found", 404)
|
||||
|
||||
|
||||
class ConversationConflictError(ConversationServiceError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("conflict", "A run is already active for this conversation", 409)
|
||||
|
||||
|
||||
class ConversationNoSessionConfigError(ConversationServiceError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"no_session_config",
|
||||
"Cannot start a run: no trusted session configuration available",
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
class ConversationDisabledError(ConversationServiceError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("disabled", "MAF roundtable feature is disabled", 503)
|
||||
|
||||
|
||||
class ConversationDispatchError(ConversationServiceError):
|
||||
def __init__(self, message: str = "MAF dispatch is not configured") -> None:
|
||||
super().__init__("dispatch_unavailable", message, 503)
|
||||
|
||||
|
||||
class ConversationRuntimeUnavailableError(ConversationServiceError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"runtime_unavailable",
|
||||
"MAF workflow runtime is not available",
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
class MafConversationService:
|
||||
"""App-layer service for user-visible MAF conversations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: MafRoundtableStore,
|
||||
*,
|
||||
maf_config: Any = None,
|
||||
event_hub: ConversationEventHub | None = None,
|
||||
agent_store: Any | None = None,
|
||||
app_config: Any | None = None,
|
||||
dispatcher: Any | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._maf_config = maf_config
|
||||
self._hub = event_hub
|
||||
self._agent_store = agent_store
|
||||
self._app_config = app_config
|
||||
self._dispatcher = dispatcher
|
||||
self._enabled_override = enabled
|
||||
self._watchers: dict[str, asyncio.Task[None]] = {}
|
||||
|
||||
@property
|
||||
def _enabled(self) -> bool:
|
||||
"""Mutating conversation APIs obey the same feature flag as MAF runs."""
|
||||
if self._enabled_override is not None:
|
||||
if not self._enabled_override:
|
||||
return False
|
||||
elif not bool(getattr(self._maf_config, "enabled", False)):
|
||||
return False
|
||||
return self.conversation_dispatch_ready
|
||||
|
||||
@property
|
||||
def conversation_dispatch_ready(self) -> bool:
|
||||
"""Expose only non-sensitive route readiness to the gateway/UI."""
|
||||
if self._maf_config is None:
|
||||
return False
|
||||
return MafDispatchCatalog(self._maf_config).conversation_dispatch_ready
|
||||
|
||||
@property
|
||||
def conversation_dispatch_status(self) -> str:
|
||||
if self._maf_config is None:
|
||||
return "configuration_unavailable"
|
||||
return MafDispatchCatalog(self._maf_config).readiness()[1]
|
||||
|
||||
def _read_scope_user(self, *, user_id: str, is_admin: bool) -> str | None:
|
||||
"""Keep administrator diagnostics read-only and config-gated."""
|
||||
if is_admin and bool(getattr(self._maf_config, "admin_diagnostic_access", False)):
|
||||
return None
|
||||
return user_id
|
||||
|
||||
async def _build_dispatch_blueprint(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
title: str,
|
||||
content: str,
|
||||
task_context: dict[str, Any] | None,
|
||||
) -> tuple[dict[str, Any] | None, MafDispatchCandidateSet]:
|
||||
cfg = self._maf_config
|
||||
if cfg is None:
|
||||
raise ConversationDispatchError("MAF session configuration is unavailable")
|
||||
|
||||
try:
|
||||
candidate_set = MafDispatchCatalog(cfg).build_candidate_set(
|
||||
task_context=task_context,
|
||||
)
|
||||
except DispatchConfigurationError as exc:
|
||||
raise ConversationDispatchError(str(exc)) from exc
|
||||
|
||||
snapshot_inputs = MafDispatchCatalog.candidate_participants(
|
||||
candidate_set,
|
||||
include_seats=True,
|
||||
)
|
||||
participants = MafDispatchCatalog.candidate_participants(
|
||||
candidate_set,
|
||||
include_seats=False,
|
||||
)
|
||||
capability_snapshot: dict[str, Any] | None = None
|
||||
|
||||
from app.maf_roundtable.capability_snapshot import (
|
||||
AgentNotAccessibleError,
|
||||
InvalidExcludedToolsError,
|
||||
build_trusted_capability_snapshot,
|
||||
seat_snapshot_for_agent,
|
||||
)
|
||||
|
||||
if self._app_config is not None:
|
||||
try:
|
||||
capability_snapshot = await build_trusted_capability_snapshot(
|
||||
user_id=user_id,
|
||||
participants=snapshot_inputs,
|
||||
app_config=self._app_config,
|
||||
agent_store=self._agent_store,
|
||||
session_model_name=getattr(cfg, "default_model_name", None),
|
||||
session_reasoning_effort=getattr(cfg, "default_reasoning_effort", None),
|
||||
)
|
||||
except AgentNotAccessibleError as exc:
|
||||
raise ConversationDispatchError(
|
||||
f"configured MAF agent is not accessible: {exc.agent_id}"
|
||||
) from exc
|
||||
except InvalidExcludedToolsError as exc:
|
||||
raise ConversationDispatchError(
|
||||
f"configured MAF agent has invalid excluded tools: {exc.agent_id}"
|
||||
) from exc
|
||||
|
||||
for participant in participants:
|
||||
participant["capability_snapshot"] = seat_snapshot_for_agent(
|
||||
capability_snapshot, str(participant["agent_id"])
|
||||
)
|
||||
|
||||
payload = {
|
||||
"maf_v2": {
|
||||
"dispatch_contract_enabled": True,
|
||||
"candidate_set": candidate_set.model_dump(mode="json"),
|
||||
"candidate_seat_agent_ids": list(candidate_set.candidate_seat_agent_ids),
|
||||
"manager_agent_id": candidate_set.manager_agent_id,
|
||||
}
|
||||
}
|
||||
blueprint = {
|
||||
"title": title,
|
||||
"task_text": content,
|
||||
"mode": candidate_set.mode,
|
||||
"model_name": getattr(cfg, "default_model_name", None),
|
||||
"reasoning_effort": getattr(cfg, "default_reasoning_effort", None),
|
||||
"manager_agent_id": candidate_set.manager_agent_id,
|
||||
"max_rounds": candidate_set.max_rounds,
|
||||
"write_policy": getattr(cfg, "default_write_policy", "confirm_high_risk"),
|
||||
"capability_snapshot": capability_snapshot,
|
||||
"task_payload": payload,
|
||||
"participants": participants,
|
||||
"start_run": True,
|
||||
}
|
||||
return blueprint, candidate_set
|
||||
|
||||
async def create_conversation(
|
||||
self, *, user_id: str, title: str = "", task_context: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
if not self._enabled:
|
||||
raise ConversationDisabledError()
|
||||
row_data = await self._store.conversations.create_conversation({
|
||||
"owner_user_id": user_id,
|
||||
"title": title,
|
||||
"task_context": task_context,
|
||||
})
|
||||
conv_row = await self._get_conv_row(row_data["id"])
|
||||
if conv_row is None:
|
||||
raise ConversationNotFoundError()
|
||||
return conversation_public_to_dict(conv_row)
|
||||
|
||||
async def list_conversations(
|
||||
self, *, user_id: str, limit: int = 50, offset: int = 0
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
rows = await self._store.conversations.list_conversations(
|
||||
user_id=user_id, limit=limit, offset=offset
|
||||
)
|
||||
total = await self._store.conversations.count_conversations(user_id=user_id)
|
||||
public = []
|
||||
for r in rows:
|
||||
conv_row = await self._get_conv_row(r["id"])
|
||||
if conv_row is not None:
|
||||
public.append(conversation_public_to_dict(conv_row))
|
||||
return public, total
|
||||
|
||||
async def get_conversation(self, *, conversation_id: str, user_id: str, is_admin: bool = False) -> dict[str, Any]:
|
||||
effective_user = self._read_scope_user(user_id=user_id, is_admin=is_admin)
|
||||
row = await self._store.conversations.get_conversation(conversation_id, user_id=effective_user)
|
||||
if row is None:
|
||||
raise ConversationNotFoundError()
|
||||
conv_row = await self._get_conv_row(conversation_id)
|
||||
if conv_row is None:
|
||||
raise ConversationNotFoundError()
|
||||
return conversation_public_to_dict(conv_row)
|
||||
|
||||
async def list_messages(
|
||||
self, *, conversation_id: str, user_id: str, is_admin: bool = False,
|
||||
before_seq: int | None = None, limit: int = 50,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
effective_user = self._read_scope_user(user_id=user_id, is_admin=is_admin)
|
||||
conv = await self._store.conversations.get_conversation(conversation_id, user_id=effective_user)
|
||||
if conv is None:
|
||||
raise ConversationNotFoundError()
|
||||
|
||||
rows = await self._store.messages.list_messages_desc(
|
||||
conversation_id, limit=limit, before_seq=before_seq
|
||||
)
|
||||
public = []
|
||||
for r in rows:
|
||||
msg_row = await self._get_msg_row(r["id"])
|
||||
if msg_row is not None:
|
||||
public.append(message_public_to_dict(msg_row))
|
||||
return public, int(conv.get("last_stream_seq", 0))
|
||||
|
||||
async def send_message(
|
||||
self, *, conversation_id: str, user_id: str, content: str
|
||||
) -> dict[str, Any]:
|
||||
if not self._enabled:
|
||||
raise ConversationDisabledError()
|
||||
conv = await self._store.conversations.get_conversation(conversation_id, user_id=user_id)
|
||||
if conv is None:
|
||||
raise ConversationNotFoundError()
|
||||
|
||||
try:
|
||||
blueprint, _candidate_set = await self._build_dispatch_blueprint(
|
||||
user_id=user_id,
|
||||
title=conv.get("title", ""),
|
||||
content=content,
|
||||
task_context=conv.get("task_context"),
|
||||
)
|
||||
except ConversationServiceError:
|
||||
raise
|
||||
|
||||
if blueprint is None:
|
||||
raise ConversationNoSessionConfigError()
|
||||
|
||||
# Do not create a durable running record if the MAF SDK is missing.
|
||||
# Unit tests may inject a small fake dispatcher without this optional
|
||||
# readiness method.
|
||||
if self._dispatcher is not None:
|
||||
check_sdk = getattr(self._dispatcher, "check_sdk_available", None)
|
||||
if callable(check_sdk):
|
||||
try:
|
||||
check_sdk()
|
||||
except Exception as exc: # noqa: BLE001 - safe public error
|
||||
logger.warning("MAF runtime unavailable before dispatch: %s", type(exc).__name__)
|
||||
raise ConversationRuntimeUnavailableError() from exc
|
||||
|
||||
result = await self._store.send_message_and_start_run(
|
||||
conversation_id=conversation_id,
|
||||
owner_user_id=user_id,
|
||||
content=content,
|
||||
session_blueprint=blueprint,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise ConversationNotFoundError()
|
||||
if result.get("conflict"):
|
||||
raise ConversationConflictError()
|
||||
if result.get("no_session_config"):
|
||||
raise ConversationNoSessionConfigError()
|
||||
|
||||
if self._hub is not None:
|
||||
try:
|
||||
await self._hub.publish(conversation_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("hub publish failed conv=%s", conversation_id)
|
||||
|
||||
if result.get("run") is None:
|
||||
raise ConversationDispatchError("dispatch run was not created")
|
||||
await self._dispatch_run(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
run=result["run"],
|
||||
)
|
||||
|
||||
return {
|
||||
"message_id": result["message"]["id"] if result.get("message") else "",
|
||||
"conversation_id": conversation_id,
|
||||
"seq": result.get("seq", 0),
|
||||
}
|
||||
|
||||
async def _publish_conversation_event(
|
||||
self,
|
||||
*,
|
||||
conversation_id: str,
|
||||
owner_user_id: str,
|
||||
event_type: str,
|
||||
event_data: dict[str, Any],
|
||||
source_event_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Persist first, then notify; no internal run/thread ids are used.
|
||||
|
||||
P0-4: when ``source_event_id`` is provided, dedup is enforced — if an
|
||||
event with the same ``(conversation_id, source_event_id)`` already
|
||||
exists, the method returns ``False`` (no duplicate written). This
|
||||
makes projection idempotent across retries and restarts.
|
||||
|
||||
Returns ``True`` if a new event was written, ``False`` if deduped.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
async with self._store.session_factory() as db:
|
||||
async with db.begin():
|
||||
conv = (
|
||||
await db.execute(
|
||||
select(MafRoundtableConversationRow)
|
||||
.where(MafRoundtableConversationRow.id == conversation_id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalars().first()
|
||||
if conv is None or conv.owner_user_id != owner_user_id:
|
||||
return False
|
||||
|
||||
# P0-4: dedup check — skip if already projected.
|
||||
if source_event_id is not None:
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableConversationEventRow,
|
||||
)
|
||||
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(MafRoundtableConversationEventRow.id).where(
|
||||
MafRoundtableConversationEventRow.conversation_id == conversation_id,
|
||||
MafRoundtableConversationEventRow.source_event_id == source_event_id,
|
||||
).limit(1)
|
||||
)
|
||||
).first()
|
||||
if existing is not None:
|
||||
return False
|
||||
|
||||
await self._store.append_conversation_item(
|
||||
db,
|
||||
conversation=conv,
|
||||
event_type=event_type,
|
||||
event_data=event_data,
|
||||
run_id=None,
|
||||
source_event_id=source_event_id,
|
||||
)
|
||||
if self._hub is not None:
|
||||
try:
|
||||
await self._hub.publish(conversation_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("hub publish failed conv=%s", conversation_id)
|
||||
return True
|
||||
|
||||
_EVENT_TYPE_MAP = {
|
||||
"agent.turn_started": "agent.message.start",
|
||||
"agent.message.completed": "agent.message.complete",
|
||||
"action.waiting_approval": "approval.required",
|
||||
"action.approved": "approval.decided",
|
||||
"action.rejected": "approval.decided",
|
||||
"run.started": "run.status",
|
||||
"run.failed": "run.error",
|
||||
"run.cancelled": "run.cancelled",
|
||||
"manager.decision": "manager.dispatch",
|
||||
"manager.dispatch": "manager.dispatch",
|
||||
"clarification.required": "clarification.required",
|
||||
"workflow.checkpointed": "run.status",
|
||||
"action.waiting_target_lease": "tool.progress",
|
||||
"action.unknown": "tool.failed",
|
||||
}
|
||||
|
||||
_ALLOWED_TYPES = frozenset({
|
||||
"agent.message.start", "agent.message.delta", "agent.message.complete",
|
||||
"seat.delivery.submitted", "manager.dispatch", "manager.review.decision",
|
||||
"tool.requested", "tool.started", "tool.progress", "tool.completed", "tool.failed",
|
||||
"skill.selected", "skill.started", "skill.completed", "skill.failed",
|
||||
"artifact.created", "approval.required", "approval.decided", "clarification.required",
|
||||
"fact.confirmed", "review.completed", "run.status", "run.error",
|
||||
"run.cancelled", "run.completed",
|
||||
})
|
||||
|
||||
_SAFE_KEYS: dict[str, dict[str, bool]] = {
|
||||
"agent.message.start": {"thread_id": False},
|
||||
"agent.message.delta": {"text": True},
|
||||
"agent.message.complete": {"content": True, "usage": True, "metadata": False},
|
||||
"tool.requested": {"tool_name": True},
|
||||
"tool.started": {"tool_name": True, "node": True},
|
||||
"tool.progress": {"tool_name": True, "status": True},
|
||||
"tool.completed": {"tool_name": True, "status": True, "content_preview": True},
|
||||
"tool.failed": {"tool_name": True, "status": True, "error_code": True},
|
||||
"skill.selected": {"skill_name": True, "message": True},
|
||||
"skill.started": {"skill_name": True, "message": True},
|
||||
"skill.completed": {"skill_name": True, "result_preview": True},
|
||||
"skill.failed": {"skill_name": True, "error": True},
|
||||
"artifact.created": {"artifact_type": True, "title": True},
|
||||
"approval.required": {"tool_name": True, "summary": True},
|
||||
"approval.decided": {"decided_by": True},
|
||||
"run.status": {"checkpoint_id": True, "reason": True, "status": True},
|
||||
"run.error": {"error_code": True, "message": True},
|
||||
"run.cancelled": {},
|
||||
"run.completed": {"status": True},
|
||||
"manager.dispatch": {
|
||||
"intent_type": True, "execution_mode": True, "convergence_path": True,
|
||||
"selected_seat_agent_ids": True, "assignments": True, "next_speaker": True,
|
||||
"reason": True, "source": True, "clarification_prompt": True,
|
||||
},
|
||||
"clarification.required": {"prompt": True, "reason": True},
|
||||
"manager.review.decision": {"decision": True, "reason": True, "quality": True},
|
||||
"fact.confirmed": {"fact_id": True, "summary": True},
|
||||
"review.completed": {"summary": True, "decision": True},
|
||||
"seat.delivery.submitted": {"content": True, "schema_status": True},
|
||||
}
|
||||
|
||||
def _build_projection_items(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
source_audit_event_id: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
source_type = str(event.get("event_type") or "")
|
||||
mapped_type = self._EVENT_TYPE_MAP.get(source_type, source_type)
|
||||
if mapped_type not in self._ALLOWED_TYPES:
|
||||
return []
|
||||
|
||||
payload = event.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
safe_keys = self._SAFE_KEYS.get(mapped_type, {})
|
||||
data: dict[str, Any] = {
|
||||
key: payload[key]
|
||||
for key in safe_keys
|
||||
if key in payload and safe_keys[key]
|
||||
}
|
||||
if event.get("agent_id"):
|
||||
data["agent_id"] = event["agent_id"]
|
||||
if event.get("turn_index") is not None:
|
||||
data["turn_index"] = event["turn_index"]
|
||||
|
||||
source_evt_id = source_audit_event_id or event.get("id") or event.get("source_event_id")
|
||||
|
||||
items: list[dict[str, Any]] = [{
|
||||
"event_type": mapped_type,
|
||||
"event_data": data,
|
||||
"source_event_id": source_evt_id,
|
||||
}]
|
||||
|
||||
if source_type == "agent.message.completed":
|
||||
delivery_data: dict[str, Any] = {
|
||||
key: payload[key]
|
||||
for key in ("content", "schema_status")
|
||||
if key in payload
|
||||
}
|
||||
if event.get("agent_id"):
|
||||
delivery_data["agent_id"] = event["agent_id"]
|
||||
items.append({
|
||||
"event_type": "seat.delivery.submitted",
|
||||
"event_data": delivery_data,
|
||||
"source_event_id": f"{source_evt_id}:delivery" if source_evt_id else None,
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
async def _resolve_conversation_for_run(self, run_id: str) -> tuple[str, str] | None:
|
||||
from sqlalchemy import select
|
||||
async with self._store.session_factory() as db:
|
||||
row = (
|
||||
await db.execute(
|
||||
select(MafRoundtableConversationRow)
|
||||
.join(
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableConversationRow.internal_session_id
|
||||
== MafRoundtableRunRow.session_id,
|
||||
)
|
||||
.where(MafRoundtableRunRow.id == run_id)
|
||||
)
|
||||
).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
return row.id, row.owner_user_id
|
||||
|
||||
async def bridge_workflow_event(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
event: dict[str, Any],
|
||||
source_audit_event_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Project a persisted per-run event onto the V2 conversation stream.
|
||||
|
||||
Returns ``True`` if at least one new conversation event was written,
|
||||
``False`` if all items were deduped or unmapped.
|
||||
|
||||
All derived events from one audit event (e.g. ``agent.message.complete``
|
||||
+ ``seat.delivery.submitted``) are written in a single transaction —
|
||||
if any insert fails, the entire batch rolls back.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
items = self._build_projection_items(event, source_audit_event_id)
|
||||
if not items:
|
||||
return False
|
||||
|
||||
resolved = await self._resolve_conversation_for_run(run_id)
|
||||
if resolved is None:
|
||||
return False
|
||||
conversation_id, _owner_user_id = resolved
|
||||
|
||||
written = 0
|
||||
async with self._store.session_factory() as db:
|
||||
async with db.begin():
|
||||
conv = (
|
||||
await db.execute(
|
||||
select(MafRoundtableConversationRow)
|
||||
.where(MafRoundtableConversationRow.id == conversation_id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalars().first()
|
||||
if conv is None:
|
||||
return False
|
||||
written = await self._store.append_conversation_events_idempotently(
|
||||
db, conversation=conv, items=items,
|
||||
)
|
||||
if written > 0 and self._hub is not None:
|
||||
try:
|
||||
await self._hub.publish(conversation_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("hub publish failed conv=%s", conversation_id)
|
||||
return written > 0
|
||||
|
||||
async def reconcile_conversation_events(self, *, run_id: str) -> int:
|
||||
"""Re-project audit events for a run into the conversation stream.
|
||||
|
||||
Scans ``maf_roundtable_events`` for the given run and re-bridges each
|
||||
event. Because ``bridge_workflow_event`` is idempotent (dedup via
|
||||
``source_event_id``), this is safe to call after a crash, a restart,
|
||||
or a projection failure. Events already projected are silently skipped.
|
||||
|
||||
Returns the number of **new** conversation events written (deduped
|
||||
events are NOT counted).
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from deerflow.persistence.maf_roundtable.model import MafRoundtableEventRow
|
||||
|
||||
written = 0
|
||||
async with self._store.session_factory() as db:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(MafRoundtableEventRow)
|
||||
.where(MafRoundtableEventRow.run_id == run_id)
|
||||
.order_by(MafRoundtableEventRow.seq.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for row in rows:
|
||||
event_dict: dict[str, Any] = {
|
||||
"id": row.id,
|
||||
"event_type": row.event_type,
|
||||
"agent_id": row.agent_id,
|
||||
"turn_index": row.turn_index,
|
||||
"source_event_id": row.source_event_id,
|
||||
"payload": row.payload_json,
|
||||
}
|
||||
try:
|
||||
if await self.bridge_workflow_event(
|
||||
run_id=run_id,
|
||||
event=event_dict,
|
||||
source_audit_event_id=row.id,
|
||||
):
|
||||
written += 1
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(
|
||||
"reconcile: failed to project audit event %s for run %s",
|
||||
row.id, run_id, exc_info=True,
|
||||
)
|
||||
return written
|
||||
|
||||
async def reconcile_active_conversations(self) -> int:
|
||||
"""Scan active/recent MAF runs and reconcile their conversation projections.
|
||||
|
||||
Called by the background reconcile task and the SSE connect trigger.
|
||||
Returns total new conversation events written across all runs.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableConversationRow,
|
||||
MafRoundtableRunRow,
|
||||
)
|
||||
|
||||
active_statuses = ("pending", "running", "waiting_approval", "waiting_input")
|
||||
total = 0
|
||||
async with self._store.session_factory() as db:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(MafRoundtableRunRow.id)
|
||||
.join(
|
||||
MafRoundtableConversationRow,
|
||||
MafRoundtableConversationRow.active_run_id == MafRoundtableRunRow.id,
|
||||
)
|
||||
.where(MafRoundtableRunRow.status.in_(active_statuses))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for run_id in rows:
|
||||
try:
|
||||
total += await self.reconcile_conversation_events(run_id=str(run_id))
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("background reconcile: run %s failed", run_id, exc_info=True)
|
||||
return total
|
||||
|
||||
async def _dispatch_run(self, *, conversation_id: str, user_id: str, run: dict[str, Any]) -> None:
|
||||
if self._dispatcher is None:
|
||||
# R1 unit callers can still exercise persistence without starting
|
||||
# the optional SDK runtime. The production dependency wiring always
|
||||
# supplies a dispatcher.
|
||||
return
|
||||
session = await self._store.sessions.get_session(run["session_id"], user_id=user_id)
|
||||
if session is None:
|
||||
raise ConversationDispatchError("internal MAF session could not be loaded")
|
||||
try:
|
||||
await self._dispatcher.dispatch(session, run, user_id=user_id)
|
||||
except Exception as exc: # noqa: BLE001 - reconcile durable state
|
||||
logger.exception("MAF dispatch failed run=%s", run["id"])
|
||||
await self._mark_dispatch_failed(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
run_id=run["id"],
|
||||
error_code=type(exc).__name__[:64],
|
||||
)
|
||||
raise ConversationRuntimeUnavailableError() from exc
|
||||
|
||||
wait_for_run = getattr(self._dispatcher, "wait_for_run", None)
|
||||
if callable(wait_for_run):
|
||||
watcher = asyncio.create_task(
|
||||
self._watch_run(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
run_id=run["id"],
|
||||
wait_for_run=wait_for_run,
|
||||
)
|
||||
)
|
||||
self._watchers[run["id"]] = watcher
|
||||
watcher.add_done_callback(lambda _task, rid=run["id"]: self._watchers.pop(rid, None))
|
||||
|
||||
async def _mark_dispatch_failed(
|
||||
self, *, conversation_id: str, user_id: str, run_id: str, error_code: str
|
||||
) -> None:
|
||||
await self._store.runs.update_run(run_id, {
|
||||
"status": "failed",
|
||||
"finished_at": datetime.now(UTC),
|
||||
"error_code": error_code,
|
||||
"error_message_safe": "MAF workflow dispatch failed",
|
||||
})
|
||||
run = await self._store.runs.get_run(run_id)
|
||||
if run is not None:
|
||||
await self._store.sessions.update_session(
|
||||
run["session_id"], user_id=user_id, fields={"status": "failed"}
|
||||
)
|
||||
await self._store.conversations.set_active_run(conversation_id, None, "failed")
|
||||
await self._publish_conversation_event(
|
||||
conversation_id=conversation_id,
|
||||
owner_user_id=user_id,
|
||||
event_type="run.error",
|
||||
event_data={"status": "failed", "error_code": error_code},
|
||||
)
|
||||
|
||||
async def _watch_run(
|
||||
self,
|
||||
*,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
run_id: str,
|
||||
wait_for_run: Any,
|
||||
) -> None:
|
||||
try:
|
||||
await wait_for_run(run_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("MAF conversation watcher failed run=%s", run_id)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.reconcile_conversation_events(run_id=run_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("watcher reconcile failed run=%s", run_id, exc_info=True)
|
||||
|
||||
run = await self._store.runs.get_run(run_id)
|
||||
if run is None:
|
||||
return
|
||||
status_value = str(run.get("status") or "failed")
|
||||
terminal = status_value in {"completed", "failed", "cancelled"}
|
||||
await self._store.conversations.set_active_run(
|
||||
conversation_id,
|
||||
None if terminal else run_id,
|
||||
status_value,
|
||||
)
|
||||
event_type = {
|
||||
"completed": "run.completed",
|
||||
"failed": "run.error",
|
||||
"cancelled": "run.cancelled",
|
||||
}.get(status_value, "run.status")
|
||||
data: dict[str, Any] = {"status": status_value}
|
||||
if run.get("error_code"):
|
||||
data["error_code"] = run["error_code"]
|
||||
await self._publish_conversation_event(
|
||||
conversation_id=conversation_id,
|
||||
owner_user_id=user_id,
|
||||
event_type=event_type,
|
||||
event_data=data,
|
||||
)
|
||||
|
||||
async def _get_conv_row(self, conversation_id: str) -> MafRoundtableConversationRow | None:
|
||||
from sqlalchemy import select
|
||||
async with self._store.session_factory() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(MafRoundtableConversationRow).where(
|
||||
MafRoundtableConversationRow.id == conversation_id
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
return row
|
||||
|
||||
async def _get_msg_row(self, message_id: str) -> MafRoundtableMessageRow | None:
|
||||
from sqlalchemy import select
|
||||
async with self._store.session_factory() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(MafRoundtableMessageRow).where(
|
||||
MafRoundtableMessageRow.id == message_id
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
return row
|
||||
@@ -1,666 +0,0 @@
|
||||
"""DeerFlow Agent Adapter for the MAF roundtable experiment (Phase 2).
|
||||
|
||||
Drives each MAF seat's actual execution through the existing DeerFlow Agent
|
||||
Runtime, preserving tools / skills / MCP / sandbox / identity / middleware
|
||||
semantics. The adapter does NOT wrap individual LangChain tools — it invokes
|
||||
a full agent run per seat turn via ``DeerFlowRuntimeClient``.
|
||||
|
||||
Architecture:
|
||||
- One DeerFlow thread per seat (bound at session start, reused across turns).
|
||||
- In-process ``start_run`` via DeerFlowRuntimeClient (no fake Request).
|
||||
- User context injected via ExecutionPrincipal (server-generated, never from body).
|
||||
- StreamBridge subscription translates runtime SSE → MAF events in real time.
|
||||
- Cancellation propagates from MAF run → DeerFlow run via RunManager.cancel.
|
||||
- All seat runs persisted to ``maf_roundtable_seat_runs`` (survives restart).
|
||||
- Critical audit events (tool.*, skill.*, approval.*, artifact.*) MUST persist;
|
||||
audit failure stops external writes; seat turn failures are soft-failed
|
||||
into ``seat_outcomes`` / ``partial_failure`` (structured mode).
|
||||
|
||||
Boundary: lives in ``app.maf_roundtable`` (may import ``deerflow.*`` and
|
||||
``app.gateway.*``); never imported by ``deerflow.*``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.context_builder import build_seat_input
|
||||
from app.maf_roundtable.event_normalizer import (
|
||||
make_run_cancelled,
|
||||
make_run_failed,
|
||||
make_turn_completed,
|
||||
make_turn_started,
|
||||
normalize_stream_event,
|
||||
)
|
||||
from app.maf_roundtable.runtime_client import DeerFlowRuntimeClient, ExecutionPrincipal
|
||||
from app.maf_roundtable.sanitize import sanitize
|
||||
from deerflow.runtime.stream_bridge.base import END_SENTINEL, HEARTBEAT_SENTINEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IDLE_TIMEOUT_S = int(os.getenv("MAF_SEAT_IDLE_TIMEOUT_SECONDS", "1200") or "1200")
|
||||
_IDLE_CHECK_INTERVAL_S = 10.0
|
||||
_MAX_CONTEXT_CHARS = int(os.getenv("MAF_MAX_CONTEXT_CHARS", "12000") or "12000")
|
||||
|
||||
_CRITICAL_EVENT_TYPES = frozenset({
|
||||
"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",
|
||||
"run.completed", "run.failed", "run.cancelled",
|
||||
})
|
||||
|
||||
|
||||
class AuditEventError(RuntimeError):
|
||||
"""Raised when a critical audit event fails to persist."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeatResponse:
|
||||
"""Standardized response from a seat turn (MAF-02-08)."""
|
||||
|
||||
agent_id: str
|
||||
turn_index: int
|
||||
content: str
|
||||
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
usage: dict[str, Any] | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
latency_ms: int | None = None
|
||||
|
||||
|
||||
class DeerFlowAgentAdapter:
|
||||
"""Drives MAF seat execution through the DeerFlow Agent Runtime.
|
||||
|
||||
Usage::
|
||||
|
||||
client = DeerFlowRuntimeClient(app)
|
||||
adapter = DeerFlowAgentAdapter(client, service=svc, user_id="u1")
|
||||
await adapter.ensure_threads(session_id, participants)
|
||||
response = await adapter.run_seat_turn(...)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime_client: DeerFlowRuntimeClient,
|
||||
*,
|
||||
service: Any,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
self._client = runtime_client
|
||||
self._service = service
|
||||
self._user_id = user_id
|
||||
self._seen_source_ids: set[str] = set()
|
||||
|
||||
# ── MAF-02-01: thread lifecycle ─────────────────────────────────────────
|
||||
|
||||
async def ensure_threads(
|
||||
self, session_id: str, run_id: str, participants: list[dict[str, Any]]
|
||||
) -> dict[str, str]:
|
||||
"""Create and bind a DeerFlow thread for each participant that lacks one.
|
||||
|
||||
Thread creation is wrapped in set_current_user so thread_meta.user_id
|
||||
matches the MAF session owner. Returns ``{agent_id: thread_id}``.
|
||||
"""
|
||||
principal = ExecutionPrincipal(user_id=self._user_id, session_id=session_id, run_id=run_id)
|
||||
thread_map: dict[str, str] = {}
|
||||
for p in participants:
|
||||
agent_id = p.get("agent_id", "")
|
||||
existing = p.get("thread_id")
|
||||
if existing:
|
||||
thread_map[agent_id] = existing
|
||||
continue
|
||||
thread_id = await self._client.create_thread(principal)
|
||||
await self._service.store.participants.bind_thread(
|
||||
session_id, agent_id, thread_id=thread_id
|
||||
)
|
||||
thread_map[agent_id] = thread_id
|
||||
return thread_map
|
||||
|
||||
# ── MAF-02-02/03/04/05: seat turn execution ─────────────────────────────
|
||||
|
||||
async def run_seat_turn(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
message: str,
|
||||
thread_id: str,
|
||||
execution_stage: str = "expert",
|
||||
model_name: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
thinking_enabled: bool = True,
|
||||
subagent_enabled: bool = False,
|
||||
excluded_tools: list[str] | None = None,
|
||||
stage_instruction: str = "",
|
||||
prior_turns: list[dict[str, Any]] | None = None,
|
||||
fact_ledger: dict[str, Any] | None = None,
|
||||
role_description: str = "",
|
||||
task_text: str = "",
|
||||
memory_recall_disabled: bool | None = None,
|
||||
memory_injection_enabled: bool | None = None,
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
write_policy: str = "confirm_high_risk",
|
||||
approved_tool: str | None = None,
|
||||
wait_timeout_seconds: float | None = None,
|
||||
) -> SeatResponse:
|
||||
"""Execute one seat turn through the DeerFlow Agent Runtime."""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
seat_message = build_seat_input(
|
||||
task_text=task_text or message,
|
||||
stage_instruction=stage_instruction,
|
||||
role_description=role_description,
|
||||
prior_turns=prior_turns or [],
|
||||
fact_ledger=fact_ledger,
|
||||
max_context_chars=_MAX_CONTEXT_CHARS,
|
||||
)
|
||||
if not task_text:
|
||||
seat_message = message
|
||||
|
||||
await self._emit_critical(run_id, make_turn_started(agent_id, turn_index, {"thread_id": thread_id}))
|
||||
|
||||
principal = ExecutionPrincipal(user_id=self._user_id, session_id=session_id, run_id=run_id)
|
||||
|
||||
# Phase-3 P0-1: atomic "occupy or read". Only the caller that wins the
|
||||
# insert (created=True) may start a DeerFlow run. An existing row is
|
||||
# handled by status so we NEVER silently start a second DeerFlow
|
||||
# execution for the same four-tuple (run, agent, turn, stage).
|
||||
seat_run, created = await self._service.store.seat_runs.get_or_create_seat_run(
|
||||
maf_run_id=run_id, agent_id=agent_id, turn_index=turn_index,
|
||||
execution_stage=execution_stage, deerflow_thread_id=thread_id,
|
||||
)
|
||||
seat_run_id = seat_run["id"]
|
||||
prior_status = seat_run.get("status")
|
||||
|
||||
if not created:
|
||||
if prior_status == "completed":
|
||||
return self._seat_response_from_snapshot(
|
||||
seat_run=seat_run,
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
)
|
||||
if prior_status in ("pending", "running", "cancelling"):
|
||||
effective_timeout = wait_timeout_seconds if wait_timeout_seconds is not None else 600.0
|
||||
seat_run = await self._service.store.seat_runs.wait_for_terminal_seat_run(
|
||||
maf_run_id=run_id,
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
execution_stage=execution_stage,
|
||||
timeout_seconds=effective_timeout,
|
||||
)
|
||||
if seat_run["status"] == "completed":
|
||||
return self._seat_response_from_snapshot(
|
||||
seat_run=seat_run,
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
)
|
||||
return SeatResponse(
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
content="",
|
||||
error=f"stage '{execution_stage}' ended as {seat_run['status']}",
|
||||
metadata={"seat_run_id": seat_run["id"], "terminal": seat_run["status"]},
|
||||
latency_ms=int((time.perf_counter() - t0) * 1000),
|
||||
)
|
||||
return SeatResponse(
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
content="",
|
||||
error=f"stage '{execution_stage}' previously terminal ({prior_status}); "
|
||||
f"manual recovery required",
|
||||
metadata={"terminal": prior_status, "seat_run_id": seat_run_id},
|
||||
latency_ms=int((time.perf_counter() - t0) * 1000),
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._execute_run(
|
||||
principal=principal,
|
||||
seat_run_id=seat_run_id,
|
||||
run_id=run_id,
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
thread_id=thread_id,
|
||||
message=seat_message,
|
||||
model_name=model_name,
|
||||
reasoning_effort=reasoning_effort,
|
||||
thinking_enabled=thinking_enabled,
|
||||
subagent_enabled=subagent_enabled,
|
||||
excluded_tools=excluded_tools or [],
|
||||
memory_recall_disabled=memory_recall_disabled,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
capability_snapshot=capability_snapshot,
|
||||
write_policy=write_policy,
|
||||
approved_tool=approved_tool,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await self._service.store.seat_runs.mark_cancelled(seat_run_id)
|
||||
await self._emit_critical(run_id, make_run_cancelled(agent_id, turn_index))
|
||||
raise
|
||||
except Exception as exc:
|
||||
latency = int((time.perf_counter() - t0) * 1000)
|
||||
# Sanitize before persisting / emitting — the raw exception body may
|
||||
# embed params or credentials; only the redacted text is stored.
|
||||
safe_error = str(sanitize(str(exc)))[:500]
|
||||
await self._service.store.seat_runs.mark_failed(seat_run_id, error=safe_error)
|
||||
await self._emit_critical(run_id, make_run_failed(agent_id, turn_index, error_code=type(exc).__name__, message=safe_error))
|
||||
return SeatResponse(
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
content="",
|
||||
error=safe_error,
|
||||
latency_ms=latency,
|
||||
)
|
||||
|
||||
raw_snapshot = {
|
||||
"content": response.content,
|
||||
"tool_calls": response.tool_calls,
|
||||
"usage": response.usage,
|
||||
"metadata": response.metadata,
|
||||
}
|
||||
snapshot = sanitize(raw_snapshot)
|
||||
if not isinstance(snapshot, dict):
|
||||
raise RuntimeError("seat result snapshot sanitization produced invalid payload")
|
||||
await self._service.store.seat_runs.mark_completed(seat_run_id, result_snapshot=snapshot)
|
||||
response.latency_ms = int((time.perf_counter() - t0) * 1000)
|
||||
await self._emit_critical(run_id, make_turn_completed(
|
||||
agent_id, turn_index,
|
||||
content=response.content,
|
||||
usage=response.usage,
|
||||
metadata=response.metadata,
|
||||
))
|
||||
return response
|
||||
|
||||
async def _execute_run(
|
||||
self,
|
||||
*,
|
||||
principal: ExecutionPrincipal,
|
||||
seat_run_id: str,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
thread_id: str,
|
||||
message: str,
|
||||
model_name: str | None,
|
||||
reasoning_effort: str | None,
|
||||
thinking_enabled: bool,
|
||||
subagent_enabled: bool,
|
||||
excluded_tools: list[str],
|
||||
memory_recall_disabled: bool | None,
|
||||
memory_injection_enabled: bool | None,
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
write_policy: str = "confirm_high_risk",
|
||||
approved_tool: str | None = None,
|
||||
) -> SeatResponse:
|
||||
record = await self._client.start_agent_run(
|
||||
principal,
|
||||
thread_id=thread_id,
|
||||
agent_id=agent_id,
|
||||
message=message,
|
||||
model_name=model_name,
|
||||
reasoning_effort=reasoning_effort,
|
||||
thinking_enabled=thinking_enabled,
|
||||
subagent_enabled=subagent_enabled,
|
||||
excluded_tools=excluded_tools,
|
||||
memory_recall_disabled=memory_recall_disabled,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
turn_index=turn_index,
|
||||
capability_snapshot=capability_snapshot,
|
||||
write_policy=write_policy,
|
||||
approved_tool=approved_tool,
|
||||
)
|
||||
|
||||
deerflow_run_id = getattr(record, "run_id", None)
|
||||
if deerflow_run_id:
|
||||
await self._service.store.seat_runs.mark_running(seat_run_id, deerflow_run_id=deerflow_run_id)
|
||||
|
||||
await self._await_with_event_translation(
|
||||
record, deerflow_run_id, run_id=run_id, agent_id=agent_id, turn_index=turn_index
|
||||
)
|
||||
|
||||
messages = await self._client.read_thread_messages(principal, thread_id)
|
||||
content = self._latest_ai_text(messages)
|
||||
tool_calls = self._tool_calls_since_last_human(messages)
|
||||
usage = self._extract_usage(messages)
|
||||
|
||||
return SeatResponse(
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
usage=usage,
|
||||
metadata={"thread_id": thread_id, "deerflow_run_id": deerflow_run_id},
|
||||
)
|
||||
|
||||
# ── MAF-02-07: real-time event translation ──────────────────────────────
|
||||
|
||||
async def _await_with_event_translation(
|
||||
self,
|
||||
record: Any,
|
||||
deerflow_run_id: str | None,
|
||||
*,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
) -> None:
|
||||
"""Await the DeerFlow run while translating StreamBridge events to MAF events.
|
||||
|
||||
P1.5 fix: ``if task in done: await task`` so exceptions propagate.
|
||||
"""
|
||||
task = getattr(record, "task", None)
|
||||
if task is None:
|
||||
return
|
||||
|
||||
bridge = getattr(self._client.app.state, "stream_bridge", None)
|
||||
if bridge is None or deerflow_run_id is None:
|
||||
await task
|
||||
return
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
last_activity = loop.time()
|
||||
stuck = asyncio.Event()
|
||||
|
||||
async def _watch() -> None:
|
||||
nonlocal last_activity
|
||||
async for ev in bridge.subscribe(deerflow_run_id, heartbeat_interval=_IDLE_CHECK_INTERVAL_S):
|
||||
if ev is END_SENTINEL:
|
||||
return
|
||||
now = loop.time()
|
||||
if ev is HEARTBEAT_SENTINEL:
|
||||
if now - last_activity >= _IDLE_TIMEOUT_S:
|
||||
stuck.set()
|
||||
return
|
||||
else:
|
||||
last_activity = now
|
||||
maf_events = normalize_stream_event(
|
||||
ev.event, ev.data, agent_id=agent_id, turn_index=turn_index
|
||||
)
|
||||
for me in maf_events:
|
||||
await self._emit(run_id, me)
|
||||
|
||||
watch = asyncio.create_task(_watch())
|
||||
try:
|
||||
done, _ = await asyncio.wait({task, watch}, return_when=asyncio.FIRST_COMPLETED)
|
||||
if task in done:
|
||||
await task
|
||||
return
|
||||
if not stuck.is_set():
|
||||
await task
|
||||
return
|
||||
idle_elapsed = loop.time() - last_activity
|
||||
logger.error(
|
||||
"MAF seat '%s' stalled: no output for %.0fs on run %s",
|
||||
agent_id, idle_elapsed, deerflow_run_id,
|
||||
)
|
||||
await self._client.cancel_run(deerflow_run_id)
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=10.0)
|
||||
except (TimeoutError, asyncio.CancelledError, Exception):
|
||||
logger.warning("DeerFlow task did not finish after cancel for run %s", deerflow_run_id)
|
||||
raise RuntimeError(f"MAF seat '{agent_id}' stalled (no output for {_IDLE_TIMEOUT_S}s)")
|
||||
finally:
|
||||
if not watch.done():
|
||||
watch.cancel()
|
||||
try:
|
||||
await watch
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
# ── MAF-02-09: cancellation ─────────────────────────────────────────────
|
||||
|
||||
async def cancel(self, maf_run_id: str) -> dict[str, Any]:
|
||||
"""Cancel all active DeerFlow runs for a MAF run (two-phase cascade).
|
||||
|
||||
Phase 1: mark all active seat runs as ``cancelling``.
|
||||
Phase 2: actually cancel each DeerFlow run, then mark the seat run
|
||||
as ``cancelled`` (success) or ``cancel_failed`` (failure).
|
||||
|
||||
Returns a structured result so the caller (dispatcher → router) can
|
||||
decide whether to finalize the parent run as ``cancelled`` or leave it
|
||||
in a non-terminal ``cancelling`` / ``cancel_failed`` state. A failed
|
||||
cancel must NEVER masquerade as cancelled.
|
||||
|
||||
Result shape::
|
||||
|
||||
{
|
||||
"all_cancelled": bool,
|
||||
"cancelled_run_ids": [...], # seat_run_ids that succeeded
|
||||
"failed_run_ids": [...], # seat_run_ids where cancel failed
|
||||
"still_active_run_ids": [...], # seat_run_ids with no deerflow id
|
||||
}
|
||||
"""
|
||||
seat_infos = await self._service.store.seat_runs.cancel_all_active(maf_run_id)
|
||||
cancelled_run_ids: list[str] = []
|
||||
failed_run_ids: list[str] = []
|
||||
still_active_run_ids: list[str] = []
|
||||
for info in seat_infos:
|
||||
seat_run_id = info["seat_run_id"]
|
||||
deerflow_run_id = info.get("deerflow_run_id")
|
||||
if not deerflow_run_id:
|
||||
# No DeerFlow run to cancel — record as still-active/unknown.
|
||||
await self._service.store.seat_runs.mark_cancel_unknown(
|
||||
seat_run_id, error="no deerflow_run_id recorded"
|
||||
)
|
||||
still_active_run_ids.append(seat_run_id)
|
||||
continue
|
||||
if await self._client.cancel_run(deerflow_run_id):
|
||||
await self._service.store.seat_runs.mark_cancelled(seat_run_id)
|
||||
cancelled_run_ids.append(seat_run_id)
|
||||
else:
|
||||
await self._service.store.seat_runs.mark_cancel_failed(
|
||||
seat_run_id, error="DeerFlow cancel_run returned False"
|
||||
)
|
||||
failed_run_ids.append(seat_run_id)
|
||||
all_cancelled = not failed_run_ids and not still_active_run_ids and bool(seat_infos)
|
||||
# If there were no active seat runs at all, treat as fully cancelled
|
||||
# (nothing left to cancel).
|
||||
if not seat_infos:
|
||||
all_cancelled = True
|
||||
return {
|
||||
"all_cancelled": all_cancelled,
|
||||
"cancelled_run_ids": cancelled_run_ids,
|
||||
"failed_run_ids": failed_run_ids,
|
||||
"still_active_run_ids": still_active_run_ids,
|
||||
}
|
||||
|
||||
# ── P1.6: audit event persistence ───────────────────────────────────────
|
||||
|
||||
async def _emit_critical(self, run_id: str, event: dict[str, Any]) -> None:
|
||||
"""Emit a critical audit event. Failure raises AuditEventError.
|
||||
|
||||
The persisted audit event row's ``id`` is passed to the conversation
|
||||
bridge as ``source_audit_event_id`` so projection dedup is always
|
||||
keyed on the real audit row — not on an ephemeral input field that
|
||||
may be absent for critical events.
|
||||
"""
|
||||
persisted: dict[str, Any] | None = None
|
||||
try:
|
||||
persisted = await self._service.append_event(
|
||||
run_id=run_id,
|
||||
user_id=self._user_id,
|
||||
event_type=event["event_type"],
|
||||
agent_id=event.get("agent_id"),
|
||||
turn_index=event.get("turn_index"),
|
||||
payload=event.get("payload"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("CRITICAL audit event %s failed to persist for run %s", event.get("event_type"), run_id, exc_info=True)
|
||||
raise AuditEventError(
|
||||
f"Critical audit event '{event.get('event_type')}' failed to persist: {exc}"
|
||||
) from exc
|
||||
await self._bridge_conversation_event(
|
||||
run_id,
|
||||
event,
|
||||
source_audit_event_id=persisted["id"] if persisted else None,
|
||||
)
|
||||
|
||||
async def _emit(self, run_id: str, event: dict[str, Any]) -> None:
|
||||
"""Emit a non-critical event (agent.message.delta). Failure degrades gracefully.
|
||||
|
||||
Dedup: events with a source_event_id are deduped at the DB level via
|
||||
the (run_id, source_event_id) unique constraint — durable across restarts.
|
||||
The in-memory _seen_source_ids set is a fast-path cache only.
|
||||
"""
|
||||
source_id = event.get("source_event_id")
|
||||
if source_id:
|
||||
if source_id in self._seen_source_ids:
|
||||
return
|
||||
self._seen_source_ids.add(source_id)
|
||||
|
||||
event_type = event.get("event_type", "")
|
||||
if event_type in _CRITICAL_EVENT_TYPES:
|
||||
await self._emit_critical(run_id, event)
|
||||
return
|
||||
persisted: dict[str, Any] | None = None
|
||||
try:
|
||||
persisted = await self._service.append_event(
|
||||
run_id=run_id,
|
||||
user_id=self._user_id,
|
||||
event_type=event_type,
|
||||
agent_id=event.get("agent_id"),
|
||||
turn_index=event.get("turn_index"),
|
||||
payload=event.get("payload"),
|
||||
source_event_id=source_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("non-critical event %s failed for run %s (degraded)", event_type, run_id, exc_info=True)
|
||||
return
|
||||
await self._bridge_conversation_event(
|
||||
run_id,
|
||||
event,
|
||||
source_audit_event_id=persisted["id"] if persisted else None,
|
||||
)
|
||||
|
||||
async def _bridge_conversation_event(
|
||||
self,
|
||||
run_id: str,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
source_audit_event_id: str | None = None,
|
||||
) -> None:
|
||||
"""Best-effort projection into the V2 conversation event stream.
|
||||
|
||||
The per-run audit write above remains authoritative. A projection
|
||||
failure must not cause a tool to execute twice or turn a completed
|
||||
seat run into a false runtime failure.
|
||||
|
||||
``source_audit_event_id`` is the real persisted audit row id — it is
|
||||
always preferred over ``event["id"]`` / ``event["source_event_id"]``
|
||||
for dedup (P0-4 fix). When the bridge retries after a restart, the
|
||||
same audit id ensures the conversation event is written exactly once.
|
||||
|
||||
P0-3: the entire ``app.state`` resolution is best-effort. If any link
|
||||
is missing (no app, no state, no conversation service), the projection
|
||||
is silently skipped — audit, recovery, cancel, lease, and tool
|
||||
execution safety are never affected.
|
||||
"""
|
||||
try:
|
||||
app = getattr(self._client, "app", None)
|
||||
state = getattr(app, "state", None)
|
||||
conversation_service = getattr(state, "maf_conversation_service", None)
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
if conversation_service is None:
|
||||
return
|
||||
bridge = getattr(conversation_service, "bridge_workflow_event", None)
|
||||
if not callable(bridge):
|
||||
return
|
||||
try:
|
||||
await bridge(
|
||||
run_id=run_id,
|
||||
event=event,
|
||||
source_audit_event_id=source_audit_event_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - audit already persisted
|
||||
logger.warning(
|
||||
"conversation event projection failed for run %s (%s)",
|
||||
run_id,
|
||||
event.get("event_type"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# ── internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _seat_response_from_snapshot(
|
||||
self,
|
||||
*,
|
||||
seat_run: dict[str, Any],
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
) -> SeatResponse:
|
||||
snapshot = seat_run.get("result_snapshot")
|
||||
if not snapshot or not isinstance(snapshot, dict):
|
||||
return SeatResponse(
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
content="",
|
||||
error="completed seat run has no replayable snapshot; manual recovery required",
|
||||
metadata={"seat_run_id": seat_run.get("id"), "terminal": seat_run.get("status")},
|
||||
latency_ms=0,
|
||||
)
|
||||
return SeatResponse(
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
content=str(snapshot.get("content", "")),
|
||||
tool_calls=list(snapshot.get("tool_calls") or []),
|
||||
usage=snapshot.get("usage"),
|
||||
metadata=dict(snapshot.get("metadata") or {}),
|
||||
error=None,
|
||||
latency_ms=0,
|
||||
)
|
||||
|
||||
def _latest_ai_text(self, messages: list[dict[str, Any]]) -> str:
|
||||
for m in reversed(messages):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
if (m.get("type") or m.get("role")) not in {"ai", "assistant"}:
|
||||
continue
|
||||
text = self._message_text(m)
|
||||
if text.strip():
|
||||
return text
|
||||
return ""
|
||||
|
||||
def _message_text(self, message: dict[str, Any]) -> str:
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
def _tool_calls_since_last_human(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
last_human = -1
|
||||
for i, m in enumerate(messages):
|
||||
if isinstance(m, dict) and (m.get("type") or m.get("role")) in {"human", "user"}:
|
||||
last_human = i
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in messages[last_human + 1:]:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
if (m.get("type") or m.get("role")) not in {"ai", "assistant"}:
|
||||
continue
|
||||
for tc in m.get("tool_calls") or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
name = tc.get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
out.append({"name": name, "args": tc.get("args") or {}, "id": tc.get("id") or ""})
|
||||
return out
|
||||
|
||||
def _extract_usage(self, messages: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
for m in reversed(messages):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
usage = m.get("usage_metadata") or m.get("usage")
|
||||
if isinstance(usage, dict) and usage:
|
||||
return usage
|
||||
return None
|
||||
@@ -1,685 +0,0 @@
|
||||
"""Deterministic, server-owned contracts for MAF Stage 3 dispatch.
|
||||
|
||||
The manager may describe work, but it does not get to choose arbitrary agent
|
||||
ids, threads, tools, or capabilities. Routing is resolved from the trusted
|
||||
MAF deployment configuration and the small, validated task-context allowlist.
|
||||
This module is intentionally independent of the MAF SDK so the policy can be
|
||||
tested with a weak/fake model and before any workflow is launched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from deerflow.config.maf_roundtable_config import MafMode
|
||||
|
||||
MafIntentType = Literal[
|
||||
"direct_answer",
|
||||
"clarification",
|
||||
"single_seat",
|
||||
"parallel_consultation",
|
||||
"chain_consultation",
|
||||
"tool_write",
|
||||
]
|
||||
MafExecutionMode = Literal["manager_only", "single", "parallel", "chain", "group_chat"]
|
||||
MafDispatchSource = Literal["server", "manager", "deterministic_fallback"]
|
||||
|
||||
|
||||
class DispatchConfigurationError(RuntimeError):
|
||||
"""The deployment has not supplied a complete trusted route."""
|
||||
|
||||
|
||||
class InvalidDispatchPlanError(ValueError):
|
||||
"""A proposed or constructed plan violates the dispatch contract."""
|
||||
|
||||
|
||||
class MafDispatchCandidateSet(BaseModel):
|
||||
"""Trusted route supplied to the manager before it chooses an intent."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
manager_agent_id: str = Field(min_length=1, max_length=64)
|
||||
candidate_seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
|
||||
mode: MafMode = "structured"
|
||||
business_code: str | None = Field(default=None, max_length=255)
|
||||
max_rounds: int = Field(ge=1, le=64)
|
||||
|
||||
|
||||
class MafIntentDecision(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
intent_type: MafIntentType
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
reason_code: str = Field(min_length=1, max_length=64)
|
||||
clarification_prompt: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _clarification_shape(self) -> MafIntentDecision:
|
||||
if self.intent_type == "clarification" and not self.clarification_prompt:
|
||||
raise ValueError("clarification intent requires clarification_prompt")
|
||||
if self.intent_type != "clarification" and self.clarification_prompt is not None:
|
||||
raise ValueError("only clarification intent may include clarification_prompt")
|
||||
return self
|
||||
|
||||
|
||||
class MafDispatchAssignment(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
agent_id: str = Field(min_length=1, max_length=64)
|
||||
role: Literal["seat"] = "seat"
|
||||
sort_order: int = Field(ge=1, le=64)
|
||||
task_goal: str = Field(default="Complete the assigned analysis", min_length=1, max_length=2000)
|
||||
delivery_requirements: list[str] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class MafManagerPlanAssignment(BaseModel):
|
||||
"""The only per-seat shape accepted from the manager model."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
agent_id: str = Field(min_length=1, max_length=64)
|
||||
task_goal: str = Field(min_length=1, max_length=2000)
|
||||
delivery_requirements: list[str] = Field(min_length=1, max_length=16)
|
||||
order: int = Field(ge=1, le=64)
|
||||
|
||||
|
||||
class MafManagerPlanOutput(BaseModel):
|
||||
"""Strict model-facing dispatch schema.
|
||||
|
||||
This model deliberately contains no thread, tool, capability, or user
|
||||
supplied routing fields. The server validates the IDs again after this
|
||||
schema passes.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
intent_type: MafIntentType
|
||||
selected_seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
|
||||
assignments: list[MafManagerPlanAssignment] = Field(default_factory=list, max_length=64)
|
||||
execution_mode: MafExecutionMode
|
||||
convergence_path: list[str] = Field(min_length=1, max_length=16)
|
||||
reason: str = Field(min_length=1, max_length=1000)
|
||||
clarification_prompt: str | None = Field(default=None, min_length=1, max_length=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_manager_shape(self) -> MafManagerPlanOutput:
|
||||
selected = self.selected_seat_agent_ids
|
||||
if len(set(selected)) != len(selected):
|
||||
raise ValueError("manager plan contains duplicate seats")
|
||||
assignment_ids = [item.agent_id for item in self.assignments]
|
||||
if assignment_ids != selected:
|
||||
raise ValueError("manager assignments must exactly match selected seats")
|
||||
if [item.order for item in self.assignments] != list(range(1, len(selected) + 1)):
|
||||
raise ValueError("manager assignment order must be contiguous")
|
||||
|
||||
required_mode: MafExecutionMode
|
||||
if self.intent_type == "clarification":
|
||||
if selected or self.assignments or self.execution_mode != "manager_only":
|
||||
raise ValueError("clarification cannot select expert seats")
|
||||
if not self.clarification_prompt:
|
||||
raise ValueError("clarification requires clarification_prompt")
|
||||
return self
|
||||
if self.clarification_prompt is not None:
|
||||
raise ValueError("only clarification may include clarification_prompt")
|
||||
if self.intent_type == "direct_answer":
|
||||
required_mode = "manager_only"
|
||||
if selected:
|
||||
raise ValueError("direct_answer cannot select expert seats")
|
||||
elif self.intent_type in {"single_seat", "tool_write"}:
|
||||
required_mode = "single"
|
||||
if len(selected) != 1:
|
||||
raise ValueError("single-seat intents require exactly one seat")
|
||||
elif self.intent_type == "parallel_consultation":
|
||||
required_mode = "parallel"
|
||||
if not selected:
|
||||
raise ValueError("parallel consultation requires a seat")
|
||||
if len(selected) < 2:
|
||||
raise ValueError("parallel consultation requires at least two seats")
|
||||
elif self.intent_type == "chain_consultation":
|
||||
required_mode = "chain"
|
||||
if not selected:
|
||||
raise ValueError("chain consultation requires a seat")
|
||||
else:
|
||||
raise ValueError("clarification cannot be dispatched")
|
||||
allowed_modes = {required_mode}
|
||||
if self.execution_mode == "group_chat" and required_mode != "manager_only":
|
||||
allowed_modes.add("group_chat")
|
||||
if self.execution_mode not in allowed_modes:
|
||||
raise ValueError(
|
||||
f"execution_mode {self.execution_mode!r} does not match {required_mode!r}"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class MafDispatchPlan(BaseModel):
|
||||
"""Validated plan containing only server-selected participants."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
manager_agent_id: str = Field(min_length=1, max_length=64)
|
||||
seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
|
||||
assignments: list[MafDispatchAssignment] = Field(default_factory=list, max_length=64)
|
||||
mode: MafMode = "structured"
|
||||
intent_type: MafIntentType
|
||||
business_code: str | None = Field(default=None, max_length=255)
|
||||
max_rounds: int = Field(ge=1, le=64)
|
||||
candidate_seat_agent_ids: list[str] = Field(default_factory=list, max_length=64)
|
||||
execution_mode: MafExecutionMode = "parallel"
|
||||
convergence_path: list[str] = Field(default_factory=list, max_length=16)
|
||||
reason: str = Field(default="server-owned route", min_length=1, max_length=1000)
|
||||
source: MafDispatchSource = "server"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_shape(self) -> MafDispatchPlan:
|
||||
if self.manager_agent_id in self.seat_agent_ids:
|
||||
raise ValueError("manager agent cannot also be a seat")
|
||||
if len(set(self.seat_agent_ids)) != len(self.seat_agent_ids):
|
||||
raise ValueError("duplicate seat agent")
|
||||
if len(set(self.candidate_seat_agent_ids)) != len(self.candidate_seat_agent_ids):
|
||||
raise ValueError("duplicate candidate seat agent")
|
||||
if self.candidate_seat_agent_ids and any(
|
||||
agent_id not in self.candidate_seat_agent_ids for agent_id in self.seat_agent_ids
|
||||
):
|
||||
raise ValueError("selected seat is outside candidate seats")
|
||||
assignment_ids = [assignment.agent_id for assignment in self.assignments]
|
||||
if assignment_ids != self.seat_agent_ids:
|
||||
raise ValueError("assignments must exactly match seat_agent_ids")
|
||||
if [a.sort_order for a in self.assignments] != list(range(1, len(self.assignments) + 1)):
|
||||
raise ValueError("assignments must have contiguous sort_order")
|
||||
if self.intent_type == "clarification":
|
||||
raise ValueError("clarification cannot be dispatched")
|
||||
if self.intent_type == "direct_answer":
|
||||
if self.seat_agent_ids or self.execution_mode != "manager_only":
|
||||
raise ValueError("direct_answer must use manager_only with no seats")
|
||||
elif self.intent_type in {"single_seat", "tool_write"}:
|
||||
if len(self.seat_agent_ids) != 1 or self.execution_mode not in {"single", "group_chat"}:
|
||||
raise ValueError("single-seat intents require one seat and single mode")
|
||||
elif self.intent_type == "parallel_consultation":
|
||||
if not self.seat_agent_ids or self.execution_mode not in {"parallel", "group_chat"}:
|
||||
raise ValueError("parallel consultation requires at least one configured seat")
|
||||
if len(self.seat_agent_ids) < 2:
|
||||
raise ValueError("parallel consultation requires at least two seats")
|
||||
elif self.intent_type == "chain_consultation":
|
||||
if not self.seat_agent_ids or self.execution_mode not in {"chain", "group_chat"}:
|
||||
raise ValueError("chain consultation requires at least one configured seat")
|
||||
if self.execution_mode == "group_chat" and self.mode != "group_chat":
|
||||
raise ValueError("group_chat execution requires a group_chat route")
|
||||
if self.intent_type != "direct_answer" and not self.seat_agent_ids:
|
||||
raise ValueError("this intent requires at least one configured seat")
|
||||
return self
|
||||
|
||||
|
||||
def resolve_intent(content: str, task_context: dict[str, Any] | None = None) -> MafIntentDecision:
|
||||
"""Resolve the common Stage 3 intents deterministically.
|
||||
|
||||
A production model can later supply a *description* for the intent, but
|
||||
this policy remains the final validator. The fallback deliberately uses
|
||||
conservative keywords and never extracts agent/tool ids from user text.
|
||||
"""
|
||||
|
||||
text = " ".join(str(content or "").strip().split())
|
||||
lowered = text.casefold()
|
||||
if len(text) < 4 or lowered in {"?", "?", "不清楚", "不明确", "再说清楚"}:
|
||||
return MafIntentDecision(
|
||||
intent_type="clarification",
|
||||
confidence=0.99,
|
||||
reason_code="insufficient_task_detail",
|
||||
clarification_prompt="请补充希望解决的具体问题、目标和必要的业务背景。",
|
||||
)
|
||||
|
||||
write_markers = (
|
||||
"写入", "发布", "创建工单", "更新任务", "提交", "删除", "write", "publish", "create ticket",
|
||||
)
|
||||
chain_markers = ("业务链", "链式", "逐阶段", "先…再", "先...再", "chain")
|
||||
parallel_markers = ("多专家", "多角度", "并行", "同时分析", "比较", "parallel", "multi-agent")
|
||||
single_markers = ("请某个专家", "单个席位", "单专家", "single seat", "指定专家")
|
||||
|
||||
if any(marker in lowered for marker in write_markers):
|
||||
intent_type: MafIntentType = "tool_write"
|
||||
reason_code = "write_request"
|
||||
elif any(marker in lowered for marker in chain_markers):
|
||||
intent_type = "chain_consultation"
|
||||
reason_code = "chain_request"
|
||||
elif any(marker in lowered for marker in parallel_markers):
|
||||
intent_type = "parallel_consultation"
|
||||
reason_code = "parallel_request"
|
||||
elif any(marker in lowered for marker in single_markers):
|
||||
intent_type = "single_seat"
|
||||
reason_code = "single_seat_request"
|
||||
else:
|
||||
intent_type = "direct_answer"
|
||||
reason_code = "default_direct_answer"
|
||||
|
||||
return MafIntentDecision(
|
||||
intent_type=intent_type,
|
||||
confidence=0.72 if task_context else 0.68,
|
||||
reason_code=reason_code,
|
||||
)
|
||||
|
||||
|
||||
def _read(value: Any, name: str, default: Any = None) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return value.get(name, default)
|
||||
return getattr(value, name, default)
|
||||
|
||||
|
||||
class MafDispatchCatalog:
|
||||
"""Resolve routes from deployment config, never from a request body."""
|
||||
|
||||
def __init__(self, maf_config: Any) -> None:
|
||||
self._config = maf_config
|
||||
|
||||
def readiness(self) -> tuple[bool, str]:
|
||||
"""Return non-sensitive deployment readiness for the conversation UI.
|
||||
|
||||
A manager-only route is sufficient for a safe direct answer. Expert
|
||||
routes are checked separately when a request selects a business chain
|
||||
and when the trusted capability snapshot is built for the user.
|
||||
"""
|
||||
manager = str(_read(self._config, "default_manager_agent_id", "") or "").strip()
|
||||
routes = _read(self._config, "business_routes", {}) or {}
|
||||
if manager:
|
||||
return True, "default_manager_configured"
|
||||
for business_code, route in routes.items():
|
||||
route_manager = str(_read(route, "manager_agent_id", "") or "").strip()
|
||||
if route_manager:
|
||||
return True, f"business_route_configured:{business_code}"
|
||||
return False, "no_trusted_manager_route"
|
||||
|
||||
@property
|
||||
def conversation_dispatch_ready(self) -> bool:
|
||||
return self.readiness()[0]
|
||||
|
||||
def _route(self, business_code: str | None) -> tuple[str | None, list[str], MafMode | None]:
|
||||
routes = _read(self._config, "business_routes", {}) or {}
|
||||
if business_code:
|
||||
route = routes.get(business_code)
|
||||
if route is None:
|
||||
# A supplied business code must match an explicitly configured
|
||||
# route. Falling back here would silently cross business
|
||||
# boundaries.
|
||||
return None, [], None
|
||||
return (
|
||||
_read(route, "manager_agent_id"),
|
||||
list(_read(route, "seat_agent_ids", []) or []),
|
||||
_read(route, "mode"),
|
||||
)
|
||||
return (
|
||||
_read(self._config, "default_manager_agent_id"),
|
||||
list(_read(self._config, "default_seat_agent_ids", []) or []),
|
||||
None,
|
||||
)
|
||||
|
||||
def build_candidate_set(
|
||||
self, *, task_context: dict[str, Any] | None
|
||||
) -> MafDispatchCandidateSet:
|
||||
"""Build only the trusted route; intent remains a manager decision."""
|
||||
business_code = str((task_context or {}).get("business_code") or "").strip() or None
|
||||
manager_id, route_seats, route_mode = self._route(business_code)
|
||||
if not manager_id:
|
||||
raise DispatchConfigurationError(
|
||||
"no trusted MAF manager route is configured for this conversation"
|
||||
)
|
||||
|
||||
normalized_manager = str(manager_id).strip()
|
||||
candidates = [str(agent_id).strip() for agent_id in route_seats]
|
||||
max_seats = int(_read(self._config, "max_seats_per_run", 16) or 16)
|
||||
# A route may intentionally be manager-only, but it must always have
|
||||
# a concrete manager. Candidate seats are optional until the manager
|
||||
# chooses an expert intent.
|
||||
if not normalized_manager:
|
||||
raise DispatchConfigurationError("trusted manager route is blank")
|
||||
if len(candidates) > max_seats:
|
||||
raise DispatchConfigurationError("configured MAF route exceeds max_seats_per_run")
|
||||
if normalized_manager in candidates or len(set(candidates)) != len(candidates):
|
||||
raise DispatchConfigurationError("configured MAF route contains duplicate agents")
|
||||
try:
|
||||
return MafDispatchCandidateSet(
|
||||
manager_agent_id=normalized_manager,
|
||||
candidate_seat_agent_ids=candidates,
|
||||
mode=route_mode or _read(self._config, "default_mode", "structured"),
|
||||
business_code=business_code,
|
||||
max_rounds=int(_read(self._config, "default_max_rounds", 8) or 8),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise DispatchConfigurationError("invalid trusted MAF dispatch route") from exc
|
||||
|
||||
def build_plan(
|
||||
self,
|
||||
*,
|
||||
intent: MafIntentDecision,
|
||||
task_context: dict[str, Any] | None,
|
||||
) -> MafDispatchPlan:
|
||||
business_code = str((task_context or {}).get("business_code") or "").strip() or None
|
||||
manager_id, route_seats, route_mode = self._route(business_code)
|
||||
if not manager_id:
|
||||
raise DispatchConfigurationError(
|
||||
"no trusted MAF manager route is configured for this conversation"
|
||||
)
|
||||
|
||||
# The server owns the route. Intent only changes how many configured
|
||||
# seats participate; it never adds an id.
|
||||
candidate_seats = route_seats[:]
|
||||
if intent.intent_type == "direct_answer":
|
||||
seat_ids = []
|
||||
execution_mode: MafExecutionMode = "manager_only"
|
||||
elif intent.intent_type in {"single_seat", "tool_write"}:
|
||||
seat_ids = route_seats[:1]
|
||||
execution_mode = "single"
|
||||
elif intent.intent_type == "chain_consultation":
|
||||
seat_ids = route_seats[:]
|
||||
execution_mode = "chain"
|
||||
else:
|
||||
seat_ids = route_seats[:]
|
||||
execution_mode = "parallel"
|
||||
|
||||
max_seats = int(_read(self._config, "max_seats_per_run", 16) or 16)
|
||||
if len(seat_ids) > max_seats:
|
||||
raise DispatchConfigurationError("configured MAF route exceeds max_seats_per_run")
|
||||
if intent.intent_type != "direct_answer" and not seat_ids:
|
||||
raise DispatchConfigurationError("selected MAF intent requires a configured seat route")
|
||||
if intent.intent_type == "parallel_consultation" and len(seat_ids) < 2:
|
||||
raise DispatchConfigurationError(
|
||||
"parallel consultation requires at least two configured seats"
|
||||
)
|
||||
|
||||
try:
|
||||
plan = MafDispatchPlan(
|
||||
manager_agent_id=str(manager_id).strip(),
|
||||
seat_agent_ids=[str(agent_id).strip() for agent_id in seat_ids],
|
||||
assignments=[
|
||||
MafDispatchAssignment(
|
||||
agent_id=str(agent_id).strip(),
|
||||
sort_order=index,
|
||||
task_goal=f"Complete the {intent.intent_type} task",
|
||||
delivery_requirements=["structured result", "evidence or limitation notes"],
|
||||
)
|
||||
for index, agent_id in enumerate(seat_ids, start=1)
|
||||
],
|
||||
mode=route_mode or _read(self._config, "default_mode", "structured"),
|
||||
intent_type=intent.intent_type,
|
||||
business_code=business_code,
|
||||
max_rounds=int(_read(self._config, "default_max_rounds", 8) or 8),
|
||||
candidate_seat_agent_ids=[str(agent_id).strip() for agent_id in candidate_seats],
|
||||
execution_mode=execution_mode,
|
||||
convergence_path=[
|
||||
"manager answer" if intent.intent_type == "direct_answer" else "manager synthesis"
|
||||
],
|
||||
reason="server-owned candidate route",
|
||||
source="server",
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise DispatchConfigurationError("invalid trusted MAF dispatch route") from exc
|
||||
|
||||
trusted_ids = {str(manager_id), *(str(agent_id) for agent_id in route_seats)}
|
||||
validate_dispatch_plan(plan, trusted_agent_ids=trusted_ids, max_seats=max_seats)
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def parse_manager_decision(
|
||||
raw: str,
|
||||
*,
|
||||
candidate_set: MafDispatchCandidateSet,
|
||||
max_seats: int,
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
) -> tuple[MafManagerPlanOutput, MafDispatchPlan | None]:
|
||||
"""Parse the manager's complete intent + dispatch decision.
|
||||
|
||||
The manager may choose only from the server-provided candidate set.
|
||||
A clarification is a valid terminal decision with no dispatch plan;
|
||||
every malformed or unauthorized response raises for safe fallback.
|
||||
"""
|
||||
try:
|
||||
payload = _extract_json_object(raw)
|
||||
proposed = MafManagerPlanOutput.model_validate(payload)
|
||||
except Exception as exc: # noqa: BLE001 - caller uses clarification fallback
|
||||
raise InvalidDispatchPlanError("manager plan schema invalid") from exc
|
||||
|
||||
candidates = list(candidate_set.candidate_seat_agent_ids)
|
||||
selected = list(proposed.selected_seat_agent_ids)
|
||||
if any(agent_id not in candidates for agent_id in selected):
|
||||
raise InvalidDispatchPlanError("manager selected a seat outside the candidate set")
|
||||
if len(selected) > max_seats:
|
||||
raise InvalidDispatchPlanError("manager plan exceeds the seat limit")
|
||||
if candidate_set.manager_agent_id in selected:
|
||||
raise InvalidDispatchPlanError("manager cannot select itself as a seat")
|
||||
if proposed.execution_mode == "group_chat" and candidate_set.mode != "group_chat":
|
||||
raise InvalidDispatchPlanError("group_chat is not enabled for this route")
|
||||
if proposed.intent_type == "chain_consultation":
|
||||
candidate_order = {agent_id: index for index, agent_id in enumerate(candidates)}
|
||||
orders = [candidate_order[agent_id] for agent_id in selected]
|
||||
if orders != sorted(orders):
|
||||
raise InvalidDispatchPlanError(
|
||||
"chain seats must preserve the configured business order"
|
||||
)
|
||||
if proposed.intent_type == "tool_write" and capability_snapshot is not None:
|
||||
agents = capability_snapshot.get("agents")
|
||||
by_id = {
|
||||
str(agent.get("agent_id")): agent
|
||||
for agent in agents or []
|
||||
if isinstance(agent, dict)
|
||||
}
|
||||
if not any(
|
||||
any(
|
||||
isinstance(tool, dict) and tool.get("side_effect") == "write"
|
||||
for tool in (by_id.get(agent_id, {}).get("tools") or [])
|
||||
)
|
||||
for agent_id in selected
|
||||
):
|
||||
raise InvalidDispatchPlanError("tool_write has no trusted write-capable seat")
|
||||
|
||||
if proposed.intent_type == "clarification":
|
||||
return proposed, None
|
||||
|
||||
assignments = [
|
||||
MafDispatchAssignment(
|
||||
agent_id=item.agent_id,
|
||||
sort_order=item.order,
|
||||
task_goal=item.task_goal,
|
||||
delivery_requirements=list(item.delivery_requirements),
|
||||
)
|
||||
for item in proposed.assignments
|
||||
]
|
||||
try:
|
||||
plan = MafDispatchPlan(
|
||||
manager_agent_id=candidate_set.manager_agent_id,
|
||||
seat_agent_ids=selected,
|
||||
assignments=assignments,
|
||||
mode=candidate_set.mode,
|
||||
intent_type=proposed.intent_type,
|
||||
business_code=candidate_set.business_code,
|
||||
max_rounds=candidate_set.max_rounds,
|
||||
candidate_seat_agent_ids=candidates,
|
||||
execution_mode=proposed.execution_mode,
|
||||
convergence_path=list(proposed.convergence_path),
|
||||
reason=proposed.reason,
|
||||
source="manager",
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise InvalidDispatchPlanError("manager plan failed final validation") from exc
|
||||
return proposed, plan
|
||||
|
||||
@staticmethod
|
||||
def parse_manager_plan(
|
||||
raw: str,
|
||||
*,
|
||||
expected_intent: MafIntentType,
|
||||
server_plan: MafDispatchPlan,
|
||||
max_seats: int,
|
||||
) -> MafDispatchPlan:
|
||||
"""Compatibility wrapper for callers with a pre-resolved intent."""
|
||||
candidate_set = MafDispatchCandidateSet(
|
||||
manager_agent_id=server_plan.manager_agent_id,
|
||||
candidate_seat_agent_ids=list(server_plan.candidate_seat_agent_ids),
|
||||
mode=server_plan.mode,
|
||||
business_code=server_plan.business_code,
|
||||
max_rounds=server_plan.max_rounds,
|
||||
)
|
||||
proposed, plan = MafDispatchCatalog.parse_manager_decision(
|
||||
raw,
|
||||
candidate_set=candidate_set,
|
||||
max_seats=max_seats,
|
||||
)
|
||||
if proposed.intent_type != expected_intent:
|
||||
raise InvalidDispatchPlanError("manager changed the expected intent")
|
||||
if plan is None:
|
||||
raise InvalidDispatchPlanError("clarification is not a dispatch plan")
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def deterministic_fallback_plan(server_plan: MafDispatchPlan) -> MafDispatchPlan:
|
||||
"""Use the server route without ever expanding model permissions."""
|
||||
return server_plan.model_copy(
|
||||
update={
|
||||
"seat_agent_ids": list(server_plan.seat_agent_ids),
|
||||
"assignments": [item.model_copy() for item in server_plan.assignments],
|
||||
"source": "deterministic_fallback",
|
||||
"reason": "manager_plan_invalid_safe_route",
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def participants(
|
||||
plan: MafDispatchPlan, *, include_candidates: bool = True
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return persistence records; thread ids are intentionally absent."""
|
||||
candidate_seats = (
|
||||
list(plan.candidate_seat_agent_ids)
|
||||
if include_candidates and plan.intent_type != "direct_answer"
|
||||
else []
|
||||
)
|
||||
return [
|
||||
{
|
||||
"agent_id": plan.manager_agent_id,
|
||||
"role": "manager",
|
||||
"sort_order": 0,
|
||||
"display_name": "",
|
||||
},
|
||||
*[
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"role": "seat",
|
||||
"sort_order": index,
|
||||
"display_name": "",
|
||||
}
|
||||
for index, agent_id in enumerate(candidate_seats, start=1)
|
||||
],
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def candidate_participants(
|
||||
candidate_set: MafDispatchCandidateSet,
|
||||
*,
|
||||
include_seats: bool = True,
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build ephemeral snapshot inputs or persisted manager-only records."""
|
||||
snapshots = {
|
||||
str(agent.get("agent_id")): dict(agent)
|
||||
for agent in (capability_snapshot or {}).get("agents", [])
|
||||
if isinstance(agent, dict)
|
||||
}
|
||||
rows = [{
|
||||
"agent_id": candidate_set.manager_agent_id,
|
||||
"role": "manager",
|
||||
"sort_order": 0,
|
||||
"display_name": "",
|
||||
"capability_snapshot": snapshots.get(candidate_set.manager_agent_id, {}),
|
||||
}]
|
||||
if include_seats:
|
||||
rows.extend({
|
||||
"agent_id": agent_id,
|
||||
"role": "seat",
|
||||
"sort_order": index,
|
||||
"display_name": "",
|
||||
"capability_snapshot": snapshots.get(agent_id, {}),
|
||||
} for index, agent_id in enumerate(candidate_set.candidate_seat_agent_ids, start=1))
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def manager_capability_summary(
|
||||
snapshot: dict[str, Any] | None,
|
||||
candidate_set: MafDispatchCandidateSet,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return only safe, non-secret capability facts for the manager prompt."""
|
||||
by_id = {
|
||||
str(agent.get("agent_id")): agent
|
||||
for agent in (snapshot or {}).get("agents", [])
|
||||
if isinstance(agent, dict)
|
||||
}
|
||||
result: list[dict[str, Any]] = []
|
||||
for agent_id in candidate_set.candidate_seat_agent_ids:
|
||||
agent = by_id.get(agent_id, {})
|
||||
tools = [
|
||||
{
|
||||
"tool_name": str(tool.get("tool_name")),
|
||||
"side_effect": tool.get("side_effect", "write"),
|
||||
"risk_level": tool.get("risk_level", "R3"),
|
||||
}
|
||||
for tool in (agent.get("tools") or [])
|
||||
if isinstance(tool, dict) and tool.get("tool_name")
|
||||
]
|
||||
result.append({
|
||||
"agent_id": agent_id,
|
||||
"model_name": agent.get("model_name"),
|
||||
"tool_names": [tool["tool_name"] for tool in tools],
|
||||
"tool_summary": tools,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def validate_dispatch_plan(
|
||||
plan: MafDispatchPlan,
|
||||
*,
|
||||
trusted_agent_ids: set[str],
|
||||
max_seats: int,
|
||||
) -> None:
|
||||
"""Final allowlist check used before persistence and workflow launch."""
|
||||
|
||||
if plan.manager_agent_id not in trusted_agent_ids:
|
||||
raise InvalidDispatchPlanError("manager agent is outside the trusted route")
|
||||
if len(plan.seat_agent_ids) > max_seats:
|
||||
raise InvalidDispatchPlanError("dispatch plan exceeds the seat limit")
|
||||
if any(agent_id not in trusted_agent_ids for agent_id in plan.seat_agent_ids):
|
||||
raise InvalidDispatchPlanError("seat agent is outside the trusted route")
|
||||
if len(set(plan.seat_agent_ids)) != len(plan.seat_agent_ids):
|
||||
raise InvalidDispatchPlanError("dispatch plan contains duplicate seats")
|
||||
if plan.manager_agent_id in plan.seat_agent_ids:
|
||||
raise InvalidDispatchPlanError("manager cannot be dispatched as a seat")
|
||||
|
||||
|
||||
def _extract_json_object(raw: str) -> dict[str, Any]:
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("empty manager plan")
|
||||
try:
|
||||
value = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError("manager plan is not JSON")
|
||||
value = json.loads(text[start:end + 1])
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("manager plan must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DispatchConfigurationError",
|
||||
"InvalidDispatchPlanError",
|
||||
"MafDispatchAssignment",
|
||||
"MafDispatchCandidateSet",
|
||||
"MafExecutionMode",
|
||||
"MafManagerPlanAssignment",
|
||||
"MafManagerPlanOutput",
|
||||
"MafDispatchPlan",
|
||||
"MafDispatchCatalog",
|
||||
"MafIntentDecision",
|
||||
"MafIntentType",
|
||||
"resolve_intent",
|
||||
"validate_dispatch_plan",
|
||||
]
|
||||
@@ -1,218 +0,0 @@
|
||||
"""MAF run dispatcher — launches WorkflowRunner after create_run succeeds.
|
||||
|
||||
The dispatcher is the bridge between the HTTP layer (router) and the workflow
|
||||
execution layer (WorkflowRunner). It:
|
||||
|
||||
1. Checks MAF SDK availability BEFORE creating the run (so the user gets a
|
||||
clean 503 rather than a run that will fail).
|
||||
2. Launches ``WorkflowRunner.execute`` as a background asyncio task.
|
||||
3. Tracks task handles for cancellation.
|
||||
4. **Resume**: after an approval decision, the router calls
|
||||
``dispatcher.resume(run_id, user_id)`` to restore the workflow from its
|
||||
checkpoint.
|
||||
5. **Cancel cascade**: ``dispatcher.cancel(run_id)`` cancels both the
|
||||
background task AND the DeerFlow sub-runs. If the task handle is gone
|
||||
(process restart), it rebuilds a runner from persisted ``seat_runs`` and
|
||||
cascades cancel to DeerFlow.
|
||||
6. Never silently degrades to sequential DeerFlow execution — if the SDK is
|
||||
missing or the workflow fails, the run is marked ``failed``.
|
||||
|
||||
Usage (from the router)::
|
||||
|
||||
from app.maf_roundtable.dispatcher import get_dispatcher
|
||||
dispatcher = get_dispatcher(request.app)
|
||||
await dispatcher.dispatch(session, run, user_id=user_id)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MafSdkNotAvailableError(RuntimeError):
|
||||
"""Raised when the MAF SDK is not installed and a run is requested."""
|
||||
|
||||
|
||||
class MafRunDispatcher:
|
||||
"""Manages background MAF workflow execution tasks."""
|
||||
|
||||
def __init__(self, app: Any) -> None:
|
||||
self._app = app
|
||||
self._tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
def check_sdk_available(self) -> None:
|
||||
"""Raise MafSdkNotAvailableError if the MAF SDK is not installed."""
|
||||
try:
|
||||
import agent_framework # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise MafSdkNotAvailableError(
|
||||
"agent_framework SDK is not installed; run 'uv sync --extra maf' to enable MAF workflows"
|
||||
) from exc
|
||||
|
||||
async def dispatch(self, session: dict[str, Any], run: dict[str, Any], *, user_id: str) -> None:
|
||||
"""Launch the workflow for a newly created run as a background task.
|
||||
|
||||
The task is tracked so it can be cancelled via ``cancel(run_id)``.
|
||||
"""
|
||||
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||
|
||||
run_id = run["id"]
|
||||
service = getattr(self._app.state, "maf_roundtable_service", None)
|
||||
if service is None:
|
||||
raise MafSdkNotAvailableError("MAF roundtable service not available")
|
||||
|
||||
runner = WorkflowRunner(self._app, service=service, user_id=user_id)
|
||||
|
||||
task = asyncio.create_task(self._execute(runner, session, run, run_id))
|
||||
self._tasks[run_id] = task
|
||||
task.add_done_callback(lambda t, rid=run_id: self._tasks.pop(rid, None))
|
||||
|
||||
async def wait_for_run(self, run_id: str) -> None:
|
||||
"""Wait for an in-process run without exposing its task to callers.
|
||||
|
||||
Conversation V2 uses this only for a server-side status bridge. The
|
||||
persisted run remains authoritative, so a missing task (for example
|
||||
after a worker restart) simply returns and lets recovery own the run.
|
||||
"""
|
||||
task = self._tasks.get(run_id)
|
||||
if task is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - runner already persisted failure
|
||||
return
|
||||
|
||||
async def _execute(self, runner: Any, session: dict[str, Any], run: dict[str, Any], run_id: str) -> None:
|
||||
"""Execute the workflow, catching all exceptions (they're already persisted by the runner)."""
|
||||
try:
|
||||
await runner.execute(session, run)
|
||||
except Exception:
|
||||
# The runner already persists the failure. Log for observability.
|
||||
logger.exception("MAF workflow execution failed for run %s", run_id)
|
||||
|
||||
async def resume(self, run_id: str, *, user_id: str) -> dict[str, Any] | None:
|
||||
"""Resume a workflow from its checkpoint after an approval decision.
|
||||
|
||||
Called by the router after ``decide_approval`` succeeds. Loads the
|
||||
runner (rebuilding if the process restarted), then calls
|
||||
``runner.resume(run_id)`` which restores ``RoundtableState.from_dict()``
|
||||
and executes the approved action (at-most-once).
|
||||
"""
|
||||
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||
|
||||
service = getattr(self._app.state, "maf_roundtable_service", None)
|
||||
if service is None:
|
||||
raise MafSdkNotAvailableError("MAF roundtable service not available")
|
||||
|
||||
runner = WorkflowRunner(self._app, service=service, user_id=user_id)
|
||||
return await runner.resume(run_id)
|
||||
|
||||
async def recover(self, run_id: str, *, user_id: str) -> dict[str, Any] | None:
|
||||
"""Re-enter the workflow from checkpoint after a process restart.
|
||||
|
||||
Unlike ``resume`` (approval path), this continues orchestration for
|
||||
``running`` runs and skips ``completed_nodes``.
|
||||
"""
|
||||
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||
|
||||
service = getattr(self._app.state, "maf_roundtable_service", None)
|
||||
if service is None:
|
||||
raise MafSdkNotAvailableError("MAF roundtable service not available")
|
||||
|
||||
# Avoid overlapping with an in-process execute task.
|
||||
existing = self._tasks.get(run_id)
|
||||
if existing is not None and not existing.done():
|
||||
logger.info("recover(%s): background task still running — skip", run_id)
|
||||
return await service.store.runs.get_run(run_id)
|
||||
|
||||
runner = WorkflowRunner(self._app, service=service, user_id=user_id)
|
||||
task = asyncio.create_task(self._recover(runner, run_id))
|
||||
self._tasks[run_id] = task
|
||||
task.add_done_callback(lambda t, rid=run_id: self._tasks.pop(rid, None))
|
||||
return await service.store.runs.get_run(run_id)
|
||||
|
||||
async def _recover(self, runner: Any, run_id: str) -> None:
|
||||
try:
|
||||
await runner.recover(run_id)
|
||||
except Exception:
|
||||
logger.exception("MAF recover failed for run %s", run_id)
|
||||
|
||||
async def cancel(self, run_id: str) -> dict[str, Any]:
|
||||
"""Cancel a running workflow AND cascade to DeerFlow sub-runs.
|
||||
|
||||
Returns a structured cancel result so the router can decide whether to
|
||||
finalize the parent run as ``cancelled`` (all sub-runs confirmed
|
||||
cancelled / already terminal) or leave it non-terminal (some sub-runs
|
||||
failed to cancel — must not masquerade as cancelled).
|
||||
|
||||
Result shape::
|
||||
|
||||
{
|
||||
"all_cancelled": bool,
|
||||
"cancelled_run_ids": [...],
|
||||
"failed_run_ids": [...],
|
||||
"still_active_run_ids": [...],
|
||||
}
|
||||
"""
|
||||
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||
|
||||
_EMPTY = {"all_cancelled": False, "cancelled_run_ids": [], "failed_run_ids": [], "still_active_run_ids": []}
|
||||
|
||||
# P1c cancel order: the parent run is already in ``cancelling`` (the
|
||||
# router sets it before calling here). We then (1) cascade-cancel the
|
||||
# DeerFlow seat runs first and confirm/record the outcome, and only
|
||||
# (2) cancel the local MAF asyncio task LAST. The router finalizes the
|
||||
# parent run as ``cancelled`` only when every sub-run confirmed
|
||||
# cancelled — a failed cascade must never masquerade as cancelled.
|
||||
service = getattr(self._app.state, "maf_roundtable_service", None)
|
||||
if service is None:
|
||||
return _EMPTY
|
||||
|
||||
run = await service.store.runs.get_run(run_id)
|
||||
if run is None:
|
||||
return _EMPTY
|
||||
|
||||
session = await service.store.sessions.get_session(run["session_id"], user_id=None)
|
||||
if session is None:
|
||||
return _EMPTY
|
||||
|
||||
user_id = session.get("user_id", "default")
|
||||
runner = WorkflowRunner(self._app, service=service, user_id=user_id)
|
||||
|
||||
# Step 1: cascade cancel to DeerFlow sub-runs (works even after a
|
||||
# process restart — the runner reads persisted seat_runs).
|
||||
try:
|
||||
result = await runner.cancel(run_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("MAF cancel cascade failed for run %s", run_id)
|
||||
result = dict(_EMPTY)
|
||||
|
||||
# Step 2: cancel the local MAF asyncio task LAST (after the external
|
||||
# sub-runs have been addressed), so we never drop the orchestrator
|
||||
# before its sub-runs are cancelled/recorded.
|
||||
task = self._tasks.get(run_id)
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
_dispatchers: dict[int, MafRunDispatcher] = {}
|
||||
|
||||
|
||||
def get_dispatcher(app: Any) -> MafRunDispatcher:
|
||||
"""Get or create the dispatcher for an app instance (keyed by id)."""
|
||||
app_id = id(app)
|
||||
if app_id not in _dispatchers:
|
||||
_dispatchers[app_id] = MafRunDispatcher(app)
|
||||
return _dispatchers[app_id]
|
||||
@@ -1,296 +0,0 @@
|
||||
"""Translate DeerFlow runtime SSE events into MAF roundtable event types.
|
||||
|
||||
Maps raw StreamBridge chunks (LangGraph Platform-aligned: ``metadata``,
|
||||
``values``, ``updates``, ``messages``, ``error``, ``end``) into the durable
|
||||
MAF event taxonomy persisted to ``maf_roundtable_events``.
|
||||
|
||||
Design constraints:
|
||||
- Pure functions only (no I/O, no state).
|
||||
- Never fabricates events: only emits what the runtime actually produced.
|
||||
- Skill events come ONLY from structured tool/skill metadata, never from
|
||||
guessing based on AI text content.
|
||||
- All payloads are sanitized (credentials/tokens/cookies stripped).
|
||||
- Supports source_event_id for dedup (messages + updates may overlap).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.maf_roundtable.sanitize import sanitize_payload
|
||||
|
||||
|
||||
def normalize_stream_event(
|
||||
sse_event: str,
|
||||
data: Any,
|
||||
*,
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert a single DeerFlow SSE frame into zero or more MAF event dicts.
|
||||
|
||||
Each returned dict has:
|
||||
``{event_type, agent_id, turn_index, payload, source_event_id?}``.
|
||||
"""
|
||||
if sse_event == "metadata":
|
||||
return [_make("run.started", agent_id, turn_index, {"run_id": _get(data, "run_id"), "thread_id": _get(data, "thread_id")})]
|
||||
|
||||
if sse_event == "messages":
|
||||
return _normalize_messages(data, agent_id, turn_index)
|
||||
|
||||
if sse_event == "updates":
|
||||
return _normalize_updates(data, agent_id, turn_index)
|
||||
|
||||
if sse_event == "error":
|
||||
return [_make("run.failed", agent_id, turn_index, {"message": _get(data, "message", ""), "name": _get(data, "name", "")})]
|
||||
|
||||
# Structured skill lifecycle events (emitted by DeerFlow skill middleware,
|
||||
# never guessed from AI text content).
|
||||
if sse_event in ("invoke_skill", "skill_search_step"):
|
||||
skill_name = _get(data, "skill_name", "") or ""
|
||||
if sse_event == "invoke_skill":
|
||||
return [_make("skill.started", agent_id, turn_index, {"skill_name": skill_name, "message": _get(data, "message", "")})]
|
||||
return [_make("skill.selected", agent_id, turn_index, {"skill_name": skill_name, "message": _get(data, "message", "")})]
|
||||
|
||||
if sse_event == "skill_result":
|
||||
return [_make("skill.completed", agent_id, turn_index, {"skill_name": _get(data, "skill_name", ""), "message": _get(data, "message", "")})]
|
||||
|
||||
if sse_event == "skill_error":
|
||||
return [_make("skill.failed", agent_id, turn_index, {"skill_name": _get(data, "skill_name", ""), "error": _get(data, "reason", "") or _get(data, "message", "")})]
|
||||
|
||||
if sse_event == "artifact_created":
|
||||
return [_make("artifact.created", agent_id, turn_index, {"artifact_type": _get(data, "artifact_type", ""), "artifact_id": _get(data, "artifact_id", ""), "title": _get(data, "title", "")})]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def make_turn_started(agent_id: str, turn_index: int, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return _make("agent.turn_started", agent_id, turn_index, payload or {})
|
||||
|
||||
|
||||
def make_turn_completed(
|
||||
agent_id: str,
|
||||
turn_index: int,
|
||||
*,
|
||||
content: str,
|
||||
usage: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"content": content}
|
||||
if usage:
|
||||
payload["usage"] = usage
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
return _make("agent.message.completed", agent_id, turn_index, payload)
|
||||
|
||||
|
||||
def make_run_completed(agent_id: str | None, turn_index: int, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return _make("run.completed", agent_id or "", turn_index, payload or {})
|
||||
|
||||
|
||||
def make_run_failed(agent_id: str | None, turn_index: int, *, error_code: str, message: str) -> dict[str, Any]:
|
||||
return _make("run.failed", agent_id or "", turn_index, {"error_code": error_code, "message": message})
|
||||
|
||||
|
||||
def make_run_cancelled(agent_id: str | None, turn_index: int) -> dict[str, Any]:
|
||||
return _make("run.cancelled", agent_id or "", turn_index, {})
|
||||
|
||||
|
||||
# ── skill lifecycle events ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_skill_selected(agent_id: str, turn_index: int, *, skill_name: str, skill_id: str = "") -> dict[str, Any]:
|
||||
return _make("skill.selected", agent_id, turn_index, {"skill_name": skill_name, "skill_id": skill_id})
|
||||
|
||||
|
||||
def make_skill_started(agent_id: str, turn_index: int, *, skill_name: str, skill_id: str = "") -> dict[str, Any]:
|
||||
return _make("skill.started", agent_id, turn_index, {"skill_name": skill_name, "skill_id": skill_id})
|
||||
|
||||
|
||||
def make_skill_completed(agent_id: str, turn_index: int, *, skill_name: str, result_preview: str = "") -> dict[str, Any]:
|
||||
return _make("skill.completed", agent_id, turn_index, {"skill_name": skill_name, "result_preview": _truncate(result_preview, 500)})
|
||||
|
||||
|
||||
def make_skill_failed(agent_id: str, turn_index: int, *, skill_name: str, error: str = "") -> dict[str, Any]:
|
||||
return _make("skill.failed", agent_id, turn_index, {"skill_name": skill_name, "error": _truncate(error, 500)})
|
||||
|
||||
|
||||
# ── artifact events ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_artifact_created(agent_id: str, turn_index: int, *, artifact_type: str, artifact_id: str = "", title: str = "") -> dict[str, Any]:
|
||||
return _make("artifact.created", agent_id, turn_index, {"artifact_type": artifact_type, "artifact_id": artifact_id, "title": title})
|
||||
|
||||
|
||||
# ── approval lifecycle events ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_action_waiting_approval(agent_id: str, turn_index: int, *, action_id: str, approval_id: str, tool_name: str, summary: str = "") -> dict[str, Any]:
|
||||
return _make("action.waiting_approval", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "tool_name": tool_name, "summary": _truncate(summary, 500)})
|
||||
|
||||
|
||||
def make_action_approved(agent_id: str, turn_index: int, *, action_id: str, approval_id: str, decided_by: str = "") -> dict[str, Any]:
|
||||
return _make("action.approved", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "decided_by": decided_by})
|
||||
|
||||
|
||||
def make_action_rejected(agent_id: str, turn_index: int, *, action_id: str, approval_id: str, decided_by: str = "") -> dict[str, Any]:
|
||||
return _make("action.rejected", agent_id, turn_index, {"action_id": action_id, "approval_id": approval_id, "decided_by": decided_by})
|
||||
|
||||
|
||||
def make_action_unknown(agent_id: str, turn_index: int, *, action_id: str, reason_code: str) -> dict[str, Any]:
|
||||
"""Record an inconclusive external outcome without leaking tool arguments."""
|
||||
return _make("action.unknown", agent_id, turn_index, {"action_id": action_id, "reason_code": reason_code})
|
||||
|
||||
|
||||
def make_action_waiting_target_lease(agent_id: str, turn_index: int, *, action_id: str, tool_name: str) -> dict[str, Any]:
|
||||
"""A second approved write is serialized behind an active target lease."""
|
||||
return _make("action.waiting_target_lease", agent_id, turn_index, {"action_id": action_id, "tool_name": tool_name})
|
||||
|
||||
|
||||
# ── workflow checkpoint events ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_workflow_checkpointed(agent_id: str | None, turn_index: int, *, checkpoint_id: str, reason: str = "") -> dict[str, Any]:
|
||||
return _make("workflow.checkpointed", agent_id or "", turn_index, {"checkpoint_id": checkpoint_id, "reason": reason})
|
||||
|
||||
|
||||
# ── internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make(event_type: str, agent_id: str, turn_index: int, payload: dict[str, Any], source_event_id: str | None = None) -> dict[str, Any]:
|
||||
ev: dict[str, Any] = {
|
||||
"event_type": event_type,
|
||||
"agent_id": agent_id,
|
||||
"turn_index": turn_index,
|
||||
"payload": sanitize_payload(payload),
|
||||
}
|
||||
if source_event_id:
|
||||
ev["source_event_id"] = source_event_id
|
||||
return ev
|
||||
|
||||
|
||||
def _get(data: Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(data, dict):
|
||||
return data.get(key, default)
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_messages(data: Any, agent_id: str, turn_index: int) -> list[dict[str, Any]]:
|
||||
"""Handle ``messages`` SSE event (streaming token deltas).
|
||||
|
||||
Format: ``[message_id, {type, content, ...}]`` (messages-tuple mode).
|
||||
"""
|
||||
events: list[dict[str, Any]] = []
|
||||
if not isinstance(data, (list, tuple)) or len(data) < 2:
|
||||
return events
|
||||
msg_id = str(data[0]) if data[0] else None
|
||||
msg = data[1] if len(data) > 1 else None
|
||||
if not isinstance(msg, dict):
|
||||
return events
|
||||
|
||||
msg_type = msg.get("type") or msg.get("role") or ""
|
||||
content = msg.get("content")
|
||||
|
||||
if msg_type in ("ai", "assistant"):
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls:
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
tc_id = tc.get("id", "")
|
||||
events.append(_make("tool.requested", agent_id, turn_index, {
|
||||
"tool_name": tc.get("name", ""),
|
||||
"tool_call_id": tc_id,
|
||||
"args": _summarize_args(tc.get("args")),
|
||||
}, source_event_id=f"{msg_id}:{tc_id}" if msg_id and tc_id else None))
|
||||
if content:
|
||||
text = _extract_text(content)
|
||||
if text:
|
||||
events.append(_make("agent.message.delta", agent_id, turn_index, {"text": text}, source_event_id=msg_id))
|
||||
|
||||
elif msg_type == "tool":
|
||||
tc_id = msg.get("tool_call_id", "")
|
||||
status = msg.get("status", "completed")
|
||||
event_type = "tool.completed" if status != "error" else "tool.failed"
|
||||
events.append(_make(event_type, agent_id, turn_index, {
|
||||
"tool_name": msg.get("name", ""),
|
||||
"tool_call_id": tc_id,
|
||||
"content_preview": _truncate(_extract_text(content), 500),
|
||||
"status": status,
|
||||
}, source_event_id=f"tool:{tc_id}" if tc_id else None))
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _normalize_updates(data: Any, agent_id: str, turn_index: int) -> list[dict[str, Any]]:
|
||||
"""Handle ``updates`` SSE event (node completion with full output).
|
||||
|
||||
Only emits tool lifecycle events from structured data. Does NOT guess
|
||||
skill execution from text content.
|
||||
"""
|
||||
events: list[dict[str, Any]] = []
|
||||
if not isinstance(data, dict):
|
||||
return events
|
||||
|
||||
for node_name, node_output in data.items():
|
||||
if not isinstance(node_output, dict):
|
||||
continue
|
||||
messages = node_output.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
continue
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg_type = msg.get("type") or msg.get("role") or ""
|
||||
if msg_type in ("ai", "assistant"):
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls:
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
tc_id = tc.get("id", "")
|
||||
events.append(_make("tool.started", agent_id, turn_index, {
|
||||
"tool_name": tc.get("name", ""),
|
||||
"tool_call_id": tc_id,
|
||||
"node": node_name,
|
||||
}, source_event_id=f"upd:{tc_id}" if tc_id else None))
|
||||
elif msg_type == "tool":
|
||||
tc_id = msg.get("tool_call_id", "")
|
||||
status = msg.get("status", "completed")
|
||||
event_type = "tool.completed" if status != "error" else "tool.failed"
|
||||
events.append(_make(event_type, agent_id, turn_index, {
|
||||
"tool_name": msg.get("name", ""),
|
||||
"tool_call_id": tc_id,
|
||||
"content_preview": _truncate(_extract_text(msg.get("content")), 500),
|
||||
"status": status,
|
||||
"node": node_name,
|
||||
}, source_event_id=f"upd:tool:{tc_id}" if tc_id else None))
|
||||
return events
|
||||
|
||||
|
||||
def _extract_text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for p in content:
|
||||
if isinstance(p, dict) and p.get("type") == "text":
|
||||
parts.append(p.get("text", ""))
|
||||
elif isinstance(p, str):
|
||||
parts.append(p)
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int) -> str:
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[:max_len] + "..."
|
||||
|
||||
|
||||
def _summarize_args(args: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(args, dict):
|
||||
return None
|
||||
from app.maf_roundtable.sanitize import sanitize
|
||||
result = sanitize(args)
|
||||
return result if isinstance(result, dict) else None
|
||||
@@ -1,391 +0,0 @@
|
||||
"""Shared fact ledger for MAF roundtable runs (Phase 3).
|
||||
|
||||
The ledger is the authoritative record of confirmed facts, tool outcomes, and
|
||||
external-action status. Chat text must never be treated as proof that a write
|
||||
succeeded — hosts synthesize conclusions from this structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
_TOOL_STATUSES = frozenset({"completed", "waiting_approval", "failed", "unknown"})
|
||||
_ACTION_STATUSES = frozenset({"pending", "executed", "failed", "unknown", "rejected"})
|
||||
|
||||
|
||||
def empty_ledger() -> dict[str, Any]:
|
||||
"""Return a fresh ledger with all Phase-3 partitions.
|
||||
|
||||
Phase-3 separation of evidence trust levels:
|
||||
- ``candidate_facts`` — unverified seat claims; the model said it, that
|
||||
does not make it true. Each carries a server-generated ``candidate_id``.
|
||||
- ``confirmed_facts`` — only facts a cross-review confirmed (with evidence
|
||||
and a confirmer) ever land here.
|
||||
- ``tool_results`` — outcomes of tools that actually ran.
|
||||
- ``pending_actions`` / ``executed_actions`` / ``failed_or_unknown_actions``
|
||||
— external writes partitioned by lifecycle state.
|
||||
"""
|
||||
return {
|
||||
"candidate_facts": [],
|
||||
"confirmed_facts": [],
|
||||
"evidence": [],
|
||||
"tool_results": [],
|
||||
"executed_actions": [],
|
||||
"pending_actions": [],
|
||||
"failed_or_unknown_actions": [],
|
||||
"conflicts": [],
|
||||
# Legacy alias kept for old checkpoints / prompts.
|
||||
"proposed_actions": [],
|
||||
}
|
||||
|
||||
|
||||
def normalize_ledger(raw: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Coerce a possibly-partial / legacy ledger into the full shape (in-place when possible)."""
|
||||
if not isinstance(raw, dict):
|
||||
return empty_ledger()
|
||||
base = empty_ledger()
|
||||
for key in base:
|
||||
if key not in raw or not isinstance(raw.get(key), list):
|
||||
raw[key] = []
|
||||
return raw
|
||||
|
||||
|
||||
def candidate_id_map(ledger: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{candidate_id: candidate_entry}`` for O(1) confirmation lookup."""
|
||||
ledger = normalize_ledger(ledger)
|
||||
return {
|
||||
str(c["candidate_id"]): c
|
||||
for c in ledger["candidate_facts"]
|
||||
if isinstance(c, dict) and c.get("candidate_id")
|
||||
}
|
||||
|
||||
|
||||
def known_evidence_refs(ledger: dict[str, Any]) -> set[str]:
|
||||
"""The set of evidence references the ledger actually has backing for.
|
||||
|
||||
A cross-review may only confirm a candidate using evidence that the ledger
|
||||
already recorded (either in ``evidence`` or as a successful
|
||||
``tool_results`` entry). This is the trust anchor that stops a weak model
|
||||
from inventing evidence references to promote a seat claim.
|
||||
"""
|
||||
ledger = normalize_ledger(ledger)
|
||||
refs: set[str] = set()
|
||||
for e in ledger["evidence"]:
|
||||
if isinstance(e, dict):
|
||||
r = e.get("ref") or e.get("text")
|
||||
if r:
|
||||
refs.add(str(r))
|
||||
elif e:
|
||||
refs.add(str(e))
|
||||
for tr in ledger["tool_results"]:
|
||||
# Only successful tool runs count as evidence (a failed tool proves
|
||||
# nothing). Each tool result is addressable as tool:<name>:<turn>.
|
||||
if isinstance(tr, dict) and tr.get("status") == "completed":
|
||||
name = tr.get("tool_name", "")
|
||||
turn = tr.get("turn_index")
|
||||
if name:
|
||||
refs.add(f"tool:{name}:{turn}" if turn is not None else f"tool:{name}")
|
||||
return refs
|
||||
|
||||
|
||||
def merge_seat_output(
|
||||
ledger: dict[str, Any],
|
||||
seat_output: dict[str, Any],
|
||||
*,
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge a validated seat structured output into the ledger.
|
||||
|
||||
Phase-3: seat ``findings`` are written to ``candidate_facts`` (unverified
|
||||
claims), NOT to ``confirmed_facts``. Promotion to ``confirmed_facts``
|
||||
happens only via :func:`confirm_candidate_facts` after cross-review.
|
||||
Proposed actions stay in ``proposed_actions`` — they are never promoted to
|
||||
``executed_actions`` here.
|
||||
"""
|
||||
ledger = normalize_ledger(ledger)
|
||||
for finding in seat_output.get("findings") or []:
|
||||
text = str(finding).strip()
|
||||
if text:
|
||||
ledger["candidate_facts"].append({
|
||||
"candidate_id": uuid4().hex,
|
||||
"text": text,
|
||||
"agent_id": agent_id,
|
||||
"source": "seat_finding",
|
||||
"confirmed": False,
|
||||
})
|
||||
for ref in seat_output.get("evidence_refs") or []:
|
||||
if isinstance(ref, dict):
|
||||
entry = dict(ref)
|
||||
entry.setdefault("agent_id", agent_id)
|
||||
ledger["evidence"].append(entry)
|
||||
elif ref:
|
||||
ledger["evidence"].append({"ref": str(ref), "agent_id": agent_id})
|
||||
for conflict in seat_output.get("conflicts") or []:
|
||||
if conflict:
|
||||
ledger["conflicts"].append({"text": str(conflict), "agent_id": agent_id})
|
||||
for action in seat_output.get("proposed_actions") or []:
|
||||
if isinstance(action, dict):
|
||||
entry = dict(action)
|
||||
entry.setdefault("agent_id", agent_id)
|
||||
entry.setdefault("status", "proposed")
|
||||
ledger["proposed_actions"].append(entry)
|
||||
elif action:
|
||||
ledger["proposed_actions"].append({"text": str(action), "agent_id": agent_id, "status": "proposed"})
|
||||
# Executed actions from model text are NOT trusted — only ActionCoordinator
|
||||
# / resume may write executed_actions. Ignore seat_output.executed_actions.
|
||||
return ledger
|
||||
|
||||
|
||||
def confirm_candidates(
|
||||
ledger: dict[str, Any],
|
||||
*,
|
||||
confirmed_candidates: list[dict[str, Any]],
|
||||
confirmed_by: str,
|
||||
rejections: list[dict[str, Any]] | None = None,
|
||||
accepted: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Promote candidate facts to ``confirmed_facts`` after cross-review.
|
||||
|
||||
Phase-3 P1-1 — server-side evidence binding. A candidate is promoted ONLY
|
||||
when **all** of the following hold:
|
||||
|
||||
1. ``candidate_id`` exists in ``candidate_facts`` (the model cannot invent a
|
||||
confirmation for a fact a seat never asserted);
|
||||
2. the candidate carries **at least one** evidence reference;
|
||||
3. every evidence reference resolves to an entry the ledger actually
|
||||
recorded — either in ``evidence`` or as a **successful** ``tool_results``
|
||||
entry. An evidence ref the model invented (not in the ledger) is a
|
||||
hallucination and blocks promotion.
|
||||
|
||||
Candidates that fail any check are left as candidates (``confirmed=False``)
|
||||
and a rejection reason is appended to ``rejections`` so the cross-review
|
||||
summary can explain why a claim was not trusted. When supplied, ``accepted``
|
||||
receives only the server-validated confirmations, so callers never need to
|
||||
infer success from the model's requested confirmation list. Idempotent: a
|
||||
candidate already present in ``confirmed_facts`` is not re-confirmed.
|
||||
"""
|
||||
ledger = normalize_ledger(ledger)
|
||||
if rejections is None:
|
||||
rejections = []
|
||||
by_id = candidate_id_map(ledger)
|
||||
valid_refs = known_evidence_refs(ledger)
|
||||
existing_ids = {
|
||||
str(f.get("candidate_id"))
|
||||
for f in ledger["confirmed_facts"]
|
||||
if isinstance(f, dict) and f.get("candidate_id")
|
||||
}
|
||||
for item in confirmed_candidates or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
cid = str(item.get("candidate_id", "")).strip()
|
||||
refs = list(item.get("evidence_refs") or [])
|
||||
if not cid:
|
||||
rejections.append({"reason": "missing_candidate_id"})
|
||||
continue
|
||||
cand = by_id.get(cid)
|
||||
if cand is None:
|
||||
rejections.append({"candidate_id": cid, "reason": "unknown_candidate_id"})
|
||||
continue
|
||||
if cid in existing_ids:
|
||||
# Already confirmed — idempotent no-op.
|
||||
continue
|
||||
if not refs:
|
||||
rejections.append({"candidate_id": cid, "reason": "no_evidence"})
|
||||
continue
|
||||
bad_refs = [r for r in refs if str(r) not in valid_refs]
|
||||
if bad_refs:
|
||||
rejections.append({
|
||||
"candidate_id": cid,
|
||||
"reason": "evidence_not_in_ledger",
|
||||
"evidence_refs": bad_refs,
|
||||
})
|
||||
continue
|
||||
cand["confirmed"] = True
|
||||
ledger["confirmed_facts"].append({
|
||||
"candidate_id": cid,
|
||||
"text": cand.get("text", ""),
|
||||
"agent_id": cand.get("agent_id"),
|
||||
"source": "cross_review_confirmed",
|
||||
"confirmed_by": confirmed_by,
|
||||
"evidence_refs": list(refs),
|
||||
})
|
||||
if accepted is not None:
|
||||
accepted.append({
|
||||
"candidate_id": cid,
|
||||
"evidence_refs": list(refs),
|
||||
})
|
||||
existing_ids.add(cid)
|
||||
return ledger
|
||||
|
||||
|
||||
def confirm_candidate_facts(
|
||||
ledger: dict[str, Any],
|
||||
*,
|
||||
confirmed_candidate_ids: list[str],
|
||||
confirmed_by: str,
|
||||
evidence_refs: list[str] | None = None,
|
||||
accepted: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Back-compat wrapper: confirm a flat id list against one shared evidence set.
|
||||
|
||||
Phase-3 P1-1 tightened confirmation so each candidate must carry at least
|
||||
one evidence reference that the ledger has backing for. The structured
|
||||
path (:func:`confirm_candidates`) is the primary API; this wrapper exists
|
||||
for callers that still pass a flat ``confirmed_candidate_ids`` list — it
|
||||
promotes a candidate ONLY when ``evidence_refs`` is non-empty AND every
|
||||
ref resolves to ledger evidence / successful tool results.
|
||||
"""
|
||||
shared_refs = list(evidence_refs or [])
|
||||
candidates = [
|
||||
{"candidate_id": cid, "evidence_refs": shared_refs}
|
||||
for cid in confirmed_candidate_ids or []
|
||||
]
|
||||
return confirm_candidates(
|
||||
ledger,
|
||||
confirmed_candidates=candidates,
|
||||
confirmed_by=confirmed_by,
|
||||
accepted=accepted,
|
||||
)
|
||||
|
||||
|
||||
def record_tool_result(
|
||||
ledger: dict[str, Any],
|
||||
*,
|
||||
tool_name: str,
|
||||
status: str,
|
||||
agent_id: str,
|
||||
turn_index: int | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a tool result. ``status`` must be one of the allowed tool statuses."""
|
||||
ledger = normalize_ledger(ledger)
|
||||
st = status if status in _TOOL_STATUSES else "unknown"
|
||||
entry: dict[str, Any] = {
|
||||
"tool_name": tool_name,
|
||||
"status": st,
|
||||
"agent_id": agent_id,
|
||||
}
|
||||
if turn_index is not None:
|
||||
entry["turn_index"] = turn_index
|
||||
if detail:
|
||||
entry["detail"] = detail
|
||||
ledger["tool_results"].append(entry)
|
||||
return ledger
|
||||
|
||||
|
||||
def record_action_transition(
|
||||
ledger: dict[str, Any],
|
||||
*,
|
||||
action_id: str,
|
||||
tool_name: str,
|
||||
status: str,
|
||||
agent_id: str | None = None,
|
||||
external_object_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Move / record an external action under the correct ledger partition.
|
||||
|
||||
Rules:
|
||||
- ``proposed`` never becomes ``executed`` via this helper unless status is
|
||||
explicitly ``executed`` (called only after ToolExecutor success).
|
||||
- ``pending`` entries are removed when transitioning to a terminal status.
|
||||
"""
|
||||
ledger = normalize_ledger(ledger)
|
||||
st = status if status in _ACTION_STATUSES else "unknown"
|
||||
entry: dict[str, Any] = {
|
||||
"action_id": action_id,
|
||||
"tool_name": tool_name,
|
||||
"status": st,
|
||||
}
|
||||
if agent_id:
|
||||
entry["agent_id"] = agent_id
|
||||
if external_object_id:
|
||||
entry["external_object_id"] = external_object_id
|
||||
|
||||
# Drop matching pending rows when leaving pending.
|
||||
if st != "pending":
|
||||
ledger["pending_actions"] = [
|
||||
p
|
||||
for p in ledger["pending_actions"]
|
||||
if not (isinstance(p, dict) and p.get("action_id") == action_id)
|
||||
]
|
||||
|
||||
if st == "pending":
|
||||
# Replace existing pending with same action_id.
|
||||
ledger["pending_actions"] = [
|
||||
p
|
||||
for p in ledger["pending_actions"]
|
||||
if not (isinstance(p, dict) and p.get("action_id") == action_id)
|
||||
]
|
||||
ledger["pending_actions"].append(entry)
|
||||
elif st == "executed":
|
||||
ledger["executed_actions"] = [
|
||||
e
|
||||
for e in ledger["executed_actions"]
|
||||
if not (isinstance(e, dict) and e.get("action_id") == action_id)
|
||||
]
|
||||
ledger["executed_actions"].append(entry)
|
||||
# Strip from failed list if previously unknown/failed.
|
||||
ledger["failed_or_unknown_actions"] = [
|
||||
e
|
||||
for e in ledger["failed_or_unknown_actions"]
|
||||
if not (isinstance(e, dict) and e.get("action_id") == action_id)
|
||||
]
|
||||
elif st in ("failed", "unknown", "rejected"):
|
||||
ledger["failed_or_unknown_actions"] = [
|
||||
e
|
||||
for e in ledger["failed_or_unknown_actions"]
|
||||
if not (isinstance(e, dict) and e.get("action_id") == action_id)
|
||||
]
|
||||
ledger["failed_or_unknown_actions"].append(entry)
|
||||
# Never keep executed copy of a failed/unknown action.
|
||||
ledger["executed_actions"] = [
|
||||
e
|
||||
for e in ledger["executed_actions"]
|
||||
if not (isinstance(e, dict) and e.get("action_id") == action_id)
|
||||
]
|
||||
return ledger
|
||||
|
||||
|
||||
def assert_ledger_invariants(ledger: dict[str, Any]) -> None:
|
||||
"""Raise AssertionError if the ledger violates evidence/action constraints."""
|
||||
ledger = normalize_ledger(ledger)
|
||||
executed_ids = {
|
||||
e.get("action_id")
|
||||
for e in ledger["executed_actions"]
|
||||
if isinstance(e, dict) and e.get("action_id")
|
||||
}
|
||||
for prop in ledger["proposed_actions"]:
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
# A proposed-only entry must not also appear as executed with status proposed.
|
||||
if prop.get("status") == "proposed" and prop.get("action_id") in executed_ids:
|
||||
# Same action_id may be proposed then later executed — that is OK if
|
||||
# the executed entry has status executed. Only flag if proposed row
|
||||
# itself claims executed.
|
||||
if prop.get("status") == "executed":
|
||||
raise AssertionError("proposed_actions entry has status=executed")
|
||||
for e in ledger["executed_actions"]:
|
||||
if isinstance(e, dict) and e.get("status") not in (None, "executed"):
|
||||
raise AssertionError(f"executed_actions entry has status={e.get('status')!r}")
|
||||
for tr in ledger["tool_results"]:
|
||||
if isinstance(tr, dict) and tr.get("status") not in _TOOL_STATUSES:
|
||||
raise AssertionError(f"invalid tool_results status={tr.get('status')!r}")
|
||||
|
||||
|
||||
def proposed_action_ids(ledger: dict[str, Any]) -> set[str]:
|
||||
ledger = normalize_ledger(ledger)
|
||||
return {
|
||||
str(p["action_id"])
|
||||
for p in ledger["proposed_actions"]
|
||||
if isinstance(p, dict) and p.get("action_id")
|
||||
}
|
||||
|
||||
|
||||
def executed_action_ids(ledger: dict[str, Any]) -> set[str]:
|
||||
ledger = normalize_ledger(ledger)
|
||||
return {
|
||||
str(e["action_id"])
|
||||
for e in ledger["executed_actions"]
|
||||
if isinstance(e, dict) and e.get("action_id")
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
"""Controlled DeerFlow runtime invocation client for MAF roundtable.
|
||||
|
||||
All invocations require an explicit ``ExecutionPrincipal`` — the server-generated
|
||||
identity derived ONLY from the authenticated MAF session owner. No raw tokens,
|
||||
cookies, or credentials ever pass into events, checkpoints, or prompts.
|
||||
|
||||
Two invocation modes (the adapter uses mode 2):
|
||||
1. Direct checkpointer access for thread creation and message reads.
|
||||
2. In-process ``start_run`` via a typed internal request that carries the
|
||||
principal's user identity (never ``user=None``).
|
||||
|
||||
Security invariants:
|
||||
- ExecutionPrincipal is constructed ONLY from the MAF session's user_id.
|
||||
- The internal request object explicitly carries the principal's user so
|
||||
downstream code sees a real identity, not a None placeholder.
|
||||
- Agent access is enforced server-side (enforce_access=True).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize_tool_result(result: Any) -> Any:
|
||||
"""Convert a tool result to a JSON-safe dict/list/str/int/float/bool/None."""
|
||||
if result is None:
|
||||
return None
|
||||
if isinstance(result, (str, int, float, bool)):
|
||||
return result
|
||||
if isinstance(result, (list, tuple)):
|
||||
return [_serialize_tool_result(v) for v in result]
|
||||
if isinstance(result, dict):
|
||||
return {str(k): _serialize_tool_result(v) for k, v in result.items()}
|
||||
return str(result)[:1000]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPrincipal:
|
||||
"""Server-generated execution identity for a MAF seat run.
|
||||
|
||||
Constructed ONLY from the authenticated MAF session owner. Never from
|
||||
request body, query params, or client-supplied data.
|
||||
"""
|
||||
|
||||
user_id: str
|
||||
session_id: str
|
||||
run_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.user_id or not self.user_id.strip():
|
||||
raise ValueError("ExecutionPrincipal requires a non-empty user_id")
|
||||
|
||||
|
||||
class _PrincipalUser:
|
||||
"""Minimal CurrentUser protocol implementation backed by ExecutionPrincipal."""
|
||||
|
||||
def __init__(self, principal: ExecutionPrincipal) -> None:
|
||||
self.id = principal.user_id
|
||||
|
||||
|
||||
class _InternalState:
|
||||
"""Typed state container for the internal run request.
|
||||
|
||||
Carries the principal's user identity explicitly — never None — so
|
||||
downstream access checks and run metadata attribution work correctly.
|
||||
"""
|
||||
|
||||
def __init__(self, principal: ExecutionPrincipal) -> None:
|
||||
self.user = _PrincipalUser(principal)
|
||||
self.auth = None
|
||||
# This marker is set only on the server-created request used by the
|
||||
# MAF dispatcher. Gateway code uses it to distinguish trusted MAF
|
||||
# execution context from user-supplied run configuration.
|
||||
self.maf_internal = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class _InternalRunRequest:
|
||||
"""Typed internal request for in-process agent run invocation.
|
||||
|
||||
Replaces the former ``SimpleNamespace(app=..., state=SimpleNamespace(user=None))``
|
||||
pattern. The state explicitly carries the principal's user identity.
|
||||
"""
|
||||
|
||||
app: Any
|
||||
state: _InternalState
|
||||
|
||||
@classmethod
|
||||
def from_principal(cls, app: Any, principal: ExecutionPrincipal) -> _InternalRunRequest:
|
||||
return cls(app=app, state=_InternalState(principal))
|
||||
|
||||
|
||||
class DeerFlowRuntimeClient:
|
||||
"""Controlled entry point for invoking the DeerFlow Agent Runtime from MAF.
|
||||
|
||||
All calls require an ExecutionPrincipal. The client:
|
||||
- Sets the user ContextVar before each invocation.
|
||||
- Creates threads with proper user attribution.
|
||||
- Invokes start_run with server-generated identity.
|
||||
- Never passes raw tokens or credentials downstream.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any) -> None:
|
||||
self._app = app
|
||||
|
||||
@property
|
||||
def app(self) -> Any:
|
||||
return self._app
|
||||
|
||||
async def create_thread(self, principal: ExecutionPrincipal) -> str:
|
||||
"""Create a DeerFlow thread owned by the principal's user.
|
||||
|
||||
The user ContextVar is set so thread_store.create records the correct
|
||||
user_id in thread_meta.
|
||||
"""
|
||||
token = set_current_user(_PrincipalUser(principal))
|
||||
try:
|
||||
checkpointer = self._app.state.checkpointer
|
||||
thread_store = self._app.state.thread_store
|
||||
thread_id = str(uuid.uuid4())
|
||||
await thread_store.create(
|
||||
thread_id,
|
||||
assistant_id=None,
|
||||
metadata={
|
||||
"thread_type": "maf_roundtable",
|
||||
"system": True,
|
||||
"maf_session_id": principal.session_id,
|
||||
"maf_run_id": principal.run_id,
|
||||
},
|
||||
)
|
||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
await checkpointer.aput(
|
||||
config,
|
||||
empty_checkpoint(),
|
||||
{"step": -1, "source": "input", "writes": None, "parents": {}, "created_at": datetime.now(UTC).isoformat()},
|
||||
{},
|
||||
)
|
||||
return thread_id
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
async def start_agent_run(
|
||||
self,
|
||||
principal: ExecutionPrincipal,
|
||||
*,
|
||||
thread_id: str,
|
||||
agent_id: str,
|
||||
message: str,
|
||||
model_name: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
thinking_enabled: bool = True,
|
||||
subagent_enabled: bool = False,
|
||||
excluded_tools: list[str] | None = None,
|
||||
memory_recall_disabled: bool | None = None,
|
||||
memory_injection_enabled: bool | None = None,
|
||||
turn_index: int = 0,
|
||||
capability_snapshot: dict[str, Any] | None = None,
|
||||
write_policy: str = "confirm_high_risk",
|
||||
approved_tool: str | None = None,
|
||||
approved_action_id: str | None = None,
|
||||
) -> Any:
|
||||
"""Start a DeerFlow agent run under the principal's identity.
|
||||
|
||||
Returns the RunRecord (with .task and .run_id). The caller must await
|
||||
the task and handle exceptions.
|
||||
|
||||
WriteGuard activation: when ``write_policy != "auto"`` the run context
|
||||
carries ``maf_run_id`` (the activation flag the WriteGuard middleware
|
||||
checks) plus the per-run data the interceptor needs to record a
|
||||
pending action. ``excluded_tools`` here MUST only contain tools the
|
||||
user explicitly disabled — write tools are no longer excluded (the
|
||||
middleware intercepts them instead, keeping them visible to the model).
|
||||
"""
|
||||
from app.gateway.routers.thread_runs import RunCreateRequest
|
||||
from app.gateway.services import RUN_SCOPE_BACKGROUND_CHILD, start_run
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"agent_name": agent_id,
|
||||
"model_name": model_name or "",
|
||||
"mode": "pro",
|
||||
"reasoning_effort": reasoning_effort or "medium",
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"is_plan_mode": False,
|
||||
"subagent_enabled": subagent_enabled,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
if memory_recall_disabled is not None:
|
||||
context["memory_recall_disabled"] = memory_recall_disabled
|
||||
if memory_injection_enabled is not None:
|
||||
context["memory_injection_enabled"] = memory_injection_enabled
|
||||
|
||||
# WriteGuard activation + per-run data (server-generated identity only).
|
||||
# ``maf_run_id`` is the flag the WriteGuard middleware checks to decide
|
||||
# whether to intercept; the rest is the data the interceptor reads.
|
||||
context["maf_run_id"] = principal.run_id
|
||||
context["maf_session_id"] = principal.session_id
|
||||
context["maf_user_id"] = principal.user_id
|
||||
context["agent_id"] = agent_id
|
||||
context["turn_index"] = turn_index
|
||||
context["write_policy"] = write_policy
|
||||
if capability_snapshot is not None:
|
||||
context["capability_snapshot"] = capability_snapshot
|
||||
# Post-approval deterministic execution: when set, the WriteGuard
|
||||
# middleware passes this single tool through without interception.
|
||||
# ``maf_approved_action_id`` binds the passthrough to a specific
|
||||
# approved action — the middleware validates both tool name AND
|
||||
# action_id so a model calling another tool with the same name is
|
||||
# still intercepted (defense-in-depth when combined with direct
|
||||
# tool execution that bypasses the LLM).
|
||||
if approved_tool:
|
||||
context["maf_approved_tool"] = approved_tool
|
||||
if approved_action_id:
|
||||
context["maf_approved_action_id"] = approved_action_id
|
||||
|
||||
body = RunCreateRequest(
|
||||
assistant_id="lead_agent",
|
||||
input={"messages": [{"type": "human", "content": [{"type": "text", "text": message}], "additional_kwargs": {}}]},
|
||||
metadata={"run_scope": RUN_SCOPE_BACKGROUND_CHILD, "maf_run_id": principal.run_id},
|
||||
config={"recursion_limit": 1000},
|
||||
context=context,
|
||||
excluded_tools=excluded_tools or [],
|
||||
on_disconnect="continue",
|
||||
multitask_strategy="reject",
|
||||
)
|
||||
# Typed internal request carrying the principal's identity — never user=None.
|
||||
request = _InternalRunRequest.from_principal(self._app, principal)
|
||||
|
||||
token = set_current_user(_PrincipalUser(principal))
|
||||
try:
|
||||
record = await start_run(
|
||||
body,
|
||||
thread_id,
|
||||
request,
|
||||
run_scope=RUN_SCOPE_BACKGROUND_CHILD,
|
||||
)
|
||||
return record
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
async def read_thread_messages(self, principal: ExecutionPrincipal, thread_id: str) -> list[dict[str, Any]]:
|
||||
"""Read the latest checkpoint messages from a thread."""
|
||||
from deerflow.runtime.serialization import serialize_channel_values
|
||||
|
||||
token = set_current_user(_PrincipalUser(principal))
|
||||
try:
|
||||
checkpointer = self._app.state.checkpointer
|
||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
ct = await checkpointer.aget_tuple(config)
|
||||
if ct is None:
|
||||
return []
|
||||
checkpoint = getattr(ct, "checkpoint", {}) or {}
|
||||
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
|
||||
return values.get("messages") or []
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
async def execute_tool_directly(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
agent_id: str,
|
||||
tool_name: str,
|
||||
params: dict[str, Any],
|
||||
principal: ExecutionPrincipal,
|
||||
turn_index: int,
|
||||
capability_snapshot: dict[str, Any] | None,
|
||||
write_policy: str,
|
||||
approved_action_id: str,
|
||||
action_record: dict[str, Any] | None,
|
||||
model_name: str | None = None,
|
||||
excluded_tools: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a DeerFlow tool directly without LLM involvement.
|
||||
|
||||
For post-approval deterministic execution this delegates to the harness
|
||||
``execute_approved_tool``, which runs the call through the **real**
|
||||
LangGraph ``ToolNode`` / ``ToolRuntime`` / middleware chain (state
|
||||
recovered from the thread checkpoint), not a faked runtime. No raw
|
||||
params enter any prompt, checkpoint, or model context.
|
||||
|
||||
Returns ``{"status": "succeeded", "result": ...}`` or
|
||||
``{"status": "failed", "error": "..."}``. A security rejection
|
||||
(unauthorized / disabled / nonexistent tool, context mismatch) is a
|
||||
``failed`` outcome and the tool executes 0 times.
|
||||
"""
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.tools.approved_tool_executor import execute_approved_tool
|
||||
|
||||
app_config = get_app_config()
|
||||
checkpointer = getattr(self._app.state, "checkpointer", None)
|
||||
store = getattr(self._app.state, "store", None)
|
||||
|
||||
return await execute_approved_tool(
|
||||
app_config=app_config,
|
||||
checkpointer=checkpointer,
|
||||
store=store,
|
||||
thread_id=thread_id,
|
||||
user_id=principal.user_id,
|
||||
session_id=principal.session_id,
|
||||
run_id=principal.run_id,
|
||||
agent_id=agent_id,
|
||||
turn_index=turn_index,
|
||||
model_name=model_name,
|
||||
excluded_tools=excluded_tools,
|
||||
capability_snapshot=capability_snapshot,
|
||||
write_policy=write_policy,
|
||||
tool_name=tool_name,
|
||||
params=params,
|
||||
approved_action_id=approved_action_id,
|
||||
action_record=action_record,
|
||||
)
|
||||
|
||||
async def cancel_run(self, deerflow_run_id: str) -> bool:
|
||||
"""Cancel a DeerFlow run by its run_id."""
|
||||
run_manager = getattr(self._app.state, "run_manager", None)
|
||||
if run_manager is None:
|
||||
return False
|
||||
try:
|
||||
await run_manager.cancel(deerflow_run_id, action="interrupt")
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Log only the exception type — never the raw message/traceback,
|
||||
# which may carry params or credentials.
|
||||
logger.warning(
|
||||
"cancel DeerFlow run %s failed (%s)", deerflow_run_id, type(exc).__name__
|
||||
)
|
||||
return False
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Recursive sanitization for MAF roundtable events, payloads, and logs.
|
||||
|
||||
Strips credentials, tokens, cookies, passwords, secrets, and API keys from
|
||||
nested data structures before they reach the event store, context builder,
|
||||
or log output. Applied uniformly to tool arguments, tool results, fact
|
||||
ledger entries, and event payloads.
|
||||
|
||||
Design:
|
||||
- Pure functions, no I/O.
|
||||
- Recursive: handles nested dicts, lists, and strings.
|
||||
- Key-based: redacts values whose key matches a sensitive pattern.
|
||||
- Value-based: redacts string values that look like tokens/keys.
|
||||
- Never raises: malformed input passes through unchanged (best-effort).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_SENSITIVE_KEYS = frozenset({
|
||||
"token", "access_token", "refresh_token", "id_token",
|
||||
"authorization", "auth", "auth_token", "authtoken",
|
||||
"cookie", "cookies", "set-cookie",
|
||||
"password", "passwd", "pwd",
|
||||
"secret", "secret_key", "client_secret",
|
||||
"api_key", "apikey", "api-key",
|
||||
"private_key", "private-key",
|
||||
"credential", "credentials",
|
||||
"session_id", "sessionid",
|
||||
"x-api-key", "x-auth-token",
|
||||
})
|
||||
|
||||
_KEY_PATTERN = re.compile(
|
||||
r"(token|authorization|cookie|password|passwd|secret|api[_-]?key|private[_-]?key|credential|bearer)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_VALUE_PATTERNS = [
|
||||
re.compile(r"^Bearer\s+\S+", re.IGNORECASE),
|
||||
re.compile(r"^(sk|pk|ak|rk)[_-][A-Za-z0-9]{16,}$"),
|
||||
re.compile(r"^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
|
||||
re.compile(r"^[A-Za-z0-9+/]{40,}={0,2}$"),
|
||||
]
|
||||
|
||||
_INLINE_SECRET_PATTERN = re.compile(
|
||||
r"(token|password|passwd|secret|api[_-]?key|authorization|cookie)\s*[=:]\s*\S+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_REDACTED = "***REDACTED***"
|
||||
_MAX_DEPTH = 12
|
||||
|
||||
|
||||
def sanitize(data: Any, *, _depth: int = 0) -> Any:
|
||||
"""Recursively sanitize sensitive fields from a data structure."""
|
||||
if _depth > _MAX_DEPTH:
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return _sanitize_dict(data, _depth)
|
||||
if isinstance(data, (list, tuple)):
|
||||
return [sanitize(item, _depth=_depth + 1) for item in data]
|
||||
if isinstance(data, str):
|
||||
return _sanitize_value_str(data)
|
||||
return data
|
||||
|
||||
|
||||
def sanitize_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Sanitize an event payload dict. Returns None if input is None."""
|
||||
if payload is None:
|
||||
return None
|
||||
result = sanitize(payload)
|
||||
return result if isinstance(result, dict) else payload
|
||||
|
||||
|
||||
def _sanitize_dict(d: dict[str, Any], depth: int) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in d.items():
|
||||
key_lower = str(key).lower().replace("-", "_")
|
||||
if key_lower in _SENSITIVE_KEYS or _KEY_PATTERN.search(key_lower):
|
||||
out[key] = _REDACTED
|
||||
elif isinstance(value, dict):
|
||||
out[key] = _sanitize_dict(value, depth + 1)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
out[key] = [sanitize(item, _depth=depth + 1) for item in value]
|
||||
elif isinstance(value, str):
|
||||
out[key] = _sanitize_value_str(value)
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def _sanitize_value_str(s: str) -> str:
|
||||
# Never skip sanitization based on length — long strings (error traces,
|
||||
# log dumps, seat-run errors) may embed credentials deep inside.
|
||||
for pat in _VALUE_PATTERNS:
|
||||
if pat.match(s):
|
||||
return _REDACTED
|
||||
if _INLINE_SECRET_PATTERN.search(s):
|
||||
return _INLINE_SECRET_PATTERN.sub(
|
||||
lambda m: m.group(0).split("=")[0].split(":")[0] + "=***REDACTED***", s
|
||||
)
|
||||
return s
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Pydantic request/response schemas for the MAF roundtable API.
|
||||
|
||||
These are the wire contracts between the frontend (Phase 6) and the backend.
|
||||
Phase 1 ships the session / participant / run / event contracts; the action /
|
||||
approval contracts are defined too so Phase 5 can wire the Action Coordinator
|
||||
without a schema break. Sensitive fields (credentials / tokens / cookies)
|
||||
must already be redacted by the writer before they reach these schemas — the
|
||||
backend does not re-redact.
|
||||
|
||||
``MafMode`` / ``MafWritePolicy`` / ``MafParticipantRole`` are imported from the
|
||||
harness config module so API validation and deployment-config validation share
|
||||
a single source of truth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Self
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from deerflow.config.maf_roundtable_config import MafMode, MafParticipantRole, MafWritePolicy
|
||||
|
||||
# ── sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ParticipantInput(BaseModel):
|
||||
"""One seat / manager agent bound to a session at creation time."""
|
||||
|
||||
agent_id: str = Field(..., min_length=1, max_length=64)
|
||||
display_name: str = Field(default="", max_length=255)
|
||||
role: MafParticipantRole = "seat"
|
||||
sort_order: int = 0
|
||||
# Subtractive only: tools removed from the server-resolved allow-list for this seat.
|
||||
excluded_tools: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
title: str = Field(default="", max_length=255)
|
||||
task_text: str = Field(..., min_length=1)
|
||||
task_payload: dict[str, Any] | None = None
|
||||
# Optional: omitted → service layer fills from MafRoundtableConfig.default_mode.
|
||||
mode: MafMode | None = None
|
||||
model_name: str | None = Field(default=None, max_length=128)
|
||||
reasoning_effort: str | None = Field(default=None, max_length=32)
|
||||
manager_agent_id: str | None = Field(default=None, max_length=64)
|
||||
# Optional: omitted → MafRoundtableConfig.default_max_rounds.
|
||||
max_rounds: int | None = Field(default=None, ge=1, le=64)
|
||||
# Optional: omitted → MafRoundtableConfig.default_write_policy.
|
||||
write_policy: MafWritePolicy | None = None
|
||||
participants: list[ParticipantInput] = Field(default_factory=list)
|
||||
# Subtractive session-wide tool exclusions (applied per seat when building snapshot).
|
||||
excluded_tools: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# Fields that must not be explicitly set to null on PUT (→ 422).
|
||||
# Clearable fields (task_payload / model_name / reasoning_effort /
|
||||
# manager_agent_id / fact_ledger / final_result) keep null = "clear value".
|
||||
_NON_NULLABLE_UPDATE_FIELDS = frozenset(
|
||||
{"title", "task_text", "mode", "max_rounds", "write_policy", "participants"}
|
||||
)
|
||||
|
||||
|
||||
class UpdateSessionRequest(BaseModel):
|
||||
"""Partial update — only allowed when the session is not yet ``running``.
|
||||
|
||||
Distinguishes "field omitted" (no change) from "field set to null":
|
||||
- Non-nullable fields (title / task_text / mode / max_rounds / write_policy /
|
||||
participants): explicit ``null`` → 422.
|
||||
- Clearable fields (task_payload / model_name / reasoning_effort /
|
||||
manager_agent_id / fact_ledger / final_result): explicit ``null`` clears.
|
||||
Do **not** use ``exclude_none=True`` — that would collapse the two cases.
|
||||
"""
|
||||
|
||||
title: str | None = Field(default=None, max_length=255)
|
||||
task_text: str | None = None
|
||||
task_payload: dict[str, Any] | None = None
|
||||
mode: MafMode | None = None
|
||||
model_name: str | None = Field(default=None, max_length=128)
|
||||
reasoning_effort: str | None = Field(default=None, max_length=32)
|
||||
manager_agent_id: str | None = Field(default=None, max_length=64)
|
||||
max_rounds: int | None = Field(default=None, ge=1, le=64)
|
||||
write_policy: MafWritePolicy | None = None
|
||||
participants: list[ParticipantInput] | None = None
|
||||
# fact_ledger / final_result are mutable runtime fields (updated by the run lifecycle).
|
||||
fact_ledger: dict[str, Any] | None = None
|
||||
final_result: dict[str, Any] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _reject_explicit_null_for_non_nullable(self) -> Self:
|
||||
for name in _NON_NULLABLE_UPDATE_FIELDS:
|
||||
if name in self.model_fields_set and getattr(self, name) is None:
|
||||
raise ValueError(f"{name} cannot be null")
|
||||
return self
|
||||
|
||||
|
||||
class ParticipantResponse(BaseModel):
|
||||
id: str
|
||||
session_id: str
|
||||
agent_id: str
|
||||
display_name: str
|
||||
role: str
|
||||
sort_order: int
|
||||
thread_id: str | None = None
|
||||
sandbox_id: str | None = None
|
||||
status: str
|
||||
last_turn_index: int | None = None
|
||||
capability_snapshot: dict[str, Any] | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
title: str
|
||||
task_text: str
|
||||
task_payload: dict[str, Any] | None = None
|
||||
mode: str
|
||||
status: str
|
||||
model_name: str | None = None
|
||||
reasoning_effort: str | None = None
|
||||
manager_agent_id: str | None = None
|
||||
max_rounds: int
|
||||
write_policy: str
|
||||
capability_snapshot: dict[str, Any] | None = None
|
||||
fact_ledger: dict[str, Any] | None = None
|
||||
final_result: dict[str, Any] | None = None
|
||||
participants: list[ParticipantResponse] = Field(default_factory=list)
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SessionListResponse(BaseModel):
|
||||
sessions: list[SessionResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── runs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RunResponse(BaseModel):
|
||||
id: str
|
||||
session_id: str
|
||||
status: str
|
||||
workflow_version: str | None = None
|
||||
maf_checkpoint: dict[str, Any] | None = None
|
||||
last_event_seq: int
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
error_code: str | None = None
|
||||
error_message_safe: str | None = None
|
||||
token_usage: dict[str, Any] | None = None
|
||||
latency_ms: int | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class RunListResponse(BaseModel):
|
||||
runs: list[RunResponse]
|
||||
|
||||
|
||||
class CreateRunRequest(BaseModel):
|
||||
workflow_version: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
# ── events ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EventResponse(BaseModel):
|
||||
id: str
|
||||
session_id: str
|
||||
run_id: str
|
||||
seq: int
|
||||
event_type: str
|
||||
agent_id: str | None = None
|
||||
turn_index: int | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class EventListResponse(BaseModel):
|
||||
events: list[EventResponse]
|
||||
latest_seq: int
|
||||
|
||||
|
||||
# ── actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ActionResponse(BaseModel):
|
||||
id: str
|
||||
session_id: str
|
||||
run_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
tool_name: str
|
||||
logical_action_id: str | None = None
|
||||
risk_level: str
|
||||
status: str
|
||||
arguments: dict[str, Any] | None = None
|
||||
result: dict[str, Any] | None = None
|
||||
external_object_id: str | None = None
|
||||
approval_id: str | None = None
|
||||
attempt_count: int
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class ActionListResponse(BaseModel):
|
||||
actions: list[ActionResponse]
|
||||
|
||||
|
||||
# ── approvals ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ApprovalResponse(BaseModel):
|
||||
id: str
|
||||
session_id: str
|
||||
run_id: str | None = None
|
||||
action_id: str | None = None
|
||||
requested_by_agent_id: str | None = None
|
||||
status: str
|
||||
summary: str
|
||||
payload: dict[str, Any] | None = None
|
||||
decided_by: str | None = None
|
||||
decided_at: str | None = None
|
||||
expires_at: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class ApprovalListResponse(BaseModel):
|
||||
approvals: list[ApprovalResponse]
|
||||
|
||||
|
||||
class ApprovalDecisionRequest(BaseModel):
|
||||
# approved | rejected
|
||||
decision: str = Field(..., pattern="^(approved|rejected)$")
|
||||
|
||||
|
||||
# ── health ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health / feature-flag probe. The frontend reads ``enabled`` to decide
|
||||
whether to show the MAF entry. ``store_available`` is false when no DB is
|
||||
configured (memory mode) — the router then returns 503 on writes.
|
||||
|
||||
SSE fields (P1): ``sse_hub_type`` reports the hub implementation
|
||||
(``in_memory`` or future ``redis``); ``sse_multi_worker`` is ``False``
|
||||
until Redis Pub/Sub is wired; ``sse_warning`` carries a human-readable
|
||||
advisory. When multi-worker is unsupported, production deployments must
|
||||
use a single worker or fail-closed.
|
||||
"""
|
||||
|
||||
enabled: bool
|
||||
conversation_dispatch_ready: bool = False
|
||||
conversation_dispatch_status: str = "configuration_unavailable"
|
||||
conversation_stream_ready: bool = False
|
||||
conversation_stream_status: str = "unknown"
|
||||
conversation_event_backend: str = "memory"
|
||||
store_available: bool
|
||||
default_mode: str
|
||||
default_write_policy: str
|
||||
default_max_rounds: int
|
||||
admin_diagnostic_access: bool
|
||||
write_guard_enabled: bool
|
||||
write_guard_ready: bool
|
||||
action_secret_configured: bool
|
||||
write_guard_status: str
|
||||
sse_hub_type: str = "in_memory"
|
||||
sse_multi_worker: bool = False
|
||||
sse_warning: str = "Single-worker only. Multi-worker requires Redis Pub/Sub."
|
||||
@@ -1,206 +0,0 @@
|
||||
"""Seat turn structured-output schema + one-shot format repair (Phase 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
RepairRunner = Callable[[str], Awaitable[str]]
|
||||
|
||||
|
||||
class SeatTurnOutput(BaseModel):
|
||||
"""Validated structured output produced by an expert seat turn."""
|
||||
|
||||
summary: str = Field(..., min_length=1, description="Short natural-language summary")
|
||||
findings: list[str] = Field(default_factory=list)
|
||||
evidence_refs: list[str | dict[str, Any]] = Field(default_factory=list)
|
||||
proposed_actions: list[str | dict[str, Any]] = Field(default_factory=list)
|
||||
# Model-claimed executed actions are informational only — the fact ledger
|
||||
# never trusts them; ActionCoordinator is the sole source of truth.
|
||||
executed_actions: list[str | dict[str, Any]] = Field(default_factory=list)
|
||||
open_questions: list[str] = Field(default_factory=list)
|
||||
conflicts: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfirmedCandidate(BaseModel):
|
||||
"""A candidate fact the cross-review confirms, WITH required evidence.
|
||||
|
||||
The server only promotes a candidate to confirmed_facts when: the
|
||||
candidate_id exists in candidate_facts AND at least one evidence_ref is
|
||||
supplied AND every evidence_ref resolves to a known evidence/tool result in
|
||||
the ledger. This prevents a weak model from promoting a bare seat claim to
|
||||
an "established fact" with no backing.
|
||||
"""
|
||||
|
||||
candidate_id: str = Field(..., min_length=1)
|
||||
evidence_refs: list[str] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class CrossReviewOutput(BaseModel):
|
||||
"""Validated output of the cross-review stage."""
|
||||
|
||||
summary: str = Field(..., min_length=1)
|
||||
# Conservative default: a review must positively assert evidence is OK; we
|
||||
# never assume it. (Previously defaulted True, which let a model that
|
||||
# omitted the field count as "evidence sufficient".)
|
||||
evidence_ok: bool = False
|
||||
gaps: list[str] = Field(default_factory=list)
|
||||
conflicts: list[str] = Field(default_factory=list)
|
||||
tool_failures: list[str] = Field(default_factory=list)
|
||||
write_status_notes: list[str] = Field(default_factory=list)
|
||||
recommendations: list[str] = Field(default_factory=list)
|
||||
# Phase-3 P1-1: confirmed candidates now carry mandatory evidence. The
|
||||
# legacy bare-id list is still accepted (for back-compat with old prompts)
|
||||
# but is treated as "no evidence" and therefore NOT promoted.
|
||||
confirmed_candidates: list[ConfirmedCandidate] = Field(default_factory=list)
|
||||
confirmed_candidate_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json_object(text: str) -> str | None:
|
||||
"""Best-effort extract of a JSON object from model text."""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
m = _JSON_FENCE_RE.search(raw)
|
||||
if m:
|
||||
raw = m.group(1).strip()
|
||||
if raw.startswith("{") and raw.endswith("}"):
|
||||
return raw
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return raw[start : end + 1]
|
||||
return None
|
||||
|
||||
|
||||
def parse_seat_output(text: str) -> tuple[SeatTurnOutput | None, list[str]]:
|
||||
"""Parse seat text into SeatTurnOutput. Returns (model, readable_errors)."""
|
||||
blob = _extract_json_object(text)
|
||||
if blob is None:
|
||||
return None, ["output is not a JSON object; emit a single JSON object matching SeatTurnOutput"]
|
||||
try:
|
||||
data = json.loads(blob)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, [f"invalid JSON: {exc.msg}"]
|
||||
if not isinstance(data, dict):
|
||||
return None, ["JSON root must be an object"]
|
||||
try:
|
||||
return SeatTurnOutput.model_validate(data), []
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for err in exc.errors():
|
||||
loc = ".".join(str(p) for p in err.get("loc", ()))
|
||||
errors.append(f"{loc}: {err.get('msg', 'invalid')}")
|
||||
return None, errors or ["schema validation failed"]
|
||||
|
||||
|
||||
def parse_cross_review_output(text: str) -> tuple[CrossReviewOutput | None, list[str]]:
|
||||
blob = _extract_json_object(text)
|
||||
if blob is None:
|
||||
return None, ["output is not a JSON object; emit CrossReviewOutput JSON"]
|
||||
try:
|
||||
data = json.loads(blob)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, [f"invalid JSON: {exc.msg}"]
|
||||
if not isinstance(data, dict):
|
||||
return None, ["JSON root must be an object"]
|
||||
try:
|
||||
return CrossReviewOutput.model_validate(data), []
|
||||
except ValidationError as exc:
|
||||
errors = [
|
||||
f"{'.'.join(str(p) for p in e.get('loc', ()))}: {e.get('msg', 'invalid')}" for e in exc.errors()
|
||||
]
|
||||
return None, errors or ["schema validation failed"]
|
||||
|
||||
|
||||
def build_repair_prompt(raw: str, errors: list[str], *, schema_name: str = "SeatTurnOutput") -> str:
|
||||
"""Build a one-shot repair instruction for the seat."""
|
||||
err_lines = "\n".join(f"- {e}" for e in errors[:12])
|
||||
return (
|
||||
f"你上一次输出未通过 {schema_name} 校验。请只输出一个 JSON 对象(不要 Markdown 说明),"
|
||||
f"修正下列问题后重试一次:\n{err_lines}\n\n"
|
||||
f"原始输出(供参考,请重写为合法 JSON):\n{raw[:4000]}"
|
||||
)
|
||||
|
||||
|
||||
def seat_output_instruction() -> str:
|
||||
"""Stage instruction fragment telling the model to emit SeatTurnOutput JSON."""
|
||||
return (
|
||||
"请以 JSON 对象回复,字段:summary(string,必填)、findings(string[])、"
|
||||
"evidence_refs、proposed_actions、executed_actions、open_questions、conflicts。"
|
||||
"proposed_actions 只表示建议;不要把未真正由系统执行的外部写入标为已执行。"
|
||||
)
|
||||
|
||||
|
||||
async def validate_or_repair_seat_output(
|
||||
*,
|
||||
raw_content: str,
|
||||
repair_runner: RepairRunner | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate seat output; optionally run one repair turn via ``repair_runner``.
|
||||
|
||||
``repair_runner`` is an async callable ``(repair_prompt: str) -> str``.
|
||||
|
||||
Returns::
|
||||
{
|
||||
"schema_status": "ok" | "schema_invalid" | "repaired",
|
||||
"output": dict | None,
|
||||
"errors": [...],
|
||||
"raw_content": str,
|
||||
"repair_attempted": bool,
|
||||
}
|
||||
"""
|
||||
model, errors = parse_seat_output(raw_content)
|
||||
if model is not None:
|
||||
return {
|
||||
"schema_status": "ok",
|
||||
"output": model.model_dump(),
|
||||
"errors": [],
|
||||
"raw_content": raw_content,
|
||||
"repair_attempted": False,
|
||||
}
|
||||
|
||||
if repair_runner is None:
|
||||
return {
|
||||
"schema_status": "schema_invalid",
|
||||
"output": None,
|
||||
"errors": errors,
|
||||
"raw_content": raw_content,
|
||||
"repair_attempted": False,
|
||||
}
|
||||
|
||||
repair_prompt = build_repair_prompt(raw_content, errors)
|
||||
try:
|
||||
repaired_text = await repair_runner(repair_prompt)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"schema_status": "schema_invalid",
|
||||
"output": None,
|
||||
"errors": errors + [f"repair failed: {type(exc).__name__}"],
|
||||
"raw_content": raw_content,
|
||||
"repair_attempted": True,
|
||||
}
|
||||
|
||||
model2, errors2 = parse_seat_output(str(repaired_text or ""))
|
||||
if model2 is not None:
|
||||
return {
|
||||
"schema_status": "repaired",
|
||||
"output": model2.model_dump(),
|
||||
"errors": [],
|
||||
"raw_content": str(repaired_text),
|
||||
"repair_attempted": True,
|
||||
}
|
||||
return {
|
||||
"schema_status": "schema_invalid",
|
||||
"output": None,
|
||||
"errors": errors2 or errors,
|
||||
"raw_content": raw_content,
|
||||
"repair_attempted": True,
|
||||
}
|
||||