From 9ae6184dc109dd59f954532b11c4aeb4e3c19987 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 2 Jun 2026 21:15:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E9=93=BE=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/docs/multi-agent-backend-dev.md | 25 + .../docs/multi-agent-business-chain-dev.md | 399 ++++++++++ frontend-web/docs/multi-agent-frontend-dev.md | 5 +- frontend-web/package.json | 3 + frontend-web/src/pages/WorkspaceRoutes.tsx | 9 + .../src/roundtable-planning/api/chains.ts | 129 ++++ .../src/roundtable-planning/api/drafts.ts | 9 + .../components/BusinessChainPicker.tsx | 266 +++++++ .../components/RecommendAgentsDialog.tsx | 95 ++- .../hooks/useStep2Orchestration.ts | 340 ++++++++- .../roundtable-planning/lib/business-chain.ts | 72 ++ .../pages/BusinessChainEditorPage.tsx | 708 ++++++++++++++++++ .../pages/RoundtablePlanningPage.tsx | 47 +- .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 4 + .../app/gateway/routers/roundtable_chains.py | 162 ++++ .../deerflow/persistence/models/__init__.py | 2 + .../persistence/roundtable_chains/__init__.py | 18 + .../persistence/roundtable_chains/model.py | 44 ++ .../persistence/roundtable_chains/sql.py | 123 +++ 20 files changed, 2440 insertions(+), 24 deletions(-) create mode 100644 frontend-web/docs/multi-agent-business-chain-dev.md create mode 100644 frontend-web/src/roundtable-planning/api/chains.ts create mode 100644 frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx create mode 100644 frontend-web/src/roundtable-planning/lib/business-chain.ts create mode 100644 frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/sql.py diff --git a/frontend-web/docs/multi-agent-backend-dev.md b/frontend-web/docs/multi-agent-backend-dev.md index 3e5bf522..b0ab554a 100644 --- a/frontend-web/docs/multi-agent-backend-dev.md +++ b/frontend-web/docs/multi-agent-backend-dev.md @@ -649,6 +649,31 @@ JSON 列在 repo(`sql.py`)里用 `json.dumps/loads` 手动序列化(与 `a --- +## 7B. 业务链条持久化(`roundtable_chains.py`,2026-06) + +「业务链条」是用户在配置页编排的**可复用、命名、有序**的智能体编排模板;选中后 Step 2 由总控**按链条顺序派活**(前端编排,后端零改动)。链条持久化仿 §7A,新增一张表 + 一组 CRUD 路由。完整前后端设计见 `frontend-web/docs/multi-agent-business-chain-dev.md`。 + +### 7B.1 数据模型(`deerflow.persistence.roundtable_chains`) + +- 表 **`roundtable_chains`**:`id`(PK str64) / `user_id`(idx) / `title`(512) / `description`(512, nullable) / `seats`(**`PortableLongText`** JSON:有序 `[{agent_id,name,description}, ...]`,`seats[0]` 先发言) / `created_at` / `updated_at`(**`BeijingDateTime`**)。 +- ORM `RoundtableChainRow` 登记进 `persistence/models/__init__.py`,启动 `Base.metadata.create_all` 自动建表;store `make_roundtable_chain_store(sf)` 接到 `app.state.roundtable_chain_store`(`deps.py`)。 + +### 7B.2 路由(`app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `` | 当前用户全部链条(含 seats,按 `updated_at` 倒序) | +| POST | `` | 新建(可带客户端 `id`);校验席位 `2 ≤ N ≤ 8` + 去重 | +| GET | `/{id}` | 取单条;非属主 → 404 | +| PUT | `/{id}` | partial 更新(`exclude_unset`);带 seats 时同样校验 | +| DELETE | `/{id}` | 204;非属主 → 404 | + +- 鉴权/属主用 `_current_user_id(request)`,跨用户一律 404,与 `roundtable_drafts` 一致。 +- 席位约束 `MIN_SEATS=2 / MAX_SEATS=8` 与前端 `CHAIN_MIN/MAX_SEATS`、推荐弹窗 `MIN/MAX_SELECTED` 对齐;前端配置页保存前先校验,后端 `_validate_seats` 兜底(400)。 +- 前端 `api/chains.ts` 直接打这组路由(snake↔camel 边界映射);测试 `tests/test_roundtable_chains.py`(CRUD + 部分更新 + 跨用户隔离)。 + +--- + ## 8. 内置席位 SOUL 设计要点 每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。 diff --git a/frontend-web/docs/multi-agent-business-chain-dev.md b/frontend-web/docs/multi-agent-business-chain-dev.md new file mode 100644 index 00000000..6a9a5338 --- /dev/null +++ b/frontend-web/docs/multi-agent-business-chain-dev.md @@ -0,0 +1,399 @@ +# 业务链条模式 · 需求与分步实现文档 + +> 面向「智能体推荐弹窗」的扩展:在现有**智能推荐**之外,新增**业务链条选择**与**业务链条配置**两块能力;选定业务链条后,Step 2 圆桌研讨**按链条内的固定顺序**逐个发言(而非现在的总控派活)。 +> +> 配套阅读: +> - 前端总览:`multi-agent-frontend-dev.md`(推荐弹窗 §6.2、Step 2 编排 §5.4、草稿持久化 §5.7) +> - 后端总览:`multi-agent-backend-dev.md`(Step 2 §6、草稿/历史持久化 §7A) +> +> 本文档约定:**「席位 / 智能体」**指一个 agent;**「业务链条 / chain」**指一组有序排列的 agent 配置,是可命名、可复用、可持久化的实体。 + +--- + +## 0. 需求原文拆解 + +用户原始诉求逐条拆成可实现条目: + +| # | 原始诉求 | 拆解 | +|---|----------|------| +| 1 | 推荐弹窗要有「模式选择」 | 弹窗顶部加模式切换(智能推荐 / 业务链条),两种模式互斥,确认后都产出**有序**的 `SelectedAgent[]` | +| 2 | 「智能推荐」就是现在这个 | 现有 `RecommendAgentsDialog` 整体收进「智能推荐」模式,行为不变 | +| 3 | 「业务链条选择」是卡片列表 | 卡片 = 标题 + 一排智能体名 tag;tag 不同颜色;tag 之间有**向左箭头**连接,表达链条次序 | +| 4 | 业务链条需要一个**配置页面** | 左侧智能体列表(复用 `GET /api/agents`),右侧有序长条卡片(名称 + 描述);支持右侧内部拖拽排序、左→右拖入指定位置 | +| 5 | 配置页用弹窗还是新页面? | **本文档决策:独立路由页面**(理由见 §2.1) | +| 6 | 选定链条后,Step 2 用链条内智能体研讨 | 复用既有 Step 2 init/stream;**总控(leader)按链条顺序逐个派活**(§6) | +| 7 | 规划成分步可实现的需求文档 | 见 §7 分阶段实现 | + +--- + +## 1. 目标与非目标 + +### 1.1 目标 +- 推荐弹窗成为「**席位来源选择器**」:无论智能推荐还是业务链条,最终都向 `onConfirm` 交付一个**有序的 `{ agent_id, name }[]`** + 一个 `orchestrationMode`。 +- 业务链条是**用户可创建、命名、保存、复用、删除**的有序智能体编排模板。 +- Step 2 支持两种编排: + - `recommend` 模式 → 现有总控**自由派活**循环(不动)。 + - `chain` 模式 → 总控仍是派活方,但**被约束按链条顺序逐个派活**(新增)。 + +### 1.2 非目标(本期不做,留扩展点) +- 链条内每个席位的 per-seat prompt/temperature 真实覆盖(仍仅前端记录,沿用现状)。 +- 链条的分支 / 条件流转(只做线性顺序链)。 +- 链条跨用户共享 / 市场(先做「本人私有链条」)。 +- 链条版本管理 / 协作编辑。 + +--- + +## 2. 关键设计决策 + +### 2.1 决策 A:配置页面用「独立路由页面」而非弹窗 ✅ + +**结论:业务链条的「编辑/管理」用独立路由页面 `/page/workspace/roundtable/chains`;业务链条的「选择」仍内嵌在推荐弹窗里(卡片列表)。** + +理由: + +| 维度 | 弹窗 | 独立页面 ✅ | +|------|------|------------| +| 双栏 + 拖拽 + 长条卡片 | 弹窗宽度(`sm:max-w-3xl`)+ `max-h-[85vh]` 拖拽空间局促,左右栏挤 | 整屏宽度,拖拽手感好 | +| 复用入口 | 只能从推荐弹窗进 | 可从侧栏、推荐弹窗「管理链条」按钮、深链接进入 | +| 可深链接/刷新 | 不可 | `/roundtable/chains` 可直达、可分享 | +| 推荐弹窗内嵌编辑器 | 弹窗套弹窗 / 模式套编辑,状态复杂 | 关注点分离:弹窗只「选」,页面只「编」 | + +**分工**: +- **推荐弹窗**:模式选择 + 业务链条**选择**(只读卡片列表,可点「去配置」跳到页面)。 +- **配置页面**:业务链条的**增删改**(双栏拖拽编辑器 + 链条列表管理)。 + +> 备选:若产品坚持不加路由,可改为**全屏 Dialog(`max-w-[90vw] h-[90vh]`)**承载编辑器,从推荐弹窗内打开。结构与页面版一致,仅外壳不同。本文档按「独立页面」实现,§7 标注了切换成本。 + +### 2.2 决策 B:链条持久化 —— 后端 MySQL(确定) ✅ + +**结论:业务链条存后端 MySQL,仿 `roundtable_drafts`(§7A)新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由,按登录用户隔离、跨设备保留。** 不走 localStorage。 + +理由:链条是可复用、需跨设备/会话保留的模板,与草稿/推荐历史性质一致;草稿当年正是因 localStorage 换设备即丢才迁后端(§7A),链条不重复踩坑。 + +落地: +- 表/路由设计见 §5.2,是「复制 §7A 草稿持久化、把字段换成 chain」的机械工作。 +- 前端 `api/chains.ts` 直接打后端接口(snake_case↔camelCase 在这层收口,仿 `api/drafts.ts`),**不提供 localStorage 实现**。 +- 后端表必须先于前端 UI 落地(§7 阶段 0 即包含后端表/路由)。 + +### 2.3 决策 C:拖拽库选型 —— `@dnd-kit` + +仓库**当前无任何拖拽库**(`package.json` 无 `@dnd-kit` / `react-dnd` / `sortablejs`;`@xyflow/react` 是节点图,不适合列表排序)。 + +| 方案 | 取舍 | +|------|------| +| **`@dnd-kit/core` + `@dnd-kit/sortable`**(推荐) | React 19 友好、可访问性好、跨容器拖入 + 列表排序 + 插入指示器开箱即用;新增 ~30KB gzip 依赖 | +| 原生 HTML5 DnD(零依赖兜底) | 不加依赖,但跨容器插入位置指示、键盘可达性都要手写,易出 bug | + +**建议**:新增 `@dnd-kit/core`、`@dnd-kit/sortable`、`@dnd-kit/utilities`。若产品方禁止新增依赖,退回原生 HTML5 DnD(§7 阶段 2 标注差异)。 + +### 2.4 决策 D:Step 2「总控按链条顺序派活」,后端零改动 ✅ + +**形态:总控(leader)仍是唯一派活方,但每一轮被前端约束「只能派给链条里的下一个席位」,逐个走完整条链,最后由总控收口。** 区别于 recommend 模式的「总控自由决定派给谁」。 + +为什么后端无需改动: +- Step 2 后端 `streamMultiAgent({ agentType: "leader" })` 每轮返回 `dispatched = [[agent, task], ...]`;`agentType: "special"` 单独驱动某席位跑一轮。这两个原语已足够。 +- 每个 special 跑完,后端 `_broadcast` 会把「子智能体 X 完成…交付内容为:…」append 进**其它所有席位 + 总控 thread** 的历史(后端 §6.4),所以链路上下文天然累积。 +- chain 模式只是**改变前端怎么提示 leader、以及怎么校验它的派活**——把每轮 leader 的提示词约束成「请向链条第 i 位【X】派活」,并对返回的 `dispatched` 做「必须命中链条下一席位」的校验/纠偏。**后端路由、SOUL、表结构都不动。** + +→ chain 模式 = 在 `useStep2Orchestration` 里新增 `runChainOrchestration`:按链条游标逐位调 leader(约束派给该位)→ 拿到 task → 调 special → 下一位 → 链尾后调一次 leader 收口。详见 §6。 + +> 与「前端绕过 leader 直接顺序 special」的区别:本方案**保留总控在每一步的协调/串接职责**(leader 结合前序进展为下一席位编写针对性任务、并在派活说明里承上启下),更符合「总控按链条顺序派活」的语义,圆桌可视化里总控气泡也照常出现。 + +--- + +## 3. 数据模型与类型 + +新增前端类型,建议放 `frontend-web/src/roundtable-planning/lib/business-chain.ts`: + +```ts +/** 业务链条里的一个有序席位。 */ +export interface ChainSeat { + agent_id: string; + /** 落库时的快照名/描述,避免 agent 改名后链条显示错乱;展示优先用实时 agent 数据,回退到快照。 */ + name: string; + description?: string; +} + +/** 一条可复用的业务链条。 */ +export interface BusinessChain { + id: string; + title: string; + description?: string; + /** 有序席位列表;seats[0] 是第一个发言者。 */ + seats: ChainSeat[]; + createdAt?: string; + updatedAt?: string; +} + +/** 推荐弹窗确认后回传给主页面的统一结果。 */ +export interface SeatSelectionResult { + /** 有序席位(与现有 handleConfirmAgents 入参兼容)。 */ + agents: Array<{ agent_id: string; name: string }>; + /** 决定 Step 2 走哪种编排。 */ + mode: "recommend" | "chain"; + /** chain 模式下带上来源链条(用于草稿快照 / Step 2 展示链名)。 */ + chain?: Pick; +} +``` + +**约束**:链条席位数沿用圆桌的 `2 ≤ N ≤ 8`(`MIN_SELECTED`/`MAX_SELECTED`),配置页保存时校验。 + +**tag 配色**:复用 `roundtable-constants.ts::getAvatarTypeFor(agentId, index)`,得到 `blue|purple|emerald|amber|pink|human`,再映射到已有的 avatar 配色 token(与圆桌可视化、Step 2 气泡同一套色板,视觉统一)。 + +--- + +## 4. 现有代码改动点速查 + +| 位置 | 改动 | 说明 | +|------|------|------| +| `components/RecommendAgentsDialog.tsx` | 包一层「模式选择」外壳,现有内容收进「智能推荐」Tab | `onConfirm` 签名从 `agents[]` 升级为 `SeatSelectionResult`(或加可选第二参 `mode`,见 §7 阶段 3 的兼容策略) | +| `pages/RoundtablePlanningPage.tsx` | `handleConfirmAgents` 接收 `mode`,存到新状态 `orchestrationMode` | 透传给 `useStep2Orchestration` | +| `hooks/useStep2Orchestration.ts` | 新增 `runChainOrchestration` + `orchestrationMode` 分支 | init effect 里按 mode 选择 `runOrchestration` 或 `runChainOrchestration` | +| `lib/roundtable-constants.ts` | 复用 `getAvatarTypeFor` / `SelectedAgent` | 不改,仅引用 | +| `hooks/useDraftPersistence.ts` + `api/drafts.ts` | Step2 快照新增 `orchestrationMode` + `chain` 字段 | 草稿能记住「这次是链条模式」 | +| `pages/WorkspaceRoutes.tsx` | 注册 `roundtable/chains` 路由 | 仿 `roundtable/planning` | +| `components/page-sidebar.tsx`(如有侧栏入口) | 加「业务链条」入口(可选) | 也可只从推荐弹窗进 | +| `api/chains.ts`(新增) | 链条 CRUD 客户端 | 直接打后端 `/api/roundtable-chains` | +| 后端 `roundtable_chains` | 表 + 路由(MySQL) | 仿 `roundtable_drafts`,**必做** | + +--- + +## 5. 链条持久化 API 设计 + +### 5.1 前端客户端 `api/chains.ts`(直接打后端) + +```ts +listChains(): Promise // 当前用户全部链条 +getChain(id: string): Promise +createChain(input: Omit): Promise +updateChain(id: string, patch: Partial): Promise +deleteChain(id: string): Promise +``` + +- 实现用 `apiFetch` 打 `/api/roundtable-chains`(§5.2),snake_case↔camelCase 在这层收口(仿 `api/drafts.ts`)。 +- 不提供 localStorage 实现;后端表是前端 UI 的硬依赖。 + +### 5.2 后端表 + 路由(仿 §7A `roundtable_drafts`) + +- 表 `roundtable_chains`:`id`(PK) / `user_id`(idx) / `title`(512) / `description`(text) / `seats`(`PortableLongText`,存 `ChainSeat[]` JSON) / `created_at` / `updated_at`(`BeijingDateTime`)。 +- 路由 `app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`:`GET ``(列表)/ `POST ``(建)/ `GET /{id}` / `PUT /{id}`(partial,`exclude_unset`)/ `DELETE /{id}`。 +- 鉴权/属主:`_current_user_id(request)`,跨用户访问一律 404,与 `roundtable_drafts` 完全一致。 +- 启动 `Base.metadata.create_all` 自动建表(MySQL 生产 / sqlite 本地走同一套 SQLAlchemy 代码,`PortableLongText` 各自降级),登记进 `persistence/models/__init__.py`,store 接到 `app.state` 并 `include_router`。 + +> 整体是「复制 §7A 的草稿持久化、把字段换成 chain」的机械工作,风险低。 + +--- + +## 6. Step 2 链条模式编排设计(`runChainOrchestration`) + +### 6.1 与现有 `runOrchestration` 的差异 + +| 维度 | `runOrchestration`(recommend) | `runChainOrchestration`(chain,新增) | +|------|--------------------------------|----------------------------------------| +| 谁派活 | 总控 leader | 总控 leader(**不变**) | +| 派给谁由谁定 | leader 自由决定 | 前端按链条游标**约束 leader 只能派给下一席位** | +| 发言顺序 | leader 每轮自由派 | **链条数组的固定顺序** | +| 循环结构 | `while` 直到 leader 返回 `[]` 共识 / MAX_CYCLES | 按链条游标走一趟(每位:leader 派活 → special 交付) | +| 收口 | leader 返回 `[]` 即共识 | 链尾后再调一次 leader 做总结收口,产出 `lastLeaderContent` + `hasConsensus` | + +> **核心**:leader 始终在场,只是**每轮的派活目标被前端钉死成链条的下一席位**——前端在 leader 的 `newMessage` 里写明「本轮请通过 `agent_orchestration` 仅向【X】派活」,并对 `leaderRes.dispatched` 做命中校验/纠偏。 + +### 6.2 伪代码 + +``` +runChainOrchestration(seedMessage, ids, coord, chainSeats): + isOrchestrating = true + cancel = false + N = chainSeats.length + + for (let i = 0; i < N; i++): // 链条游标,逐位推进 + if cancel: break + seat = chainSeats[i] + seatMeta = agentNameToRole[seat.agent_id] + + // ── (a) 总控派活给「链条下一席位」────────────────────────── + highlightSpeaker(null) // leader 阶段不高亮任何 sub + leaderBubble = appendStreamingDialogue("总控协调", confidence:"链条派活中") + leaderTask = buildChainLeaderTask({ + seedMessage, // 任务目标/约束(首轮) + position: i, total: N, + nextSeat: seat, // 「本轮请仅向【X(seat.agent_id)】派活」 + isFirst: i === 0, + }) + leaderRes = await streamMultiAgent(leader, ids, coord, leaderTask, model=globalModel) + + // 约束/纠偏:取 dispatched 中命中 seat.agent_id 的那条 task; + // 若 leader 没派或派错(派给非 seat、或派给多个),前端兜底: + // - 命中就用它给出的 task 文本(保留总控的承上启下措辞); + // - 没命中则用 leader 的正文 content 作为兜底 task,或回退到模板 task。 + task = pickChainTask(leaderRes, seat) + // leader 若返回 clarification → 暂停链条,走与现状一致的澄清条(§6.3) + finalizeDialogue(leaderBubble, leaderRes.content, "派活说明") + + // ── (b) 被派席位交付(special,顺序、不并行)────────────────── + markSpeakerStreaming(seatMeta.speakerId) + seatBubble = appendStreamingDialogue(seatMeta.displayName, confidence:"调度中") + perSeatModel = agentConfigs[seatMeta.speakerId]?.model || globalModel + specRes = await streamMultiAgent(special, ids, seat.agent_id, task, model=perSeatModel) + finalizeDialogue(seatBubble, specRes.content, "子智能体交付") + markSpeakerIdle(seatMeta.speakerId) + // 后端 _broadcast 已把本席位交付注入其余 thread 历史 → 下一席位天然可见 + + if cancel: return + + // ── (c) 总控收口:综合全链交付,产出最终结论 ────────────────── + leaderBubble = appendStreamingDialogue("总控协调", confidence:"汇总链条结论") + leaderRes = await streamMultiAgent(leader, ids, coord, + "各链条席位已按顺序完成发言,请综合所有交付,输出最终方案,不要再派活。") + finalizeDialogue(leaderBubble, leaderRes.content, "全案盖印") + setLastLeaderContent(leaderRes.content) + setHasConsensus(true); setConsensusPercentage(100) + isOrchestrating = false +``` + +要点: +- **派活方仍是总控**,符合「总控按链条顺序派活」的语义;圆桌可视化里每位席位前都先出现一个总控派活气泡,再出现该席位交付气泡。 +- **约束手段是 prompt + 前端校验,不动后端**:`buildChainLeaderTask` 把「本轮只能派给【X】」写进 leader 的 `newMessage`;`pickChainTask` 对 `leaderRes.dispatched` 做命中校验,派错/未派时兜底,保证链条游标严格推进。 +- **复用现有三段式气泡 / 高亮 / abort 基建**(`appendStreamingDialogue` / `finalizeDialogue` / `markSpeakerStreaming` / `activeAbortersRef` / `orchestrationCancelRef`),只是驱动顺序不同。 +- **special 仍是顺序**(不并行),与现状不变量一致(文件顶部注释 §1)。 +- **收口 leader 调用默认开**(产出 Step 3 结论);若链条末位本身是「总结陈述」类,可关掉收口,直接拿末位交付当 `lastLeaderContent`。配置位 `chainNeedsLeaderSummary`(默认 true)。 + +### 6.3 澄清 / 挂起 / 恢复 / 干预 +- **澄清**:链条中途某轮 leader 返回 `clarification` → 与现状一致,暂停链条、底部弹澄清条;用户回答后从**当前游标**继续(需记录 `chainCursor`)。 +- **挂起/恢复**:`isRoundTableRunning` toggle 复用现有机制;恢复时从 `chainCursor` 续跑。MVP 若不做断点续跑,可先只支持「整体停止 / 从头重跑」,UI 文案说明(扩展点,§7 阶段 4 标注)。 +- **干预**:可在两席位之间插入一条用户指令,由下一轮 leader 派活前消化。MVP 同上可后置。 + +### 6.4 多轮链条(扩展点,非 MVP) +单趟遍历是 MVP。若要「链条跑完后总控判断是否再来一轮」,可在收口 leader 处复用 `dispatched===[]?` 判定:收口 leader 若仍派活则回到链条头再跑一轮,直到共识或 MAX_CYCLES。MVP 不做,避免复杂度。 + +### 6.5 init 复用 +chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = chainSeats.map(s => s.agent_id)`(**按链条顺序**传入,使 `buildRoleTemplates` 的 seat id 顺序 = 链条顺序,且后端注入 coordinator 的「可调度席位清单」也按此序)。init effect 里按 `orchestrationMode` 决定 init 完成后调 `runOrchestration` 还是 `runChainOrchestration`。 + +--- + +## 7. 分阶段实现计划 + +> 每阶段都可独立交付、独立验收。建议顺序:**数据层 → 配置页 → 弹窗模式 → 链条选择 → Step2 编排 → 持久化兼容与收尾**。 + +### 阶段 0 · 后端链条表 + 脚手架(1.5 天) ✅ 已完成 +**目标**:打地基(含后端持久化),不含前端 UI。 +- [x] **后端**:仿 §7A `roundtable_drafts` 新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由(§5.2): + - [x] `persistence/roundtable_chains/`(model + sql repo + `make_roundtable_chain_store`),登记进 `persistence/models/__init__.py`。 + - [x] `app/gateway/routers/roundtable_chains.py`(GET 列表 / POST / GET{id} / PUT{id} / DELETE),鉴权属主同 `roundtable_drafts`;席位 2–8 + 去重校验。 + - [x] `deps.py` 接 `app.state.roundtable_chain_store`,`app.py` `include_router`。 + - [x] 后端测试 `tests/test_roundtable_chains.py`:CRUD + 部分更新 + 跨用户隔离(5 用例全过)。 +- [x] **前端**:新增 `lib/business-chain.ts`(`ChainSeat` / `BusinessChain` / `SeatSelectionResult`,§3)+ `api/chains.ts`(直接打后端,§5.1)。 +- [x] (决策 C)安装 `@dnd-kit/core` `@dnd-kit/sortable` `@dnd-kit/utilities`。 +- **验收**:✅ 后端 5 用例通过;✅ 表注册进 `Base.metadata`、路由 `/api/roundtable-chains` 加载正常;✅ ruff 0 错;✅ 前端 typecheck 新增文件 0 错。 + +### 阶段 1 · 业务链条配置页面(2–3 天) ✅ 已完成 +**目标**:能创建/编辑/保存/删除链条。 +- [x] 路由:`WorkspaceRoutes.tsx` 注册 `roundtable/chains`(套 `WorkspaceLayout`;本页**不需要** `ChatRuntime`,无沙箱依赖)。 +- [x] 页面 `roundtable-planning/pages/BusinessChainEditorPage.tsx`: + - [x] 顶部:链条列表下拉(` { + const id = e.target.value; + if (!id) { + startNewChain(); + return; + } + const chain = chains.find((c) => c.id === id); + if (chain) loadChain(chain); + }} + className="h-8 rounded-md border border-input bg-card px-2.5 text-sm font-medium shadow-sm outline-none focus:border-primary/60 focus:ring-2 focus:ring-primary/20" + > + + {chains.map((c) => ( + + ))} + + + + +
+ + +
+ + + {/* 标题 + 描述 */} +
+
+ 标题 + setTitle(e.target.value)} + placeholder="如:应急方案三段链" + className="bg-card" + maxLength={120} + /> +
+
+ 描述 + setDescription(e.target.value)} + placeholder="链条用途说明(可选)" + className="bg-card" + maxLength={500} + /> +
+
+ + {/* 双栏拖拽编辑器 */} + +
+ {/* 左栏:候选智能体 */} +
+
+
+ + 候选智能体 + + {pool.length} + + + 拖到右侧 / 点击加入 + +
+ +
+
+ {poolLoading ? ( +
+ + 正在加载候选智能体… +
+ ) : pool.length === 0 ? ( +
+ {search || filterTagIds.length + ? "没有匹配的智能体,换个关键词或标签试试。" + : `候选池为空,请先在「智能体」里创建至少 ${CHAIN_MIN_SEATS} 个智能体。`} +
+ ) : ( + pool.map((a) => ( + addAgentToSeats(a.agent_id)} + /> + )) + )} +
+
+ + {/* 右栏:有序席位 */} + +
+ + + {activeDrag ? ( +
+ + {activeDrag.name} +
+ ) : null} +
+
+ + ); +} + +// ── 左栏候选卡片(draggable)───────────────────────────────────────────── + +function PoolAgentCard({ + agent, + inChain, + onAdd, +}: { + agent: RecommendCandidate; + inChain: boolean; + onAdd: () => void; +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: POOL_PREFIX + agent.agent_id, + disabled: inChain, + }); + return ( +
+
+
{agent.name || agent.agent_id}
+ {agent.description && ( +
+ {agent.description} +
+ )} +
+ {inChain ? ( + + 已加入 + + ) : ( + + )} +
+ ); +} + +// ── 右栏(droppable 容器 + sortable 列表)─────────────────────────────── + +function SeatsColumn({ + seats, + seatItemIds, + seatCountInvalid, + onRemove, +}: { + seats: ChainSeat[]; + seatItemIds: string[]; + seatCountInvalid: boolean; + onRemove: (agentId: string) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: SEATS_DROPPABLE_ID }); + return ( +
+
+ + 链条席位 + + 按顺序发言 + + + {seats.length} / {CHAIN_MAX_SEATS} + {seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`} + +
+
+ {seats.length === 0 ? ( +
+ + 从左侧把智能体拖到这里,组成有序业务链条 +
+ ) : ( + + {seats.map((seat, index) => ( + onRemove(seat.agent_id)} + /> + ))} + + )} +
+
+ ); +} + +// ── 单个席位长条(sortable)───────────────────────────────────────────── + +function SeatStrip({ + seat, + index, + isLast, + onRemove, +}: { + seat: ChainSeat; + index: number; + isLast: boolean; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: SEAT_PREFIX + seat.agent_id }); + const avatarType = getAvatarTypeFor(seat.agent_id, index); + return ( +
+ {/* 顺序连接线(最后一个不画) */} + {!isLast && ( + + )} + + + {index + 1} + +
+
{seat.name || seat.agent_id}
+ {seat.description && ( +
+ {seat.description} +
+ )} +
+ +
+ ); +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 71e990b1..84f9c34e 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -23,6 +23,7 @@ * 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级 */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { Activity, Check, @@ -36,6 +37,7 @@ import { Clock, Cpu, History, + ListOrdered, Loader2, Pencil, Save, @@ -69,10 +71,12 @@ import { type RoleEntry, type SelectedAgent, } from "@/roundtable-planning/lib/roundtable-constants"; +import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain"; export default function RoundtablePlanningPage() { const { theme } = useTheme(); const isDark = theme === "dark"; + const navigate = useNavigate(); const { models: availableModels, isLoading: modelsLoading } = useModels(); @@ -81,6 +85,13 @@ export default function RoundtablePlanningPage() { const [showToast, setShowToast] = useState(null); const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState(false); const [selectedModel, setSelectedModel] = useState(""); + // Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。 + // 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。 + const [orchestrationMode, setOrchestrationMode] = useState<"recommend" | "chain">( + "recommend", + ); + // chain 模式来源链条(仅展示/持久化用)。 + const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null); const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true); // 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在 @@ -154,6 +165,7 @@ export default function RoundtablePlanningPage() { const step2 = useStep2Orchestration({ currentStep, selectedModel, + orchestrationMode, buildInitialIntent, triggerToast, setRoles, @@ -205,9 +217,17 @@ export default function RoundtablePlanningPage() { consensusPercentage: step2.consensusPercentage, budgetLimit: step2.budgetLimit, seatStubMessages: step2.seatStubMessages, + orchestrationMode, + chain: chainMeta, }), hydrateStep1: step1.hydrateFromDraft, - hydrateStep2: step2.hydrateFromDraft, + // 包一层:先把页面持有的编排模式 / 链条恢复出来(老草稿无字段 → recommend), + // 再交给 step2 hook 恢复其余 Step 2 状态。 + hydrateStep2: (snapshot) => { + setOrchestrationMode(snapshot.orchestrationMode ?? "recommend"); + setChainMeta(snapshot.chain ?? null); + step2.hydrateFromDraft(snapshot); + }, cancelAllStreams: () => { step2.abortAllInFlight(); step1.stopIntentStream(); @@ -267,23 +287,32 @@ export default function RoundtablePlanningPage() { // ─── 步骤间转场 handler ─────────────────────────────────── const handleConfirmAgents = useCallback( - (agents: Array<{ agent_id: string; name: string }>) => { - const normalized: SelectedAgent[] = agents.map(({ agent_id, name }) => ({ + (result: SeatSelectionResult) => { + const normalized: SelectedAgent[] = result.agents.map(({ agent_id, name }) => ({ agent_id, // 内置 agent 走 BUILTIN_AGENT_META;非内置用候选池传过来的 name。 name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id, })); step2.setSelectedAgents(normalized); step2.resetInitGate(); + // result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活。 + setOrchestrationMode(result.mode); + setChainMeta(result.mode === "chain" ? result.chain ?? null : null); setIsRecommendDialogOpen(false); setCurrentStep(2); - triggerToast("成功确认!已切换至第二阶段圆桌研讨系统"); + triggerToast( + result.mode === "chain" + ? `成功确认!将按业务链条「${result.chain?.title ?? ""}」顺序研讨` + : "成功确认!已切换至第二阶段圆桌研讨系统", + ); }, [step2, triggerToast], ); const handleStartNewTask = useCallback(() => { step2.resetStep2State(); + setOrchestrationMode("recommend"); + setChainMeta(null); step1.stopIntentStream(); drafts.loadedFromDraftRef.current = false; drafts.setCurrentDraftId(null); @@ -353,6 +382,16 @@ export default function RoundtablePlanningPage() { />
+ +