Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
fdfb888be9
20 files changed
+2441
-24
No files matched your search
@@ -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 + 简短结论)、边界(只产出本席位、等总控派活)。
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
# 业务链条模式 · 需求与分步实现文档
|
||||
|
||||
> 面向「智能体推荐弹窗」的扩展:在现有**智能推荐**之外,新增**业务链条选择**与**业务链条配置**两块能力;选定业务链条后,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<BusinessChain, "id" | "title">;
|
||||
}
|
||||
```
|
||||
|
||||
**约束**:链条席位数沿用圆桌的 `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<BusinessChain[]> // 当前用户全部链条
|
||||
getChain(id: string): Promise<BusinessChain>
|
||||
createChain(input: Omit<BusinessChain,"id"|"createdAt"|"updatedAt">): Promise<BusinessChain>
|
||||
updateChain(id: string, patch: Partial<BusinessChain>): Promise<BusinessChain>
|
||||
deleteChain(id: string): Promise<void>
|
||||
```
|
||||
|
||||
- 实现用 `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] 顶部:链条列表下拉(`<select>` 列出已存链条 + 新建项)/ 新建按钮 / 标题+描述输入 / 保存 / 删除(`window.confirm`)。
|
||||
- [x] **左栏**:候选智能体(`listAgents()` + `filterRecommendCandidates`),竖排卡片,可拖、可点「+」快捷加入;已加入的置灰「已加入」。
|
||||
- [x] **右栏**:有序长条卡片(序号 tag + 名称 + 描述 + 移除),`SortableContext` 内拖拽排序;空态有占位提示。
|
||||
- [x] 拖拽用 `@dnd-kit`:左栏 `useDraggable`,右栏 `useDroppable` + `SortableContext`/`useSortable`,`onDragEnd` 计算插入 index(pool→右栏按落点插入;seat→seat `arrayMove`);`DragOverlay` 跟手卡片。
|
||||
- [x] 校验:`2 ≤ seats ≤ 8`(前端禁用保存 + toast,后端 `_validate_seats` 兜底),重复 agent 去重提示。
|
||||
- [x] 保存走 `api/chains.ts`(create 用 `nanoid()` 客户端 id,update 走同一 id)。
|
||||
- **验收**:✅ typecheck 0 新增错误(76 个预存量错误均在无关模块)。**人工验收待跑**:新建「情报→方案→风险→总结」链保存 → 刷新后下拉重载 → 右栏拖拽排序 → 左栏拖入指定位 → 2–8 边界禁用保存。
|
||||
- **切换成本备注**:若改全屏 Dialog 方案,编辑器组件原样复用,仅去掉路由、外面套 `Dialog`。
|
||||
- **入口说明**:圆桌本身未在侧栏(`sidebar-menu.ts` 入口被注释),本页暂经直链 `/page/workspace/roundtable/chains` 或顶栏「返回圆桌」往返;阶段 3 推荐弹窗「去配置」按钮会导航到此。
|
||||
- **视觉(后续重画)**:本页**不**套圆桌 `roundtable-planning` scope,直接遵循《浅红色/深蓝色 UI 规范》——用 app 语义 token(光 `--primary` 红 hue27 / 暗 蓝 hue259,已内置自适应)+ 规范小圆角(输入/按钮/tag/卡片内项 2px = `R_CTRL`,分栏/面板 4px = `R_CARD`)+ 克制阴影(`PANEL_SHADOW`)。筛选框复用通用 `TagSearchBar`,配色随 app 中性 token,不再被红 scope 染色。
|
||||
|
||||
### 阶段 2 · 推荐弹窗「模式选择」外壳(1 天) ✅ 已完成
|
||||
**目标**:弹窗顶部模式切换,智能推荐行为不变。
|
||||
- [x] `RecommendAgentsDialog` 顶部加**分段按钮**模式切换:`智能推荐 | 业务链条`(active 态品牌色 + 卡底;DialogDescription 随模式切换文案)。
|
||||
- [x] 现有全部内容原样收进「智能推荐」面板(`{mode === "recommend" && (<>…</>)}` 包裹,**零行为变更**)。
|
||||
- [x] 「业务链条」面板放占位:居中提示 + 「去配置业务链条」按钮(`handleGoConfig` → 关弹窗 + 跳 `roundtable/chains`)。
|
||||
- [x] `onConfirm` 统一升级为回传 `SeatSelectionResult`(含 `mode`):智能推荐确认回传 `{ agents, mode: "recommend" }`;主页面 `handleConfirmAgents` 同步改签名取 `result.agents`(`result.mode` 预留给阶段 4)。
|
||||
- **验收**:✅ typecheck 0 新增错误(总数仍 76);智能推荐分支 JSX/逻辑原样保留(流式/勾选/进入研讨未改);模式切换只切显示、`state`/`selectedIds` 不卸载 → 候选池不丢。**人工验收待跑**:切到业务链条→再切回,推荐结果与勾选仍在。
|
||||
|
||||
### 阶段 3 · 业务链条选择卡片列表(1.5 天) ✅ 已完成
|
||||
**目标**:弹窗「业务链条」面板里选链条。新增 `components/BusinessChainPicker.tsx`,替换阶段 2 的占位。
|
||||
- [x] 进入该 Tab 时 `listChains()` 拉本人链条 + `listAgents()` 取存活 id/名称,渲染**卡片列表**:
|
||||
- [x] 卡片标题(链条 title)+ 「N 席」徽章 + 选中 ✓;描述行。
|
||||
- [x] 标题下一排**智能体名 tag**:按链条顺序**从左到右**、不同颜色(`getAvatarTypeFor`→`chainTagClasses`),tag 之间插 `→`(`ArrowRightIcon`)箭头连接(`seats[0]` 最左、先发言)。
|
||||
- [x] 底部「配置链条」按钮(跳 `roundtable/chains`)。
|
||||
- [x] 空态:无链条时引导「去配置页面创建第一条业务链条」+ 按钮。
|
||||
- [x] 选中一条 → 高亮(`ring-primary`);底部「进入研讨」`onConfirm({ agents: 存活席位有序, mode:"chain", chain:{id,title} })`。
|
||||
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。agent 列表加载失败时 `existingIds=null` → 不误判删除。
|
||||
- **验收**:✅ typecheck 0 新增错误(总数仍 76)。卡片渲染标题 + 彩色 tag + ← 箭头;选链 → `onConfirm(mode:"chain")` → `handleConfirmAgents` 按链条顺序 `setSelectedAgents` → Step 2 左侧「参与角色」顺序 = 链条顺序(**按顺序派活在阶段 4**)。**人工 UI 验收待跑**。
|
||||
- **箭头方向**(已确认):tag 左→右排列、`→`(`ArrowRightIcon`)连接,`seats[0]` 最左、先发言,箭头顺读即链条研讨顺序。
|
||||
|
||||
### 阶段 4 · Step 2 链条顺序编排(2–3 天) ✅ 已完成
|
||||
**目标**:chain 模式按链顺序逐席发言。**后端零改动。**
|
||||
- [x] 主页面新增 `orchestrationMode` 状态,`handleConfirmAgents` 从 `SeatSelectionResult.mode` 写入(chain 模式 toast 带链名);`handleStartNewTask` 复位为 `recommend`;透传给 `useStep2Orchestration`。chain 顺序 = `selectedAgents`(确认时已按链条顺序写入),无需单独 `chainSeats`。
|
||||
- [x] `useStep2Orchestration` 新增 `runChainOrchestration`(§6.2)+ `orchestrationModeRef`/`selectedAgentsRef`;复用现有三段式气泡/高亮/abort 基建。
|
||||
- [x] 新增 `runActiveOrchestration` 分发器:init / 恢复 / 干预 / 澄清回复 4 处调用点统一改走它,按 `orchestrationModeRef` 选 `runOrchestration`(recommend)或 `runChainOrchestration`(chain)。
|
||||
- [x] `runChainOrchestration`:按 `selectedAgents` 游标,每位先 leader 约束派活(`buildChainLeaderTask` 钉死目标席位 + `pickChainTask` 命中校验/纠偏,派错/未派都兜底推进)→ 再对**该席位**跑 special;中途 leader 若 `clarification` 则暂停(MVP 回复后整链重跑)。
|
||||
- [x] `initMultiAgent` 的 `agentNames` 即 `roundtableAgentIds`(由 `selectedAgents` 派生,已是链条顺序,§6.5)。
|
||||
- [x] 收口 leader(`finishedAllSeats` 时触发),产出 `lastLeaderContent` + `hasConsensus=true` + `consensusPercentage=100` 解锁 Step 3。
|
||||
- **验收**:✅ typecheck 0 新增错误(总数仍 76);✅ 唯一直接引用 `runOrchestration` 的是 dispatcher。逻辑层:每席「总控派活气泡 → 席位交付气泡」、严格顺序、单 special 在跑、`_broadcast` 注入前序上下文、leader 纠偏推进、跑完解锁 Step 3、abort 可中断。**人工 UI 验收待跑**。
|
||||
- **MVP 边界**:chain 模式的「挂起→断点续跑」「逐席间干预」未做断点续跑,`clarification`/恢复/干预当前为**整链重跑**(`chainCursor` 列为扩展点)。
|
||||
|
||||
### 阶段 5 · 草稿持久化兼容 + 收尾(1 天) ✅ 已完成
|
||||
**目标**:草稿记住模式,回归不破。
|
||||
- [x] `Step2Snapshot`(`useStep2Orchestration.ts`)+ `DraftStep2Snapshot`(`api/drafts.ts`)新增 `orchestrationMode?: "recommend"|"chain"` 与 `chain?: {id,title}|null`。
|
||||
- [x] 恢复路径:`orchestrationMode`/`chainMeta` 是**主页面 state**,`getStep2Snapshot` 写入;**包裹版 `hydrateStep2`** 在加载时 `setOrchestrationMode(snapshot.orchestrationMode ?? "recommend")` + `setChainMeta(snapshot.chain ?? null)` 再调 `step2.hydrateFromDraft`;老草稿无字段 → 默认 `recommend`(向后兼容)。`handleStartNewTask` 复位两者。
|
||||
- [x] 文档回写:`multi-agent-frontend-dev.md` §5.7(快照形状 + `orchestrationMode/chain` 说明)、§6.2(席位来源选择器导语);`multi-agent-backend-dev.md` 新增 §7B(`roundtable_chains` 表/路由)。
|
||||
- **验收**:✅ `pnpm typecheck` 0 新增错误(总数仍 76)。逻辑:chain 会话保存草稿 → 重载 `orchestrationMode="chain"` + 链信息恢复;老草稿(无字段)默认 recommend 照常加载。**人工 UI 验收 + `pnpm build` 待跑**。
|
||||
|
||||
### 工期粗估
|
||||
| 阶段 | 估时 |
|
||||
|------|------|
|
||||
| 0 后端链条表 + 脚手架 | 1.5d |
|
||||
| 1 配置页 | 2–3d |
|
||||
| 2 弹窗模式壳 | 1d |
|
||||
| 3 链条选择卡片 | 1.5d |
|
||||
| 4 Step2 链编排 | 2–3d |
|
||||
| 5 持久化兼容收尾 | 1d |
|
||||
| **合计** | **约 9–11 人日**(含后端 `roundtable_chains` 表/路由) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收清单(端到端)
|
||||
|
||||
1. 推荐弹窗顶部有「智能推荐 / 业务链条」切换;智能推荐与现状逐像素一致。
|
||||
2. 配置页 `/page/workspace/roundtable/chains` 左栏列出智能体、右栏有序长条卡片;左→右拖入指定位、右栏内拖拽排序均生效;2–8 校验生效;保存/重载/删除正常。
|
||||
3. 业务链条卡片:标题 + 彩色智能体 tag + ← 箭头连接,渲染正确。
|
||||
4. 选定链条 → Step 2 席位顺序 = 链条顺序,并**按顺序逐个发言**,后序能看到前序交付。
|
||||
5. chain 跑完解锁 Step 3;草稿记住 chain 模式并可重载。
|
||||
6. 删除链条中引用的 agent 后,选择端有降级提示,不崩。
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与开放问题
|
||||
|
||||
| # | 项 | 说明 / 建议 |
|
||||
|---|----|-------------|
|
||||
| 1 | 配置页:弹窗 vs 页面 | 本文档定为**独立页面**(§2.1)。若产品要弹窗,编辑器组件可复用,仅换外壳。**请确认。** |
|
||||
| 2 | 链条持久化 | **已定:后端 MySQL `roundtable_chains`**(§2.2 / §5.2),仿草稿持久化。 |
|
||||
| 3 | Step 2 链编排 | **已定:总控按链条顺序派活**(§2.4 / §6),后端零改动。 |
|
||||
| 4 | 新增 `@dnd-kit` 依赖 | 若禁止新增依赖,退原生 HTML5 DnD(手感与可达性下降)。**请确认。** |
|
||||
| 5 | ← 箭头方向 / tag 阅读顺序 | 默认 `seats[0]` 在最左、先发言;箭头仅作视觉连接。**请确认是否要「从右往左读」。** |
|
||||
| 6 | chain 模式是否需要收口 leader 总结 | 默认需要(产出 Step 3 结论)。若末位席位自带总结可关掉。 |
|
||||
| 7 | leader 派活纠偏策略 | leader 派错/未派时前端兜底推进游标(§6.2 `pickChainTask`);可加 toast 提示「已按链条强制推进」。 |
|
||||
| 8 | chain 模式断点续跑 / 逐席干预 | MVP 不做,列为扩展点(§6.3 / §7 阶段 4)。 |
|
||||
| 9 | 链条是否私有 | 本期仅本人私有(仿草稿按 `user_id` 隔离)。共享/市场留扩展。 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 路径速查(新增/改动)
|
||||
|
||||
| 资源 | 路径 | 状态 |
|
||||
|------|------|------|
|
||||
| 链条类型 | `frontend-web/src/roundtable-planning/lib/business-chain.ts` | 新增 |
|
||||
| 链条 API 客户端 | `frontend-web/src/roundtable-planning/api/chains.ts` | 新增 |
|
||||
| 配置页 | `frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx` | 新增 |
|
||||
| 链条选择面板 | `frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx` | 新增 |
|
||||
| 链条卡片 | `frontend-web/src/roundtable-planning/components/BusinessChainCard.tsx` | 新增 |
|
||||
| 推荐弹窗(加模式壳) | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` | 改 |
|
||||
| 主页面(mode 透传) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 改 |
|
||||
| Step2 编排(加 chain) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` | 改 |
|
||||
| 草稿快照(加 mode) | `hooks/useDraftPersistence.ts` + `api/drafts.ts` | 改 |
|
||||
| 路由注册 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | 改 |
|
||||
| 后端链条持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/` | 新增 |
|
||||
| 后端链条路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py` | 新增 |
|
||||
| 后端 store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_chain_store`)+ `app.py` | 改 |
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -439,8 +439,9 @@ isOrchestrating = false
|
||||
> **重大变更**:草稿从浏览器 `localStorage` 迁到**后端 MySQL**,按登录用户隔离、跨设备/会话保留。旧 `utils/drafts.ts` 已删除;`useDraftPersistence` 仍保持对外接口不变(列表项含 `name`,由后端 `title` 映射而来),主页面无需改动。后端表/路由见 backend doc「圆桌草稿持久化」。
|
||||
|
||||
- **API 客户端 `api/drafts.ts`**:`listDrafts()`(轻量元数据)/ `getDraft(id)`(全量 step1+step2)/ `createDraft` / `updateDraft`(partial)/ `deleteDraft` / `deriveDraftName`。snake_case↔camelCase 映射在这一层收口(后端 `furthest_step/created_at/updated_at` ↔ 前端 `furthestStep/...`)。
|
||||
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
|
||||
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages, orchestrationMode?, chain?}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
|
||||
- **`selectedAgents`(2026-06 修复)**:本次研讨实际选中的席位。**不存的话加载草稿后 Step 2/3 左侧「参与角色」会回退到 `DEFAULT_SELECTED_AGENTS`(默认 6 个)而非当时真正选的那几个** —— `roleTemplates`/`agentNameToRole` 都由 `selectedAgents` 派生。`useStep2Orchestration.hydrateFromDraft` 在该字段非空时 `setSelectedAgents`,老草稿(无此字段)保持当前值不强行覆盖。
|
||||
- **`orchestrationMode?` / `chain?`(2026-06 业务链条)**:Step 2 编排模式(`recommend`=总控自由派活 / `chain`=按业务链条顺序派活)+ 来源链条 `{id,title}`。是**主页面 state**,由页面在 `getStep2Snapshot` 写入、在**包裹版 `hydrateStep2`** 里读出后 `setOrchestrationMode`/`setChainMeta`(step2 hook 的 `hydrateFromDraft` 不直接消费)。老草稿无字段 → 默认 `recommend`。业务链条完整设计见 `multi-agent-business-chain-dev.md`。
|
||||
- `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。
|
||||
- **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。
|
||||
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。
|
||||
@@ -481,6 +482,8 @@ isOrchestrating = false
|
||||
|
||||
### 6.2 `RecommendAgentsDialog`
|
||||
|
||||
> **席位来源选择器(2026-06 业务链条)**:弹窗顶部加了**模式分段切换** `智能推荐 | 业务链条`。智能推荐 = 本节描述的现状(零行为变更,整体收进 `{mode==="recommend"}` 分支);业务链条 = `BusinessChainPicker`(选一条已配置链条,卡片标题 + 彩色席位 tag + `→` 箭头)。`onConfirm` 统一回传 `SeatSelectionResult{ agents, mode, chain? }`;`mode==="chain"` 时 Step 2 由总控**按链条顺序派活**(`useStep2Orchestration.runChainOrchestration`)。配置页与编排详见 `multi-agent-business-chain-dev.md`。
|
||||
|
||||
- **新增 `draftId` prop(2026-06)**:本次推荐归属的草稿/会话 id。由主页面在打开弹窗前 `await drafts.ensureDraft(1)` 拿到并传入(见 §6.6)。`null` 时退化为「直接推荐、不存历史」的旧行为。
|
||||
- 打开时管线(`start(forceFresh=false)`):`listAgents() → filterRecommendCandidates()` → **若 `draftId` 有历史 → `listRecommendHistory()` 取最近一次直接展示(不重跑模型)**;否则 `streamRecommend()` 跑新推荐。
|
||||
- 阶段 UI(`InternalState.phase`):
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@langchain/core": "^1.1.15",
|
||||
"@langchain/langgraph-sdk": "^1.5.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
|
||||
@@ -39,6 +39,7 @@ import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
|
||||
import StudioNotebooksPage from "./StudioNotebooksPage";
|
||||
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
|
||||
export function WorkspaceLayout({
|
||||
@@ -250,6 +251,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/chains"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<BusinessChainEditorPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Backend-persisted business-chain storage for /page/roundtable/planning.
|
||||
*
|
||||
* A business chain is a named, reusable, *ordered* list of agents. Persisted
|
||||
* server-side (MySQL) bound to the logged-in user, so it follows the account
|
||||
* across devices/sessions. Backend: `/api/roundtable-chains` (see
|
||||
* `app/gateway/routers/roundtable_chains.py`).
|
||||
*
|
||||
* snake_case ⇄ camelCase mapping is collapsed at this boundary (mirroring
|
||||
* `api/drafts.ts`) so callers stay clean.
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
||||
|
||||
/** Fields accepted when creating a chain. */
|
||||
export interface ChainCreatePayload {
|
||||
/** Optional stable id so the client can keep one id across create + updates. */
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
seats: ChainSeat[];
|
||||
}
|
||||
|
||||
/** Partial update — send only what changed. */
|
||||
export interface ChainUpdatePayload {
|
||||
title?: string;
|
||||
description?: string;
|
||||
seats?: ChainSeat[];
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`${ctx} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ── server ⇄ client field mapping ──────────────────────────────────────────
|
||||
// Backend uses snake_case (created_at / updated_at); the rest of the frontend
|
||||
// reads camelCase. `seats` is already camel-clean ({agent_id, name, description}).
|
||||
|
||||
interface ServerChain {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
seats: ChainSeat[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
function toChain(s: ServerChain): BusinessChain {
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title ?? "",
|
||||
description: s.description ?? undefined,
|
||||
seats: Array.isArray(s.seats) ? s.seats : [],
|
||||
createdAt: s.created_at ?? undefined,
|
||||
updatedAt: s.updated_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function fromCreate(p: ChainCreatePayload) {
|
||||
return {
|
||||
...(p.id ? { id: p.id } : {}),
|
||||
title: p.title,
|
||||
...(p.description !== undefined ? { description: p.description } : {}),
|
||||
seats: p.seats,
|
||||
};
|
||||
}
|
||||
|
||||
function fromUpdate(p: ChainUpdatePayload) {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (p.title !== undefined) out.title = p.title;
|
||||
if (p.description !== undefined) out.description = p.description;
|
||||
if (p.seats !== undefined) out.seats = p.seats;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List the current user's chains, newest first. */
|
||||
export async function listChains(): Promise<BusinessChain[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
|
||||
return (data.chains ?? []).map(toChain);
|
||||
}
|
||||
|
||||
/** Fetch one chain. Returns null on 404. */
|
||||
export async function getChain(id: string): Promise<BusinessChain | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
|
||||
if (res.status === 404) return null;
|
||||
const data = await readJson<ServerChain>(res, "get chain");
|
||||
return toChain(data);
|
||||
}
|
||||
|
||||
export async function createChain(payload: ChainCreatePayload): Promise<BusinessChain> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fromCreate(payload)),
|
||||
});
|
||||
const data = await readJson<ServerChain>(res, "create chain");
|
||||
return toChain(data);
|
||||
}
|
||||
|
||||
export async function updateChain(id: string, patch: ChainUpdatePayload): Promise<BusinessChain | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fromUpdate(patch)),
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
const data = await readJson<ServerChain>(res, "update chain");
|
||||
return toChain(data);
|
||||
}
|
||||
|
||||
export async function deleteChain(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`delete chain failed (${res.status}): ${text}`);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,15 @@ export interface DraftStep2Snapshot {
|
||||
budgetLimit: number;
|
||||
/** Per-seat stub AI messages for the sandbox `write-file:` virtual URL loader. */
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
/**
|
||||
* Step 2 orchestration mode (2026-06 business chains). recommend = leader
|
||||
* dispatches freely; chain = dispatch follows the saved business chain order.
|
||||
* Optional — drafts saved before this won't carry it (hydrate defaults to
|
||||
* "recommend").
|
||||
*/
|
||||
orchestrationMode?: "recommend" | "chain";
|
||||
/** Source business chain (display only), when `orchestrationMode === "chain"`. */
|
||||
chain?: { id: string; title: string } | null;
|
||||
}
|
||||
|
||||
/** Lightweight metadata row returned by the list endpoint. */
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 业务链条选择面板(推荐弹窗「业务链条」模式的内容区)。
|
||||
*
|
||||
* 拉取当前用户的业务链条(`listChains()`),渲染卡片列表:
|
||||
* - 卡片标题(链条 title)+ 存活席位计数。
|
||||
* - 标题下一排**智能体名 tag**:按链条顺序从左到右排列、不同颜色(`getAvatarTypeFor`
|
||||
* → `chainTagClasses`),tag 之间用 `→`(ArrowRight)箭头连接表达发言次序。
|
||||
* - 链条里有 agent 已被删除 → 该 tag 置灰删除线 + 提示;卡片只用**存活**席位,
|
||||
* 存活不足 `CHAIN_MIN_SEATS` 则不可使用。
|
||||
*
|
||||
* 选中一条 → 高亮;底部「进入研讨」回传 `SeatSelectionResult{ mode:"chain" }`,
|
||||
* 由父弹窗的 `onConfirm` 透传给主页面(席位顺序 = 链条顺序)。
|
||||
*
|
||||
* 详见 `docs/multi-agent-business-chain-dev.md` §6.2 / §7 阶段 3。
|
||||
*/
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
LayersIcon,
|
||||
Loader2Icon,
|
||||
SettingsIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
import { filterRecommendCandidates } from "../api/recommend";
|
||||
import { listChains } from "../api/chains";
|
||||
import {
|
||||
CHAIN_MIN_SEATS,
|
||||
chainTagClasses,
|
||||
type BusinessChain,
|
||||
type SeatSelectionResult,
|
||||
} from "../lib/business-chain";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
|
||||
export interface BusinessChainPickerProps {
|
||||
onConfirm: (result: SeatSelectionResult) => void;
|
||||
onCancel: () => void;
|
||||
/** 跳转到业务链条配置页(同时关闭弹窗)。 */
|
||||
onGoConfig: () => void;
|
||||
}
|
||||
|
||||
export function BusinessChainPicker({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onGoConfig,
|
||||
}: BusinessChainPickerProps) {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// null = 候选池未知(agent 列表加载失败)→ 不把任何席位判成「已删除」。
|
||||
const [existingIds, setExistingIds] = useState<Set<string> | null>(null);
|
||||
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [chainList, agents] = await Promise.all([
|
||||
listChains(),
|
||||
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setChains(chainList);
|
||||
if (agents) {
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
setExistingIds(new Set(candidates.map((c) => c.agent_id)));
|
||||
setNameById(new Map(candidates.map((c) => [c.agent_id, c.name])));
|
||||
} else {
|
||||
setExistingIds(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isExisting = useMemo(
|
||||
() => (agentId: string) => existingIds === null || existingIds.has(agentId),
|
||||
[existingIds],
|
||||
);
|
||||
|
||||
const selectedChain = useMemo(
|
||||
() => chains.find((c) => c.id === selectedId) ?? null,
|
||||
[chains, selectedId],
|
||||
);
|
||||
const survivingSeats = useMemo(
|
||||
() => (selectedChain ? selectedChain.seats.filter((s) => isExisting(s.agent_id)) : []),
|
||||
[selectedChain, isExisting],
|
||||
);
|
||||
const canUse = !!selectedChain && survivingSeats.length >= CHAIN_MIN_SEATS;
|
||||
|
||||
const handleUse = () => {
|
||||
if (!canUse || !selectedChain) return;
|
||||
const agents = survivingSeats.map((s) => ({
|
||||
agent_id: s.agent_id,
|
||||
name: nameById.get(s.agent_id) ?? s.name,
|
||||
}));
|
||||
onConfirm({
|
||||
agents,
|
||||
mode: "chain",
|
||||
chain: { id: selectedChain.id, title: selectedChain.title },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1 -mr-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-8 text-sm text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载业务链条…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
加载业务链条失败:{error}
|
||||
</div>
|
||||
) : chains.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center">
|
||||
<LayersIcon className="size-10 text-primary/40" />
|
||||
<div className="text-sm font-semibold">还没有业务链条</div>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,
|
||||
之后即可在这里选择使用。
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onGoConfig} className="gap-1.5">
|
||||
<SettingsIcon className="size-4" />
|
||||
去配置业务链条
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5 py-0.5">
|
||||
{chains.map((chain) => (
|
||||
<ChainCard
|
||||
key={chain.id}
|
||||
chain={chain}
|
||||
selected={chain.id === selectedId}
|
||||
isExisting={isExisting}
|
||||
nameById={nameById}
|
||||
onSelect={() => setSelectedId(chain.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DialogFooter className="shrink-0 flex-row sm:justify-between items-center gap-2 flex-wrap border-t pt-3 mt-1">
|
||||
<Button variant="ghost" size="sm" onClick={onGoConfig} className="h-8 gap-1.5 text-xs">
|
||||
<SettingsIcon className="size-3.5" />
|
||||
配置链条
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedChain && !canUse && (
|
||||
<span className="text-xs text-destructive">存活席位不足 {CHAIN_MIN_SEATS} 个</span>
|
||||
)}
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleUse} disabled={!canUse}>
|
||||
进入研讨
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 单张链条卡片 ─────────────────────────────────────────────────────────
|
||||
|
||||
function ChainCard({
|
||||
chain,
|
||||
selected,
|
||||
isExisting,
|
||||
nameById,
|
||||
onSelect,
|
||||
}: {
|
||||
chain: BusinessChain;
|
||||
selected: boolean;
|
||||
isExisting: (agentId: string) => boolean;
|
||||
nameById: Map<string, string>;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const survivingCount = chain.seats.filter((s) => isExisting(s.agent_id)).length;
|
||||
const deletedCount = chain.seats.length - survivingCount;
|
||||
const usable = survivingCount >= CHAIN_MIN_SEATS;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"w-full rounded-xl border p-3 text-left transition-all",
|
||||
selected
|
||||
? "border-primary bg-primary/[0.06] ring-1 ring-primary/40"
|
||||
: "border-border bg-card hover:border-primary/40 hover:bg-primary/[0.03]",
|
||||
)}
|
||||
>
|
||||
{/* 标题行 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-sm font-semibold">{chain.title || "未命名链条"}</div>
|
||||
{selected && (
|
||||
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<CheckIcon className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||
{chain.seats.length} 席
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{chain.description && (
|
||||
<div className="mt-1 line-clamp-1 text-xs text-muted-foreground">{chain.description}</div>
|
||||
)}
|
||||
|
||||
{/* 智能体名 tag(按顺序,← 箭头连接) */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
{chain.seats.map((seat, i) => {
|
||||
const alive = isExisting(seat.agent_id);
|
||||
const label = nameById.get(seat.agent_id) ?? seat.name ?? seat.agent_id;
|
||||
return (
|
||||
<Fragment key={seat.agent_id}>
|
||||
{i > 0 && (
|
||||
<ArrowRightIcon className="size-3 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
alive
|
||||
? chainTagClasses(getAvatarTypeFor(seat.agent_id, i))
|
||||
: "border-dashed border-border bg-muted text-muted-foreground/60 line-through",
|
||||
)}
|
||||
title={alive ? undefined : "该智能体已不存在"}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 删除提示 */}
|
||||
{deletedCount > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-400">
|
||||
<TriangleAlertIcon className="size-3 shrink-0" />
|
||||
{usable
|
||||
? `${deletedCount} 个智能体已删除,将按存活 ${survivingCount} 个席位研讨`
|
||||
: `${deletedCount} 个智能体已删除,存活不足 ${CHAIN_MIN_SEATS} 个,暂不可用`}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
LayersIcon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
SparklesIcon,
|
||||
@@ -29,7 +31,9 @@ import {
|
||||
StreamingCursor,
|
||||
} from "./MessageBubble";
|
||||
import { MessageStepsCard } from "./MessageStepsCard";
|
||||
import { BusinessChainPicker } from "./BusinessChainPicker";
|
||||
import type { IntentSummary } from "../api/intent";
|
||||
import type { SeatSelectionResult } from "../lib/business-chain";
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
listRecommendHistory,
|
||||
@@ -68,8 +72,12 @@ export interface RecommendAgentsDialogProps {
|
||||
*/
|
||||
draftId?: string | null;
|
||||
onCancel: () => void;
|
||||
/** Called with the user's final selection of agent ids (in display order). */
|
||||
onConfirm: (agents: Array<{ agent_id: string; name: string }>) => void;
|
||||
/**
|
||||
* Called with the user's final seat selection + the orchestration mode.
|
||||
* 智能推荐模式回传 `{ agents, mode: "recommend" }`;业务链条模式(阶段 3)
|
||||
* 回传 `{ agents, mode: "chain", chain }`。
|
||||
*/
|
||||
onConfirm: (result: SeatSelectionResult) => void;
|
||||
}
|
||||
|
||||
const MIN_SELECTED = 2;
|
||||
@@ -189,6 +197,9 @@ export function RecommendAgentsDialog({
|
||||
// 映射也作用于弹窗;浅色不挂,弹窗浅色外观不变。
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const navigate = useNavigate();
|
||||
// 席位来源模式:智能推荐 / 业务链条。默认**业务链条**。
|
||||
const [mode, setMode] = useState<"recommend" | "chain">("chain");
|
||||
const [state, setState] = useState<InternalState>(INITIAL_STATE);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -442,18 +453,27 @@ export function RecommendAgentsDialog({
|
||||
[intent, draftId, cancelInflight, analyze, applyHistory],
|
||||
);
|
||||
|
||||
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
|
||||
// in-flight stream so the next open starts clean.
|
||||
// 推荐管线只在「智能推荐」模式下跑一次。默认是业务链条模式 —— 此时不触发推荐
|
||||
// 模型调用,等用户切到「智能推荐」tab 才启动;每次 open 内只跑一次(切 tab 来回
|
||||
// 不重复跑),关闭时复位 latch。
|
||||
const recommendStartedRef = useRef(false);
|
||||
// 关闭 / 卸载时中止 in-flight 推荐流。
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
cancelInflight();
|
||||
return;
|
||||
}
|
||||
void start(false);
|
||||
return () => {
|
||||
cancelInflight();
|
||||
};
|
||||
}, [open, start, cancelInflight]);
|
||||
}, [cancelInflight]);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
recommendStartedRef.current = false;
|
||||
cancelInflight();
|
||||
return;
|
||||
}
|
||||
if (mode === "recommend" && !recommendStartedRef.current) {
|
||||
recommendStartedRef.current = true;
|
||||
void start(false);
|
||||
}
|
||||
}, [open, mode, start, cancelInflight]);
|
||||
|
||||
const handleToggle = useCallback((agentId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
@@ -510,9 +530,14 @@ export function RecommendAgentsDialog({
|
||||
for (const c of otherCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
|
||||
}
|
||||
onConfirm(ordered);
|
||||
onConfirm({ agents: ordered, mode: "recommend" });
|
||||
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
|
||||
|
||||
const handleGoConfig = useCallback(() => {
|
||||
onCancel();
|
||||
navigate("/page/workspace/roundtable/chains");
|
||||
}, [navigate, onCancel]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) onCancel();
|
||||
@@ -534,10 +559,45 @@ export function RecommendAgentsDialog({
|
||||
选择参会智能体
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
|
||||
{mode === "recommend"
|
||||
? "根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。"
|
||||
: "从已配置的业务链条中选择一条,Step 2 将按链条顺序进行多智能体研讨。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 模式选择:智能推荐 / 业务链条 */}
|
||||
<div className="shrink-0 inline-flex self-start rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("recommend")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-3 py-1.5 transition-colors",
|
||||
mode === "recommend"
|
||||
? "bg-card text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<SparklesIcon className="size-3.5" />
|
||||
智能推荐
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("chain")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-3 py-1.5 transition-colors",
|
||||
mode === "chain"
|
||||
? "bg-card text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<LayersIcon className="size-3.5" />
|
||||
业务链条
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ===== 智能推荐模式(现状,零行为变更)===== */}
|
||||
{mode === "recommend" && (
|
||||
<>
|
||||
{/* ============================================================
|
||||
TOP: 模型生成内容
|
||||
包含状态行 + 步骤卡 + 思考过程(`<think>`)+ 推荐理由 markdown。
|
||||
@@ -684,6 +744,17 @@ export function RecommendAgentsDialog({
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 业务链条模式 ===== */}
|
||||
{mode === "chain" && (
|
||||
<BusinessChainPicker
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
onGoConfig={handleGoConfig}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -104,6 +104,15 @@ export type Step2Snapshot = {
|
||||
* 老草稿可能没有这字段,反序列化时按空对象兜底。
|
||||
*/
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
/**
|
||||
* 本次 Step 2 编排模式(2026-06 业务链条)。recommend = 总控自由派活;
|
||||
* chain = 按业务链条顺序派活。老草稿无此字段 → 加载时默认 recommend。
|
||||
* 注:该字段是主页面 state,由页面在 getStep2Snapshot/hydrateStep2 里读写,
|
||||
* step2 hook 的 hydrateFromDraft 不直接消费它。
|
||||
*/
|
||||
orchestrationMode?: "recommend" | "chain";
|
||||
/** chain 模式来源链条(仅展示用,如「按业务链条 X 研讨」)。 */
|
||||
chain?: { id: string; title: string } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -119,11 +128,74 @@ const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {
|
||||
7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
|
||||
};
|
||||
|
||||
/**
|
||||
* chain 模式:为「链条第 i 个席位」构造**约束总控只能派给它**的 leader prompt。
|
||||
* 后端不变 —— 靠 prompt 把派活目标钉死成链条下一席位,再由 `pickChainTask` 校验/纠偏。
|
||||
*/
|
||||
function buildChainLeaderTask(args: {
|
||||
seedMessage: string;
|
||||
position: number;
|
||||
total: number;
|
||||
seatAgentId: string;
|
||||
seatName: string;
|
||||
isFirst: boolean;
|
||||
}): string {
|
||||
const { seedMessage, position, total, seatAgentId, seatName, isFirst } = args;
|
||||
const ordinal = `${position + 1}/${total}`;
|
||||
if (isFirst) {
|
||||
return [
|
||||
"这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:",
|
||||
seedMessage,
|
||||
"",
|
||||
`本次研讨严格按预设链条顺序进行,共 ${total} 个席位,现在是第 ${ordinal} 个席位。`,
|
||||
`请你作为总控,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,结合任务背景为它分配聚焦其职责的本轮任务。`,
|
||||
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
`链条研讨继续,现在轮到第 ${ordinal} 个席位。`,
|
||||
`请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,为它分配承接前序结论、聚焦其职责的本轮任务。`,
|
||||
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 leader 终态里取出「派给当前链条席位」的任务文本,带纠偏:
|
||||
* 1) 命中 agent_name === seatAgentId 的派活 → 用它的 task;
|
||||
* 2) 总控派了活但写偏 id → 仍用它给出的 task 文本(强制跑正确席位);
|
||||
* 3) 总控没派活只写了说明 → 用 leader 正文,再不行回退到模板任务。
|
||||
* 无论哪种,调用方都强制对**链条席位**跑 special,确保游标严格推进。
|
||||
*/
|
||||
function pickChainTask(
|
||||
dispatched: LeaderDispatch[],
|
||||
leaderContent: string,
|
||||
seatAgentId: string,
|
||||
seatName: string,
|
||||
): string {
|
||||
const wanted = seatAgentId.toLowerCase();
|
||||
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
|
||||
if (exact && exact[1]?.trim()) return exact[1];
|
||||
const any = dispatched.find(([, task]) => task?.trim());
|
||||
if (any && any[1]?.trim()) return any[1];
|
||||
if (leaderContent?.trim()) {
|
||||
return `请你作为「${seatName}」,基于以下总控说明与此前各席位的交付,从你的专业职责出发给出你的分析与交付:\n${leaderContent.trim()}`;
|
||||
}
|
||||
return `请你作为「${seatName}」,基于任务背景与此前各席位的交付,从你的专业职责出发给出你的分析与交付。`;
|
||||
}
|
||||
|
||||
export interface UseStep2OrchestrationOptions {
|
||||
/** 当前页面 currentStep,===2 触发自动 init。 */
|
||||
currentStep: number;
|
||||
/** 全局选中模型(顶栏「推理模型」下拉)。 */
|
||||
selectedModel: string;
|
||||
/**
|
||||
* 编排模式:
|
||||
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
|
||||
* - `chain` —— 总控**按业务链条顺序**逐位派活(`runChainOrchestration`)。
|
||||
* 由推荐弹窗确认时的 `SeatSelectionResult.mode` 决定;chain 模式下席位顺序 =
|
||||
* `selectedAgents` 的顺序。
|
||||
*/
|
||||
orchestrationMode: "recommend" | "chain";
|
||||
/** 进入 Step 2 时拼装第一轮 leader prompt 的函数(读 Step 1 状态)。 */
|
||||
buildInitialIntent: () => string;
|
||||
/** 主页面统一的 toast。 */
|
||||
@@ -224,6 +296,7 @@ export function useStep2Orchestration(
|
||||
const {
|
||||
currentStep,
|
||||
selectedModel,
|
||||
orchestrationMode,
|
||||
buildInitialIntent,
|
||||
triggerToast,
|
||||
setRoles,
|
||||
@@ -321,6 +394,15 @@ export function useStep2Orchestration(
|
||||
const threadIdsRef = useRef<ThreadIdMap | null>(threadIds);
|
||||
const coordinatorNameRef = useRef<string | null>(coordinatorName);
|
||||
const isOrchestratingRef = useRef<boolean>(isOrchestrating);
|
||||
// chain 编排:模式 + 当前选中席位(=链条顺序)。同样打到 ref,让编排函数 deps 收窄。
|
||||
const orchestrationModeRef = useRef(orchestrationMode);
|
||||
const selectedAgentsRef = useRef<SelectedAgent[]>(selectedAgents);
|
||||
useEffect(() => {
|
||||
orchestrationModeRef.current = orchestrationMode;
|
||||
}, [orchestrationMode]);
|
||||
useEffect(() => {
|
||||
selectedAgentsRef.current = selectedAgents;
|
||||
}, [selectedAgents]);
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
@@ -1138,6 +1220,250 @@ export function useStep2Orchestration(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Chain orchestration loop ─────────────────────────────────────
|
||||
// chain 模式:总控**按 selectedAgents(=链条)顺序**逐位派活,而非自由派活。
|
||||
// 每位:(a) 约束 leader 只派给该席位 → (b) 对该席位跑 special;走完整链后 (c)
|
||||
// 由 leader 收口产出最终结论。后端零改动 —— 顺序与约束全在前端 prompt + 校验。
|
||||
// **不要并行 special**(与 runOrchestration 同一不变量)。
|
||||
const runChainOrchestration = useCallback(
|
||||
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
|
||||
if (isOrchestratingRef.current) return;
|
||||
setIsOrchestrating(true);
|
||||
orchestrationCancelRef.current = false;
|
||||
|
||||
const chainSeats = selectedAgentsRef.current;
|
||||
const total = chainSeats.length;
|
||||
const leaderThreadId = resolveSeatThreadId(ids, coord);
|
||||
let finishedAllSeats = true;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
if (orchestrationCancelRef.current) {
|
||||
finishedAllSeats = false;
|
||||
break;
|
||||
}
|
||||
const seat = chainSeats[i];
|
||||
const meta = agentNameToRoleRef.current[seat.agent_id];
|
||||
const seatName = meta?.displayName ?? seat.name ?? seat.agent_id;
|
||||
|
||||
// ── (a) 总控按链条顺序派活给当前席位 ──
|
||||
highlightSpeaker(null);
|
||||
const leaderBubbleId = appendStreamingDialogue({
|
||||
sender: "总控协调",
|
||||
avatarType: "blue",
|
||||
initialContent: "",
|
||||
confidence: "链条派活中",
|
||||
agentThreadId: leaderThreadId,
|
||||
});
|
||||
const leaderTask = buildChainLeaderTask({
|
||||
seedMessage,
|
||||
position: i,
|
||||
total,
|
||||
seatAgentId: seat.agent_id,
|
||||
seatName,
|
||||
isFirst: i === 0,
|
||||
});
|
||||
|
||||
const leaderAborter = new AbortController();
|
||||
activeAbortersRef.current.add(leaderAborter);
|
||||
let leaderRes;
|
||||
try {
|
||||
leaderRes = await streamMultiAgent({
|
||||
agentType: "leader",
|
||||
dAgentThreadId: ids,
|
||||
agentName: coord,
|
||||
newMessage: leaderTask,
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: leaderAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
setDialogueConfidence(leaderBubbleId, label);
|
||||
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||
});
|
||||
} catch (err) {
|
||||
activeAbortersRef.current.delete(leaderAborter);
|
||||
if (leaderAborter.signal.aborted || (err as Error).name === "AbortError") {
|
||||
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
|
||||
finishedAllSeats = false;
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
activeAbortersRef.current.delete(leaderAborter);
|
||||
if (orchestrationCancelRef.current) {
|
||||
finalizeDialogue(leaderBubbleId, leaderRes.content || "(已停止)", "已停止");
|
||||
finishedAllSeats = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// 链条中途总控仍可能要求澄清 → 暂停链条,等用户回复后从头重跑(MVP)。
|
||||
if (leaderRes.clarification) {
|
||||
finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
|
||||
highlightSpeaker(null);
|
||||
setPendingClarification(leaderRes.clarification);
|
||||
setClarificationDraft("");
|
||||
triggerToast("❓ 总控需要补充信息,请回答下方问题后继续");
|
||||
finishedAllSeats = false;
|
||||
break;
|
||||
}
|
||||
|
||||
finalizeDialogue(leaderBubbleId, leaderRes.content, "派活说明");
|
||||
if (leaderRes.content) setLastLeaderContent(leaderRes.content);
|
||||
|
||||
const task = pickChainTask(
|
||||
leaderRes.dispatched,
|
||||
leaderRes.content,
|
||||
seat.agent_id,
|
||||
seatName,
|
||||
);
|
||||
|
||||
// ── (b) 当前席位交付(special,顺序、不并行)──
|
||||
if (meta) markSpeakerStreaming(meta.speakerId);
|
||||
const seatThreadId = resolveSeatThreadId(ids, seat.agent_id);
|
||||
const perSeatModel = meta
|
||||
? agentConfigsRef.current[meta.speakerId]?.model
|
||||
: undefined;
|
||||
const subBubbleId = appendStreamingDialogue({
|
||||
sender: seatName,
|
||||
avatarType: meta?.avatarType ?? "blue",
|
||||
initialContent: "",
|
||||
confidence: "调度中",
|
||||
agentThreadId: seatThreadId,
|
||||
});
|
||||
|
||||
const subAborter = new AbortController();
|
||||
activeAbortersRef.current.add(subAborter);
|
||||
let aborted = false;
|
||||
try {
|
||||
const specRes = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
dAgentThreadId: ids,
|
||||
agentName: seat.agent_id,
|
||||
displayName: seatName,
|
||||
newMessage: task,
|
||||
model: perSeatModel || selectedModelRef.current || undefined,
|
||||
signal: subAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
setDialogueConfidence(subBubbleId, label);
|
||||
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
|
||||
});
|
||||
if (orchestrationCancelRef.current) {
|
||||
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
|
||||
aborted = true;
|
||||
} else {
|
||||
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
|
||||
}
|
||||
} catch (err) {
|
||||
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
|
||||
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
|
||||
aborted = true;
|
||||
} else {
|
||||
console.error(`[chain-special:${seat.agent_id}] error:`, err);
|
||||
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
|
||||
}
|
||||
} finally {
|
||||
activeAbortersRef.current.delete(subAborter);
|
||||
if (meta) markSpeakerIdle(meta.speakerId);
|
||||
}
|
||||
if (aborted) {
|
||||
finishedAllSeats = false;
|
||||
break;
|
||||
}
|
||||
|
||||
setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90)));
|
||||
}
|
||||
|
||||
// ── (c) 总控收口:综合全链交付,产出最终结论 ──
|
||||
if (!orchestrationCancelRef.current && finishedAllSeats && total > 0) {
|
||||
highlightSpeaker(null);
|
||||
const closeoutBubbleId = appendStreamingDialogue({
|
||||
sender: "总控协调",
|
||||
avatarType: "blue",
|
||||
initialContent: "",
|
||||
confidence: "汇总链条结论",
|
||||
agentThreadId: leaderThreadId,
|
||||
});
|
||||
const closeoutAborter = new AbortController();
|
||||
activeAbortersRef.current.add(closeoutAborter);
|
||||
try {
|
||||
const closeoutRes = await streamMultiAgent({
|
||||
agentType: "leader",
|
||||
dAgentThreadId: ids,
|
||||
agentName: coord,
|
||||
newMessage:
|
||||
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。",
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: closeoutAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
setDialogueConfidence(closeoutBubbleId, label);
|
||||
appendStepIfNew(closeoutBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||
});
|
||||
finalizeDialogue(closeoutBubbleId, closeoutRes.content, "全案盖印");
|
||||
if (closeoutRes.content) setLastLeaderContent(closeoutRes.content);
|
||||
setConsensusPercentage(100);
|
||||
setHasConsensus(true);
|
||||
triggerToast("🎉 业务链条研讨完成!已生成全案结论");
|
||||
} catch (err) {
|
||||
if (closeoutAborter.signal.aborted || (err as Error).name === "AbortError") {
|
||||
finalizeDialogue(closeoutBubbleId, "(已停止)", "已停止");
|
||||
} else {
|
||||
console.error("chain closeout error:", err);
|
||||
finalizeDialogue(closeoutBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
|
||||
}
|
||||
} finally {
|
||||
activeAbortersRef.current.delete(closeoutAborter);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("chain orchestration error:", err);
|
||||
triggerToast(`❌ 链条研讨异常: ${(err as Error).message}`);
|
||||
} finally {
|
||||
highlightSpeaker(null);
|
||||
clearAllStreamingSpeakers();
|
||||
setIsOrchestrating(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
appendStreamingDialogue,
|
||||
appendToDialogue,
|
||||
appendStepIfNew,
|
||||
setDialogueConfidence,
|
||||
phaseLabel,
|
||||
finalizeDialogue,
|
||||
highlightSpeaker,
|
||||
markSpeakerStreaming,
|
||||
markSpeakerIdle,
|
||||
clearAllStreamingSpeakers,
|
||||
triggerToast,
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
resolveSeatThreadId,
|
||||
],
|
||||
);
|
||||
|
||||
// 按当前模式分发到对应编排函数。init / 恢复 / 干预 / 澄清回复统一走这里。
|
||||
const runActiveOrchestration = useCallback(
|
||||
(seedMessage: string, ids: ThreadIdMap, coord: string) =>
|
||||
orchestrationModeRef.current === "chain"
|
||||
? runChainOrchestration(seedMessage, ids, coord)
|
||||
: runOrchestration(seedMessage, ids, coord),
|
||||
[runOrchestration, runChainOrchestration],
|
||||
);
|
||||
|
||||
// ─── Effects ─────────────────────────────────────────────────────
|
||||
// Step 2 init —— 首次进入时建协调 thread + 各席位 thread,然后触发第一轮编排。
|
||||
// 离开 Step 2 **不**销毁 threadIds,保证回来还能继续。
|
||||
@@ -1190,7 +1516,7 @@ export function useStep2Orchestration(
|
||||
confidence: "系统就绪",
|
||||
});
|
||||
triggerToast("✅ 多智能体圆桌初始化完成");
|
||||
await runOrchestration(
|
||||
await runActiveOrchestration(
|
||||
buildInitialIntent(),
|
||||
initRes.thread_ids,
|
||||
initRes.main_agent_name,
|
||||
@@ -1234,7 +1560,7 @@ export function useStep2Orchestration(
|
||||
const pending = pendingInjectPromptRef.current;
|
||||
if (pending) {
|
||||
pendingInjectPromptRef.current = null;
|
||||
void runOrchestration(
|
||||
void runActiveOrchestration(
|
||||
pending,
|
||||
threadIdsRef.current,
|
||||
coordinatorNameRef.current,
|
||||
@@ -1242,7 +1568,7 @@ export function useStep2Orchestration(
|
||||
return;
|
||||
}
|
||||
|
||||
void runOrchestration(
|
||||
void runActiveOrchestration(
|
||||
"用户已恢复研讨,请继续上一轮的协调工作。",
|
||||
threadIdsRef.current,
|
||||
coordinatorNameRef.current,
|
||||
@@ -1311,8 +1637,8 @@ export function useStep2Orchestration(
|
||||
triggerToast("干预指令已发送,会商继续执行");
|
||||
|
||||
// 直接启动新一轮编排
|
||||
void runOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
|
||||
}, [customAgentInput, appendDialogue, abortAllInFlight, runOrchestration, triggerToast]);
|
||||
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
|
||||
}, [customAgentInput, appendDialogue, abortAllInFlight, runActiveOrchestration, triggerToast]);
|
||||
|
||||
const submitClarification = useCallback(
|
||||
(rawAnswer: string) => {
|
||||
@@ -1335,13 +1661,13 @@ export function useStep2Orchestration(
|
||||
});
|
||||
setPendingClarification(null);
|
||||
setClarificationDraft("");
|
||||
void runOrchestration(
|
||||
void runActiveOrchestration(
|
||||
`用户已回复你刚才的澄清问题。\n问题:${question}\n用户回答:${answer}\n请基于该回答继续派活或给出结论。`,
|
||||
threadIdsRef.current,
|
||||
coordinatorNameRef.current,
|
||||
);
|
||||
},
|
||||
[appendDialogue, pendingClarification, runOrchestration, triggerToast],
|
||||
[appendDialogue, pendingClarification, runActiveOrchestration, triggerToast],
|
||||
);
|
||||
|
||||
// 预算滑块 —— 写死的演示分支,跟实际派活没有数据通路。删除或重构请同步
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 业务链条(Business Chain)共享类型。
|
||||
*
|
||||
* 业务链条 = 一组**有序**的智能体编排模板,可命名、可复用、持久化在后端 MySQL
|
||||
* (`/api/roundtable-chains`,按登录用户隔离)。在 Step 2 圆桌研讨里,总控会**按
|
||||
* 链条顺序逐个派活**(见 `docs/multi-agent-business-chain-dev.md` §6)。
|
||||
*
|
||||
* 这里只放纯类型 / 常量,不含 React 状态或请求逻辑(CRUD 在 `api/chains.ts`)。
|
||||
*/
|
||||
|
||||
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
|
||||
export interface ChainSeat {
|
||||
agent_id: string;
|
||||
/**
|
||||
* 落库时的名称快照。展示时优先用实时 agent 数据(`listAgents()`),agent 被删
|
||||
* 或改名时回退到此快照,避免链条卡片显示错乱。
|
||||
*/
|
||||
name: string;
|
||||
/** 描述快照,同样作为实时数据的回退。 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */
|
||||
export interface BusinessChain {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** 有序席位列表。 */
|
||||
seats: ChainSeat[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推荐弹窗(席位来源选择器)确认后回传给主页面的统一结果。
|
||||
* 两种模式(智能推荐 / 业务链条)最终都产出**有序**的 `agents` + 一个 `mode`。
|
||||
*/
|
||||
export interface SeatSelectionResult {
|
||||
/** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。 */
|
||||
agents: Array<{ agent_id: string; name: string }>;
|
||||
/** 决定 Step 2 走哪种编排:自由派活 vs 按链条顺序派活。 */
|
||||
mode: "recommend" | "chain";
|
||||
/** chain 模式带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
|
||||
chain?: Pick<BusinessChain, "id" | "title">;
|
||||
}
|
||||
|
||||
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
|
||||
export const CHAIN_MIN_SEATS = 2;
|
||||
export const CHAIN_MAX_SEATS = 8;
|
||||
|
||||
/**
|
||||
* 把 `getAvatarTypeFor()` 产出的头像配色(blue/purple/emerald/amber/pink/human)
|
||||
* 映射成 tag chip 的 tailwind 类(背景 + 文字 + 边框,含深色变体)。
|
||||
* 配置页的智能体 tag 与链条选择卡片的席位 tag 复用同一套色,视觉统一。
|
||||
*/
|
||||
export function chainTagClasses(avatarType: string): string {
|
||||
switch (avatarType) {
|
||||
case "purple":
|
||||
return "bg-purple-500/10 text-purple-700 border-purple-300 dark:text-purple-300 dark:border-purple-700/60";
|
||||
case "emerald":
|
||||
return "bg-emerald-500/10 text-emerald-700 border-emerald-300 dark:text-emerald-300 dark:border-emerald-700/60";
|
||||
case "amber":
|
||||
return "bg-amber-500/10 text-amber-700 border-amber-300 dark:text-amber-300 dark:border-amber-700/60";
|
||||
case "pink":
|
||||
return "bg-pink-500/10 text-pink-700 border-pink-300 dark:text-pink-300 dark:border-pink-700/60";
|
||||
case "human":
|
||||
return "bg-slate-500/10 text-slate-700 border-slate-300 dark:text-slate-300 dark:border-slate-600/60";
|
||||
case "blue":
|
||||
default:
|
||||
return "bg-blue-500/10 text-blue-700 border-blue-300 dark:text-blue-300 dark:border-blue-700/60";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
/**
|
||||
* 业务链条配置页面(路由 `/page/workspace/roundtable/chains`)。
|
||||
*
|
||||
* 双栏编辑器:
|
||||
* - 左栏:候选智能体列表(`listAgents()` + `filterRecommendCandidates` 复用,剔除
|
||||
* intent/recommender/coordinator),支持**名称检索 + 标签筛选**(`TagSearchBar`,
|
||||
* 服务端过滤),竖排卡片,可拖到右栏、也可点击快捷加入。
|
||||
* - 右栏:当前链条的**有序**长条卡片(序号 + 名称 + 描述),可拖拽排序、删除单项;
|
||||
* 左栏拖入时按落点插入到对应位置。
|
||||
*
|
||||
* 拖拽用 `@dnd-kit`:右栏是 `SortableContext`(席位互相排序),左栏卡片是
|
||||
* `useDraggable`(pool→右栏插入)。落点计算在 `onDragEnd`。
|
||||
*
|
||||
* 视觉:直接遵循《浅红色 UI 规范》/《深蓝色 UI 规范》—— 用 app 语义 token(光红暗蓝
|
||||
* 已内置)+ 规范小圆角(输入/按钮/tag 2px、卡片/分栏 4px)+ 克制阴影。**不**套圆桌页
|
||||
* 的 `roundtable-planning` scope。
|
||||
*
|
||||
* 持久化走 `api/chains.ts`(后端 MySQL `/api/roundtable-chains`)。席位数约束
|
||||
* 2 ≤ N ≤ 8、agent 去重,保存时校验(后端 `_validate_seats` 也兜底)。
|
||||
*
|
||||
* 详见 `docs/multi-agent-business-chain-dev.md` §7 阶段 1。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
ArrowLeft,
|
||||
GripVertical,
|
||||
Layers,
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
type RecommendCandidate,
|
||||
} from "@/roundtable-planning/api/recommend";
|
||||
import {
|
||||
createChain,
|
||||
deleteChain,
|
||||
listChains,
|
||||
updateChain,
|
||||
} from "@/roundtable-planning/api/chains";
|
||||
import {
|
||||
CHAIN_MAX_SEATS,
|
||||
CHAIN_MIN_SEATS,
|
||||
chainTagClasses,
|
||||
type BusinessChain,
|
||||
type ChainSeat,
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
const POOL_PREFIX = "pool::";
|
||||
const SEAT_PREFIX = "seat::";
|
||||
const SEATS_DROPPABLE_ID = "seats-droppable";
|
||||
|
||||
// 规范圆角(§4):输入/按钮/tag 2px,卡片/分栏 4px。app 默认 --radius=12px 偏大,
|
||||
// 这里用 arbitrary 值精确命中规范。
|
||||
const R_CARD = "rounded-[4px]";
|
||||
const R_CTRL = "rounded-[2px]";
|
||||
// 规范阴影(§3):克制的浅投影,深色下几乎不可见。
|
||||
const PANEL_SHADOW = "shadow-[0_1px_2px_rgba(15,23,42,0.05)]";
|
||||
|
||||
type ActiveDrag =
|
||||
| { kind: "pool"; agentId: string; name: string }
|
||||
| { kind: "seat"; agentId: string; name: string }
|
||||
| null;
|
||||
|
||||
export default function BusinessChainEditorPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ─── 数据 ────────────────────────────────────────────────────────────
|
||||
const [pool, setPool] = useState<RecommendCandidate[]>([]);
|
||||
const [poolLoading, setPoolLoading] = useState(true);
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
// 左栏检索/筛选 —— 服务端过滤(listAgents 支持 search + tag_ids)。
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterTagIds, setFilterTagIds] = useState<string[]>([]);
|
||||
|
||||
// ─── 当前编辑中的链条 ─────────────────────────────────────────────────
|
||||
const [currentChainId, setCurrentChainId] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [seats, setSeats] = useState<ChainSeat[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
||||
const selectValue = currentChainId ?? "";
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const poolById = useMemo(() => {
|
||||
const m = new Map<string, RecommendCandidate>();
|
||||
for (const a of pool) m.set(a.agent_id, a);
|
||||
return m;
|
||||
}, [pool]);
|
||||
const seatIdSet = useMemo(() => new Set(seats.map((s) => s.agent_id)), [seats]);
|
||||
|
||||
// ─── 链条列表(一次性)─────────────────────────────────────────────────
|
||||
const refreshChains = useCallback(async () => {
|
||||
try {
|
||||
setChains(await listChains());
|
||||
} catch (err) {
|
||||
console.warn("[chains] refresh list failed:", err);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void refreshChains();
|
||||
}, [refreshChains]);
|
||||
|
||||
// ─── 候选池:随 search / tagIds 服务端过滤 ────────────────────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPoolLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const agents = await listAgents({
|
||||
search: search || undefined,
|
||||
tagIds: filterTagIds.length ? filterTagIds : undefined,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setPool(filterRecommendCandidates(agents));
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
toast.error(
|
||||
`加载候选智能体失败:${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) setPoolLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [search, filterTagIds]);
|
||||
|
||||
// ─── 链条选择 / 新建 ──────────────────────────────────────────────────
|
||||
const loadChain = useCallback((chain: BusinessChain) => {
|
||||
setCurrentChainId(chain.id);
|
||||
setTitle(chain.title);
|
||||
setDescription(chain.description ?? "");
|
||||
setSeats(chain.seats.map((s) => ({ ...s })));
|
||||
}, []);
|
||||
|
||||
const startNewChain = useCallback(() => {
|
||||
setCurrentChainId(null);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setSeats([]);
|
||||
}, []);
|
||||
|
||||
// ─── 席位增删改 ───────────────────────────────────────────────────────
|
||||
const addAgentToSeats = useCallback(
|
||||
(agentId: string, atIndex?: number) => {
|
||||
const agent = poolById.get(agentId);
|
||||
if (!agent) return;
|
||||
setSeats((prev) => {
|
||||
if (prev.some((s) => s.agent_id === agentId)) {
|
||||
toast.info(`「${agent.name}」已在链条中`);
|
||||
return prev;
|
||||
}
|
||||
if (prev.length >= CHAIN_MAX_SEATS) {
|
||||
toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`);
|
||||
return prev;
|
||||
}
|
||||
const seat: ChainSeat = {
|
||||
agent_id: agent.agent_id,
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
};
|
||||
const next = [...prev];
|
||||
const i = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length));
|
||||
next.splice(i, 0, seat);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[poolById],
|
||||
);
|
||||
|
||||
const removeSeat = useCallback((agentId: string) => {
|
||||
setSeats((prev) => prev.filter((s) => s.agent_id !== agentId));
|
||||
}, []);
|
||||
|
||||
// ─── DnD ─────────────────────────────────────────────────────────────
|
||||
const onDragStart = useCallback(
|
||||
(e: DragStartEvent) => {
|
||||
const id = String(e.active.id);
|
||||
if (id.startsWith(POOL_PREFIX)) {
|
||||
const agentId = id.slice(POOL_PREFIX.length);
|
||||
const a = poolById.get(agentId);
|
||||
setActiveDrag({ kind: "pool", agentId, name: a?.name ?? agentId });
|
||||
} else if (id.startsWith(SEAT_PREFIX)) {
|
||||
const agentId = id.slice(SEAT_PREFIX.length);
|
||||
const s = seats.find((x) => x.agent_id === agentId);
|
||||
setActiveDrag({ kind: "seat", agentId, name: s?.name ?? agentId });
|
||||
}
|
||||
},
|
||||
[poolById, seats],
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
setActiveDrag(null);
|
||||
const activeId = String(e.active.id);
|
||||
const overId = e.over ? String(e.over.id) : null;
|
||||
if (!overId) return;
|
||||
|
||||
const overSeatIndex = overId.startsWith(SEAT_PREFIX)
|
||||
? seats.findIndex((s) => SEAT_PREFIX + s.agent_id === overId)
|
||||
: -1;
|
||||
|
||||
if (activeId.startsWith(POOL_PREFIX)) {
|
||||
const agentId = activeId.slice(POOL_PREFIX.length);
|
||||
const insertAt = overSeatIndex >= 0 ? overSeatIndex : seats.length;
|
||||
addAgentToSeats(agentId, insertAt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeId.startsWith(SEAT_PREFIX)) {
|
||||
const fromIndex = seats.findIndex(
|
||||
(s) => SEAT_PREFIX + s.agent_id === activeId,
|
||||
);
|
||||
if (fromIndex < 0) return;
|
||||
const toIndex = overSeatIndex >= 0 ? overSeatIndex : seats.length - 1;
|
||||
if (fromIndex === toIndex) return;
|
||||
setSeats((prev) => arrayMove(prev, fromIndex, toIndex));
|
||||
}
|
||||
},
|
||||
[seats, addAgentToSeats],
|
||||
);
|
||||
|
||||
// ─── 保存 / 删除 ──────────────────────────────────────────────────────
|
||||
const seatCountInvalid =
|
||||
seats.length < CHAIN_MIN_SEATS || seats.length > CHAIN_MAX_SEATS;
|
||||
const canSave = title.trim().length > 0 && !seatCountInvalid && !saving;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!title.trim()) {
|
||||
toast.warning("请先填写链条标题");
|
||||
return;
|
||||
}
|
||||
if (seatCountInvalid) {
|
||||
toast.warning(`链条席位数须在 ${CHAIN_MIN_SEATS}–${CHAIN_MAX_SEATS} 之间`);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payloadSeats = seats.map((s) => ({
|
||||
agent_id: s.agent_id,
|
||||
name: poolById.get(s.agent_id)?.name ?? s.name,
|
||||
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
|
||||
}));
|
||||
if (currentChainId) {
|
||||
const updated = await updateChain(currentChainId, {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
seats: payloadSeats,
|
||||
});
|
||||
if (!updated) {
|
||||
toast.error("链条已不存在,可能被删除");
|
||||
startNewChain();
|
||||
await refreshChains();
|
||||
return;
|
||||
}
|
||||
toast.success("已保存");
|
||||
} else {
|
||||
const id = nanoid();
|
||||
const created = await createChain({
|
||||
id,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
seats: payloadSeats,
|
||||
});
|
||||
setCurrentChainId(created.id);
|
||||
toast.success("已创建业务链条");
|
||||
}
|
||||
await refreshChains();
|
||||
} catch (err) {
|
||||
toast.error(`保存失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
title,
|
||||
description,
|
||||
seats,
|
||||
seatCountInvalid,
|
||||
currentChainId,
|
||||
poolById,
|
||||
refreshChains,
|
||||
startNewChain,
|
||||
]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!currentChainId) {
|
||||
startNewChain();
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认删除业务链条「${title || "未命名"}」?此操作不可恢复。`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteChain(currentChainId);
|
||||
toast.success("已删除");
|
||||
startNewChain();
|
||||
await refreshChains();
|
||||
} catch (err) {
|
||||
toast.error(`删除失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [currentChainId, title, startNewChain, refreshChains]);
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────
|
||||
const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
|
||||
{/* 顶栏 */}
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-5 py-2.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/page/workspace/roundtable/planning")}
|
||||
className={cn("h-8 gap-1.5 px-2 text-muted-foreground hover:text-foreground", R_CTRL)}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回圆桌
|
||||
</Button>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={cn("flex size-8 items-center justify-center bg-primary/10 text-primary", R_CARD)}>
|
||||
<Layers className="size-4" />
|
||||
</span>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold">业务链条配置</div>
|
||||
<div className="text-xs text-muted-foreground">编排有序的智能体研讨链路</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 链条选择 + 新建 */}
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<select
|
||||
value={selectValue}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
if (!id) {
|
||||
startNewChain();
|
||||
return;
|
||||
}
|
||||
const chain = chains.find((c) => c.id === id);
|
||||
if (chain) loadChain(chain);
|
||||
}}
|
||||
className={cn(
|
||||
"h-8 border border-input bg-card px-2.5 text-sm text-foreground outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
<option value="">+ 新建链条…</option>
|
||||
{chains.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title || "未命名"}({c.seats.length} 席)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={startNewChain} className={cn("h-8 gap-1.5", R_CTRL)}>
|
||||
<Plus className="size-4" />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={!currentChainId}
|
||||
className={cn("h-8 gap-1.5 text-destructive hover:text-destructive", R_CTRL)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className={cn("h-8 gap-1.5", R_CTRL)}
|
||||
>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 标题 + 描述 */}
|
||||
<div className="flex shrink-0 flex-col gap-2.5 border-b border-border bg-card px-5 py-3 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-2 sm:w-80">
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">标题</span>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="如:应急方案三段链"
|
||||
className={cn("h-8 bg-background", R_CTRL)}
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">描述</span>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="链条用途说明(可选)"
|
||||
className={cn("h-8 bg-background", R_CTRL)}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 双栏拖拽编辑器 */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-hidden p-4 md:grid-cols-[360px_1fr]">
|
||||
{/* 左栏:候选智能体 */}
|
||||
<section className={cn("flex min-h-0 flex-col overflow-hidden border border-border bg-card", R_CARD, PANEL_SHADOW)}>
|
||||
<div className="shrink-0 space-y-2.5 border-b border-border px-3.5 py-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
|
||||
<Users className="size-3.5 text-primary" />
|
||||
候选智能体
|
||||
<span className={cn("bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary tabular-nums", R_CTRL)}>
|
||||
{pool.length}
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] font-normal text-muted-foreground">
|
||||
拖到右侧 / 点击加入
|
||||
</span>
|
||||
</div>
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
tagIds={filterTagIds}
|
||||
onTagIdsChange={setFilterTagIds}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto p-2.5">
|
||||
{poolLoading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载候选智能体…
|
||||
</div>
|
||||
) : pool.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||
{search || filterTagIds.length
|
||||
? "没有匹配的智能体,换个关键词或标签试试。"
|
||||
: `候选池为空,请先在「智能体」里创建至少 ${CHAIN_MIN_SEATS} 个智能体。`}
|
||||
</div>
|
||||
) : (
|
||||
pool.map((a) => (
|
||||
<PoolAgentCard
|
||||
key={a.agent_id}
|
||||
agent={a}
|
||||
inChain={seatIdSet.has(a.agent_id)}
|
||||
onAdd={() => addAgentToSeats(a.agent_id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 右栏:有序席位 */}
|
||||
<SeatsColumn
|
||||
seats={seats}
|
||||
seatItemIds={seatItemIds}
|
||||
seatCountInvalid={seatCountInvalid}
|
||||
onRemove={removeSeat}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeDrag ? (
|
||||
<div className={cn("flex items-center gap-2 border border-primary/50 bg-card px-3 py-2 text-sm font-medium text-foreground shadow-[0_4px_12px_rgba(15,23,42,0.12)]", R_CTRL)}>
|
||||
<GripVertical className="size-4 text-primary" />
|
||||
{activeDrag.name}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 左栏候选卡片(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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"group flex items-start gap-2 border p-2.5 transition-colors",
|
||||
R_CTRL,
|
||||
inChain
|
||||
? "cursor-not-allowed border-dashed border-border bg-muted/40 opacity-60"
|
||||
: "cursor-grab border-border bg-card hover:border-primary/50 hover:bg-primary/[0.03] active:cursor-grabbing",
|
||||
isDragging && "opacity-30",
|
||||
)}
|
||||
{...(inChain ? {} : listeners)}
|
||||
{...attributes}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{agent.name || agent.agent_id}</div>
|
||||
{agent.description && (
|
||||
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{inChain ? (
|
||||
<span className={cn("shrink-0 self-center bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary", R_CTRL)}>
|
||||
已加入
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"shrink-0 self-center p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-primary hover:text-primary-foreground group-hover:opacity-100",
|
||||
R_CTRL,
|
||||
)}
|
||||
title="加入链条"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 右栏(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 (
|
||||
<section className={cn("flex min-h-0 flex-col overflow-hidden border border-border bg-card", R_CARD, PANEL_SHADOW)}>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3 text-xs font-semibold text-foreground">
|
||||
<ListOrdered className="size-3.5 text-primary" />
|
||||
链条席位
|
||||
<span className={cn("bg-muted px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground", R_CTRL)}>
|
||||
按顺序发言
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto px-1.5 py-0.5 text-[11px] font-medium tabular-nums",
|
||||
R_CTRL,
|
||||
seatCountInvalid
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{seats.length} / {CHAIN_MAX_SEATS}
|
||||
{seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 space-y-2 overflow-y-auto p-3.5 transition-colors",
|
||||
isOver && "bg-primary/[0.03]",
|
||||
)}
|
||||
>
|
||||
{seats.length === 0 ? (
|
||||
<div className={cn("flex h-full flex-col items-center justify-center gap-2 border border-dashed border-border text-sm text-muted-foreground", R_CARD)}>
|
||||
<Layers className="size-7 opacity-40" />
|
||||
从左侧把智能体拖到这里,组成有序业务链条
|
||||
</div>
|
||||
) : (
|
||||
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
|
||||
{seats.map((seat, index) => (
|
||||
<SeatStrip
|
||||
key={seat.agent_id}
|
||||
seat={seat}
|
||||
index={index}
|
||||
onRemove={() => onRemove(seat.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 单个席位长条(sortable)─────────────────────────────────────────────
|
||||
|
||||
function SeatStrip({
|
||||
seat,
|
||||
index,
|
||||
onRemove,
|
||||
}: {
|
||||
seat: ChainSeat;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: SEAT_PREFIX + seat.agent_id });
|
||||
const avatarType = getAvatarTypeFor(seat.agent_id, index);
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn(
|
||||
"flex items-center gap-3 border border-border bg-card p-2.5",
|
||||
R_CTRL,
|
||||
isDragging && "z-10 opacity-70 shadow-[0_4px_12px_rgba(15,23,42,0.12)]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-grab touch-none text-muted-foreground/60 hover:text-foreground active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title="拖动排序"
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 shrink-0 items-center justify-center border text-[12px] font-semibold tabular-nums",
|
||||
R_CTRL,
|
||||
chainTagClasses(avatarType),
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{seat.name || seat.agent_id}</div>
|
||||
{seat.description && (
|
||||
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
|
||||
{seat.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className={cn("shrink-0 p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive", R_CTRL)}
|
||||
title="移除"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
// 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() {
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
id="business-chain-btn"
|
||||
onClick={() => navigate("/page/workspace/roundtable/chains")}
|
||||
title="配置可复用的业务链条(有序智能体编排)"
|
||||
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||
>
|
||||
<ListOrdered className="w-3.5 h-3.5 text-primary" />
|
||||
业务链条
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="new-task-btn"
|
||||
onClick={handleStartNewTask}
|
||||
|
||||
@@ -43,6 +43,7 @@ from app.gateway.routers import (
|
||||
public_scheduled_tasks,
|
||||
recommend,
|
||||
recommended_questions,
|
||||
roundtable_chains,
|
||||
roundtable_drafts,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
@@ -854,6 +855,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
|
||||
app.include_router(roundtable_drafts.router)
|
||||
|
||||
# Roundtable-planning business chains at /api/roundtable-chains
|
||||
app.include_router(roundtable_chains.router)
|
||||
|
||||
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
||||
app.include_router(suggestions.router)
|
||||
|
||||
|
||||
@@ -160,6 +160,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
|
||||
|
||||
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
||||
|
||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
||||
|
||||
# Run event store (has its own factory with config-driven backend selection)
|
||||
run_events_config = getattr(config, "run_events", None)
|
||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""CRUD API for roundtable-planning business chains (业务链条).
|
||||
|
||||
A business chain is a named, reusable, *ordered* list of agents the user
|
||||
assembles in the chain editor. In Step 2 the coordinator dispatches to the seats
|
||||
following this order. Bound to the requesting user (not the browser), so it
|
||||
follows the account across devices/sessions — same model as roundtable drafts.
|
||||
|
||||
Routes (prefix ``/api/roundtable-chains``):
|
||||
GET "" list current user's chains (full records, incl. seats)
|
||||
POST "" create a chain
|
||||
GET "/{chain_id}" one chain
|
||||
PUT "/{chain_id}" partial update (title / description / seats)
|
||||
DELETE "/{chain_id}" delete
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/roundtable-chains", tags=["roundtable-chains"])
|
||||
|
||||
# Seat-count bounds mirror the roundtable selection bounds (2 ≤ N ≤ 8).
|
||||
MIN_SEATS = 2
|
||||
MAX_SEATS = 8
|
||||
|
||||
|
||||
# ── schemas ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ChainSeatSchema(BaseModel):
|
||||
agent_id: str = Field(min_length=1, max_length=128)
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class ChainResponse(BaseModel):
|
||||
id: str
|
||||
title: str = ""
|
||||
description: str | None = None
|
||||
seats: list[ChainSeatSchema] = []
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
|
||||
|
||||
class ChainListResponse(BaseModel):
|
||||
chains: list[ChainResponse]
|
||||
|
||||
|
||||
class ChainCreateRequest(BaseModel):
|
||||
# Optional client-provided id so the browser can keep one stable id across
|
||||
# the create + subsequent updates without an extra round-trip.
|
||||
id: str | None = Field(default=None, max_length=64)
|
||||
title: str = Field(default="", max_length=512)
|
||||
description: str | None = Field(default=None, max_length=2048)
|
||||
seats: list[ChainSeatSchema] = []
|
||||
|
||||
|
||||
class ChainUpdateRequest(BaseModel):
|
||||
# All optional → callers send only what changed.
|
||||
title: str | None = Field(default=None, max_length=512)
|
||||
description: str | None = Field(default=None, max_length=2048)
|
||||
seats: list[ChainSeatSchema] | None = None
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> str:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
store = getattr(request.app.state, "roundtable_chain_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Roundtable chain store not available")
|
||||
return store
|
||||
|
||||
|
||||
def _validate_seats(seats: list[ChainSeatSchema]) -> None:
|
||||
"""Enforce 2 ≤ N ≤ 8 and reject duplicate agent ids within a chain."""
|
||||
if len(seats) < MIN_SEATS or len(seats) > MAX_SEATS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"业务链条席位数须在 {MIN_SEATS}–{MAX_SEATS} 之间(当前 {len(seats)})",
|
||||
)
|
||||
seen: set[str] = set()
|
||||
for s in seats:
|
||||
if s.agent_id in seen:
|
||||
raise HTTPException(status_code=400, detail=f"业务链条存在重复智能体: {s.agent_id}")
|
||||
seen.add(s.agent_id)
|
||||
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=ChainListResponse)
|
||||
async def list_chains(request: Request) -> ChainListResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
rows = await store.list_chains(user_id)
|
||||
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.post("", response_model=ChainResponse, status_code=201)
|
||||
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
|
||||
_validate_seats(body.seats)
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
row = await store.create_chain(
|
||||
user_id,
|
||||
{
|
||||
"id": (body.id or uuid4().hex),
|
||||
"title": body.title.strip(),
|
||||
"description": body.description,
|
||||
"seats": [s.model_dump() for s in body.seats],
|
||||
},
|
||||
)
|
||||
return ChainResponse(**row)
|
||||
|
||||
|
||||
@router.get("/{chain_id}", response_model=ChainResponse)
|
||||
async def get_chain(request: Request, chain_id: str) -> ChainResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
row = await store.get_chain(chain_id, user_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Chain not found")
|
||||
return ChainResponse(**row)
|
||||
|
||||
|
||||
@router.put("/{chain_id}", response_model=ChainResponse)
|
||||
async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest) -> ChainResponse:
|
||||
if body.seats is not None:
|
||||
_validate_seats(body.seats)
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
row = await store.update_chain(chain_id, user_id, **fields)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Chain not found")
|
||||
return ChainResponse(**row)
|
||||
|
||||
|
||||
@router.delete("/{chain_id}", status_code=204)
|
||||
async def delete_chain(request: Request, chain_id: str) -> None:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
deleted = await store.delete_chain(chain_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Chain not found")
|
||||
+2
@@ -22,6 +22,7 @@ from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||
from deerflow.persistence.roundtable_drafts.model import (
|
||||
RoundtableDraftRow,
|
||||
RoundtableRecommendHistoryRow,
|
||||
@@ -50,6 +51,7 @@ __all__ = [
|
||||
"LlmCallMetricRow",
|
||||
"NotificationRow",
|
||||
"RecommendedQuestionRow",
|
||||
"RoundtableChainRow",
|
||||
"RoundtableDraftRow",
|
||||
"RoundtableRecommendHistoryRow",
|
||||
"RunEventRow",
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"""Roundtable-planning business-chain persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||
from deerflow.persistence.roundtable_chains.sql import RoundtableChainRepository
|
||||
|
||||
__all__ = [
|
||||
"RoundtableChainRepository",
|
||||
"RoundtableChainRow",
|
||||
"make_roundtable_chain_store",
|
||||
]
|
||||
|
||||
|
||||
def make_roundtable_chain_store(session_factory):
|
||||
if session_factory is None:
|
||||
return None
|
||||
return RoundtableChainRepository(session_factory)
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"""ORM model for roundtable-planning business chains.
|
||||
|
||||
A "business chain" (业务链条) is a named, reusable, *ordered* list of agents the
|
||||
user assembles in the chain editor. In Step 2 the coordinator dispatches to the
|
||||
seats following this order (see ``multi-agent-business-chain-dev.md``).
|
||||
|
||||
Persisted per user (like ``roundtable_drafts``) so it follows the account across
|
||||
devices/sessions. ``seats`` is stored as a JSON array serialized into
|
||||
``PortableLongText`` (MySQL ``LONGTEXT``) rather than ``PortableJSON`` for
|
||||
headroom, mirroring the sibling roundtable persistence modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
|
||||
class RoundtableChainRow(Base):
|
||||
__tablename__ = "roundtable_chains"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
# Human-readable chain title shown on the picker card.
|
||||
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
# Optional free-text description.
|
||||
description: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
# JSON: ordered [{"agent_id": str, "name": str, "description": str}, ...].
|
||||
# seats[0] is the first seat to speak. Stored as LONGTEXT-backed JSON string.
|
||||
seats: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
"""SQLAlchemy-backed roundtable business-chain store.
|
||||
|
||||
The ``seats`` JSON column is stored as a serialized string in
|
||||
``PortableLongText`` and round-tripped here with ``json.dumps`` / ``json.loads``
|
||||
— same approach as ``roundtable_drafts`` / ``ai_writing_sessions``.
|
||||
|
||||
All write/read paths are scoped by ``user_id`` for ownership isolation; a row
|
||||
that belongs to another user is treated as not found.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _loads(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _dumps(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
class RoundtableChainRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(row: RoundtableChainRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
"description": row.description,
|
||||
"seats": _loads(row.seats) or [],
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
async def list_chains(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(RoundtableChainRow)
|
||||
.where(RoundtableChainRow.user_id == user_id)
|
||||
.order_by(RoundtableChainRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
return self._to_dict(row)
|
||||
|
||||
async def create_chain(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = RoundtableChainRow(
|
||||
id=data["id"],
|
||||
user_id=user_id,
|
||||
title=(data.get("title") or "").strip(),
|
||||
description=(data.get("description") or None),
|
||||
seats=_dumps(data.get("seats") or []),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
|
||||
async def update_chain(
|
||||
self,
|
||||
chain_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
title=_UNSET,
|
||||
description=_UNSET,
|
||||
seats=_UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
if title is not _UNSET and title is not None:
|
||||
row.title = str(title).strip()
|
||||
if description is not _UNSET:
|
||||
row.description = description or None
|
||||
if seats is not _UNSET:
|
||||
row.seats = _dumps(seats)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
|
||||
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
Reference in new issue
Block a user