feat(maf): conversation-driven MAF roundtable (R1/R2) + server-owned stage-3 dispatch
Backend:
- Conversation API at /api/maf-roundtable/conversations: create/list/detail,
paginated messages, atomic send+run start, owner-only isolation,
admin read-only diagnostic access
- SSE real-time event stream (GET /conversations/{id}/stream) backed by
ConversationEventHub + conversation_events store; multi-worker deployment doc
- Deterministic server-owned stage-3 dispatch contracts (dispatch_contracts.py):
routing resolved from trusted MAF deployment config instead of manager-chosen
agent ids; config.yaml keeps stage-3 routing deployment-owned
(default_manager_agent_id / seat ids / business_routes / max_seats_per_run)
- Conversation persistence (conversations/messages stores) + v2 migration script
- Tests: conversations, conversation stream, migration, phase3 conversation dispatch
Frontend:
- Vitest test infra for MAF (vitest.maf.config.ts + test:maf script) with
component tests for ApprovalCard/RunHeader/SessionsPage and use-maf-events
- Session form lib, flow-graph dialog / run-detail / sessions page updates,
routes and nav tweaks
- Docs: R0 legacy interaction baseline mapping, updated implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
df408831e5
commit
99642fca6c
46 files changed
+6358
-1306
No files matched your search
@@ -0,0 +1,198 @@
|
||||
# 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] 任何人可根据本文档判断新版是否「像原页面」。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -106,19 +107,23 @@
|
||||
},
|
||||
"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"
|
||||
"vite-plugin-source-identifier": "^1.1.2",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, Network, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -196,18 +196,7 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/maf")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/maf">
|
||||
<Network />
|
||||
<span>MAF 会商</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{/* [MAF-R0] 实验原型已从正式菜单隐藏,仅保留路由供开发诊断:/page/maf */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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"]);
|
||||
});
|
||||
});
|
||||
@@ -104,10 +104,9 @@ describe("createInitialSessionForm:弹窗默认值来自 health", () => {
|
||||
assert.ok(line.includes("group_chat"));
|
||||
assert.ok(line.includes("auto"));
|
||||
assert.ok(line.includes("12"));
|
||||
// 空健康值走兜底占位
|
||||
const fallback = defaultsSummaryLine(null);
|
||||
assert.ok(fallback.includes("structured"));
|
||||
assert.ok(fallback.includes("confirm_high_risk"));
|
||||
// health 未就绪时不伪造后端默认值
|
||||
assert.equal(defaultsSummaryLine(null), "正在读取后端默认配置…");
|
||||
assert.equal(defaultsSummaryLine(undefined), "正在读取后端默认配置…");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -500,6 +499,35 @@ describe("buildMafFlowGraph 流程图构造", () => {
|
||||
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 二次脱敏(真实函数)──────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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,");
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,9 @@ export function MafFlowGraphDialog({ visible, onClose, checkpoint, streaming }:
|
||||
({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>
|
||||
)}
|
||||
@@ -115,23 +118,22 @@ function FlowGraphBody({ graph }: { graph: MafFlowGraphData }) {
|
||||
<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}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`cursor-pointer rounded transition ${isFocused ? "ring-1 ring-blue-300" : ""}`}
|
||||
onClick={() => setFocusedId(isFocused ? null : n.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setFocusedId(isFocused ? null : n.id);
|
||||
}
|
||||
}}
|
||||
className={`rounded transition ${isFocused ? "ring-1 ring-blue-300" : ""}`}
|
||||
>
|
||||
<TCard bordered>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
@@ -140,19 +142,30 @@ function FlowGraphBody({ graph }: { graph: MafFlowGraphData }) {
|
||||
</TTag>
|
||||
<span className="text-sm font-semibold text-slate-800">{n.label}</span>
|
||||
{inc.length > 0 && (
|
||||
<span className="ml-auto text-[10px] text-slate-400">← {inc.length}</span>
|
||||
<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 && (inc.length > 0 || out.length > 0) && (
|
||||
<div className="mt-2 space-y-1 border-t border-slate-100 pt-1.5">
|
||||
{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>
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface MafFlowGraphData {
|
||||
title: string;
|
||||
nodes: MafFlowGraphNode[];
|
||||
edges: MafFlowGraphEdge[];
|
||||
/** 是否因规模上限截断了节点/边/源文本(弱模型超大输出保护)。 */
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/** 节点类型 → 中文标签(图例 / 类型徽章)。 */
|
||||
@@ -65,6 +67,21 @@ 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 {
|
||||
@@ -179,14 +196,20 @@ function collectObjects(text: string, key: string): Record<string, unknown>[] {
|
||||
|
||||
function toNode(raw: Record<string, unknown>): MafFlowGraphNode | null {
|
||||
const id = asString(raw.id);
|
||||
const label = asString(raw.label) || asString(raw.name);
|
||||
if (!id || !label) return null;
|
||||
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 detail = asString(raw.detail) || asString(raw.description) || undefined;
|
||||
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, type, detail, group };
|
||||
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[] {
|
||||
@@ -226,6 +249,12 @@ export function extractMafFlowGraph(text: string): MafFlowGraphData | null {
|
||||
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) {
|
||||
@@ -235,6 +264,10 @@ export function extractMafFlowGraph(text: string): MafFlowGraphData | null {
|
||||
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[] = [];
|
||||
@@ -253,15 +286,26 @@ export function extractMafFlowGraph(text: string): MafFlowGraphData | null {
|
||||
}
|
||||
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 (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false;
|
||||
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;
|
||||
});
|
||||
return { title, nodes, edges: validEdges };
|
||||
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 证据图 ──────────────────────────────────────────────────
|
||||
@@ -332,7 +376,7 @@ export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlow
|
||||
id: factId,
|
||||
label: truncate(fact.text),
|
||||
type: "task",
|
||||
detail: fact.text,
|
||||
detail: clampText(fact.text, MAX_FLOW_DETAIL_CHARS),
|
||||
group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认",
|
||||
});
|
||||
|
||||
@@ -349,7 +393,7 @@ export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlow
|
||||
id: evId,
|
||||
label: truncate(refStr.replace(/^tool:/, ""), 24),
|
||||
type: evKind === "tool" ? "deliverable" : "role",
|
||||
detail: refStr,
|
||||
detail: clampText(refStr, MAX_FLOW_DETAIL_CHARS),
|
||||
group: evKind === "tool" ? "工具证据" : "外部证据",
|
||||
});
|
||||
addEdge(ROOT_ID, evId);
|
||||
@@ -365,7 +409,21 @@ export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlow
|
||||
return true;
|
||||
});
|
||||
|
||||
return { title: "事实证据图", nodes, edges: dedupEdges };
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -22,10 +22,13 @@ 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";
|
||||
@@ -49,6 +52,23 @@ function RunBody({ runId }: { runId: string }) {
|
||||
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);
|
||||
@@ -83,7 +103,11 @@ function RunBody({ runId }: { runId: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div ref={headerRef}>
|
||||
<MafRunHeader run={run} />
|
||||
<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]">
|
||||
@@ -101,6 +125,7 @@ function RunBody({ runId }: { runId: string }) {
|
||||
: undefined}
|
||||
/>
|
||||
<MafFactFlowPanel checkpoint={run.maf_checkpoint} streaming={live} />
|
||||
<MafFactLedgerPanel checkpoint={run.maf_checkpoint} />
|
||||
|
||||
{/* 最终结论(MAF-06-07:区分建议动作与已执行动作) */}
|
||||
{showFinalResult && (
|
||||
@@ -150,6 +175,8 @@ function RunBody({ runId }: { runId: string }) {
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<MafActionAuditPanel runId={runId} live={live} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 可选高级项(模型/推理强度/最大轮数/写入策略)。智能体列表复用 @/core/agents。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Loader2, MessageSquarePlus, Play, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -30,6 +30,15 @@ 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> = {
|
||||
@@ -89,9 +98,9 @@ function SessionsBody() {
|
||||
theme="default"
|
||||
variant="outline"
|
||||
size="small"
|
||||
icon={<MessageSquarePlus className="h-3.5 w-3.5" />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
icon={<MessageSquarePlus className="h-3.5 w-3.5" />}
|
||||
新建
|
||||
</TButton>
|
||||
</div>
|
||||
@@ -206,10 +215,10 @@ function SessionDetail({
|
||||
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}`)}
|
||||
>
|
||||
icon={<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />}
|
||||
已有会商进行中
|
||||
已有会商进行中
|
||||
</TButton>
|
||||
)}
|
||||
<TButton
|
||||
@@ -217,9 +226,9 @@ function SessionDetail({
|
||||
size="small"
|
||||
loading={actions.start.isPending}
|
||||
disabled={!!liveRun}
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
onClick={onStart}
|
||||
>
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
启动会商
|
||||
</TButton>
|
||||
</div>
|
||||
@@ -264,7 +273,7 @@ function SessionDetail({
|
||||
);
|
||||
}
|
||||
|
||||
function CreateSessionDialog({
|
||||
export function CreateSessionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
@@ -277,76 +286,86 @@ function CreateSessionDialog({
|
||||
const sessionActions = useMafSessionActions();
|
||||
const { agents, isLoading: agentsLoading, error: agentsError } = useAgents();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [taskText, setTaskText] = useState("");
|
||||
const [mode, setMode] = useState("structured");
|
||||
const [managerAgentId, setManagerAgentId] = useState("");
|
||||
const [seatAgentIds, setSeatAgentIds] = useState<string[]>([]);
|
||||
// 高级项
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [modelName, setModelName] = useState("");
|
||||
const [reasoningEffort, setReasoningEffort] = useState("");
|
||||
const [maxRounds, setMaxRounds] = useState("");
|
||||
const [writePolicy, setWritePolicy] = useState("");
|
||||
// 表单状态:单一对象,由 createInitialSessionForm 根据 health 默认值初始化。
|
||||
const [form, setForm] = useState<SessionFormState>(() =>
|
||||
createInitialSessionForm(health.data),
|
||||
);
|
||||
const [maxRoundsError, setMaxRoundsError] = useState<string>("");
|
||||
|
||||
// 默认 mode 跟随后端 default_mode(在 effect 中设置,避免 render 内 setState)
|
||||
// 仅在弹窗「由关闭变打开」时重置表单(干净表单 + 从 health 重新取默认值)。
|
||||
// 不在 health.data 变化时重置——避免打开中途 health 刷新把用户已填内容冲掉。
|
||||
const prevOpenRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (health.data?.default_mode) {
|
||||
setMode(health.data.default_mode);
|
||||
if (open && !prevOpenRef.current) {
|
||||
setForm(createInitialSessionForm(health.data));
|
||||
setMaxRoundsError("");
|
||||
}
|
||||
}, [health.data?.default_mode]);
|
||||
prevOpenRef.current = open;
|
||||
}, [open, health.data]);
|
||||
|
||||
// 默认 write_policy 跟随后端 default_write_policy
|
||||
// 切换经理时,自动从已选席位中移除(后端参与者唯一约束)
|
||||
useEffect(() => {
|
||||
if (health.data?.default_write_policy) {
|
||||
setWritePolicy(health.data.default_write_policy);
|
||||
}
|
||||
}, [health.data?.default_write_policy]);
|
||||
setForm((prev) => {
|
||||
const nextSeats = filterSeatsForManager(
|
||||
prev.seatAgentIds,
|
||||
prev.managerAgentId,
|
||||
);
|
||||
|
||||
// 切换经理时,自动从已选席位中移除(后端参与者唯一约束:同一 agent 不能同时是 manager 和 seat)
|
||||
useEffect(() => {
|
||||
if (managerAgentId) {
|
||||
setSeatAgentIds((prev) => prev.filter((id) => id !== managerAgentId));
|
||||
}
|
||||
}, [managerAgentId]);
|
||||
return nextSeats.length === prev.seatAgentIds.length
|
||||
? prev
|
||||
: { ...prev, seatAgentIds: nextSeats };
|
||||
});
|
||||
}, [form.managerAgentId]);
|
||||
|
||||
const toggleSeat = (agentId: string) => {
|
||||
// 经理不能同时作为席位
|
||||
if (agentId === managerAgentId) return;
|
||||
setSeatAgentIds((prev) =>
|
||||
prev.includes(agentId)
|
||||
? prev.filter((id) => id !== agentId)
|
||||
: [...prev, agentId],
|
||||
);
|
||||
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 (!taskText.trim()) {
|
||||
if (!healthReady) {
|
||||
toast.error("后端默认配置尚未就绪,请稍后重试");
|
||||
return;
|
||||
}
|
||||
if (!form.taskText.trim()) {
|
||||
toast.error("请填写任务说明");
|
||||
return;
|
||||
}
|
||||
if (!managerAgentId) {
|
||||
if (!form.managerAgentId) {
|
||||
toast.error("请选择经理智能体");
|
||||
return;
|
||||
}
|
||||
if (seatAgentIds.length === 0) {
|
||||
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 === managerAgentId);
|
||||
const managerAgent = agents.find((a) => a.id === form.managerAgentId);
|
||||
participants.push({
|
||||
agent_id: managerAgentId,
|
||||
agent_id: form.managerAgentId,
|
||||
display_name: managerAgent?.name ?? "",
|
||||
role: "manager",
|
||||
sort_order: 0,
|
||||
});
|
||||
// 席位
|
||||
seatAgentIds.forEach((agentId, idx) => {
|
||||
form.seatAgentIds.forEach((agentId, idx) => {
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
participants.push({
|
||||
agent_id: agentId,
|
||||
@@ -357,49 +376,37 @@ function CreateSessionDialog({
|
||||
});
|
||||
|
||||
const body: CreateSessionBody = {
|
||||
title: title.trim(),
|
||||
task_text: taskText.trim(),
|
||||
mode: mode as "structured" | "group_chat",
|
||||
manager_agent_id: managerAgentId,
|
||||
title: form.title.trim(),
|
||||
task_text: form.taskText.trim(),
|
||||
manager_agent_id: form.managerAgentId,
|
||||
participants,
|
||||
};
|
||||
// 高级项(仅非空时传)
|
||||
if (modelName.trim()) body.model_name = modelName.trim();
|
||||
if (reasoningEffort.trim()) body.reasoning_effort = reasoningEffort.trim();
|
||||
if (maxRounds.trim()) {
|
||||
const rounds = Number(maxRounds);
|
||||
if (!Number.isInteger(rounds) || rounds < 1 || rounds > 64) {
|
||||
toast.error("最大轮数必须为 1–64 的整数");
|
||||
return;
|
||||
}
|
||||
body.max_rounds = rounds;
|
||||
}
|
||||
if (writePolicy) body.write_policy = writePolicy as CreateSessionBody["write_policy"];
|
||||
// 仅传被改写的模式 / 写入策略 / 最大轮数(来自 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("会话已创建");
|
||||
// 重置表单
|
||||
setTitle("");
|
||||
setTaskText("");
|
||||
setManagerAgentId("");
|
||||
setSeatAgentIds([]);
|
||||
setModelName("");
|
||||
setReasoningEffort("");
|
||||
setMaxRounds("");
|
||||
setWritePolicy("");
|
||||
setShowAdvanced(false);
|
||||
onOpenChange(false);
|
||||
onCreated(s.id);
|
||||
},
|
||||
onError: (e: Error) => toast.error(`创建失败:${e.message}`),
|
||||
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
|
||||
@@ -416,20 +423,29 @@ function CreateSessionDialog({
|
||||
<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={title}
|
||||
onChange={(val: string) => setTitle(val)}
|
||||
value={form.title}
|
||||
onChange={(val: string) => patch({ title: val })}
|
||||
placeholder="例如:某任务多视角研讨"
|
||||
/>
|
||||
</div>
|
||||
@@ -440,8 +456,8 @@ function CreateSessionDialog({
|
||||
任务说明 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<TTextarea
|
||||
value={taskText}
|
||||
onChange={(val: string) => setTaskText(val)}
|
||||
value={form.taskText}
|
||||
onChange={(val: string) => patch({ taskText: val })}
|
||||
placeholder="描述本次会商要解决的问题、背景和目标(必填)"
|
||||
autosize={{ minRows: 3, maxRows: 8 }}
|
||||
/>
|
||||
@@ -451,8 +467,8 @@ function CreateSessionDialog({
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-600">会商模式</label>
|
||||
<TSelect
|
||||
value={mode}
|
||||
onChange={(val) => setMode(String(val))}
|
||||
value={form.mode}
|
||||
onChange={(val) => patch({ mode: String(val) })}
|
||||
options={[
|
||||
{ label: "structured(规划→专家→评审→总结)", value: "structured" },
|
||||
{ label: "group_chat(协调员动态点名)", value: "group_chat" },
|
||||
@@ -475,8 +491,8 @@ function CreateSessionDialog({
|
||||
</div>
|
||||
) : (
|
||||
<TSelect
|
||||
value={managerAgentId}
|
||||
onChange={(val) => setManagerAgentId(String(val))}
|
||||
value={form.managerAgentId}
|
||||
onChange={(val) => patch({ managerAgentId: String(val) })}
|
||||
options={agentOptions}
|
||||
placeholder="请选择…"
|
||||
filterable
|
||||
@@ -507,20 +523,20 @@ function CreateSessionDialog({
|
||||
{!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={seatAgentIds.includes(a.id)}
|
||||
onChange={() => toggleSeat(a.id)}
|
||||
disabled={a.id === managerAgentId}
|
||||
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 === managerAgentId && (
|
||||
{a.id === form.managerAgentId && (
|
||||
<span className="text-[10px] text-slate-400">(已选为经理)</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{seatAgentIds.length > 0 && (
|
||||
{form.seatAgentIds.length > 0 && (
|
||||
<div className="mt-1 text-[10px] text-slate-400">
|
||||
已选 {seatAgentIds.length} 个席位
|
||||
已选 {form.seatAgentIds.length} 个席位
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -529,18 +545,24 @@ function CreateSessionDialog({
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
onClick={() => patch({ showAdvanced: !form.showAdvanced })}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{showAdvanced ? "收起高级选项" : "展开高级选项"}
|
||||
{form.showAdvanced ? "收起高级选项" : "展开高级选项"}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
{/* 高级项未展开时,显示一行只读后端默认摘要(值只来自 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={modelName}
|
||||
onChange={(val: string) => setModelName(val)}
|
||||
value={form.modelName}
|
||||
onChange={(val: string) => patch({ modelName: val })}
|
||||
placeholder="留空使用默认"
|
||||
size="small"
|
||||
/>
|
||||
@@ -548,8 +570,8 @@ function CreateSessionDialog({
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[11px] text-slate-500">推理强度</label>
|
||||
<TSelect
|
||||
value={reasoningEffort}
|
||||
onChange={(val) => setReasoningEffort(String(val))}
|
||||
value={form.reasoningEffort}
|
||||
onChange={(val) => patch({ reasoningEffort: String(val) })}
|
||||
size="small"
|
||||
options={[
|
||||
{ label: "留空使用默认", value: "" },
|
||||
@@ -562,18 +584,24 @@ function CreateSessionDialog({
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[11px] text-slate-500">最大轮数(1–64)</label>
|
||||
<TInput
|
||||
value={maxRounds}
|
||||
onChange={(val: string) => setMaxRounds(val)}
|
||||
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={writePolicy}
|
||||
onChange={(val) => setWritePolicy(String(val))}
|
||||
value={form.writePolicy}
|
||||
onChange={(val) => patch({ writePolicy: String(val) })}
|
||||
size="small"
|
||||
options={[
|
||||
{ label: "默认", value: "" },
|
||||
@@ -600,6 +628,7 @@ export function DeleteSessionButton({ sessionId }: { sessionId: string }) {
|
||||
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("已删除"),
|
||||
@@ -607,7 +636,6 @@ export function DeleteSessionButton({ sessionId }: { sessionId: string }) {
|
||||
});
|
||||
}}
|
||||
>
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
删除
|
||||
</TButton>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* [MAF-R0 实验原型 — 仅供开发诊断,已从正式菜单移除]
|
||||
*
|
||||
* MAF 多智能体会商路由树(/page/maf/*)。
|
||||
* 本原型不再新增业务功能。新版会商将使用 /api/maf-roundtable/* + conversation 页面。
|
||||
*
|
||||
* 仿 RoundtableRoutes:顶层包一层满高容器,内部 <Routes>:
|
||||
* index → 会话列表
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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/**"],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user