Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/config.yaml
#	offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
#	offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py
This commit is contained in:
Administrator committed 2026-06-10 09:09:26 +08:00
commit 87e0ae683e
112 files changed
+24826 -1591

No files matched your search

+2 -1
View File
@@ -4,7 +4,8 @@
"PowerShell(uv --version)",
"PowerShell(netstat -ano)",
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })"
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })",
"Bash(curl -s -X POST -H \"Content-Type: application/json\" -d '{\"username\":\"demo\"}' http://localhost:8001/api/v1/auth/login/username)"
]
}
}
@@ -0,0 +1,610 @@
# 圆桌 DAG 编排 + 并行执行显示 —— 分步实现需求文档
> 目标读者:实现本功能的前端 / 后端工程师。
> 本文是**可逐步落地**的需求 + 设计文档,每个 Phase 都能独立交付、独立验收。
---
## 0. 背景与目标
### 0.1 现状
Step 2 圆桌目前有两种编排模式(`orchestrationMode`,见 `hooks/useStep2Orchestration.ts`):
- **`free`(自由模式)**:总控(leader)自由决定派活给谁,`runOrchestration` 循环驱动。
- **`chain`(线性链条)**:用户把席位排成一条**线性**链 `A → B → C`,`runChainOrchestration` 按顺序逐个派活,最后总控收口。链路用 `DispatchChainModal.tsx` 的**横向流程图**展示(总控 → 席位1 → 席位2 → … → 共识 → 报告)。
链条数据现在只是一个**有序数组** `selectedAgents`(线性),节点状态类型见 `lib/job-types.ts`(`ChainNodeState = pending | active | done`)。
### 0.2 目标(本次新增)
1. **DAG 编排**:用户可视化指定**智能体之间的直接依赖关系**——有的串行(`A → B → C`),有的并行(`A B C` 同时跑)。编排结果是一张**流程图(DAG)**。
2. **并行执行**:DAG 里同一层(互不依赖)的智能体**并行**跑,墙钟时间从「串行求和」降到「并行取最慢」。
3. **并行禁止写文件**:并行的智能体**禁用文件写入 / 沙箱命令**,避免它们同时打开沙箱互相打架。产物以消息正文交付;落文件留给串行 / 收口阶段。
4. **并行高性能显示**:并行时收起左侧、顶部头像条切换、主区同时显示 2 个智能体的消息流,其余**只接流不渲染**,避免页面卡死。
### 0.3 设计总原则
- **复用** 现有 special run / leader run / rollback 抢占 / 轻量交付状态接口(`GET /threads/{id}/last-ai-message`),不另起炉灶。
- **线性链是 DAG 的特例**:`chain` 模式可平滑迁移成"每层 1 个节点"的 DAG,老数据不破坏。
- **分层(Stage)优先于自由连线**:编辑交互首选「分层」模型(每个 stage 放若干并行席位,stage 间串行),实现成本低、覆盖 95% 的"串 / 并"诉求;自由连线画布作为后续可选增强(见 §2.3)。
### 0.4 已确认的设计决策(✅ 本期按这些实现)
> 以下三项已与需求方确认锁定,实现时直接按此,不再二选一。
1. **✅ 编排模型 = 分层 Stage**:本期只做分层(每个 stage 放若干并行席位、stage 间串行)。**自由连线画布本期不做**,仅在数据模型层预留(§1.2)以免将来返工。
2. **✅ 并行跳过 leader、前端直派**:DAG 由用户显式指定,执行时前端**直接**对 stage 内席位发起 special run,**不**每个 stage 再跑一轮总控派活;只在最后 `finalSynthesis` 跑一轮 leader 综合(即 §3.2 的方案 B)。
3. **✅ 主区固定 2 列,窄屏降 1 列**:并行时主区最多并排显示 2 个被选中智能体的消息流;窄屏(断点见 §5.3)降为 1 列 + 头像条横向滚动。不做 3+ 列。
---
## 1. 数据模型
### 1.1 DAG 的「分层」表示(推荐,主模型)
把 DAG 表示成**有序的 stage 列表**,每个 stage 内的席位并行、stage 之间串行:
```ts
// lib/dag-types.ts (新建)
/** 一个并行批次:stage 内所有席位并行执行。 */
export interface OrchestrationStage {
/** stage 稳定 id(拖拽重排 / diff 用)。 */
id: string;
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
agentIds: string[];
}
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
export interface OrchestrationPlan {
/** 编排模式标识,持久化进 step2。 */
mode: "dag";
/** 串行执行的批次;每批内并行。 */
stages: OrchestrationStage[];
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
finalSynthesis: boolean;
}
```
- **串行** `A → B → C` = `stages: [{agentIds:[A]}, {agentIds:[B]}, {agentIds:[C]}]`。
- **并行** `A B C` = `stages: [{agentIds:[A,B,C]}]`。
- **混合** `A →(B C)→ D` = `stages: [{[A]}, {[B,C]}, {[D]}]`。
> 为什么用分层而不是「邻接表 / 边」:执行调度本就是「拓扑分批」,分层就是分批的结果,前端不必再做拓扑排序,也天然避免环;编辑器交互更直观(拖卡片进 stage)。真正的自由连线 DAG 见 §2.3(可选)。
### 1.2 通用 DAG 表示(§2.3 自由连线时才需要)
若将来做自由连线画布,再引入边表示,并在执行前做一次**拓扑排序 → 分层**,落回 §1.1 的 stages 结构给调度器用:
```ts
export interface DagEdge { from: string; to: string } // from 完成后才能跑 to
export interface DagGraph { nodes: string[]; edges: DagEdge[] }
// 执行前:toposortToStages(graph) -> OrchestrationStage[]
```
### 1.3 持久化
- 复用 `roundtable_drafts.step2` JSON(见后端 `app.gateway.routers` roundtable-drafts + `hooks/useStep2Orchestration.ts` 里对 `step2` 的读写)。
- 在 `step2` 增加字段:`orchestrationPlan: OrchestrationPlan | null`。
- `orchestrationMode` 扩展为 `"free" | "chain" | "dag"`;`chain` 旧数据读取时即时转换为单节点-stage 的 `dag`(兼容层,不改老 draft)。
### 1.4 校验(纯函数,可单测)
`lib/dag-types.ts` 内提供:
```ts
validatePlan(plan: OrchestrationPlan, selectedAgentIds: string[]): {
ok: boolean;
errors: string[]; // 面向用户的中文提示
}
```
校验项:
- 每个 `agentId` 必须在本次选定席位 `selectedAgents` 内(防止编排了不存在的席位 → 复用后端 `_partition_dispatch` 同思路的"必须在 roster 内")。
- 不允许空 stage;不允许同一席位出现在多个 stage(一个席位只跑一次)。
- 至少 1 个 stage。
- (可选)单 stage 并行度上限提示(如 > 6 并行给 warning:远程模型并发 + 沙箱压力)。
---
## 2. 编排编辑器(流程图构建)交互
### 2.1 入口与整体布局
- 在 Step 2 顶部模式切换里新增 **「依赖编排」**(`dag`)模式(与现有 自由 / 链条 并列)。
- 选择 `dag` 后弹出 / 进入**编排编辑器**(复用 Dialog 大弹窗,参考 `DispatchChainModal` 的尺寸 `w-[94vw] max-w-[1240px]`)。
### 2.2 推荐交互:分层(Stage)编辑器(Phase 2 实现)
```
┌ 依赖编排 ───────────────────────────────────────── [校验✓] [保存] ┐
│ 可选席位(点/拖加入某个 stage) │
│ [情报收集] [环境评估] [方案设计] [风险审查] [执行规划] [前端实现] │
│ ─────────────────────────────────────────────────────────────── │
│ Stage 1(并行) Stage 2(并行) Stage 3 │
│ ┌──────────┐ ─────▶ ┌──────────┐ ┌────────┐ ─▶ ┌──────────┐ │
│ │ 情报收集 │ │ 方案设计 │ │风险审查│ │ 执行规划 │ │
│ └──────────┘ └──────────┘ └────────┘ └──────────┘ │
│ [+ 拖席位到此] [+] [+] │
│ [+ 新建 Stage(串在最后)] │
│ ─────────────────────────────────────────────────────────────── │
│ 说明:同一 Stage 内的席位会**并行**执行;Stage 之间**串行** │
│ (后一个 Stage 能看到前面所有 Stage 的交付摘要)。 │
└───────────────────────────────────────────────────────────────────┘
```
交互要点:
- **加入 stage**:从顶部可选席位**拖拽**到某个 stage,或点席位 →「加入 Stage N」。
- **移动 / 重排**:席位可在 stage 间拖动;stage 可整体左右拖动重排(改串行顺序)。
- **删除**:席位 / 空 stage 可删。
- **并行度提示**:stage 内 ≥2 席位即标「并行」徽标;过多给 warning。
- **实时校验**:`validatePlan` 实时跑,错误在顶部红条提示,「保存」在校验通过前禁用。
- **预览流程图**:编辑区下方实时渲染只读流程图缩略(复用 §7 的 DAG 流程图组件)。
> 拖拽可用现成轻量方案(HTML5 DnD 或 `@dnd-kit`,看项目是否已有依赖;没有就先用「点选 + 加入/移出按钮」的无拖拽版,Phase 2 先上无拖拽、Phase 2.5 再加拖拽)。
### 2.3 可选增强:自由连线画布(后续,非本期必须)
- 节点自由摆放 + 拉线建边 → `DagGraph`(§1.2)。
- 实现成本高(画布 / 连线 / 命中检测 / 自动布局),且分层模型已覆盖绝大多数诉求。**本期不做**,仅在数据模型上预留 §1.2,避免将来返工。
### 2.4 验收(Phase 2)
- 能拖 / 点把席位分配到多个 stage,保存进 `step2.orchestrationPlan`。
- 刷新 / 重进 draft 能恢复编排。
- 非法编排(空 stage / 席位重复 / 席位不在 roster)有明确中文提示且无法保存。
---
## 3. 执行调度(前端编排循环 + 后端复用)
### 3.1 新增 `runDagOrchestration`(`useStep2Orchestration.ts`)
与现有 `runOrchestration` / `runChainOrchestration` 并列,逻辑:
```
for (stageIndex, stage of plan.stages):
if isStale() break // 复用现有 epoch 抢占(人工干预/暂停)
// 1) 总控按本 stage 的席位并行派活(一轮 leader,dispatched = stage.agentIds)
// —— 或跳过 leader,前端直接对 stage 内每个席位发起 special run(见 3.2)
// 2) 并行跑 stage 内所有席位
await Promise.all(stage.agentIds.map(seat =>
streamMultiAgent({ agentType:"special", agentName:seat,
newMessage: buildStageTask(seat, plan, stageIndex),
parallelNoFile: stage.agentIds.length > 1, // §4
... })))
// 3) 等本 stage 全部 ✅(Promise.all 天然 barrier)再进入下一 stage
// 4) finalSynthesis:最后一轮 leader(synthesisMode=true)综合所有 stage 交付
```
要点:
- **批内并行**:`Promise.all`(不是现在的 `for await` 串行)。
- **批间串行**:`Promise.all` 即 barrier,自动等齐。
- **依赖可见性**:进入 stage N 的席位时,把**前面所有 stage 已交付席位的摘要**拼进它的 `newMessage`(复用 §交付状态:网关 `_collect_delivery_status` / `GET last-ai-message?max_chars=N`,前端也可在 stage 边界拉一次摘要拼进 task)。这样 `(B C)` 能看到 `A` 的产出。
- **抢占 / 干预 / 暂停**:每个 stage 边界检查 `isStale()`(epoch),与现有自由 / 链条模式一致。
- **rollback**:special run 已统一 `multitask_strategy=rollback`,并行多席位各自不同 thread,互不冲突。
### 3.2 是否每 stage 都走 leader? — ✅ 已定:前端直派(方案 B)
**本期采用方案 B**:DAG 由用户**显式**指定,不需要 leader 再"决定派给谁"。前端**直接**对 stage 内每个席位发起 special run,省掉每 stage 的 leader LLM 往返(更快、更可控);只在所有 stage 完成后、`finalSynthesis` 为真时跑**一轮** leader(`synthesisMode=true`)综合所有交付。
> (备选方案 A:每 stage 跑一轮 leader 派活——更贴现有架构但每 stage 多一次 leader LLM、更慢。**本期不采用**,留档备查。)
### 3.3 后端改动
- **基本不需要新接口**:special run(`POST /api/multi-agent/run/stream` agent_type=special)、leader run、轻量交付读(`GET /threads/{id}/last-ai-message`)、轻量追加(`POST /threads/{id}/messages/append`)都已就绪。
- 仅需 §4 的「并行禁写文件」run policy 开关。
### 3.4 验收(Phase 3)
- `(B C)` 两席位**同时**出现在执行(后端日志两条 special run 时间重叠;前端两个流并行增长)。
- stage 间严格串行(stage 2 不早于 stage 1 全部完成)。
- stage N 席位的输入里能看到 stage <N 的交付摘要。
---
## 4. 并行时禁止写文件(防沙箱打架)
### 4.1 后端 run policy
`app/gateway/roundtable_run_policy.py` 现有角色:`leader` / `seat` / `report`。新增一个角色或开关:
- 方案:给 `seat` 增加一个「并行变体」`seat_parallel`,其 `excluded_tools` 在 `seat` 基础上**再禁** `write_file` / `bash` / `str_replace` / `present_files`(与 `report` 角色禁写的思路一致)。
- `_special_run`(`multi_agent.py`)按请求里的一个新 flag(如 `no_file: true`)选择 `seat_parallel` policy。
### 4.2 前端传参
- `streamMultiAgent` 请求体新增 `parallel_no_file?: boolean`(或复用 `no_file`)。
- `runDagOrchestration` 在 `stage.agentIds.length > 1`(真并行)时置 `true`;单节点 stage(实际串行)不限制,可正常写文件。
### 4.3 产物归属
- 并行席位的产物 = **消息正文**(被 `_collect_delivery_status` / 综合块读取)。
- 需要落文件 / 画图的(如 Step3 报告、`roundtable-report`)放在**串行的收口阶段**单独跑,不在并行批次里。
### 4.4 验收(Phase 4)
- 并行 stage 的席位 run 日志显示 `Excluded ... ['write_file','bash','str_replace','present_files', ...]`。
- 并行期间不再出现多个席位抢同一沙箱 / 写文件冲突。
---
## 5. 并行执行的前端显示
### 5.1 布局总览(并行进行时)
```
┌ 多智能体圆桌会商中心 ─────────────────────────────────────────────┐
│ [头像条:进行中的并行智能体] [模型选择 ▾] [暂停会商] │
│ (情报)● (方案)● (风险)● (执行) … │
│ ↑选中 ↑选中 loading hidden │
│ ───────────────────────────────────────────────────────────────── │
│ ┌─ 方案设计(选中)──────────┐ ┌─ 风险审查(选中)──────────┐ │
│ │ 一、整体架构…(流式渲染) │ │ 1. 合规风险 IDFA/OAID… │ │
│ │ … │ │ … │ │
│ └────────────────────────────┘ └────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
(左侧"参与角色"栏在并行时整体收起,腾出横向空间放两列消息)
```
### 5.2 关键交互(与需求逐条对齐)
1. **收起左侧**:并行执行时把左侧「参与角色 / 人工参与」栏整体折叠(一个 `isParallelRunning` 状态控制;非并行恢复)。
2. **头像条**:位置在**消息列表上方、模型选择器左侧**。展示**当前并行批次的所有进行中智能体头像**:
- **进行中** → 头像加 loading 动画(复用 `DispatchChainModal` 的 `animate-ping` 光环 + `Loader2Icon animate-spin` 角标)。
- **已完成** → 打勾(复用现有 `CheckIcon` 角标)。
- **选中** → 高亮描边(当前正在主区显示的那 2 个)。
3. **主区同时显示 2 个**:被选中的 2 个智能体的消息列表**并排两列**渲染。
4. **多于 2 个先隐藏**:未选中的智能体**不渲染 DOM**(见 §6 性能)。
5. **点头像切换**:点击未选中的头像 → 把它设为选中之一(替换当前 2 个里的一个,或按"最近点击保留 2 个"的 LRU 策略);被换下的智能体**停止渲染但继续接流**。
### 5.3 选中策略 — ✅ 固定 2 列,窄屏降 1 列
- 维护 `selectedParallelSeats: string[]`,**最多 2**(固定 2 列)。**不做 3+ 列**。
- **响应式**:宽屏 2 列并排;窄屏(建议断点 `< 1024px` / `lg`)降为**1 列**,头像条横向滚动切换;此时 `selectedParallelSeats` 仍可存 2 个,但主区只渲染当前 1 个(点头像切换哪个在前)。
- 默认选中本批次**最先开始**的(宽屏 2 个 / 窄屏 1 个)。
- 点头像:若已选中则取消;未选中则加入(超过上限时按 LRU 踢掉最早选中的)。被换下的席位**停止渲染但继续接流**(§6)。
### 5.4 验收(Phase 5)
- 并行时左侧收起、出现头像条、主区两列。
- 进行中头像有 loading;完成打勾;选中有高亮。
- 点头像能切换主区显示的智能体,且切换是**即时**的(数据已在内存,见 §6)。
---
## 6. 流式高性能策略(避免页面卡死)
> 核心矛盾:N 个并行席位同时高频 `onTextDelta`,若都 setState + Markdown 渲染会卡死。
### 6.1 全接收、惰性渲染
- **所有**并行 run 的流都正常订阅(不丢数据):每个席位的增量文本**累积进 store / ref**(`Map<agentId, { text, status, charCount }>`)。
- **只有被选中(主区显示)的席位**才把累积文本挂到 DOM 渲染(Markdown)。
- **未选中**席位:只更新内存 + 头像条上的轻量进度(字数 / loading),**不挂载消息 DOM**。
- 切换选中时:把该席位已累积的全文一次性渲染 + 继续接增量。
### 6.2 渲染节流
- 选中席位的流式渲染**节流/合帧**:用 `requestAnimationFrame` 或 ~50–100ms 批量 flush 累积的 token,而不是每个 token 一次 setState。
- Markdown 渲染对"流式中"可降级(纯文本 / 轻量渲染),**完成后**再做完整 Markdown(现有 `MessageBubble` 的 `streaming` 标志可复用:streaming 时简渲、done 后全渲)。
### 6.3 头像条进度
- 头像条只显示**廉价**信息:loading 动画 + 可选字数(`charCount`,从内存 Map 取,节流更新,例如每 500ms 刷一次)。不触发消息体渲染。
### 6.4 数据流(建议结构)
```
streamMultiAgent(seat).onTextDelta(text) →
parallelStore.append(seatId, text) // 纯内存累积,O(1)
if seatId ∈ selectedParallelSeats: // 仅选中的进 React state(节流)
scheduleFlush(seatId) // rAF / 节流 setState
else:
bumpCharCount(seatId) // 仅更新头像条进度(节流)
```
### 6.5 验收(Phase 6)
- 6+ 席位并行时页面不卡(输入 / 滚动 / 切换流畅)。
- 切到一个之前隐藏的席位,能立刻看到它**到目前为止的全部**输出(证明流没丢、只是没渲染)。
---
## 7. 流程图执行态可视化(DAG 版)
- 把现有 `DispatchChainModal.tsx`(横向**线性**流程图)扩展 / 复刻成 **DAG 流程图**:
- 按 stage 分**列**,stage 内席位**纵向并列**,stage 间用连接线。
- 节点状态复用 `ChainNodeState`(pending / active / done)+ 现有动画(`animate-ping` / `Loader2Icon` / `CheckIcon`)。
- 末尾接 `共识 → 结果绘制` 里程碑(沿用现有)。
- 用途:编辑器里的只读预览(§2.2 底部)+ 执行时的总览(可选小窗 / 顶部缩略)。
```
总控 ─▶ ┌ Stage1 ┐ ─▶ ┌ Stage2 ┐ ─▶ ┌ Stage3 ┐ ─▶ 🤝共识 ─▶ 📊报告
│ 情报● │ │ 方案✓ │ │ 执行○ │
└────────┘ │ 风险● │ └────────┘
└────────┘
```
### 7.1 验收(Phase 7)
- 编辑器底部 / 执行时能看到 DAG 流程图,节点状态随执行实时更新(active/done)。
### 7.2 实施结论(2026-06-07,✅ 已完成)
- **新组件** `components/DagFlowChart.tsx`:纯展示组件,复刻 `DispatchChainModal` 的节点 / 连线 / 里程碑视觉(`animate-ping` 光环 + `Loader2Icon` 转圈 + `CheckIcon` 打勾 + `rt-chain-connector` 连线动画),但按 **stage 分列**:总控 ─▶ ┌Stage1┐ ─▶ ┌Stage2┐ ─▶ 🤝共识 ─▶ 📊结果绘制,stage 内席位**纵向并列**。`compact` 紧凑模式给缩略图用。所有状态由 props 传入(`stages` / `resolveSeat` / `seatStates` / `coordinatorState` / `consensusState` / `reportState`);不传 `seatStates` → 全 pending(只读预览)。`aggregateStageState` 把 stage 内席位聚合成列状态(任一 active→active、全 done→done)驱动 stage 间连线。
- **编辑器只读预览**(§2.2 底部):`BusinessChainEditorPage` 底部加「编排预览」section,把 `stageGroups`(去空 stage) 映射成 `DagFlowChart` 的 stages + `resolvePreviewSeat`(席位名/配色),`compact`、全 pending、随编辑实时变。
- **执行态总览**(Step 2 顶部缩略):`useStep2Orchestration` 新增 `dagNodeStates: Record<agentId, ChainNodeState>`,`runDagOrchestration` 跨 stage 维护(进入 stage→该批 active、席位终态→done、`resetStep2State` 清空);经 `RoundtablePlanningPage` 把 `orchestrationMode/orchestrationPlan/dagNodeStates` 传到 `Step2Panel`,dag 模式下会话区顶部 `compact` 渲染 DagFlowChart。总控/共识里程碑状态由 `Step2Panel` 从节点状态 + `isOrchestrating`/`hasConsensus` 派生(中转轮时无席位 active + isOrchestrating → 总控 active)。
- 改动文件:`components/DagFlowChart.tsx`(新)、`pages/BusinessChainEditorPage.tsx`、`hooks/useStep2Orchestration.ts`、`components/Step2Panel.tsx`、`pages/RoundtablePlanningPage.tsx`。typecheck 零新增(基线 65)。
- **下次实地验收**:①编辑器底部随分层编辑实时画出流程图;②Step2 dag 模式顶部出现缩略流程图,跑起来后当前 stage 列点亮(active 转圈)、完成打勾、中转时总控亮、最后共识亮。
---
## 8. 分步实现计划(每步可独立交付 + 验收)
| Phase | 内容 | 主要文件 | 依赖 | 可独立验收 |
|---|---|---|---|---|
| **1** | DAG 数据模型 + 校验 + 持久化字段 | `lib/dag-types.ts`(新)、draft step2 读写 | — | 纯函数单测:`validatePlan` / `chain→dag` 兼容转换 |
| **2** | 分层编排编辑器(先无拖拽:点选加入/移出) | `components/DagPlanEditor.tsx`(新)、Step2 模式切换 | P1 | 能编排并保存/恢复,非法编排有提示 |
| **2.5** | 编辑器拖拽 | 同上 + `@dnd-kit`(若引入) | P2 | 拖拽分配/重排 |
| **3** | `runDagOrchestration`:批内并行 + 批间串行 + 依赖摘要注入 | `hooks/useStep2Orchestration.ts`、`api/multi-agent.ts` | P1 | 后端日志并行 special run 时间重叠;stage 严格串行 |
| **4** | 并行禁写文件 run policy | `roundtable_run_policy.py`、`multi_agent.py`、`api/multi-agent.ts` | P3 | 并行 run 的 excluded_tools 含 write_file/bash/... |
| **5** | 并行显示骨架:收起左侧 + 头像条 + 两列 + 选中切换 | `components/Step2Panel.tsx`、`components/ParallelSeatStrip.tsx`(新) | P3 | 见 §5.4 |
| **6** | 流式高性能:全接收 / 惰性渲染 / 节流 | `hooks/useStep2Orchestration.ts`(parallelStore)、`Step2Panel` | P5 | 见 §6.5(6+ 并行不卡) |
| **7** | DAG 流程图执行态可视化 | `components/DagFlowChart.tsx`(复刻自 `DispatchChainModal`) | P2、P3 | 见 §7.1 |
| **8** | 边界 / 异常 | 各处 | 全部 | 见 §9 |
> 落地顺序建议:**P1 → P3 → P4 → P5 → P6**(先把"能并行跑 + 能看 + 不卡"打通),**P2 编辑器**可与 P3 并行开发(先用一份 hardcode/简单 UI 的 plan 驱动 P3 联调),**P7 / P2.5 / 2.3** 作为增强后置。
---
## 9. 边界与异常(Phase 8)
- **某并行节点失败 / 超时**:单个席位失败不拖垮整 stage;标该节点 error,stage 其余 ✅ 仍推进;收口时把"X 失败"如实告知总控(复用交付状态 ⬜ + 失败标记)。
- **依赖未满足**:调度器保证 stage 串行,理论不会发生;防御性校验 stage N 启动前 stage <N 必须全部终态(done/failed)。
- **人工干预 / 暂停 / 恢复**:在 stage 边界检查 `isStale()`(epoch);干预指令插入后,从"下一个未开始的 stage"继续(已完成 stage 不重跑)。
- **并行禁写文件 vs 席位本想写文件**:在席位 SOUL / 派活提示里说明"本轮为并行研讨,产出请直接写在回复正文,不要写文件",降低模型困惑。
- **窄屏**:主区两列降为一列 + 头像条横向滚动。
- **单 stage 全并行(一次性 N 个)**:给并行度 warning(远程模型并发限流 / 成本)。
---
## 10. 与现有代码的衔接点速查
- 编排循环:`hooks/useStep2Orchestration.ts`(`runOrchestration` / `runChainOrchestration` 旁新增 `runDagOrchestration`;复用 `isStale()` epoch、`abortAllInFlight`、`appendStreamingDialogue` / `appendToDialogue`)。
- 流式 API:`api/multi-agent.ts`(`streamMultiAgent`,新增 `parallel_no_file` 入参;`onTextDelta(text, messageId)` 已是按席位回调,天然支持并行分流)。
- 显示:`components/Step2Panel.tsx`(消息列表 `step2RoundtableDialogues.map`;新增头像条 `ParallelSeatStrip` + 两列容器 + 折叠左侧)。
- 流程图:`components/DispatchChainModal.tsx` / `lib/job-types.ts` / `lib/business-chain.ts`(复刻成 DAG 版)。
- 常量 / 元数据:`lib/roundtable-constants.ts`(`BUILTIN_AGENT_META` 头像/中文名、`buildAgentNameToRole`)。
- 后端 run policy:`app/gateway/roundtable_run_policy.py`(新增 `seat_parallel`)、`app/gateway/routers/multi_agent.py`(`_special_run` 读 `no_file` flag)。
- 交付摘要:`GET /api/threads/{id}/last-ai-message?max_chars=N`(依赖注入 stage N 输入用)。
---
## 11. 一句话总结
把「线性链条」升级为「分层 DAG」:**编辑器**用分层 stage(拖卡片)表达串/并;**调度**用 `Promise.all` 批内并行、批间 barrier 串行,依赖摘要在 stage 边界注入;**并行禁写文件**避免沙箱打架;**显示**收起左侧、头像条切换、主区两列、未选中只接流不渲染(rAF 节流)保证不卡。全部建立在现有 special/leader run + 轻量交付接口之上,后端几乎零新接口。
---
## 12. 已知问题与排查记录(2026-06-06)
### 12.1 ❗并行席位「步骤条」不显示 —— 根因:`seat_parallel` 工具面禁得过多
**现象**:并行两列里的席位气泡看不到步骤条(`MessageStepsCard`),而串行席位有。
**排查结论(已定位,待修)**:
1. **步骤条只渲染「工具调用」步骤**。`MessageStepsCard` 的 `isToolStep` 只认 `tool_calling:<toolName>` 的步骤进折叠卡;纯阶段步骤(`connecting:` / `streaming:` 这类 reasoning)只渲染成**底部一行小灯泡**,不出大卡(见 `MessageStepsCard.tsx` L80-100)。
2. **并行席位几乎调不动任何会产生步骤的工具**。后端 `app/gateway/roundtable_run_policy.py`:
- `_SEAT_EXCLUDED = [web_search, ask_clarification, present_files, view_image, agent_orchestration]` —— **注意 `web_search` 对所有席位(串行也)就禁了**(注释「圆桌研讨里联网搜索一律禁用」)。
- `_SEAT_PARALLEL_EXCLUDED = _SEAT_EXCLUDED + [write_file, bash, str_replace]`。
- 于是并行席位可用工具几乎只剩 `read_file / ls / task / write_todos / memory / hindsight_*`,研究/分析类席位基本一个都不会调 → **没有任何 `tool_calling` 步骤** → 步骤条(大卡)不出现。
3. **对比串行为何「有步骤条」**:串行席位(`seat` policy)**能 `write_file`**,会产生 `tool_calling:write_file` 步骤 → 大卡出现。并行被禁了 write_file + 又没有 search,所以一个工具步都没有。
**用户诉求**:并行时**只禁真正会冲突的写操作**,搜索信息 / 调用技能等**必要操作不要禁**。
**关键事实(影响方案选择)**:圆桌每个席位是**独立 thread = 独立 sandbox**(后端 per-thread 隔离 `.deer-flow/users/{uid}/threads/{tid}/`)。所以并行席位写文件**在后端并不会互相冲突**;当初 §4「并行禁写文件」的「打架」其实主要是**前端单沙箱预览**(`activeArtifactThreadId` 只能同时显示一个)+ 产物归集到正文的简化考量,并非后端真冲突。→ 这意味着「并行禁写」的必要性比原设想弱,可放宽。
**✅ 决策:选 A(2026-06-06 已实施)**:把 `seat_parallel` 放宽到**只禁 `write_file / bash / str_replace`**(防前端单沙箱预览串台 + 产物走正文)+ 仍禁 `ask_clarification / present_files / view_image / agent_orchestration`(打断/越权类),但**放开 `web_search` 与技能**(搜索不碰 sandbox;技能靠 `read_file`)。改在 `app/gateway/roundtable_run_policy.py` 的 `_SEAT_PARALLEL_EXCLUDED`(**不再继承** seat 的 web_search 禁用,显式列举),测试 `tests/test_roundtable_run_policy.py` 同步更新(`test_seat_parallel_allows_web_search_and_skills`)。
→ 并行席位现在能联网搜索/用技能 → 产生 `tool_calling` 步骤 → 步骤条显示。
> 备选 B(连写文件也放开,沙箱按 thread 隔离)/ C(重新评估「圆桌串行也一律禁 web_search」)**本期不做**,留档。串行席位目前仍禁搜索(`seat` policy 不变)。
### 12.2 其它已修(本轮反馈)
- 最左侧**全局工作区侧栏**并行时收起:改用与「打开沙箱」同一路径 `useSidebarSafe()?.setOpen(false)`(之前只收了圆桌「参与角色」栏 `step2SidebarExpanded`)。并行结束**不自动展开**(与沙箱收起不回弹一致)。
- 并行两列改回**完整 Markdown**(撤销 §6.2 流式纯文本降级 `plainWhileStreaming`),与串行同款 `Step2Message`。
- 头像条**不再显示字数**(去掉 charCount 徽标 + 字段)。
- **致命 bug 已修**:用户干预 / 并行跑完后**不再重跑整张 DAG**。`dagPlanExecutedRef` 闸门 + DAG 各阶段跑完后释放锁 `await runOrchestration(handoff)` 交回总控继续派活/收口;后续干预/恢复一律走 leader 自由派活。
- **并行两列宽表溢出已修(2026-06-07)**:模型输出的 Markdown **表格**(如「法律免责条款」表)没有宽度约束 / 横向滚动容器,在并行两列的窄列里会**超出左列、盖到相邻列**。修法:`MessageBubble.sharedMarkdownComponents.table`(+ `markdown-components.tsx`)把 `<table>` 套进 `<div class="max-w-full overflow-x-auto">`——表格保持自然宽度、超出时**列内横向滚动**而非撑破列宽。`min-w-0` 链(grid `minmax(0,1fr)` → 列 `min-w-0` → 气泡 `flex-1 min-w-0`)本就齐全,只差 table 这一层。
- **关于「并行席位无步骤条」**:步骤条(`MessageStepsCard` 大卡)只在席位产生 `tool_calling` 步骤时出现(§12.1)。纯分析型席位若本轮**直接写分析正文、没调用任何工具**(web_search 等),就只显示一行「正在输出」思考指示、不出大卡——这是设计使然,非解析失败。要确认是否真调了工具:浏览器 devtools 设 `window.__MULTI_AGENT_DEBUG__=true` 看 `frame# event=messages` 里有无 tool_calls,或查后端 special run 日志。
### 12.3 ✅需求变更:Stage 间「经总控中转 + 广播」(2026-06-06 提出,2026-06-07 已实施)
**当前实现(方案 B,§3.2)**:DAG 各 stage 由前端**直接**派活,stage 间不跑 leader,靠前端把 `buildDependencySummary` 拼进下一 stage 的 task(`priorSummary`)。各 stage 跑完后才 `await runOrchestration` 交回总控收口。
**新诉求**:业务链条调用时,**每一轮 stage 之间都要经总控中转 + 广播**,而不是前端直接串下去。具体:
1. 第 1 轮并行调用 stage1 的 A、B、C;
2. A/B/C **全部完成后**,把这一批的**输出广播出来**(广播给其它席位 + 总控 thread);
3. **总控(leader)接收**这批交付 → 再**往下继续派活**(派 stage2 的 D、E);
4. 派活时把(承上的)消息**广播给下一轮的 D、E**;
5. 以此类推,直到所有 stage 跑完再收口。
> 即把 §3.2 的「方案 B 前端直派」改成 **「方案 A' = 计划约束下的总控中转」**:stage 边界插入一轮 leader,由它综合上一批交付并驱动下一批(下一批席位仍由编排计划**钉死**为 D、E,不是 leader 自由乱派——leader 负责「承上启下 + 广播上下文」,计划负责「派给谁」)。
**实施要点(下次会话)**:
- **每个 stage 完成 → 广播该批输出**:后端 `_special_run` 已对每个 seat 做 detached 后广播(「子智能体X完成…交付内容为:…」→ 其它席位 + 总控)。但它是 **detached/best-effort + 截断**;要让总控**可靠**地在下一轮前看到,需在 stage barrier 处**等广播落地**(或前端在 stage 边界用 `GET /threads/{id}/last-ai-message` 主动收齐各席位交付再喂给 leader)。
- **插入 leader 中转轮**:`runDagOrchestration` 在进入下一 stage 前,跑一轮 leader(`synthesisMode=true` 让后端注入上一批**完整**交付),让总控产出一段「承上启下」说明;把这段说明(而非机械 `priorSummary`)作为下一 stage 席位 task 的上下文 / 或广播给下一批席位 thread(复用 `POST /threads/{id}/messages/append`)。
- **下一批仍由计划钉死**:leader 中转轮**不**自由决定派给谁(计划已定 D、E);可参考 `buildChainLeaderTask` 的「约束 leader 只派给指定席位」思路,或干脆 leader 只做综合、前端照计划派 D、E 并把 leader 综合 + 广播注入其 task。
- **与「并行禁写」「§12.1 放开搜索」并存**:中转/广播不影响工具面。
- **与致命 bug 修复并存**:最后一个 stage 跑完仍走「交回总控(`runOrchestration`)收口/继续派活」;用户干预仍走 leader、不重跑 DAG。
**影响文件(预估)**:`hooks/useStep2Orchestration.ts`(`runDagOrchestration` 的 stage 循环里加 leader 中转 + 广播收齐)、可能复用后端 `_leader_run`(synthesisMode 注入全文)/ `/messages/append`(广播)/ `/last-ai-message`(收齐交付)。后端基本无需新接口。
**✅ 实施结论(2026-06-07)**:后端**零改动**,全部在前端 `runDagOrchestration` 内完成,复用既有「传摘要按需传全文」优化链路:
1. **中转轮位置**:每个**非最后**的 stage barrier 到达后(`Promise.all` 收齐本批交付),插入**一轮 leader**「中转轮」,展示为「总控协调(承上启下)」气泡。最后一个 stage 后**不**插中转,综合/收口仍交给末尾的 `runOrchestration` 接管(与致命 bug 修复一致)。
2. **可靠收齐交付(传摘要)**:中转轮跑 `streamMultiAgent({agentType:"leader", synthesisMode:false})`。后端 `_leader_run` 本就在每轮 leader 前用 `_collect_delivery_status`(走**轻量 `GET /last-ai-message?max_chars=600` 接口**)**客观核对**各席位真实交付并把**摘要**注入总控输入(`_build_seat_status_block`)——比 detached 广播可靠(直接实读各席位 thread,不依赖 best-effort 广播是否落地)。故中转轮传 `synthesisMode:false`:只综合本批、**不收口**,只注入摘要;**完整交付正文**留到末尾收口的 synthesis 轮(`_build_full_deliverables_block`,全 ✅ 才注入)按需读取——即「**传摘要按需传全文**」。
3. **承上启下驱动下一批**:中转轮产出的综合说明存进 `leaderBridge`,作为**下一 stage** 席位 task 的上下文(`buildStageTask({priorSummary: leaderBridge, priorIsBridge:true})`,措辞为「承上启下综合说明」而非机械「交付摘要」),替代原来的 `buildDependencySummary(deliveries)`。中转失败/无产出时回退机械依赖摘要,不阻断。
4. **下一批由计划钉死**:中转轮提示(`buildDagTransitTask`)明确告知总控「下一阶段席位已由编排计划钉死为 X、Y,你**不要**自行 `agent_orchestration` 派活、**不要**收口」,前端仍照计划直派下一批;即便弱模型仍派了活,前端忽略其 `dispatched`、只取 `content` 当 bridge。
5. **广播**:本批输出广播给其它席位 + 总控 = 后端 `_special_run` 既有 detached 后广播(截断摘要);承上消息给下一批 = 注入其 task `new_message`(席位 run 输入必见,比 `/messages/append` 更可靠,省一次写盘)。
6. **澄清/抢占/失败**:中转轮触发澄清 → 暂停 DAG(回复后走 leader 自由派活、不重跑 DAG,沿用 `dagPlanExecutedRef` 闸门);被 `isStale()`/abort 抢占 → 静默收尾;异常 → 该气泡标错误、清空 bridge 继续。
**纯函数**:`lib/dag-types.ts` 新增 `buildDagTransitTask`(中转轮提示)+ `buildStageTask` 加 `priorIsBridge` 形参;`lib/dag-types.test.ts` 加 4 个单测(共 27 个全过)。
> ⚠️ **§12.3 的「单独中转轮 + 前端直派」已被 §12.4 取代**(2026-06-07 同日):见下。`buildDagTransitTask` / `buildStageTask` / `buildDependencySummary` 仍保留在 lib(带单测),但 `runDagOrchestration` 不再调用它们。
### 12.4 ✅ 需求再变更:DAG 改成「总控驱动每 stage 派活」(2026-06-07,§3.2 方案 A)
**新诉求(用户原话)**:把并行(DAG)逻辑改得和串行一样——串行时总控给多个智能体派活、它们完成后汇报给总控、总控再继续往下派活;并行也改成「由总控判断要执行哪些步骤,这些步骤完成后再汇报给总控,总控继续往下派活」。
**已确认的两个关键决策**:
1. **每批「派给谁」**:✅ **计划钉死成员,但走总控派活**(不是总控自由乱派)。即 stage 成员仍由编排计划定死,但改成总控每轮**真正** `agent_orchestration` 派活给这些席位(约束只派给它们、且每个都派到)。= §3.2「方案 A」+ 批内并行。
2. **适用范围**:✅ **只改 dag 模式**(recommend 仍串行逐个、chain 不动)。
**实现(后端零改动,全在 `runDagOrchestration`)**:每个 stage 两步——
- **(a) 总控派活轮**:跑一轮 `leader`,提示 `buildDagStageLeaderTask`(首 stage 带任务背景;后续 stage 靠后端注入的此前交付摘要承上;列出本 stage 钉死席位的 `agent_name`,要求逐个派到、不派清单外、不收口)。`synthesisMode:false` → 后端 `_build_seat_status_block` 走轻量 `last-ai-message` 注入各席位**摘要**(传摘要按需传全文)。
- **(b) 批内并行**:本 stage 席位并行跑总控派给各自的 task——`pickDagSeatTask(leaderRes.dispatched, content, agentId, name)` 命中其 `agent_name`;总控漏派/写偏则回退总控正文/模板,**不借用别席位 task**(多席位并行避免张冠李戴)。完成后交付经后端 `_special_run` detached 后广播回总控;下一 stage 的派活轮即可看到。
- barrier 后进入下一 stage,总控再派下一批。全部 stage 跑完 → 交回 `runOrchestration` 收口(synthesisMode 按需注入全文)。
**相比 §12.3 的变化**:§12.3 是「前端直派 + stage 间单独插一轮只综合的中转轮」;§12.4 把**每个 stage 本身**就改成「总控派活轮 + 批内并行」——总控既承上(注入的摘要)又派活,不再需要单独的中转轮。与 chain 模式同构,区别是一次钉死一批、批内并行。
**纯函数**:`lib/dag-types.ts` 新增 `buildDagStageLeaderTask`(每 stage 派活提示)+ `pickDagSeatTask`(取某席位 task,多席位不张冠李戴);`lib/dag-types.test.ts` 加 5 个单测(共 32 个全过)。`runDagOrchestration` 不再用 `buildStageTask/buildDependencySummary/buildDagTransitTask`(保留在 lib)。
**Phase 7 流程图兼容**:总控派活轮无席位 active + `isOrchestrating` → 流程图「总控」节点显示 active(承上派活中),随后该 stage 列点亮,无缝衔接。
**下次实地验收**:①每个 stage 前出现「总控协调(派活中/承上派活中)」气泡,内容是总控对本 stage 席位的派活说明;②后端日志该轮 leader 的 `_partition_dispatch` 派给的正是本 stage 钉死席位;③这些席位并行跑(special 日志时间重叠);④完成后下一 stage 总控派活轮能看到上一批交付摘要(`[leader-roster]` 日志)。
### 12.5 ✅ 流程图弹窗化 + DAG「快速/沙箱」执行子模式(2026-06-07)
三个改动一起做:
**(1) 流程图改成弹窗**:Step2 顶部不再内联 `DagFlowChart`(挤占两列消息高度,截图 `样式优化-1.png`)。改成一条细栏「分层编排执行中 · 共 N 个阶段 + [查看编排流程图]」按钮,点开 `Dialog` 看**全尺寸**流程图(非 compact)。编辑器(`BusinessChainEditorPage`)底部预览保持内联不变。改 `Step2Panel.tsx`(加 `dagFlowOpen` state + Dialog)。
**(2) 推荐弹窗新增 DAG 执行子模式(快速/沙箱)**:`BusinessChainPicker` 在选中链条**有分层并行**(走 dag)时,「进入研讨」旁出现「执行方式:快速·双列禁写 / 沙箱·单列可写」二选一(只 dag 时出现,§已确认)。
- **快速(`fast`,默认)**:同 stage 席位**并行**、主区**双列**、`parallelNoFile=true`(seat_parallel **禁写文件**)。= 之前的行为。
- **沙箱(`sandbox`)**:同 stage 席位**仍并行**,但主区**单列**(`selectedParallelSeats` 最多 1)、`parallelNoFile=false`(seat policy **允许写文件**);点头像切换查看,沙箱跟随当前席位。
- 数据流:`SeatSelectionResult.dagExecMode` → `RoundtablePlanningPage` state(持久化进 `DraftStep2(Run)Snapshot.dagExecMode`,mirror orchestrationPlan)→ `useStep2Orchestration` option → `runDagOrchestration`。
**(3) 沙箱跟随当前显示席位(防 bug 重点,用户特别强调)**:`useStep2Orchestration`
- `sandboxFollowThreadIdRef` = 当前显示席位的 thread;`openSeatVirtualArtifact` 的 auto 守卫:sandbox 模式下,**后台并行席位**写文件时若不是当前显示席位,**不自动打开**(否则沙箱内容和主区列表错位)。
- 切换显示席位的 effect(keyed on `selectedParallelSeats` + `dagExecMode`):该席位**有产物**(stub 里有 write_file/str_replace)→ `openSeatLatestArtifact` 强制打开它最新的文件;**无产物**→ 关闭沙箱面板(`setActiveArtifactThreadId(null)` + `deselectArtifact` + `setArtifactsOpen(false)`)。
- 当前显示席位 live 写文件 → `handleArtifactToolPhase` auto-open,守卫放行(follow===该席位)。后台席位写 → 守卫拦掉、只缓存 stub;切到它时 effect 再打开。
- `toggleParallelSeat`:sandbox 单列 = 点头像切换(始终保留 1 个,不取消到空);fast 双列 = 原 LRU 2 个。
**改动文件**:`lib/business-chain.ts`(SeatSelectionResult.dagExecMode)、`components/BusinessChainPicker.tsx`(子模式 UI)、`api/drafts.ts`(快照 dagExecMode)、`pages/RoundtablePlanningPage.tsx`(state/snapshot/hydrate/confirm/reset 全链)、`hooks/useStep2Orchestration.ts`(option + parallelNoFile/单列 + 沙箱跟随 + toggle cap)、`components/Step2Panel.tsx`(流程图弹窗 + 单列 grid)。typecheck 零新增(基线 65),dag-types 32 单测全过。
**下次实地验收(沙箱跟随是重点)**:①推荐弹窗选有并行的链条 → 出现「快速/沙箱」二选一;②快速=双列禁写(无沙箱);③沙箱=单列、席位能写文件、点头像切换列表时沙箱内容**同步**切换:有产物显示、无产物关闭;④后台席位写文件不会把沙箱抢到非当前列表;⑤流程图点按钮弹窗、全尺寸、节点随执行点亮。
### 12.6 ✅ 修:并行席位「跟着派活」(2026-06-07)
**现象**(截图 `bug-1.png`):总控派活后,并行席位的气泡里显示的是**总控的整段派活叙述**(「我来调度各智能体…第1阶段任务清单:席位1…席位2…」),还去调 `skill_list` 找 `agent_orchestration`——席位在**模仿派活**。
**根因**:总控这轮**没真正调用 `agent_orchestration` 工具**(弱模型 deepseek 用文字罗列了派活计划)→ 后端解析的 `dispatched=[]` → 前端 `pickDagSeatTask` 回退把**总控整段叙述**喂给**每个**席位 → 席位照抄叙述、模仿派活。(席位本就被 run policy 禁了 `agent_orchestration`,**派不了活**,只是空转一轮叙述 + 调 `skill_list`。)
**修 v1(护栏,效果不足)**:`pickDagSeatTask` 加角色边界护栏 + 回退措辞改「挑出属于你的那份」;`buildDagStageLeaderTask` 加「必须真正调用工具」。→ **没拦住**:总控对 deepseek 仍只写文字不调工具(`dispatched` 还是空),席位拿到的**任务正文本身**就是那段「我来调度…席位1/2/3」,护栏被淹没;3/4 席位仍模仿派活,有的甚至绕道 `bash`+`python requests.get('/api/agents')` 调内部接口派活(bug-1~4)。
**✅ 修 v2(根治:回退绝不注入派活叙述)**:`pickDagSeatTask` 去掉 `leaderContent` 入参——
- **命中**总控结构化派活(真调了工具)→ `请你作为「X」,完成总控派给你的本阶段任务:<dispatched task>` + 护栏;
- **未命中 / `dispatched` 空**(总控只写文字)→ **绝不**把总控派活叙述喂给席位,改给**干净本职任务**:「请你作为「X」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。任务背景与此前各阶段交付已在你的会话上下文里(后端广播注入),据此承接」+ 护栏。护栏强化到「不要用 bash/python/脚本访问内部接口(/api/agents、agent_orchestration)派活、不要复述『我来调度』总控口吻」。
- `buildDagStageLeaderTask` 的「必须真正调用工具」保留(让命中率更高、尽量走 exact-match)。
- `dag-types.test.ts` 改 2 单测(共 33 过)。typecheck 65。
> ⚠️ **残留次要污染源(若仍复发再处理)**:DAG 每个 stage 的 leader 轮,后端 `_leader_run` 会把 leader 的输入(含「请你作为总控…派活」)**预广播**进所有席位线程(`总控智能体收到用户消息:…`)。席位的**当轮任务**已干净(修 v2),这条只是历史上下文,理论上被「最新的干净任务 + 护栏」压制。若实测仍模仿派活,下一步:后端给 DAG leader 轮加 `suppress_pre_broadcast` / 或广播一条**净化**过的背景(去掉派活措辞)。
### 12.7 ✅ 修 4 项(2026-06-07):席位仍派活(v3 后端)+ 沙箱关不掉 + 并行不自动下滑 + 流程图只到 Step2
实测 §12.6 仍有席位派活(视觉设计师 `skill_manage` 找 agent-orchestration、列 dispatch all 4)——**坐实**了「残留次要污染源」:后端 leader 预广播把派活 prompt 推进席位线程,弱模型从历史里捡到派活意图。一起修了 4 项:
1. **席位仍派活(根治·后端)**:`_leader_run` 加 `suppress_pre_broadcast` 参数;DAG「每 stage 派活轮」前端传 `suppressPreBroadcast: true`(`api/multi-agent.ts` → `suppress_pre_broadcast`,run/stream 读取)。为真时:**不**把总控派活 prompt 预广播给席位、**也不**把「总控给 X 派活」detached 广播给兄弟席位。recommend/chain 不传该 flag,行为不变。配套:`pickDagSeatTask` 加 `seedMessage` 入参——预广播抑制后席位线程没有用户原始消息了,把**任务背景**直接写进席位 task(此前各阶段交付仍由 special 完成后广播注入)。→ 席位线程里**任何地方都没有派活语句**,根治模仿。
2. **沙箱关闭按钮关不掉**:沙箱跟随 effect 之前每次 render(deps 身份变)都重跑 → 把用户刚关的沙箱强制重开。加 `lastReconciledSeatRef` 守卫:**只在显示席位真正变化时**才开/关沙箱;用户手动关闭(席位没变)时 effect 早退,不再回弹。
3. **并行时自动下滑失效**:平铺列表 ↔ 网格(两列/单列)切换时 `scrollHeight` 突变、`scrollTop` 被浏览器 clamp → `useAutoScroll` 误判「用户上滚」而停跟随。`Step2Panel` 加 effect:`isParallelRunning` / `selectedParallelSeats` 变化时 `scrollStep2ToBottom()` 重新拉到底 + 恢复跟随。
4. **流程图只展示 Step2**:`DagFlowChart` 加 `showReportMilestone`(默认 true);Step2 弹窗 + 编辑器预览传 `false` —— 只到「共识」(Step2 终点),去掉「结果绘制」(那是 Step3)。
**改动**:后端 `multi_agent.py`(`_leader_run` suppress 两处广播 + run/stream 读 flag);前端 `api/multi-agent.ts`、`lib/dag-types.ts`(pickDagSeatTask + seedMessage)、`hooks/useStep2Orchestration.ts`(传 flag + seed + 沙箱跟随守卫)、`components/Step2Panel.tsx`(自动下滑 effect + showReportMilestone)、`components/DagFlowChart.tsx`、`pages/BusinessChainEditorPage.tsx`。`dag-types.test.ts` 共 **34 单测过**;前端 tsc 65 基线零新增;后端 `py_compile` OK。
### 12.8 ✅ 席位产出 md 文件 + 禁写 .py(2026-06-07)
**现象**:某次 7 席位**全都没产出 md 文件**;且一直有的问题——席位**有时写 .py 脚本**(用户要求:不写 .py,其它格式可以)。
**根因(没 md)**:两层都堵了——(a)**快速模式** `seat_parallel` policy 禁了 `write_file`(原「产物走正文」设计);(b)DAG 席位**提示词**写的是「直接输出你的交付正文」,即使沙箱模式(允许写)也不会去写文件。
**修**:
1. **后端 policy 放开写文件、统一禁 bash**(`roundtable_run_policy.py`):
- `seat_parallel`:去掉 `write_file` / `str_replace` 禁用(**可写 md**),保留禁 `bash` + 打断/越权类;仍放开 `web_search`。
- `seat`:新增禁 `bash`(原来允许)——圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 再跑生成产物,乱且抓不到预览);改为**直接 write_file 写成品文档**。
- `report`:`bash` 已随 `seat` 禁,改为 `[*_SEAT_EXCLUDED, "str_replace"]`(集合不变)。
- 测试 `test_roundtable_run_policy.py`:`test_seats_can_write_files_but_not_run_bash`(seat / seat_parallel 都可写文件、都禁 bash),6 用例过。
2. **前端提示词**(`pickDagSeatTask`)加「产物要求」:**把完整交付写成一个 .md 文件**(write_file 到 outputs)+ 正文给摘要;**禁写 .py 或任何脚本文件**,.md/.html/.txt 等其它格式都可以,唯独不要 .py。
3. **快速模式(双列)不自动开沙箱**(`openSeatVirtualArtifact` 新增守卫):放开写文件后,双列里多席位并发写文件会让单沙箱预览反复串台——fast 模式 DAG 并行批次**完全不自动开沙箱**(文件仍写盘,Step3/文件卡可查看);沙箱模式(单列)仍按「沙箱跟随当前席位」预览。
**关于 .py 的强度**:`write_file` 是全局工具(所有 agent 共用),无法在网关层按扩展名硬拦,故 .py 禁止走**提示词**(强约束)+ **禁 bash**(写了也跑不了,脚本生成产物的动机被掐断)。若实测仍偶发写 .py 且需**硬拦**,下一步:给 lead_agent 的 `write_file` 工具加一个「按 run context 禁某些扩展名」的开关,网关 `_run_payload` 对圆桌席位置 `[".py"]`(只影响圆桌 run,不动其它 agent)。
**改动**:`roundtable_run_policy.py`、`test_roundtable_run_policy.py`、`lib/dag-types.ts`、`lib/dag-types.test.ts`(35 过)、`hooks/useStep2Orchestration.ts`。前端 tsc 65、后端 py_compile OK + 6 policy 单测过。⚠️ **改了后端,需重启 Gateway**。
### 12.9 ✅ 席位「按需取前序全文」只读工具 read_peer_delivery(2026-06-07)
**诉求**:A,B,C → D,E 时,D,E 默认拿 A,B,C 的**摘要**,但识别到需要**全文**时也能取到。之前只有总控(leader)能「摘要默认+按需全文」(跨 thread 读),下游席位**不能**(席位间 thread 隔离、只有 1500 字截断广播)。
**实现**(真·按需,全文只在调用工具时才进 LLM 上下文):
1. **新 harness 只读工具** `read_peer_delivery(seat_name)`(`packages/harness/deerflow/tools/builtins/roundtable_peers_tool.py`):从 `runtime.context["roundtable_peer_deliveries"]`(网关注入的 `[{name,content}]`)按席位名取全文;空名→列出可取席位;非圆桌场景→「无」。只读 context dict,**不跨 thread、不碰 sandbox、无网络**,纯 `deerflow.*`(不违反 harness→app 防火墙)。注册进 `tools/tools.py` 的 builtin(始终在,只读无副作用)。
2. **网关注入 context**:`_run_payload` 加 `extra_context`;`_special_run` 收 `peer_deliveries` → 注入 `context.roundtable_peer_deliveries`;run/stream 读 `peer_deliveries`(规整 `[{name,content}]`)。**不进 prompt → 不调工具就不耗 token**。
3. **run policy**:`read_peer_delivery` 从 `leader`(总控自己跨 thread 读,不需要)+ `report`(Step3 不读前序)排除;**席位(seat/seat_parallel)保留**。
4. **前端**:`runDagOrchestration` 跨 stage 累积**完整交付** `priorDeliveries`,stage>0 席位的 special run 带 `peerDeliveries`(`api/multi-agent.ts` → `peer_deliveries`);`pickDagSeatTask` 加 `hasPeers` 形参,有前序时在 task 里提示「需要某前序席位全文时调用 read_peer_delivery('<名>'),平时用摘要」。
**最终语义**:D、E 默认看 A、B、C 的**摘要**(1500 字广播);识别到不够 → 调 `read_peer_delivery('情报收集')` 取**全文**。总控仍是摘要默认+综合轮全文。**= 用户要的「摘要默认、按需全文」对席位也成立了。**
**改动**:后端 `roundtable_peers_tool.py`(新)、`tools/tools.py`、`roundtable_run_policy.py`、`multi_agent.py`;前端 `api/multi-agent.ts`、`lib/dag-types.ts`、`lib/dag-types.test.ts`(36 过)、`hooks/useStep2Orchestration.ts`。前端 tsc 65;后端 py_compile OK + 6 policy 单测过 + 工具导入/行为 smoke 通过。⚠️ **改了后端 + 新增 harness 工具,需重启 Gateway**。
### 12.10 ✅ 修:沙箱模式第一个席位不自动开沙箱(2026-06-07,前端)
**现象**:沙箱(单列)模式并行时,**第一个**席位的消息列表不自动打开沙箱(要手动开);切换到其它席位时正常自动打开。
**根因**:沙箱跟随 effect(§12.5)在选中席位**还没写产物**时走「关闭沙箱」分支,里面同时调了 `deselectArtifact()` **和** `setArtifactsOpen(false)`。后者是 context 的 **wrapped setOpen**(`context.tsx:73`):`关闭时若 autoOpen 为真 → 把 autoOpen 置 false`。于是第一个席位刚选中(还没写文件)→ autoOpen 被关 → 该席位**随后 live 写文件**时,`openSeatVirtualArtifact` 的 `!flags.autoOpen` 守卫拦掉自动打开。切换到**已写过**文件的席位能开,是因为那条路走 `openSeatLatestArtifact`(force,auto=false,绕过 autoOpen 守卫)。
**修**(一行):关闭分支只保留 `deselectArtifact()`(它走 raw `setOpen(false)`,**不**动 autoOpen),删掉多余的 `setArtifactsOpen(false)`。这样面板关掉、但 autoOpen 仍为真 → 第一个席位 live 写文件时正常自动开沙箱。(若用户**手动**关过沙箱,autoOpen 本就为 false,不会自动重开 —— 尊重用户。)改 `hooks/useStep2Orchestration.ts` 沙箱跟随 effect。tsc 65。
### 12.11 ✅ 修:快速模式又生成文件了 + Fragment 警告(2026-06-07);记录无关 404
1. **快速模式不应生成文件(回退 §12.8 的过度放开)**:§12.8 为「让席位产 md」把 `seat_parallel`(快速模式)也放开了 write_file —— 但**快速模式(双列)的设计就是产物走正文、不写文件**,写文件归**沙箱模式**(单列 + 沙箱预览)。修:
- `roundtable_run_policy.py`:`seat_parallel` 重新**禁 write_file / str_replace**(保留禁 bash);`seat`(沙箱模式)仍可写。
- `useStep2Orchestration.ts`:`parallelNoFile = dagExecMode !== "sandbox"`(快速模式一律禁写,含单节点 stage;沙箱模式放开)。
- `pickDagSeatTask` 加 `allowFiles` 形参:沙箱(true)=写 .md 文件 + 禁 .py;快速(false)=产物走正文、不写文件。hook 按 `dagExecMode === "sandbox"` 传。
- 测试:`test_sandbox_seat_writes_files_fast_parallel_does_not`(6 过);`dag-types.test.ts` 拆成沙箱/快速两个产物要求用例(37 过)。
- **语义**:快速=双列、产物走正文、不写文件;沙箱=单列、写 .md、沙箱跟随预览。
2. **`Invalid prop data-lov-id supplied to React.Fragment`**:`DagFlowChart` 的 stage 循环用了具名 `<Fragment key>`,dev 代码标注插件(lovable-tagger)给它注入 `data-lov-id`,而 Fragment 只接受 key/children → React 警告。修:换成 `<div className="contents" key=...>`(display:contents 不生成盒子,Connector/StageColumn 仍是外层 flex 直接子项,布局不变,且能接收 data-*)。移除 `Fragment` import。
3. **(无关,非本期引入)`/api/getTokenByEmail?email=…%40local.deerflow` 404 ×60**:来自 `src/core/studio/api.ts` 的 `fetchStudioToken`(studio/expert-auth 引导),用户邮箱被映射到 legacy `@local.deerflow` 域、后端 `getTokenByEmail` 不认 → 404,React Query 跨多个 `useStudioToken` 调用点重试 → 重复多次。**与圆桌 DAG 改动无关**(圆桌头像是本地 SVG,本期未新增任何前端 GET)。需要的话单独排查 studio token 链路。
---
## 13. 实施进度(截至 2026-06-06,下次会话续)
| Phase | 内容 | 状态 |
|---|---|---|
| 1 | DAG 数据模型 + 校验 + 持久化字段(`lib/dag-types.ts`,24 单测) | ✅ 完成 |
| 2 | 分层编辑器(**做进业务链条页**):链条加 `stages` 字段(前后端 + alembic `20260607_03` + 11 后端单测);`BusinessChainEditorPage` 竖列分组 Stage(DnD + 按钮);纯逻辑 `lib/chain-stages.ts`(19 单测) | ✅ 完成 |
| 3 | `runDagOrchestration`:批内 `Promise.all` 并行 + 批间 barrier 串行 + 依赖摘要注入(方案 B 前端直派) | ✅ 完成 |
| 4 | 并行 run policy `seat_parallel`(`roundtable_run_policy.py` + `multi_agent.py` 读 `parallel_no_file`,6 后端单测);**§12.1 方案 A 已放开 web_search/技能** | ✅ 完成 |
| 5 | 并行显示:收起左侧 + 头像条 + 两列 + 选中切换 | ✅ 完成(本轮修了侧栏/Markdown/字数) |
| 6 | 流式高性能:ref 全量累积 + 节流 flush(仅选中渲染) | ✅ 完成 |
| 7 | DAG 流程图执行态可视化(`DispatchChainModal` 复刻成按 stage 分列;后改成**弹窗**见 §12.5) | ✅ 完成(2026-06-07) |
**端到端闭环已通**:链条页编排分层 → 存库 → 选链条进 Step2 → `runDagOrchestration` 并行直派 → 各阶段完交回总控派活/收口。
**✅ §12.3 已实施(2026-06-07)**:见 §12.3「实施结论」。`runDagOrchestration` 在每个非末尾 stage barrier 后插入 leader 中转轮(`synthesisMode:false` 走轻量 `last-ai-message` 注入摘要、收口轮才按需全文),承上启下驱动下一批;后端零改动;`dag-types.ts` 加 `buildDagTransitTask` + `buildStageTask.priorIsBridge`,27 单测全过。
**下次会话 TODO(优先级从高到低)**:
1. **运行时实地验收 §12.3**:①每个 stage 之间出现「总控协调(承上启下)」气泡,内容是对上一批的综合;②后端日志中转轮 `[leader-roster] ... 注入席位交付状态` 显示上一批 ✅、未跑的 ⬜;③下一 stage 席位 task 里带的是总控承上说明(非机械摘要);④中转轮不应真的派活(即使派了前端也忽略)。
2. 运行时实地验收(旧):①§12.1 后并行席位能搜索、步骤条显示;②并行 special 日志时间重叠 + `Excluded` 不含 web_search 含 write_file;③用户干预后是「总控派活」而非重跑 DAG;④最左全局侧栏 + 参与角色栏并行收起且不回弹。
3. **✅ Phase 7 DAG 流程图可视化已完成(2026-06-07)**:见 §7.2。`DagFlowChart` 组件 + 编辑器只读预览 + Step2 执行态缩略(`dagNodeStates` 实时驱动)。
4. (可选)串行也确定性注入上一轮交付摘要。
**关键文件速查**:
- 前端:`hooks/useStep2Orchestration.ts`(`runDagOrchestration` / `dagPlanExecutedRef` / 并行 store+flush)、`components/Step2Panel.tsx`(两列+头像条)、`components/ParallelSeatStrip.tsx`、`lib/dag-types.ts`、`lib/chain-stages.ts`、`pages/BusinessChainEditorPage.tsx`、`pages/RoundtablePlanningPage.tsx`(侧栏收起 effect + handleConfirmAgents dag 分支)、`components/BusinessChainPicker.tsx`(链条→dag)。
- 后端:`app/gateway/roundtable_run_policy.py`(`seat_parallel`,**待放宽**)、`app/gateway/routers/multi_agent.py`(`_special_run` 读 `parallel_no_file`)、`persistence/roundtable_chains/`(`stages` 列)、`migrations/versions/20260607_03_chain_stages.py`。
- 测试:`frontend-web` 用 esbuild 转译 `.test.ts` 后 `node --test`(项目无 vitest);后端 `PYTHONPATH=. uv run pytest tests/test_roundtable_chain_stages.py tests/test_roundtable_run_policy.py`。
+10540
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -119,7 +119,8 @@
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
border-radius: 4px;
backdrop-filter: blur(2px);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
position: absolute;
left: 108px;
top: 0;
@@ -207,6 +207,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Settings,
children: [
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
label: "业务链条管理",
icon: Network,
path: "/page/strategy/business-chain-mapping",
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
@@ -0,0 +1,273 @@
/**
* 业务链条管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。
*
* 表格三列:业务(只读)/ 智能体 / 业务链。编辑模式下后两列为可搜索下拉:
* - 智能体来自 `/api/agents`
* - 业务链来自 `/api/roundtable-chains?scope=public`(只列公共链,保证全局共享时各账号都能解析)
* 保存调 `PUT /api/business-mapping/{code}`。映射全局共享、无用户隔离。
*/
import { useEffect, useMemo, useState } from "react";
import { Loading, Select } from "tdesign-react";
import { Link2, Pencil, Save, Users, X } from "lucide-react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { listAgents } from "@/core/agents/api";
import type { Agent } from "@/core/agents/types";
import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import {
listBusinessMappings,
updateBusinessMapping,
type BusinessMapping,
} from "@/strategy-components/api/business-mapping";
interface DraftSelection {
agentId: string | null;
chainId: string | null;
}
export default function BusinessChainMappingPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const [rows, setRows] = useState<BusinessMapping[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [editingCode, setEditingCode] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftSelection>({ agentId: null, chainId: null });
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const [mappingRes, agentRes, chainRes] = await Promise.allSettled([
listBusinessMappings(),
listAgents(),
listChains("public"),
]);
if (cancelled) return;
if (mappingRes.status === "fulfilled") setRows(mappingRes.value);
else toast.error(`加载业务映射失败:${mappingRes.reason}`);
if (agentRes.status === "fulfilled") setAgents(agentRes.value);
if (chainRes.status === "fulfilled") setChains(chainRes.value);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const agentOptions = useMemo(
() => agents.map((a) => ({ label: a.name || a.id, value: a.id })),
[agents],
);
const chainOptions = useMemo(
() => chains.map((c) => ({ label: c.title || c.id, value: c.id })),
[chains],
);
const beginEdit = (row: BusinessMapping) => {
setEditingCode(row.businessCode);
setDraft({ agentId: row.agentId, chainId: row.chainId });
};
const cancelEdit = () => {
setEditingCode(null);
setDraft({ agentId: null, chainId: null });
};
const handleSave = async (row: BusinessMapping) => {
setSaving(true);
try {
const agentName = draft.agentId
? agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null
: null;
const chainTitle = draft.chainId
? chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null
: null;
const updated = await updateBusinessMapping(row.businessCode, {
agentId: draft.agentId,
agentName,
chainId: draft.chainId,
chainTitle,
});
if (!updated) {
toast.error(`业务「${row.label}」不存在,保存失败`);
return;
}
setRows((prev) => prev.map((r) => (r.businessCode === row.businessCode ? updated : r)));
toast.success(`已保存「${row.label}」的配置`);
cancelEdit();
} catch (err) {
toast.error(`保存失败:${(err as Error).message}`);
} finally {
setSaving(false);
}
};
const shellClass = isDark
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
const cardClass = isDark
? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]"
: "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]";
const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]";
const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const codeBadge = isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
<div className="space-y-1">
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
业务链条管理
</h1>
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。
</p>
</div>
<Loading loading={loading} showOverlay={false}>
<div className={`overflow-hidden rounded-3xl ${cardClass}`}>
<table className="w-full border-collapse text-sm">
<thead>
<tr className={`text-left text-xs uppercase tracking-widest ${headRowClass}`}>
<th className="px-6 py-4 font-semibold">业务</th>
<th className="px-6 py-4 font-semibold">智能体</th>
<th className="px-6 py-4 font-semibold">业务链</th>
<th className="px-6 py-4 text-right font-semibold">操作</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && !loading ? (
<tr>
<td colSpan={4} className={`px-6 py-12 text-center ${mutedText}`}>
暂无业务数据
</td>
</tr>
) : (
rows.map((row) => {
const editing = editingCode === row.businessCode;
return (
<tr key={row.businessCode} className={`border-t ${rowBorder}`}>
{/* 业务(只读) */}
<td className="px-6 py-4 align-middle">
<div className="flex items-center gap-2">
<span className={`text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{row.label}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-mono ${codeBadge}`}>
{row.businessCode}
</span>
</div>
</td>
{/* 智能体 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[200px]"
value={draft.agentId ?? undefined}
options={agentOptions}
filterable
clearable
placeholder="搜索并选择智能体"
onChange={(v) => setDraft((d) => ({ ...d, agentId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Users className={`h-3.5 w-3.5 ${mutedText}`} />
{row.agentName || row.agentId ? (
<span>{row.agentName || row.agentId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 业务链 */}
<td className="px-6 py-4 align-middle">
{editing ? (
<Select
className="min-w-[220px]"
value={draft.chainId ?? undefined}
options={chainOptions}
filterable
clearable
placeholder="搜索并选择业务链(公共)"
onChange={(v) => setDraft((d) => ({ ...d, chainId: (v as string) ?? null }))}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<Link2 className={`h-3.5 w-3.5 ${mutedText}`} />
{row.chainTitle || row.chainId ? (
<span>{row.chainTitle || row.chainId}</span>
) : (
<span className={mutedText}>未配置</span>
)}
</span>
)}
</td>
{/* 操作 */}
<td className="px-6 py-4 text-right align-middle">
{editing ? (
<div className="inline-flex items-center gap-2">
<button
type="button"
disabled={saving}
onClick={() => handleSave(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(34,197,94,0.18)] text-[#86EFAC]" : "bg-[#EEFCF7] text-[#0F9D6C]"
}`}
>
<Save className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
disabled={saving}
onClick={cancelEdit}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium disabled:opacity-50 ${
isDark ? "bg-[rgba(110,136,176,0.18)] text-slate-300" : "bg-[#F5F5F5] text-[#575757]"
}`}
>
<X className="h-3.5 w-3.5" />
取消
</button>
</div>
) : (
<button
type="button"
onClick={() => beginEdit(row)}
className={`inline-flex items-center gap-1 rounded-xl px-3 py-1.5 text-xs font-medium ${
isDark ? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]" : "bg-[#FFF1F0] text-[#CF1322]"
}`}
>
<Pencil className="h-3.5 w-3.5" />
编辑
</button>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Loading>
</div>
</div>
);
}
+42
View File
@@ -17,6 +17,7 @@ import {
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { TodoList } from "@/components/workspace/todo-list";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
@@ -33,6 +34,13 @@ import {
} from "./iframe-chat-submit";
type BrainyChatStatus = "started" | "finished";
type BrainyAuthTokenMessage = {
source: "brainy-iframe";
type: "brainy:auth-token";
payload: {
accessToken: string;
};
};
type BrainyChatInterruptMessage = {
source?: string;
type?: string;
@@ -57,6 +65,18 @@ function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
);
}
function postBrainyAuthToken(accessToken: string) {
const message: BrainyAuthTokenMessage = {
source: "brainy-iframe",
type: "brainy:auth-token",
payload: {
accessToken,
},
};
window.parent?.postMessage(message, "*");
}
export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
@@ -79,6 +99,28 @@ export default function IframeChatPage() {
setMounted(true);
}, []);
useEffect(() => {
let lastToken: string | undefined;
const emitAccessToken = () => {
const accessToken = getAccessToken();
if (!accessToken || accessToken === lastToken) {
return;
}
lastToken = accessToken;
postBrainyAuthToken(accessToken);
};
emitAccessToken();
window.addEventListener("focus", emitAccessToken);
window.addEventListener("storage", emitAccessToken);
return () => {
window.removeEventListener("focus", emitAccessToken);
window.removeEventListener("storage", emitAccessToken);
};
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
+5 -3
View File
@@ -20,6 +20,7 @@ import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanni
import "@/roundtable-planning/styles/roundtable-planning.css";
import AgentChatPage from "./AgentChatPage";
import AgentsPage from "./AgentsPage";
import BusinessChainMappingPage from "./BusinessChainMappingPage";
import ChatPage from "./ChatPage";
import ChatsPage from "./ChatsPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
@@ -106,9 +107,9 @@ export default function StrategyRoutes() {
path="plan-task/plan-task-fx"
element={
<TaskAnalyzes
title="浠诲姟FX"
category="瑙勫垝浠诲姟"
subCategory="瑙勫垝浠诲姟"
title="任务分析"
category="规划任务"
subCategory="规划任务"
/>
}
/>
@@ -172,6 +173,7 @@ export default function StrategyRoutes() {
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route path="business-chain-mapping" element={<BusinessChainMappingPage />} />
<Route
path="scheduled"
@@ -22,6 +22,12 @@ export interface ChainCreatePayload {
title: string;
description?: string;
seats: ChainSeat[];
/** Optional layered orchestration (agent_id groups). null/absent = linear. */
stages?: string[][] | null;
/** Optional per-stage goals aligned to `stages` (dag-only). null = none. */
stageGoals?: (string | null)[] | null;
/** Optional coordinator orchestration prompt (dag-only). null/"" = none. */
coordinatorPrompt?: string | null;
}
/** Partial update — send only what changed. */
@@ -29,8 +35,19 @@ export interface ChainUpdatePayload {
title?: string;
description?: string;
seats?: ChainSeat[];
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
stages?: string[][] | null;
/** Per-stage goals aligned to `stages`; send `null` to clear, omit to leave. */
stageGoals?: (string | null)[] | null;
/** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */
coordinatorPrompt?: string | null;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean;
}
/** 列表筛选范围:`mine` = 我的;`public` = 所有已发布的公共链条。 */
export type ChainScope = "mine" | "public";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
@@ -50,6 +67,11 @@ interface ServerChain {
title: string;
description: string | null;
seats: ChainSeat[];
stages?: string[][] | null;
stage_goals?: (string | null)[] | null;
coordinator_prompt?: string | null;
is_public?: boolean;
is_mine?: boolean;
created_at: string | null;
updated_at: string | null;
}
@@ -60,6 +82,12 @@ function toChain(s: ServerChain): BusinessChain {
title: s.title ?? "",
description: s.description ?? undefined,
seats: Array.isArray(s.seats) ? s.seats : [],
stages: Array.isArray(s.stages) ? s.stages : null,
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
coordinatorPrompt: s.coordinator_prompt ?? null,
isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
createdAt: s.created_at ?? undefined,
updatedAt: s.updated_at ?? undefined,
};
@@ -71,6 +99,9 @@ function fromCreate(p: ChainCreatePayload) {
title: p.title,
...(p.description !== undefined ? { description: p.description } : {}),
seats: p.seats,
...(p.stages !== undefined ? { stages: p.stages } : {}),
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}),
};
}
@@ -79,18 +110,34 @@ function fromUpdate(p: ChainUpdatePayload) {
if (p.title !== undefined) out.title = p.title;
if (p.description !== undefined) out.description = p.description;
if (p.seats !== undefined) out.seats = p.seats;
// Forward stages even when explicitly null (clears layering server-side).
if (p.stages !== undefined) out.stages = p.stages;
// Forward stage goals even when explicitly null (clears goals server-side).
if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals;
if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt;
if (p.isPublic !== undefined) out.is_public = p.isPublic;
return out;
}
// ── CRUD ────────────────────────────────────────────────────────────────────
/** List the current user's chains, newest first. */
export async function listChains(): Promise<BusinessChain[]> {
const res = await apiFetch(BASE(), { method: "GET" });
/**
* List chains, newest first. `scope`:
* - `"mine"`(默认):当前用户自己的链条;
* - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。
*/
export async function listChains(scope: ChainScope = "mine"): Promise<BusinessChain[]> {
const url = scope === "public" ? `${BASE()}?scope=public` : BASE();
const res = await apiFetch(url, { method: "GET" });
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
return (data.chains ?? []).map(toChain);
}
/** 发布 / 取消发布一条链条(仅 owner 生效)。返回更新后的链条,404 返回 null。 */
export async function setChainPublic(id: string, isPublic: boolean): Promise<BusinessChain | null> {
return updateChain(id, { isPublic });
}
/** 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" });
@@ -17,6 +17,7 @@ import { getBackendBaseURL } from "@/core/config";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/**
@@ -50,20 +51,33 @@ export interface DraftStep2Snapshot {
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").
* dispatches freely; chain = dispatch follows the saved business chain order;
* dag = layered Stage DAG (stage-internal parallel, stage-to-stage serial,
* see `lib/dag-types.ts`). Optional — drafts saved before this won't carry it
* (hydrate defaults to "recommend").
*/
orchestrationMode?: "recommend" | "chain";
orchestrationMode?: "recommend" | "chain" | "dag";
/** Source business chain (display only), when `orchestrationMode === "chain"`. */
chain?: { id: string; title: string } | null;
/**
* Layered DAG plan, when `orchestrationMode === "dag"`. Persisted so a reloaded
* draft restores the编排. Optional — null/absent for non-dag drafts. Old `chain`
* drafts are converted on read via `chainToDagPlan` (compat, original draft
* untouched).
*/
orchestrationPlan?: OrchestrationPlan | null;
/**
* dag 执行子模式:fast(并行双列禁写)/ sandbox(并行单列可写、沙箱跟随)。
* 仅 `orchestrationMode === "dag"` 有意义;缺省 fast。
*/
dagExecMode?: "fast" | "sandbox";
}
export interface DraftStep2RunSnapshot {
id: string;
title: string;
createdAt: string;
source: "initial" | "recommend" | "chain" | "manual";
source: "initial" | "recommend" | "chain" | "dag" | "manual";
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
@@ -73,8 +87,11 @@ export interface DraftStep2RunSnapshot {
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
orchestrationMode?: "recommend" | "chain" | "dag";
chain?: { id: string; title: string } | null;
orchestrationPlan?: OrchestrationPlan | null;
/** dag 执行子模式:fast / sandbox(见 DraftStep2Snapshot.dagExecMode)。 */
dagExecMode?: "fast" | "sandbox";
step3?: DraftStep3Snapshot | null;
jobId?: string | null;
jobStatus?: JobStatus | null;
@@ -203,6 +203,30 @@ export interface StreamMultiAgentRequest {
newMessage: string;
model?: string;
skillStopNames?: string[];
/**
* 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付
* 席位的**完整交付正文**额外注入总控输入,供其写最终方案。派活决策轮(首轮等)传 false/省略,
* 后端只给摘要,使总控上下文精简、响应快。
*/
synthesisMode?: boolean;
/**
* 仅 leader 用。置 true 抑制「把总控本轮输入广播给各席位 thread」的预广播。DAG「每 stage
* 派活轮」置真——否则总控的派活 prompt 会进席位历史,弱模型据此模仿派活。后端缺省 false。
*/
suppressPreBroadcast?: boolean;
/**
* 仅 special 用。置 true 表示这是 DAG 并行批次里的席位:后端按 `seat_parallel`
* run policy **禁用** write_file / bash / str_replace / present_files 等写文件 /
* 沙箱工具(§4),避免同批多席位并发抢同一沙箱。产物以消息正文交付。后端缺失
* 该字段时按 false 处理(普通 seat policy),旧前端不受影响。
*/
parallelNoFile?: boolean;
/**
* 仅 special 用。前序席位的**完整交付** `[{name, content}]`。后端注入 run context
* (`roundtable_peer_deliveries`),席位用 `read_peer_delivery` 工具**按需**取全文
* (平时只看广播摘要)。不进 prompt → 不调工具就不耗 token。
*/
peerDeliveries?: Array<{ name: string; content: string }>;
signal?: AbortSignal;
/** Fires for every non-status data frame the upstream emits. */
onUpstreamFrame?: (frame: unknown) => void;
@@ -415,6 +439,14 @@ export async function streamMultiAgent(
new_message: req.newMessage,
model: req.model,
skill_stop_names: req.skillStopNames ?? [],
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
synthesis_mode: req.synthesisMode ?? false,
// 仅 leader 用;DAG 派活轮抑制预广播,避免总控派活 prompt 污染席位 thread。省略 → false。
suppress_pre_broadcast: req.suppressPreBroadcast ?? false,
// 仅 special 用;DAG 并行批次禁写文件(seat_parallel policy)。省略 → 后端按 false 处理。
parallel_no_file: req.parallelNoFile ?? false,
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
peer_deliveries: req.peerDeliveries ?? [],
}),
signal: req.signal,
});
@@ -0,0 +1,151 @@
/**
* 智能体详情抽屉(右侧 Sheet)。
*
* 业务链条配置页:左栏候选卡片点击卡片本体、右栏席位点「查看详情」都打开这个抽屉,
* 展示该智能体的详情(与「智能体」详情页展示的内容差不多):名称 / 模型 / 描述 /
* 标签 / 技能 + 工具组 / 人设(SOUL.md)。
*
* 详情按需拉取:`useAgent(open ? agentId : null)` —— 抽屉打开时才请求 `GET /api/agents/{id}`
* (该接口才返回 soul),关闭不请求。字段渲染对齐 `components/workspace/agents/agent-card.tsx`。
*/
import { Bot, Globe, Loader2, Lock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { useAgent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
export function AgentDetailSheet({
agentId,
/** 卡片/席位上的名称快照,详情还没拉回来时先用它占位。 */
fallbackName,
open,
onOpenChange,
}: {
agentId: string | null;
fallbackName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
// 抽屉打开时才拉详情(含 soul);关闭传 null 让 useAgent 不发请求。
const { agent, isLoading } = useAgent(open ? agentId : null);
const name = agent?.name || fallbackName || agentId || "";
const skillItems = agent?.skills ?? [];
const toolGroups = agent?.tool_groups ?? [];
const hasSkillsOrTools = skillItems.length > 0 || toolGroups.length > 0;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b border-border px-5 py-4">
<SheetTitle className="flex items-center gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Bot className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-base font-bold">{name}</span>
{agent?.model && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{desensitize(agent.model)}
</Badge>
)}
</SheetTitle>
<SheetDescription className="sr-only">智能体详情</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4 text-sm">
{isLoading && !agent ? (
<div className="flex items-center gap-2 py-8 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载详情…
</div>
) : !agent ? (
<div className="py-8 text-center text-muted-foreground">
未找到该智能体的详情(可能已被删除)。
</div>
) : (
<>
{/* 状态徽标 */}
<div className="flex flex-wrap items-center gap-1.5">
{agent.builtin && (
<Badge variant="secondary" className="text-[10px]">内置</Badge>
)}
{agent.published && (
<Badge variant="outline" className="gap-1 text-[10px]">
<Globe className="size-3" />
已发布
</Badge>
)}
{agent.is_private && (
<Badge variant="outline" className="gap-1 text-[10px]">
<Lock className="size-3" />
私密
</Badge>
)}
</div>
{/* 描述 */}
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
描述
</h4>
<p className="whitespace-pre-wrap leading-relaxed text-foreground">
{agent.description?.trim() || "(无描述)"}
</p>
</section>
{/* 标签 */}
{agent.tags && agent.tags.length > 0 && (
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
标签
</h4>
<TagChipList tags={agent.tags} />
</section>
)}
{/* 技能 / 工具组 */}
{hasSkillsOrTools && (
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
技能与工具
</h4>
<div className="flex flex-wrap gap-1.5">
{toolGroups.map((group) => (
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
{desensitize(group)}
</Badge>
))}
{skillItems.map((skill) => (
<Badge key={`sk:${skill}`} variant="secondary" className="text-xs">
{desensitize(skill)}
</Badge>
))}
</div>
</section>
)}
{/* 人设(SOUL.md) */}
{agent.soul?.trim() && (
<section className="space-y-1.5">
<h4 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
人设
</h4>
<pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-3 text-[12px] leading-relaxed text-foreground">
{agent.soul}
</pre>
</section>
)}
</>
)}
</div>
</SheetContent>
</Sheet>
);
}
@@ -9,9 +9,10 @@
* 卡片席位名直接用落库快照(`seat.name`),无需实时校验,点「设置」进编辑页后
* 会看到实时数据。
*/
import { Fragment, useEffect, useState } from "react";
import { Fragment, useCallback, useEffect, useState } from "react";
import {
ArrowRightIcon,
GlobeIcon,
LayersIcon,
Loader2Icon,
SettingsIcon,
@@ -28,7 +29,7 @@ import {
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { listChains } from "../api/chains";
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
@@ -47,16 +48,32 @@ export function BusinessChainListDialog({
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 筛选:我的 / 公共(已发布)。
const [scope, setScope] = useState<ChainScope>("mine");
const [publishingId, setPublishingId] = useState<string | null>(null);
// 每次打开都重新拉一次,保证配置页改动后回来看到的是最新列表。
const refresh = useCallback(async (s: ChainScope) => {
setLoading(true);
setError(null);
try {
const list = await listChains(s);
setChains(list);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
// 每次打开 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
setLoading(true);
setError(null);
void (async () => {
try {
const list = await listChains();
const list = await listChains(scope);
if (!cancelled) setChains(list);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
@@ -67,7 +84,22 @@ export function BusinessChainListDialog({
return () => {
cancelled = true;
};
}, [open]);
}, [open, scope]);
const handleTogglePublic = useCallback(
async (chain: BusinessChain) => {
setPublishingId(chain.id);
try {
await setChainPublic(chain.id, !chain.isPublic);
await refresh(scope);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPublishingId(null);
}
},
[refresh, scope],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -78,10 +110,29 @@ export function BusinessChainListDialog({
业务链条
</DialogTitle>
<DialogDescription>
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条。
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条;「发布」后所有账号都能在「公共」里查看 / 选用。
</DialogDescription>
</DialogHeader>
{/* 筛选:我的 / 公共 */}
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
{(["mine", "public"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition",
scope === s
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{s === "mine" ? "我的" : "公共"}
</button>
))}
</div>
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
{loading ? (
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
@@ -95,9 +146,13 @@ export function BusinessChainListDialog({
) : chains.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<LayersIcon className="size-10 text-primary/40" />
<div className="text-sm font-semibold">还没有业务链条</div>
<div className="text-sm font-semibold">
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
</div>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。
{scope === "public"
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
: "业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
</p>
</div>
) : (
@@ -106,7 +161,9 @@ export function BusinessChainListDialog({
<ChainRow
key={chain.id}
chain={chain}
publishing={publishingId === chain.id}
onSettings={() => onConfigure(chain.id)}
onTogglePublic={() => handleTogglePublic(chain)}
/>
))}
</div>
@@ -128,28 +185,61 @@ export function BusinessChainListDialog({
function ChainRow({
chain,
publishing,
onSettings,
onTogglePublic,
}: {
chain: BusinessChain;
publishing: boolean;
onSettings: () => void;
onTogglePublic: () => void;
}) {
const isMine = chain.isMine !== false;
return (
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
{/* 标题行 + 设置按钮 */}
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
<div className="flex items-center gap-2.5">
<div className="truncate text-base font-semibold">{chain.title || "未命名链条"}</div>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{chain.seats.length} 席
</span>
{chain.isPublic && (
<span className="shrink-0 inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<GlobeIcon className="size-3" />
公共
</span>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{/* 发布 / 取消发布:仅本人的链条可操作。 */}
{isMine && (
<Button
variant={chain.isPublic ? "secondary" : "outline"}
size="sm"
onClick={onTogglePublic}
disabled={publishing}
className="h-8 gap-1.5 px-3 text-sm"
>
{publishing ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<GlobeIcon className="size-4" />
)}
{chain.isPublic ? "取消发布" : "发布"}
</Button>
)}
{/* 设置(编辑):仅本人的链条;公共里别人的链条只读浏览。 */}
{isMine && (
<Button
variant="ghost"
size="sm"
onClick={onSettings}
className="ml-auto h-8 shrink-0 gap-1.5 px-3 text-sm"
className="h-8 gap-1.5 px-3 text-sm"
>
<SettingsIcon className="size-4" />
设置
</Button>
)}
</div>
</div>
{chain.description && (
@@ -13,7 +13,7 @@
*
* 详见 `docs/multi-agent-business-chain-dev.md` §6.2 / §7 阶段 3。
*/
import { Fragment, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
ArrowRightIcon,
CheckIcon,
@@ -29,13 +29,14 @@ import { cn } from "@/lib/utils";
import { listAgents } from "@/core/agents";
import { filterRecommendCandidates } from "../api/recommend";
import { listChains } from "../api/chains";
import { listChains, type ChainScope } from "../api/chains";
import {
CHAIN_MIN_SEATS,
chainTagClasses,
type BusinessChain,
type SeatSelectionResult,
} from "../lib/business-chain";
import { buildPlanFromStages } from "../lib/dag-types";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface BusinessChainPickerProps {
@@ -54,16 +55,21 @@ export function BusinessChainPicker({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
// 筛选:我的 / 公共(已发布)。
const [scope, setScope] = useState<ChainScope>("mine");
// null = 候选池未知(agent 列表加载失败)→ 不把任何席位判成「已删除」。
const [existingIds, setExistingIds] = useState<Set<string> | null>(null);
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setSelectedId(null); // 切换筛选后清空选中(旧选中可能不在新列表里)。
void (async () => {
try {
const [chainList, agents] = await Promise.all([
listChains(),
listChains(scope),
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
]);
if (cancelled) return;
@@ -85,7 +91,7 @@ export function BusinessChainPicker({
return () => {
cancelled = true;
};
}, []);
}, [scope]);
const isExisting = useMemo(
() => (agentId: string) => existingIds === null || existingIds.has(agentId),
@@ -102,22 +108,68 @@ export function BusinessChainPicker({
);
const canUse = !!selectedChain && survivingSeats.length >= CHAIN_MIN_SEATS;
// 选中链条若有分层、且过滤到存活席位后**仍含并行 stage,或任一 stage 带「本阶段目标」**
// → 走 dag。并行要 dag 才能并行调度;带目标也要 dag —— 目标靠总控**逐阶段派活**注入
// (buildDagStageLeaderTask),chain 模式不消费目标,故串行带目标链同样走 dag。此时让用户在
// 「快速(并行双列禁写) / 沙箱(并行单列可写、沙箱跟随)」之间二选一(§DAG 执行子模式)。
const dagPlan = useMemo(() => {
if (!selectedChain?.stages || selectedChain.stages.length === 0) return null;
const surviveIds = survivingSeats.map((s) => s.agent_id);
const plan = buildPlanFromStages(
selectedChain.stages,
surviveIds,
true,
selectedChain.stageGoals,
selectedChain.coordinatorPrompt,
);
const hasParallel = plan.stages.some((st) => st.agentIds.length > 1);
const hasGoal = plan.stages.some((st) => (st.goal?.trim() ?? "") !== "");
const hasCoord = (plan.coordinatorPrompt?.trim() ?? "") !== "";
return hasParallel || hasGoal || hasCoord ? plan : null;
}, [selectedChain, survivingSeats]);
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
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 },
});
const chainMeta = { id: selectedChain.id, title: selectedChain.title };
const seatNameById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.name]));
const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id;
// 链条带分层、过滤存活后仍并行 → dag 编排(携带快速/沙箱子模式);否则退回线性 chain。
if (dagPlan) {
// dag:席位顺序按 stage 展平(与 plan 一致)。
const agents = dagPlan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id) }));
onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode });
return;
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
onConfirm({ agents, mode: "chain", chain: chainMeta });
};
return (
<>
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* 筛选:我的 / 公共 */}
<div className="mb-2 flex shrink-0 items-center gap-1 self-start rounded-lg border border-border bg-muted/40 p-0.5">
{(["mine", "public"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition",
scope === s
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{s === "mine" ? "我的" : "公共"}
</button>
))}
</div>
<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">
@@ -131,15 +183,20 @@ export function BusinessChainPicker({
) : 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>
<div className="text-sm font-semibold">
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
</div>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,
之后即可在这里选择使用。
{scope === "public"
? "公共链条是别人(或你自己)发布出来、所有账号可选用的链条。可在「我的」里发布一条。"
: "业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,之后即可在这里选择使用。"}
</p>
{scope === "mine" && (
<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">
@@ -163,6 +220,40 @@ export function BusinessChainPicker({
<SettingsIcon className="size-3.5" />
配置链条
</Button>
{/* dag 分层并行 → 选执行子模式(快速双列禁写 / 沙箱单列可写)。 */}
{dagPlan && (
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-muted-foreground">执行方式</span>
<div className="flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5">
<button
type="button"
onClick={() => setDagExecMode("fast")}
title="同 stage 席位并行执行,主区双列同时显示两个;并行期间禁止写文件(产物走正文)"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition",
dagExecMode === "fast"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
快速 · 双列禁写
</button>
<button
type="button"
onClick={() => setDagExecMode("sandbox")}
title="同 stage 席位仍并行执行,但主区单列显示、允许写文件;点上方头像切换查看,沙箱跟随当前席位"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition",
dagExecMode === "sandbox"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
沙箱 · 单列可写
</button>
</div>
</div>
)}
<div className="flex items-center gap-2">
{selectedChain && !canUse && (
<span className="text-xs text-destructive">存活席位不足 {CHAIN_MIN_SEATS} 个</span>
@@ -217,6 +308,11 @@ function ChainCard({
<CheckIcon className="size-3" />
</span>
)}
{chain.stages && chain.stages.length > 0 && (
<span className="shrink-0 rounded-full border border-amber-400/40 bg-amber-400/10 px-1.5 py-0.5 text-[10px] font-semibold text-amber-600 dark:text-amber-300">
分层并行
</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>
@@ -228,15 +324,23 @@ function ChainCard({
{/* 智能体名 tag(按顺序,← 箭头连接) */}
<div className="mt-2 flex flex-wrap items-center gap-1">
{chain.seats.map((seat, i) => {
{chain.seats.flatMap((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" />
)}
// 不用 <Fragment>:dev 期 tagger 会往 Fragment 注入 data-lov-id 触发 React 警告,
// 这里改成带 key 的扁平数组(箭头 + tag),注入落到真实元素上即可。
const nodes: ReactNode[] = [];
if (i > 0) {
nodes.push(
<ArrowRightIcon
key={`arrow-${seat.agent_id}`}
className="size-3 shrink-0 text-muted-foreground/50"
/>,
);
}
nodes.push(
<span
key={`tag-${seat.agent_id}`}
className={cn(
"rounded border px-1.5 py-0.5 text-[11px] font-medium",
alive
@@ -246,9 +350,9 @@ function ChainCard({
title={alive ? undefined : "该智能体已不存在"}
>
{label}
</span>
</Fragment>
</span>,
);
return nodes;
})}
</div>
@@ -0,0 +1,360 @@
/**
* 分层 DAG 流程图(Phase 7,§7)。
*
* 把 `DispatchChainModal` 的**线性**流程图复刻成 **DAG 版**:
* 总控 ─▶ ┌Stage1┐ ─▶ ┌Stage2┐ ─▶ … ─▶ 🤝共识 ─▶ 📊结果绘制
* │情报● │ │方案✓ │
* └风险● ┘ └──────┘
* - 按 stage 分**列**,stage 内席位**纵向并列**(并行批次),stage 间用连接线(串行)。
* - 节点状态复用 {@link ChainNodeState}(pending / active / done)+ 同款动画
* (`animate-ping` 光环 / `Loader2Icon` 转圈 / `CheckIcon` 打勾)。
* - 末尾接「共识 → 结果绘制」里程碑(沿用 `DispatchChainModal`)。
*
* 纯展示组件:所有状态由 props 传入。两种用途:
* 1) **编辑器只读预览**(`BusinessChainEditorPage` 底部):不传 `seatStates` → 全 pending,
* 仅展示编排结构。
* 2) **执行态总览**(Step 2 顶部缩略):`runDagOrchestration` 维护的 `dagNodeStates`
* 传进 `seatStates`,节点随执行实时 active/done。
*
* 节点视觉/连线 CSS 与 `DispatchChainModal` 同源(`rt-chain-connector` 在
* `styles/roundtable-planning.css` 全局段);此处自带紧凑版节点渲染,不改动既有 modal。
*/
import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars";
import { chainTagClasses } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import type { ChainNodeState } from "../lib/job-types";
/** 单个席位的展示元数据(名称 + 头像配色)。 */
export interface DagFlowSeatMeta {
name: string;
avatarType: string;
}
export interface DagFlowChartProps {
/** 分层计划的 stages(`OrchestrationStage[]` 同形,只取 agentIds)。 */
stages: ReadonlyArray<{ id?: string; agentIds: string[] }>;
/**
* 把 `agentId`(小写)+ 全局序号解析成 {name, avatarType}。
* 缺省回退:name=agentId、avatarType=getAvatarTypeFor(agentId, order)。
*/
resolveSeat?: (agentId: string, order: number) => DagFlowSeatMeta;
/** `agentId`(小写)→ 节点状态。缺省 / 未命中 = pending(只读预览即全 pending)。 */
seatStates?: Record<string, ChainNodeState>;
/** 总控节点状态(默认 pending)。 */
coordinatorState?: ChainNodeState;
/** 共识里程碑状态(默认 pending)。 */
consensusState?: ChainNodeState;
/** 结果绘制里程碑状态(默认 pending)。 */
reportState?: ChainNodeState;
/** 是否显示末尾里程碑(共识 / 结果绘制)。默认 true。 */
showMilestones?: boolean;
/**
* 是否显示「结果绘制」里程碑。`结果绘制` 属于 **Step 3**;Step 2 的流程图只到「共识」即可,
* 故 Step 2 / 编辑器预览传 false,只展示 Step 2 流程(总控 → stages → 共识)。默认 true。
*/
showReportMilestone?: boolean;
/** 紧凑模式(顶部缩略 / 底部预览用更小的节点)。默认 false。 */
compact?: boolean;
className?: string;
}
/** 一个 stage 的聚合状态:任一 active → active;全 done → done;否则 pending。 */
function aggregateStageState(
agentIds: string[],
seatStates: Record<string, ChainNodeState> | undefined,
): ChainNodeState {
if (!seatStates || agentIds.length === 0) return "pending";
let anyActive = false;
let allDone = true;
for (const id of agentIds) {
const st = seatStates[id.toLowerCase()] ?? "pending";
if (st === "active") anyActive = true;
if (st !== "done") allDone = false;
}
if (anyActive) return "active";
return allDone ? "done" : "pending";
}
export function DagFlowChart({
stages,
resolveSeat,
seatStates,
coordinatorState = "pending",
consensusState = "pending",
reportState = "pending",
showMilestones = true,
showReportMilestone = true,
compact = false,
className,
}: DagFlowChartProps) {
const resolve = (agentId: string, order: number): DagFlowSeatMeta =>
resolveSeat?.(agentId, order) ?? {
name: agentId,
avatarType: getAvatarTypeFor(agentId, order),
};
// 全局序号:跨 stage 连续递增(getAvatarTypeFor 兜底配色 + 节点编号)。
let runningOrder = 0;
return (
<div
className={cn(
"custom-scrollbar overflow-x-auto overflow-y-hidden",
compact ? "py-3" : "py-6",
className,
)}
>
<div className={cn("flex min-w-max items-center", compact ? "gap-0 px-2" : "gap-0 px-4")}>
{/* 总控节点 */}
<FlowNode label="总控协调" avatarType="human" state={coordinatorState} compact={compact} />
{stages.map((stage, stageIdx) => {
const stageState = aggregateStageState(stage.agentIds, seatStates);
const isParallel = stage.agentIds.length > 1;
return (
// 用 display:contents 的包裹 div(而非 <Fragment key>):dev 代码标注插件会给具名
// <Fragment> 注入 data-lov-id,而 Fragment 只接受 key/children → React 报
// 「Invalid prop data-lov-id supplied to React.Fragment」。div.contents 不生成盒子,
// Connector / StageColumn 仍作为外层 flex 的直接子项,布局不变,且能正常接收 data-*。
<div className="contents" key={stage.id ?? `stage-${stageIdx}`}>
<Connector active={stageState === "active"} done={stageState === "done"} compact={compact} />
<StageColumn
index={stageIdx}
isParallel={isParallel}
compact={compact}
>
{stage.agentIds.map((agentId) => {
const order = runningOrder++;
const meta = resolve(agentId, order);
const state = seatStates?.[agentId.toLowerCase()] ?? "pending";
return (
<FlowNode
key={agentId}
label={meta.name}
avatarType={meta.avatarType}
state={state}
compact={compact}
/>
);
})}
</StageColumn>
</div>
);
})}
{showMilestones && (
<>
<Connector
active={consensusState === "active"}
done={consensusState === "done"}
compact={compact}
/>
<MilestoneNode label="共识达成" emoji="🤝" state={consensusState} compact={compact} />
{showReportMilestone && (
<>
<Connector
active={reportState === "active"}
done={reportState === "done"}
compact={compact}
/>
<MilestoneNode label="结果绘制" emoji="📊" state={reportState} compact={compact} />
</>
)}
</>
)}
</div>
</div>
);
}
// ── Stage 列(纵向并列若干席位)────────────────────────────────────────────
function StageColumn({
index,
isParallel,
compact,
children,
}: {
index: number;
isParallel: boolean;
compact: boolean;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center gap-1.5">
<span
className={cn(
"select-none rounded-full px-2 py-0.5 font-semibold text-muted-foreground",
compact ? "text-[10px]" : "text-[11px]",
)}
>
Stage {index + 1}
{isParallel && (
<span className="ml-1 rounded bg-primary/10 px-1 text-primary">并行</span>
)}
</span>
<div
className={cn(
"flex flex-col items-center rounded-xl border border-dashed border-border/70 bg-muted/20",
compact ? "gap-2 p-1.5" : "gap-3 p-2.5",
)}
>
{children}
</div>
</div>
);
}
// ── 席位 / 总控节点 ─────────────────────────────────────────────────────────
function FlowNode({
label,
avatarType,
state,
compact,
}: {
label: string;
avatarType: string;
state: ChainNodeState;
compact: boolean;
}) {
const tag = chainTagClasses(avatarType);
const ringSize = compact ? "size-10" : "size-14";
const avatarSize = compact ? "h-9 w-9" : "h-12 w-12";
return (
<div className={cn("flex shrink-0 flex-col items-center gap-1.5 text-center", compact ? "w-16" : "w-24")}>
<div className={cn("relative flex items-center justify-center", ringSize)}>
{state === "active" && (
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" />
)}
<span
className={cn(
"relative flex items-center justify-center rounded-full border-2 transition-all",
ringSize,
state === "active" && "border-primary shadow-[0_0_0_4px_rgba(22,119,255,0.12)]",
state === "done" && "border-emerald-400/70",
state === "pending" && "border-border opacity-45 grayscale",
)}
>
{getAvatar(avatarType, avatarSize)}
{state === "done" && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
<CheckIcon className="size-2.5" />
</span>
)}
{state === "active" && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
<Loader2Icon className="size-2.5 animate-spin" />
</span>
)}
</span>
</div>
<span
className={cn(
"max-w-full truncate rounded border px-1.5 py-0.5 font-medium",
compact ? "text-[10px]" : "text-[11px]",
state === "pending" ? "border-border bg-muted text-muted-foreground" : tag,
)}
title={label}
>
{label}
</span>
</div>
);
}
// ── 里程碑节点(共识 / 结果绘制)────────────────────────────────────────────
function MilestoneNode({
label,
emoji,
state,
compact,
}: {
label: string;
emoji: string;
state: ChainNodeState;
compact: boolean;
}) {
const ringSize = compact ? "size-10" : "size-14";
return (
<div className={cn("flex shrink-0 flex-col items-center gap-1.5 text-center", compact ? "w-16" : "w-24")}>
<div className={cn("relative flex items-center justify-center", ringSize)}>
{state === "active" && (
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" />
)}
<span
className={cn(
"relative flex items-center justify-center rounded-full border-2 transition-all",
ringSize,
compact ? "text-lg" : "text-2xl",
state === "active" && "border-primary bg-primary/5 shadow-[0_0_0_4px_rgba(22,119,255,0.12)]",
state === "done" && "border-emerald-400/70 bg-emerald-500/5",
state === "pending" && "border-border bg-muted opacity-45 grayscale",
)}
>
{emoji}
{state === "done" && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
<CheckIcon className="size-2.5" />
</span>
)}
{state === "active" && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
<Loader2Icon className="size-2.5 animate-spin" />
</span>
)}
</span>
</div>
<span
className={cn(
"rounded border px-1.5 py-0.5 font-semibold",
compact ? "text-[10px]" : "text-[11px]",
state === "pending"
? "border-border bg-muted text-muted-foreground"
: state === "active"
? "border-primary/40 bg-primary/10 text-primary"
: "border-emerald-400/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
)}
>
{label}
</span>
</div>
);
}
// ── 连接线(带流动动画,复用 DispatchChainModal 的 rt-chain-connector CSS)────
function Connector({
active,
done,
compact,
}: {
active: boolean;
done: boolean;
compact: boolean;
}) {
return (
<div className={cn("flex shrink-0 items-center justify-center", compact ? "h-10 w-10" : "h-14 w-14")}>
<div className="relative flex w-full items-center">
<span
className={cn(
"rt-chain-connector",
active && "rt-chain-connector--active",
done && !active && "rt-chain-connector--done",
)}
/>
<ChevronRightIcon
className={cn(
"absolute -right-1 size-4 transition-colors",
active ? "text-primary" : done ? "text-primary/70" : "text-muted-foreground/40",
)}
/>
</div>
</div>
);
}
@@ -211,6 +211,13 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
[artifactsBySeat],
);
// 只保留「有产出文件」的席位;加载中 / 出错的席位仍保留以展示其状态。
// 全部席位都没有文件(且都已加载完成、无错误)时,整块「生成的报告文件」隐藏。
const seatsWithFiles = useMemo(
() => artifactsBySeat.filter((s) => s.loading || s.error || s.files.length > 0),
[artifactsBySeat],
);
const toggleSeat = useCallback((key: string) => {
setExpandedSeats((prev) => {
const next = new Set(prev);
@@ -424,7 +431,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
)}
</section>
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览 */}
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览。
仅当存在「有产出文件 / 加载中 / 出错」的席位时才渲染;全部席位无文件则整块隐藏。 */}
{seatsWithFiles.length > 0 && (
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
@@ -434,11 +443,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</span>
</h4>
{artifactsBySeat.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
{artifactsBySeat.map((seat) => (
{seatsWithFiles.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
@@ -501,8 +507,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
))}
</div>
)}
</section>
)}
{/* Footer */}
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
@@ -167,8 +167,13 @@ export const sharedMarkdownComponents = {
{children}
</a>
),
// 宽表在并行两列 / 窄气泡里会超出容器(模型常输出多列表格,如「法律免责条款」表)。
// 套一层 max-w-full + overflow-x-auto 的块级容器:表格保持自然宽度,超出时容器内**横向
// 滚动**而不是撑破列宽 / 盖到相邻列。table 不设 max-w,否则单元格会被挤到错乱换行。
table: ({ children }: { children?: React.ReactNode }) => (
<table className="roundtable-chat-message my-1 border-collapse">{children}</table>
<div className="my-1 max-w-full overflow-x-auto">
<table className="roundtable-chat-message border-collapse">{children}</table>
</div>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
@@ -0,0 +1,77 @@
/**
* 并行批次头像条(§5.2)。
*
* 并行 stage 执行时显示在消息列表上方、模型选择器左侧。展示当前批次的所有席位头像:
* - 进行中(running)→ `animate-ping` 光环 + `Loader2` 角标;
* - 已完成(done)→ ✓ 角标;失败(error)→ ✕ 角标;
* - 选中(在主区两列里显示的那 ≤2 个)→ 高亮描边。
* 点头像 = 切换该席位是否在主区显示(LRU,最多 2,见 hook `toggleParallelSeat`)。
*
* 复用 `Avatars.getAvatar` 的头像 SVG 与 `DispatchChainModal` 的 loading 动画思路。
*/
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration";
export interface ParallelSeatStripProps {
batch: ParallelSeatView[];
/** 当前主区显示的席位 agentId(最多 2)。 */
selectedSeats: string[];
onToggleSeat: (agentId: string) => void;
}
export function ParallelSeatStrip({ batch, selectedSeats, onToggleSeat }: ParallelSeatStripProps) {
if (batch.length === 0) return null;
const selected = new Set(selectedSeats);
const runningCount = batch.filter((s) => s.status === "running").length;
return (
<div className="flex items-center gap-2 overflow-x-auto py-1">
<span className="shrink-0 text-[calc(10px*var(--ui-font-scale,1))] font-bold text-slate-500">
并行 {batch.length} 席
{runningCount > 0 && <span className="text-primary">({runningCount} 进行中)</span>}
</span>
<div className="flex items-center gap-1.5">
{batch.map((seat) => {
const isSelected = selected.has(seat.agentId);
const isRunning = seat.status === "running";
return (
<button
key={seat.agentId}
type="button"
onClick={() => onToggleSeat(seat.agentId)}
title={`${seat.displayName}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`}
className={cn(
"relative shrink-0 rounded-full transition",
isSelected ? "ring-2 ring-primary ring-offset-1" : "ring-1 ring-transparent hover:ring-slate-300",
)}
>
{/* 进行中光环 */}
{isRunning && (
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" aria-hidden />
)}
<span className={cn("relative block", !isSelected && "opacity-80")}>
{getAvatar(seat.avatarType, "h-7 w-7")}
</span>
{/* 状态角标 */}
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 flex size-3.5 items-center justify-center rounded-full border border-white shadow-sm",
seat.status === "running" && "bg-primary text-white",
seat.status === "done" && "bg-emerald-500 text-white",
seat.status === "error" && "bg-rose-500 text-white",
)}
>
{seat.status === "running" && <Loader2Icon className="size-2.5 animate-spin" />}
{seat.status === "done" && <CheckIcon className="size-2.5" strokeWidth={3} />}
{seat.status === "error" && <XIcon className="size-2.5" strokeWidth={3} />}
</span>
</button>
);
})}
</div>
</div>
);
}
@@ -16,17 +16,25 @@
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Cpu,
FileIcon,
HelpCircle,
Loader2,
Network,
Pause,
Play,
Sparkles,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
/** 后台挂起入口 —— 暂时隐藏,恢复时改为 true。 */
const SHOW_BACKGROUND_SUSPEND = false;
@@ -42,13 +50,19 @@ import {
StreamingCursor,
} from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import type {
AgentConfig,
ParallelSeatView,
Step2Dialogue,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import type {
RoleEntry,
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import {
getAvatarTypeFor,
type RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
@@ -71,6 +85,18 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
// ─── 并行显示(§5)────────────────────────────────────────────
parallelBatch: ParallelSeatView[];
selectedParallelSeats: string[];
isParallelRunning: boolean;
onToggleParallelSeat: (agentId: string) => void;
// ─── Phase 7 DAG 流程图执行态总览(§7)─────────────────────────
/** 当前编排模式;=== "dag" 且 plan 非空时,顶部显示 DAG 流程图缩略。 */
orchestrationMode: "recommend" | "chain" | "dag";
/** dag 模式的分层计划(流程图按 stage 分列渲染)。 */
orchestrationPlan: OrchestrationPlan | null;
/** agentId(小写) → 节点状态;驱动流程图节点 active/done。 */
dagNodeStates: Record<string, ChainNodeState>;
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
agentNameToRole: Record<
string,
@@ -120,6 +146,13 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs,
selectedAgentId,
roles,
parallelBatch,
selectedParallelSeats,
isParallelRunning,
onToggleParallelSeat,
orchestrationMode,
orchestrationPlan,
dagNodeStates,
agentNameToRole,
selectedModel,
availableModels,
@@ -147,12 +180,74 @@ export function Step2Panel(props: Step2PanelProps) {
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
useEffect(() => {
scrollStep2ToBottom();
}, [isParallelRunning, selectedParallelSeats, scrollStep2ToBottom]);
const resolveAgentName = useCallback(
(idOrName: string) =>
agentNameToRole[idOrName.toLowerCase()]?.displayName ?? idOrName,
[agentNameToRole],
);
// 并行两列:按 bubbleId 取席位流式气泡。固定 2 列(窄屏 1 列,见 CSS 断点)。
const dialogueById = useMemo(() => {
const m = new Map<number, Step2Dialogue>();
for (const d of step2RoundtableDialogues) m.set(d.id, d);
return m;
}, [step2RoundtableDialogues]);
// 主区两列要显示的席位:取被选中的(最多 2),回退到批次最先两个。
const parallelColumns = useMemo(() => {
if (!isParallelRunning) return [];
const pick = selectedParallelSeats.length > 0
? selectedParallelSeats
: parallelBatch.slice(0, 2).map((s) => s.agentId);
return pick
.map((agentId) => parallelBatch.find((s) => s.agentId === agentId))
.filter((s): s is ParallelSeatView => Boolean(s))
.slice(0, 2);
}, [isParallelRunning, selectedParallelSeats, parallelBatch]);
// ─── Phase 7 DAG 流程图执行态总览(§7)──────────────────────────────────
const showDagFlow =
orchestrationMode === "dag" && (orchestrationPlan?.stages.length ?? 0) > 0;
const resolveDagSeat = useCallback(
(agentId: string, order: number) => {
const meta = agentNameToRole[agentId.toLowerCase()];
return {
name: meta?.displayName ?? agentId,
avatarType: meta?.avatarType ?? getAvatarTypeFor(agentId, order),
};
},
[agentNameToRole],
);
// 总控 / 共识 / 报告里程碑状态:由节点状态 + 编排/共识标志派生。
const { dagCoordinatorState, dagConsensusState } = useMemo(() => {
const states = Object.values(dagNodeStates);
const anyActive = states.includes("active");
const anyStarted = states.some((s) => s !== "pending");
const allDone = states.length > 0 && states.every((s) => s === "done");
const coordinator: ChainNodeState = anyActive
? "done"
: isOrchestrating
? "active"
: anyStarted
? "done"
: "pending";
const consensus: ChainNodeState = hasConsensus
? "done"
: allDone
? "active"
: "pending";
return { dagCoordinatorState: coordinator, dagConsensusState: consensus };
}, [dagNodeStates, isOrchestrating, hasConsensus]);
// Phase 7:DAG 流程图弹窗开关(点按钮查看,不再内联占空间)。
const [dagFlowOpen, setDagFlowOpen] = useState(false);
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
@@ -249,6 +344,54 @@ export function Step2Panel(props: Step2PanelProps) {
{/* Conversation log */}
<div className="relative flex-1 min-h-0 flex flex-col">
{/* Phase 7:dag 模式 —— 流程图改成「点按钮看弹窗」,不再内联占空间(§7)。 */}
{showDagFlow && orchestrationPlan && (
<div className="shrink-0 flex items-center justify-between gap-2 border-t border-slate-150 bg-slate-50/60 px-6 py-2">
<span className="text-xs font-medium text-slate-500">
分层编排执行中 · 共 {orchestrationPlan.stages.length} 个阶段
</span>
<button
type="button"
onClick={() => setDagFlowOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs font-semibold text-primary transition hover:bg-primary/10"
>
<Network className="size-3.5" />
查看编排流程图
</button>
</div>
)}
{showDagFlow && orchestrationPlan && (
<Dialog open={dagFlowOpen} onOpenChange={setDagFlowOpen}>
<DialogContent className="flex max-h-[88vh] w-[94vw] flex-col gap-0 sm:max-w-[1100px]">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-primary" />
分层编排流程图
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto">
<DagFlowChart
stages={orchestrationPlan.stages}
resolveSeat={resolveDagSeat}
seatStates={dagNodeStates}
coordinatorState={dagCoordinatorState}
consensusState={dagConsensusState}
showReportMilestone={false}
/>
</div>
</DialogContent>
</Dialog>
)}
{/* 并行批次头像条(§5.2):消息列表上方,点头像切换主区两列显示的席位。 */}
{isParallelRunning && (
<div className="shrink-0 px-6 py-2 bg-white border-t border-slate-150">
<ParallelSeatStrip
batch={parallelBatch}
selectedSeats={selectedParallelSeats}
onToggleSeat={onToggleParallelSeat}
/>
</div>
)}
<div
ref={step2ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
@@ -260,7 +403,44 @@ export function Step2Panel(props: Step2PanelProps) {
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
</div>
)}
{step2RoundtableDialogues.map((msg) => (
{isParallelRunning && parallelColumns.length > 0 ? (
// 并行进行时:主区固定 2 列(窄屏降 1 列),每列渲染一个被选中席位的消息流。
// 未选中席位的气泡仍在 step2RoundtableDialogues 内存里(只是此刻不渲染),
// 切换头像即可立刻看到其全部输出(§6 全接收/惰性渲染的基础)。
<div
className={
parallelColumns.length > 1
? "grid grid-cols-1 gap-4 lg:grid-cols-2"
: "grid grid-cols-1 gap-4"
}
>
{parallelColumns.map((seat) => {
const msg = dialogueById.get(seat.bubbleId);
return (
<div
key={seat.agentId}
className="flex min-w-0 flex-col rounded-xl border border-slate-200 bg-slate-50/40 p-3"
>
{msg ? (
<Step2Message
msg={msg}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) openArtifactPreview(msg.agentThreadId, path);
}}
/>
) : (
<div className="py-6 text-center text-xs text-slate-400">
{seat.displayName} · 等待输出…
</div>
)}
</div>
);
})}
</div>
) : (
step2RoundtableDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
@@ -272,7 +452,8 @@ export function Step2Panel(props: Step2PanelProps) {
}
}}
/>
))}
))
)}
</div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
</div>
@@ -60,8 +60,11 @@ export const markdownComponents = {
{children}
</a>
),
// 宽表套横向滚动容器,避免超出卡片宽度(同 MessageBubble.sharedMarkdownComponents)。
table: ({ children }: any) => (
<table className="roundtable-chat-message my-2 border-collapse">{children}</table>
<div className="my-2 max-w-full overflow-x-auto">
<table className="roundtable-chat-message border-collapse">{children}</table>
</div>
),
th: ({ children }: any) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
@@ -25,6 +25,12 @@ import {
type StreamPhase,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import {
buildDagStageLeaderTask,
pickDagSeatTask,
type OrchestrationPlan,
} from "@/roundtable-planning/lib/dag-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import {
DEFAULT_SELECTED_AGENTS,
MAX_CYCLES,
@@ -56,6 +62,20 @@ export type Step2Dialogue = {
}>;
};
/**
* 并行批次里的一个席位视图(§5 并行显示)。`runDagOrchestration` 在真并行 stage
* 期间发布整批;头像条 / 两列主区据此渲染,`bubbleId` 指向 `step2RoundtableDialogues`
* 里该席位的流式气泡。
*/
export interface ParallelSeatView {
agentId: string;
displayName: string;
avatarType: string;
status: "running" | "done" | "error";
/** 该席位流式气泡在 step2RoundtableDialogues 里的 id。 */
bubbleId: number;
}
/**
* Stub AI message —— 用于把 special seat SSE 拿到的工具调用注入到 ThreadContext
* 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL
@@ -110,9 +130,11 @@ export type Step2Snapshot = {
* 注:该字段是主页面 state,由页面在 getStep2Snapshot/hydrateStep2 里读写,
* step2 hook 的 hydrateFromDraft 不直接消费它。
*/
orchestrationMode?: "recommend" | "chain";
orchestrationMode?: "recommend" | "chain" | "dag";
/** chain 模式来源链条(仅展示用,如「按业务链条 X 研讨」)。 */
chain?: { id: string; title: string } | null;
/** dag 模式的分层编排计划(见 `lib/dag-types.ts`)。同样是主页面 state,hook 经 option 读取。 */
orchestrationPlan?: OrchestrationPlan | null;
};
/**
@@ -191,11 +213,25 @@ export interface UseStep2OrchestrationOptions {
/**
* 编排模式:
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
* - `chain` —— 总控**按业务链条顺序**逐位派活(`runChainOrchestration`)。
* - `chain` —— 总控**按业务链条顺序**逐位派活(`runChainOrchestration`);
* - `dag` —— **分层 DAG**:前端直派,stage 内并行、stage 间串行(`runDagOrchestration`,
* 需配合 `orchestrationPlan`)。
* 由推荐弹窗确认时的 `SeatSelectionResult.mode` 决定;chain 模式下席位顺序 =
* `selectedAgents` 的顺序。
*/
orchestrationMode: "recommend" | "chain";
orchestrationMode: "recommend" | "chain" | "dag";
/**
* dag 模式的分层编排计划。`orchestrationMode === "dag"` 且本计划非空时,
* `runActiveOrchestration` 走 `runDagOrchestration`;否则回退到 recommend / chain。
*/
orchestrationPlan?: OrchestrationPlan | null;
/**
* dag 执行子模式(仅 `orchestrationMode === "dag"` 生效):
* - `fast`(默认):同 stage 席位并行、主区**双列**、**禁写文件**(seat_parallel);
* - `sandbox`:同 stage 席位仍并行,但主区**单列**、**允许写文件**(seat),沙箱跟随当前
* 显示的席位(切头像 → 沙箱内容随之切换/关闭)。
*/
dagExecMode?: "fast" | "sandbox";
/** 进入 Step 2 时拼装第一轮 leader prompt 的函数(读 Step 1 状态)。 */
buildInitialIntent: () => string;
/** 主页面统一的 toast。 */
@@ -228,6 +264,17 @@ export interface UseStep2OrchestrationReturn {
budgetLimit: number;
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
// ─── 并行显示(§5)────────────────────────────────────────────────────
/** 当前并行批次(非空 = 正在并行 stage)。 */
parallelBatch: ParallelSeatView[];
/** 主区当前并排显示的席位 agentId(最多 2)。 */
selectedParallelSeats: string[];
/** 是否正在并行执行(左侧栏折叠 / 头像条 / 两列显示的总开关)。 */
isParallelRunning: boolean;
/** 点头像切换主区显示的席位(LRU,最多 2)。 */
toggleParallelSeat: (agentId: string) => void;
/** Phase 7 DAG 流程图执行态:agentId(小写) → pending/active/done。非 dag 模式为空。 */
dagNodeStates: Record<string, ChainNodeState>;
// ─── Derived ────────────────────────────────────────────────────────
roleTemplates: ReadonlyArray<Omit<RoleEntry, "status">>;
agentNameToRole: Record<
@@ -297,6 +344,8 @@ export function useStep2Orchestration(
currentStep,
selectedModel,
orchestrationMode,
orchestrationPlan,
dagExecMode = "fast",
buildInitialIntent,
triggerToast,
setRoles,
@@ -364,6 +413,30 @@ export function useStep2Orchestration(
DEFAULT_AGENT_CONFIGS,
);
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(null);
// 并行批次(§5):runDagOrchestration 在真并行 stage 期间发布;空 = 非并行。
const [parallelBatch, setParallelBatch] = useState<ParallelSeatView[]>([]);
// Phase 7 DAG 流程图执行态(§7):agentId(小写) → pending/active/done。
// runDagOrchestration 跨 stage 维护;DagFlowChart 据此实时点亮节点。非 dag 模式 = 空。
const [dagNodeStates, setDagNodeStates] = useState<Record<string, ChainNodeState>>({});
// 主区当前并排显示的席位(最多 2,固定 2 列;窄屏只渲染第 1 个)。
const [selectedParallelSeats, setSelectedParallelSeats] = useState<string[]>([]);
// §6 流式高性能:所有并行席位的增量文本**全量累积进 ref**(O(1),不触发渲染);
// 只有被选中的席位才在节流 flush 时把全文挂到 React state 渲染 Markdown。
// key = bubbleId。
const parallelStreamRef = useRef<Map<number, { text: string; charCount: number }>>(
new Map(),
);
const parallelFlushTimerRef = useRef<number | null>(null);
// flush 时要读「当前选中席位」与「批次 bubbleId↔agentId 映射」—— 打到 ref 避免
// 把 flush 函数绑进会频繁变化的 state 依赖。
const selectedParallelSeatsRef = useRef<string[]>([]);
const parallelBatchRef = useRef<ParallelSeatView[]>([]);
useEffect(() => {
selectedParallelSeatsRef.current = selectedParallelSeats;
}, [selectedParallelSeats]);
useEffect(() => {
parallelBatchRef.current = parallelBatch;
}, [parallelBatch]);
// ─── Derived ──────────────────────────────────────────────────────
const roleTemplates = useMemo(
@@ -381,6 +454,12 @@ export function useStep2Orchestration(
// ─── Refs ────────────────────────────────────────────────────────
const orchestrationCancelRef = useRef<boolean>(false);
// 编排「世代号」。每次启动一轮编排(runOrchestration / runChainOrchestration)就 +1。
// 旧循环捕获自己的 epoch,在每个取消检查点比对 —— 一旦 epoch 变了(说明已有新循环
// 接管,例如人工干预),旧循环立即退出。这能根治「新循环把 orchestrationCancelRef
// 重置为 false 反而把正在退出的旧循环『反取消』,导致旧循环继续又发一个 leader run」
// 的并发双发 bug:epoch 是单调递增的,新循环无法把它『重置』回旧循环的值。
const orchestrationEpochRef = useRef<number>(0);
const step2InitRef = useRef<boolean>(false);
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
/** 干预研审:恢复会商后待执行的 leader prompt。 */
@@ -397,10 +476,29 @@ export function useStep2Orchestration(
const isOrchestratingRef = useRef<boolean>(isOrchestrating);
// chain 编排:模式 + 当前选中席位(=链条顺序)。同样打到 ref,让编排函数 deps 收窄。
const orchestrationModeRef = useRef(orchestrationMode);
const orchestrationPlanRef = useRef<OrchestrationPlan | null>(orchestrationPlan ?? null);
// dag 执行子模式(fast/sandbox) + 沙箱模式下「当前允许显示沙箱的席位 thread」。
// sandbox 模式单列:只有**当前显示**的席位才可自动打开沙箱;后台并行席位写文件不抢占
// (见 openSeatVirtualArtifact 的 auto 守卫)。null = 非 sandbox 模式 / 无限制。
const dagExecModeRef = useRef<"fast" | "sandbox">(dagExecMode);
const sandboxFollowThreadIdRef = useRef<string | null>(null);
// 「上次已 reconcile 的显示席位」键(`${mode}:${seat}`)。沙箱跟随 effect 只在**显示席位真正
// 变化**时开/关沙箱,避免每次 render(deps 身份变化)都强制重开,把用户手动关闭的沙箱又弹回来。
const lastReconciledSeatRef = useRef<string>("");
useEffect(() => {
dagExecModeRef.current = dagExecMode;
}, [dagExecMode]);
// dag 计划「已跑过一遍」闸门:DAG 编排只在初次执行一次计划;之后的用户干预 / 恢复 /
// 澄清回复都改走 **leader 自由派活循环**(runOrchestration),由总控接着派活,而**不是**
// 重新把整张 DAG 从头跑一遍(致命 bug 修复)。新任务 / 重新进入时由 resetInitGate 复位。
const dagPlanExecutedRef = useRef<boolean>(false);
const selectedAgentsRef = useRef<SelectedAgent[]>(selectedAgents);
useEffect(() => {
orchestrationModeRef.current = orchestrationMode;
}, [orchestrationMode]);
useEffect(() => {
orchestrationPlanRef.current = orchestrationPlan ?? null;
}, [orchestrationPlan]);
useEffect(() => {
selectedAgentsRef.current = selectedAgents;
}, [selectedAgents]);
@@ -493,16 +591,46 @@ export function useStep2Orchestration(
* auto=false(用户点击步骤 / 文件卡)始终强制打开。
*/
const openSeatVirtualArtifact = useCallback(
(seatThreadId: string, virtualUrl: string, auto: boolean) => {
(
seatThreadId: string,
virtualUrl: string,
auto: boolean,
opts?: { manual?: boolean },
) => {
// manual=true → 视为「用户手动接管」,会把全局 autoSelect 置 false(尊重用户后续不再
// 自动选/自动开)。默认沿用旧语义:auto=false 即手动点击(文件卡 / 步骤)。
// 但**系统程序化打开**(如沙箱跟随 openSeatLatestArtifact)必须传 manual:false ——
// 否则会误关 autoSelect,毒化随后实时 write_file 的 auto=true 自动打开,表现为
// 「单个智能体发言时沙箱不自动打开、只能手动开」(763eb4d 沙箱跟随引入的回归)。
const manual = opts?.manual ?? !auto;
if (auto) {
if (hasAutoOpenedRef.current.has(virtualUrl)) return;
const flags = autoOpenFlagsRef.current;
if (!flags.autoOpen || !flags.autoSelect) return;
// 快速模式(双列)DAG 并行批次:**完全不**自动开沙箱 —— 双列只看正文,多席位并发写
// 文件(现已放开 write_file)会让单沙箱预览反复串台。文件仍写盘,Step3 / 文件卡可查看。
if (
orchestrationModeRef.current === "dag" &&
dagExecModeRef.current === "fast" &&
parallelBatchRef.current.length > 0
) {
return;
}
// sandbox 单列模式:并行的**后台**席位写文件时不要抢占沙箱 —— 只有当前显示的
// 席位(sandboxFollowThreadIdRef)才允许自动打开,否则沙箱内容会和主区列表错位。
if (
dagExecModeRef.current === "sandbox" &&
sandboxFollowThreadIdRef.current &&
sandboxFollowThreadIdRef.current !== seatThreadId
) {
return;
}
hasAutoOpenedRef.current.add(virtualUrl);
}
setActiveArtifactThreadId(seatThreadId);
const open = () => {
selectArtifact(virtualUrl, auto);
// select 的第 2 参 = autoSelect:true 表示「自动选,不毒化」;只有真正手动接管才传 false。
selectArtifact(virtualUrl, !manual);
setArtifactsOpen(true);
};
if (auto) {
@@ -524,6 +652,34 @@ export function useStep2Orchestration(
[],
);
/**
* sandbox 模式「沙箱跟随」用:打开某席位 thread **最新**写入文件的虚拟预览(强制 auto=false)。
* 从该 thread 的 stub messages 里倒序找第一个 write_file / str_replace 的 path。
* 无产物返回 false(调用方据此关闭沙箱)。
*/
const openSeatLatestArtifact = useCallback(
(seatThreadId: string): boolean => {
const stub = seatStubMessagesRef.current[seatThreadId]?.[0];
if (!stub) return false;
for (let i = stub.tool_calls.length - 1; i >= 0; i -= 1) {
const tc = stub.tool_calls[i];
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const path =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
if (!path) continue;
const virtualUrl = buildWriteFileVirtualUrl(path, stub.id, tc.id);
// manual:false —— 沙箱跟随是系统行为,强制打开但**不**关闭 autoSelect(否则毒化
// 后续实时自动打开,见 openSeatVirtualArtifact 注释)。
openSeatVirtualArtifact(seatThreadId, virtualUrl, false, { manual: false });
return true;
}
return false;
},
[buildWriteFileVirtualUrl, openSeatVirtualArtifact],
);
/**
* 反查某个产出文件是由哪个席位 thread 写入的。
*
@@ -882,6 +1038,120 @@ export function useStep2Orchestration(
);
}, []);
/** 整段替换某气泡正文(§6 flush:把 ref 里累积的全文一次性挂上去)。 */
const setDialogueContent = useCallback((id: number, fullText: string) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, content: fullText } : d)),
);
}, []);
// ─── §6 并行流式:全接收 / 惰性渲染 / 节流 ───────────────────────────────
/**
* 节流 flush(~80ms 一拍,约 12fps):**仅被选中**的席位把 ref 累积的全文挂到
* React state 渲染 Markdown(≤2 个)。未选中席位只在 ref 里累积、不挂 DOM —— 6+
* 并行也不卡。切到它时下拍即显全文(再叠加选中变化 effect 的即时挂载)。
*/
const flushParallelStreams = useCallback(() => {
parallelFlushTimerRef.current = null;
const store = parallelStreamRef.current;
if (store.size === 0) return;
const selected = new Set(selectedParallelSeatsRef.current);
for (const seat of parallelBatchRef.current) {
if (selected.has(seat.agentId)) {
const entry = store.get(seat.bubbleId);
if (entry) setDialogueContent(seat.bubbleId, entry.text);
}
}
}, [setDialogueContent]);
const scheduleParallelFlush = useCallback(() => {
if (parallelFlushTimerRef.current !== null) return;
parallelFlushTimerRef.current = window.setTimeout(flushParallelStreams, 80);
}, [flushParallelStreams]);
/** 并行席位的 onTextDelta:全量累积进 ref(O(1)),再调度一次节流 flush。 */
const ingestParallelDelta = useCallback(
(bubbleId: number, text: string) => {
if (!text) return;
const store = parallelStreamRef.current;
const cur = store.get(bubbleId) ?? { text: "", charCount: 0 };
cur.text += text;
cur.charCount = cur.text.length;
store.set(bubbleId, cur);
scheduleParallelFlush();
},
[scheduleParallelFlush],
);
/** 清空并行流式 store + 取消挂起的 flush(stage 结束 / 抢占 / 异常兜底)。 */
const resetParallelStreams = useCallback(() => {
parallelStreamRef.current = new Map();
if (parallelFlushTimerRef.current !== null) {
window.clearTimeout(parallelFlushTimerRef.current);
parallelFlushTimerRef.current = null;
}
}, []);
// 卸载兜底:清掉可能挂起的 flush 定时器。
useEffect(() => resetParallelStreams, [resetParallelStreams]);
// 选中变化 → 立刻把 ref 里已累积的全文挂给**新选中**的席位(切头像即显全部输出,
// 证明流没丢、只是之前没渲染,§6.5)。
useEffect(() => {
const store = parallelStreamRef.current;
if (store.size === 0) return;
const selected = new Set(selectedParallelSeats);
for (const seat of parallelBatchRef.current) {
if (selected.has(seat.agentId)) {
const entry = store.get(seat.bubbleId);
if (entry) setDialogueContent(seat.bubbleId, entry.text);
}
}
}, [selectedParallelSeats, setDialogueContent]);
// sandbox 单列模式「沙箱跟随」(防 bug 重点,用户特别强调):切换当前显示的席位时,
// 沙箱内容必须等于该席位的内容 —— 有产物则打开它的沙箱,无产物则关闭沙箱面板。
// 并把「当前允许显示沙箱的席位 thread」记到 sandboxFollowThreadIdRef,供 openSeatVirtualArtifact
// 的 auto 守卫拦掉后台并行席位的抢占(见该函数)。非 sandbox 模式 / 无选中时清空,不干预沙箱。
useEffect(() => {
const seat = dagExecMode === "sandbox" ? selectedParallelSeats[0] ?? "" : "";
// 只在「显示席位真正变化」时 reconcile。否则:本 effect 的 deps(openSeatLatestArtifact /
// setArtifactsOpen 等)身份每次 render 都可能变 → effect 反复跑 → 把用户刚手动关闭的沙箱
// 又强制重开,表现为「关闭按钮关不掉」。用 key 守卫后,用户关闭(席位没变)时直接早退。
const key = `${dagExecMode}:${seat}`;
if (lastReconciledSeatRef.current === key) return;
lastReconciledSeatRef.current = key;
if (dagExecMode !== "sandbox" || !seat) {
sandboxFollowThreadIdRef.current = null;
return;
}
const ids = threadIdsRef.current;
if (!ids) {
sandboxFollowThreadIdRef.current = null;
return;
}
const tid = resolveSeatThreadId(ids, seat);
sandboxFollowThreadIdRef.current = tid ?? null;
if (!tid) return;
const opened = openSeatLatestArtifact(tid);
if (!opened) {
// 当前显示席位**暂时**还没有沙箱产物(常见:并行批次第一个席位刚选中、还没写文件)→
// 关闭沙箱面板,但**只用 deselect**(它走 raw setOpen(false),**不**关掉 autoOpen);
// 千万别再调 setArtifactsOpen(false) —— 那个 wrapped setter 会把 autoOpen 置 false
// (context.tsx:73),导致该席位**随后 live 写文件时 openSeatVirtualArtifact 的
// `!autoOpen` 守卫拦掉自动打开**(正是「第一个席位不自动开沙箱、要手动开」的 bug)。
setActiveArtifactThreadId(null);
deselectArtifact();
}
}, [
selectedParallelSeats,
dagExecMode,
resolveSeatThreadId,
openSeatLatestArtifact,
deselectArtifact,
]);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setStep2RoundtableDialogues((prev) =>
@@ -969,6 +1239,43 @@ export function useStep2Orchestration(
);
}, [setRoles]);
/**
* DAG 并行批次:把**一整批**席位同时标为「研讨中」(与 markSpeakerStreaming
* 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"清成"在线"。
* activeSpeakerSet 天然是 Set,这里放整批 speakerId。
*/
const markParallelSpeakers = useCallback(
(speakerIds: number[]) => {
const wanted = new Set(speakerIds);
setActiveSpeakerSet(new Set(speakerIds));
setRoles((prev) =>
prev.map((item) => {
if (wanted.has(item.id)) return { ...item, status: "研讨中" };
if (item.status === "研讨中") return { ...item, status: "在线" };
return item;
}),
);
},
[setRoles],
);
/**
* 点头像切换主区显示的席位(§5.3):已选中→取消;未选中→加入,超过 2 个按 LRU
* 踢掉最早选中的。被换下的席位停止渲染但流仍在内存累积(气泡始终在列表里)。
*/
const toggleParallelSeat = useCallback((agentId: string) => {
setSelectedParallelSeats((prev) => {
// sandbox 单列:点头像 = 切换到该席位(始终保留 1 个,点已选中的不取消到空)。
if (dagExecModeRef.current === "sandbox") {
return prev.length === 1 && prev[0] === agentId ? prev : [agentId];
}
// fast 双列:点已选中 → 取消;未选中 → 加入,超过 2 个按 LRU 踢最早的。
if (prev.includes(agentId)) return prev.filter((id) => id !== agentId);
const next = [...prev, agentId];
return next.length > 2 ? next.slice(next.length - 2) : next;
});
}, []);
// ─── Abort ────────────────────────────────────────────────────────
const abortAllInFlight = useCallback(() => {
activeAbortersRef.current.forEach((ctrl) => {
@@ -993,17 +1300,26 @@ export function useStep2Orchestration(
const runOrchestration = useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
// **同步**抢占编排锁。isOrchestratingRef 平时由 [isOrchestrating] 的 effect 异步
// 回写(见上方),滞后一拍;若只靠它,人工干预里 setIsRoundTableRunning(true) 触发的
// 「恢复/干预」effect 会在 ref 尚未变 true 的同一 tick 内再调一次本函数 —— 两个 leader
// run 并发打到同一总控 thread(rollback 抢占 → 截断的那个旧报 502、存活的那个重复出现)。
// 这里立刻把 ref 置 true,让重入守卫即时生效,堵住二次触发。
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
// 认领本轮 epoch。任何更晚启动的循环都会让 epoch 自增 → 本循环 isStale() 立即为真。
const myEpoch = (orchestrationEpochRef.current += 1);
// 单一「该退出了吗」判定:被显式取消,**或**已被更新的循环接管(epoch 变了)。
// 后者专治新循环重置 orchestrationCancelRef=false 把旧循环反取消的并发双发。
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
let nextLeaderTask = seedMessage;
let safetyCounter = 0;
try {
while (
!orchestrationCancelRef.current &&
safetyCounter < MAX_CYCLES
) {
while (!isStale() && safetyCounter < MAX_CYCLES) {
safetyCounter += 1;
// Leader(协调智能体)正在派活,**不应该**让任何 sub agent 显示"研讨中"——
// 协调者本身不在右侧"参与角色"面板里(COMMANDER_SEAT_ID=1 是人类主持人,
@@ -1034,6 +1350,9 @@ export function useStep2Orchestration(
dAgentThreadId: ids,
agentName: coord,
newMessage: nextLeaderTask,
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
// 首轮纯派活、无交付,传 false 保持上下文精简。
synthesisMode: safetyCounter > 1,
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
@@ -1064,7 +1383,7 @@ export function useStep2Orchestration(
}
activeAbortersRef.current.delete(leaderAborter);
if (orchestrationCancelRef.current) break;
if (isStale()) break;
if (leaderRes.clarification) {
finalizeDialogue(
@@ -1101,7 +1420,7 @@ export function useStep2Orchestration(
// **顺序**派活 —— 不要改回 Promise.all,详见 §5.4。
for (const [subName, task] of leaderRes.dispatched as LeaderDispatch[]) {
if (orchestrationCancelRef.current) break;
if (isStale()) break;
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
if (meta) markSpeakerStreaming(meta.speakerId);
const perSeatModel = meta
@@ -1149,7 +1468,7 @@ export function useStep2Orchestration(
onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, seatThreadId),
});
if (orchestrationCancelRef.current) {
if (isStale()) {
finalizeDialogue(
subBubbleId,
specRes.content || "(已停止)",
@@ -1181,7 +1500,7 @@ export function useStep2Orchestration(
if (aborted) break;
}
if (orchestrationCancelRef.current) break;
if (isStale()) break;
setConsensusPercentage((prev) =>
Math.min(95, parseFloat((prev + 8).toFixed(1))),
@@ -1229,8 +1548,15 @@ export function useStep2Orchestration(
const runChainOrchestration = useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
// 同步抢占编排锁(原因同 runOrchestration):堵住人工干预时 isRoundTableRunning
// toggle effect 在同一 tick 内的二次触发,避免并发双 leader run。
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
// epoch 接管:新循环启动即自增,旧链条循环据此退出(详见 runOrchestration / 声明处注释)。
const myEpoch = (orchestrationEpochRef.current += 1);
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
const chainSeats = selectedAgentsRef.current;
const total = chainSeats.length;
@@ -1239,7 +1565,7 @@ export function useStep2Orchestration(
try {
for (let i = 0; i < total; i += 1) {
if (orchestrationCancelRef.current) {
if (isStale()) {
finishedAllSeats = false;
break;
}
@@ -1295,7 +1621,7 @@ export function useStep2Orchestration(
throw err;
}
activeAbortersRef.current.delete(leaderAborter);
if (orchestrationCancelRef.current) {
if (isStale()) {
finalizeDialogue(leaderBubbleId, leaderRes.content || "(已停止)", "已停止");
finishedAllSeats = false;
break;
@@ -1357,7 +1683,7 @@ export function useStep2Orchestration(
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
});
if (orchestrationCancelRef.current) {
if (isStale()) {
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
aborted = true;
} else {
@@ -1384,7 +1710,7 @@ export function useStep2Orchestration(
}
// ── (c) 总控收口:综合全链交付,产出最终结论 ──
if (!orchestrationCancelRef.current && finishedAllSeats && total > 0) {
if (!isStale() && finishedAllSeats && total > 0) {
highlightSpeaker(null);
const closeoutBubbleId = appendStreamingDialogue({
sender: "总控协调",
@@ -1402,6 +1728,8 @@ export function useStep2Orchestration(
agentName: coord,
newMessage:
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。",
// 链条收口就是综合轮:让后端注入各席位完整交付正文。
synthesisMode: true,
model: selectedModelRef.current || undefined,
signal: closeoutAborter.signal,
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
@@ -1456,13 +1784,366 @@ export function useStep2Orchestration(
],
);
// ─── DAG orchestration loop ───────────────────────────────────────
// dag 模式(§3 + §3.2 方案 A,2026-06-07 改成「总控驱动」):分层执行 —— stage 内**并行**
// (Promise.all)、stage 间**串行**(Promise.all 即 barrier)。每个 stage 的流程与串行同构:
// 1) **总控派活轮**:由【总控】对本 stage 计划钉死的席位**真正派活**(buildDagStageLeaderTask
// 约束它只派给这些席位、且每个都派到;不自由乱派、不收口)。总控本轮也"承上"——后端
// _build_seat_status_block 走轻量 last-ai-message 把此前各席位真实交付(摘要)注入其输入
// (§传摘要按需传全文);
// 2) **批内并行**:本 stage 席位并行跑总控派给各自的 task(pickDagSeatTask 命中其 agent_name,
// 漏派则回退总控正文/模板,不借用别席位 task);完成后交付经后端 detached 广播回总控;
// 3) barrier 后进入下一 stage,总控再派下一批(它已能看到上一批交付摘要)。
// 所有 stage 跑完后,把锁交回 runOrchestration 由总控综合 / 收口(此时 synthesisMode 才按需
// 注入各席位**完整交付正文**——全文只在收口这一轮付出)。
//
// 与「前端直派(方案 B)」的区别:每 stage 都经总控决策派活(像串行/链条那样),计划只钉死
// 「派给谁」、总控负责「派什么活 + 承上启下」。与 chain 模式同构,区别是一次钉死一批、批内并行。
//
// 与 runOrchestration / runChainOrchestration 的「不要 Promise.all」不变量不同 ——
// DAG 并行就是要 Promise.all;N 路 token 级 setState 的卡顿由 Phase 6(全接收 /
// 惰性渲染 / 节流)解决,且单 stage 并行度有 warning 上限(PARALLEL_DEGREE_WARN_THRESHOLD)。
const runDagOrchestration = useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
const plan = orchestrationPlanRef.current;
if (!plan || plan.stages.length === 0) {
triggerToast("⚠️ 没有可执行的 DAG 编排计划");
return;
}
if (isOrchestratingRef.current) return;
// 同步抢占编排锁(原因同 runOrchestration):堵住人工干预 / 恢复 toggle 在同一
// tick 内的二次触发,避免并发双发。
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
const myEpoch = (orchestrationEpochRef.current += 1);
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
// 标记 DAG 计划已开始执行:此后任何 runActiveOrchestration(干预 / 恢复 / 澄清)
// 都改走 leader 循环,不再重跑整张 DAG。
dagPlanExecutedRef.current = true;
const leaderThreadId = resolveSeatThreadId(ids, coord);
let finishedAllStages = true;
// 跨 stage 累积的**完整交付** {name, content}[] —— 注入下一 stage 席位的 run context
// (peerDeliveries),供 read_peer_delivery 工具按需取全文(§12.9 传摘要按需传全文)。
const priorDeliveries: Array<{ name: string; content: string }> = [];
// Phase 7:初始化 DAG 流程图节点状态 —— 计划内所有席位置 pending,执行过程中逐个点亮。
const markDagNode = (agentId: string, state: ChainNodeState) =>
setDagNodeStates((prev) => ({ ...prev, [agentId.toLowerCase()]: state }));
setDagNodeStates(
Object.fromEntries(
plan.stages.flatMap((st) => st.agentIds.map((id) => [id.toLowerCase(), "pending" as ChainNodeState])),
),
);
try {
for (let s = 0; s < plan.stages.length; s += 1) {
if (isStale()) {
finishedAllStages = false;
break;
}
const stage = plan.stages[s];
const isParallel = stage.agentIds.length > 1;
// 本 stage 计划钉死的席位元数据(供派活轮 + 并行执行共用)。
const stageSeats = stage.agentIds.map((agentId) => {
const meta = agentNameToRoleRef.current[agentId.toLowerCase()];
return { agentId, meta, name: meta?.displayName ?? agentId };
});
// ── (a) 总控派活轮(§3.2 方案 A + 批内并行):由【总控】对本 stage 计划钉死的
// 席位**真正派活**(约束只派给这些席位、且每个都派到),而不是前端直发。总控本轮
// 也"承上":后端 _build_seat_status_block 已把此前各席位真实交付(摘要)注入其输入
// (传摘要按需传全文)。它不自由乱派、不收口。与 chain 同构,区别是一次钉死一批、批内并行。
highlightSpeaker(null);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: s === 0 ? "派活中" : "承上派活中",
agentThreadId: leaderThreadId,
});
const leaderTask = buildDagStageLeaderTask({
seedMessage,
stageIndex: s,
totalStages: plan.stages.length,
seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })),
isFirst: s === 0,
goal: stage.goal, // 业务链条里为本阶段写的「本阶段目标」(可空)
coordinatorPrompt: plan.coordinatorPrompt, // 写给总控的整体派活指引(可空,每阶段都注入)
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderRes;
try {
leaderRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
// 派活轮:传 false,后端只注入各席位摘要;完整交付正文留给末尾收口的 synthesis 轮。
synthesisMode: false,
// 抑制预广播:别把总控的派活 prompt 播进席位 thread(否则弱模型从历史里捡到派活
// 意图、跟着模仿派活 / 绕道 bash 调内部接口)。席位任务由 pickDagSeatTask 干净下发。
suppressPreBroadcast: true,
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, "(已停止)", "已停止");
finishedAllStages = false;
break;
}
throw err;
}
activeAbortersRef.current.delete(leaderAborter);
if (isStale()) {
finalizeDialogue(leaderBubbleId, leaderRes.content || "(已停止)", "已停止");
finishedAllStages = false;
break;
}
// 派活轮也可能要求澄清 → 暂停 DAG(回复后走 leader 自由派活、不重跑 DAG)。
if (leaderRes.clarification) {
finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
setPendingClarification(leaderRes.clarification);
setClarificationDraft("");
triggerToast("❓ 总控需要补充信息,请回答下方问题后继续");
finishedAllStages = false;
break;
}
finalizeDialogue(leaderBubbleId, leaderRes.content, "派活说明");
if (leaderRes.content) setLastLeaderContent(leaderRes.content);
// ── (b) 本 stage 席位【并行】执行总控下发的 task ──
// 预备各席位气泡(在 Promise.all 之前同步建好,这样并行显示(§5)的头像条/两列
// 能立刻拿到每个席位的 bubbleId 去渲染对应消息流)。
const seatPrep = stageSeats.map(({ agentId, meta, name }) => {
const avatarType = meta?.avatarType ?? "blue";
const seatThreadId = resolveSeatThreadId(ids, agentId);
const bubbleId = appendStreamingDialogue({
sender: name,
avatarType,
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
return { agentId, meta, seatName: name, avatarType, seatThreadId, bubbleId };
});
// 整批高亮(并行)。单节点 stage 退化为高亮一个,行为与串行一致。
markParallelSpeakers(
seatPrep.map((p) => p.meta?.speakerId).filter((v): v is number => typeof v === "number"),
);
// Phase 7:本 stage 席位置 active(流程图点亮当前列)。
for (const p of seatPrep) markDagNode(p.agentId, "active");
// 并行 stage(≥2 席位)才发布「并行批次」给前端头像条 + 主区显示。
// fast(双列)默认选中最先两个;sandbox(单列)只选最先一个,点头像切换。
if (isParallel) {
setParallelBatch(
seatPrep.map((p) => ({
agentId: p.agentId,
displayName: p.seatName,
avatarType: p.avatarType,
status: "running" as const,
bubbleId: p.bubbleId,
})),
);
const cols = dagExecModeRef.current === "sandbox" ? 1 : 2;
setSelectedParallelSeats(seatPrep.slice(0, cols).map((p) => p.agentId));
}
const markBatchStatus = (agentId: string, status: ParallelSeatView["status"]) =>
setParallelBatch((prev) =>
prev.map((p) => (p.agentId === agentId ? { ...p, status } : p)),
);
// ── 批内并行:对 stage 内每个席位发 special run,跑总控本轮派给它的 task ──
// 每个席位独立 try/catch 兜底,保证**单席位失败不拖垮整 stage**(§9):
// 任务函数永不 reject → Promise.all 不会因一个席位异常整体崩。
const stageResults = await Promise.all(
seatPrep.map(async ({ agentId, meta, seatName, seatThreadId, bubbleId }): Promise<{ name: string; content: string } | null> => {
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
// 用总控本轮派给该席位的 task(命中其 agent_name);总控没用工具只写文字时,
// pickDagSeatTask 给干净本职任务、**不**注入总控派活叙述(根治「席位模仿派活」)。
// 带上 seedMessage:预广播已抑制,席位 thread 里没有用户原始消息,靠 task 补背景。
const task = pickDagSeatTask(
leaderRes.dispatched,
agentId,
seatName,
seedMessage,
priorDeliveries.length > 0,
dagExecModeRef.current === "sandbox", // 仅沙箱模式要求写 .md 文件;快速模式产物走正文
stage.goal, // 兜底注入「本阶段目标」(总控没派好时席位仍据此对齐)
);
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: agentId,
displayName: seatName,
newMessage: task,
// 写文件只在 **沙箱模式**(seat policy)开放;**快速模式一律禁写文件**(seat_parallel,
// 产物走正文,§12.10 用户要求 fast 不生成文件)。与 stage 是否并行无关:fast 单节点
// stage 也禁写(保持 fast=无文件的一致体验)。
parallelNoFile: dagExecModeRef.current !== "sandbox",
// 前序各阶段的完整交付 → 供本席位 read_peer_delivery 按需取全文(§12.9)。
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
signal: subAborter.signal,
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
onTextDelta: (text) =>
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
});
if (isStale()) {
finalizeDialogue(bubbleId, specRes.content || "(已停止)", "已停止");
markBatchStatus(agentId, "done");
return null;
}
finalizeDialogue(bubbleId, specRes.content, "子智能体交付");
markBatchStatus(agentId, "done");
return specRes.content?.trim() ? { name: seatName, content: specRes.content } : null;
} catch (err) {
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(bubbleId, "(已停止)", "已停止");
markBatchStatus(agentId, "done");
} else {
console.error(`[dag-special:${agentId}] error:`, err);
finalizeDialogue(bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
markBatchStatus(agentId, "error");
}
return null;
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
// Phase 7:席位到终态(成功/失败/抢占)→ 流程图节点打勾。ChainNodeState 无
// error 态,失败也归 done(节点已结束);失败细节体现在气泡正文。
markDagNode(agentId, "done");
// 终态后从 ref store 删除,节流 flush 不再覆盖已 finalize 的正文。
parallelStreamRef.current.delete(bubbleId);
}
}),
);
// 本 stage 结束(barrier 到达):收起并行批次,回到普通流式列表 + 清流式 store。
if (isParallel) {
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
}
// Promise.all 即 barrier:本 stage 全部终态后才进入下一 stage(批间串行)。
// 下一 stage 的总控派活轮会"承上"(后端注入本批真实交付摘要),无需前端再拼摘要。
if (isStale()) {
finishedAllStages = false;
break;
}
// 累积本 stage 的**完整交付**,供后续 stage 席位 read_peer_delivery 按需取全文(§12.9)。
for (const r of stageResults) {
if (r && r.content?.trim()) priorDeliveries.push(r);
}
setConsensusPercentage(
Math.min(95, Math.round(((s + 1) / plan.stages.length) * 90)),
);
}
// ── 计划阶段全部完成 → **交给总控(leader 自由派活循环)继续** ──
// 关键修复:并行/各阶段输出完后,一定要回到总控,由它综合并**按需继续派活 / 收口**,
// 而不是停在一次性 synthesis。这里把 DAG 的编排锁释放,委托给 runOrchestration
// (它有自己的重入守卫 + epoch + 收敛逻辑)。后续用户干预 / 恢复同样走 leader
// (见 runActiveOrchestration 的 dagPlanExecutedRef 闸门),不再重跑整张 DAG。
if (!isStale() && finishedAllStages && plan.finalSynthesis) {
highlightSpeaker(null);
isOrchestratingRef.current = false; // 释放锁,让 runOrchestration 即刻接管
setIsOrchestrating(false);
await runOrchestration(
"各阶段席位已按依赖编排完成交付。请你作为总控,综合各席位的交付:若仍需补充则继续派活,否则直接输出最终方案并收口。",
ids,
coord,
);
return;
} else if (!isStale() && finishedAllStages) {
// 不收口(plan.finalSynthesis=false):全部 stage 跑完即视为完成,不再叫总控。
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast("✅ 依赖编排各阶段已全部完成");
}
} catch (err) {
console.error("dag orchestration error:", err);
triggerToast(`❌ 依赖编排研讨异常: ${(err as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
// 兜底收起并行批次(正常路径已在 stage 结束时清空,异常 / 抢占时这里保证不残留)。
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
setIsOrchestrating(false);
}
},
[
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
highlightSpeaker,
markParallelSpeakers,
markSpeakerIdle,
clearAllStreamingSpeakers,
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
resolveSeatThreadId,
ingestParallelDelta,
resetParallelStreams,
runOrchestration,
],
);
// 按当前模式分发到对应编排函数。init / 恢复 / 干预 / 澄清回复统一走这里。
const runActiveOrchestration = useCallback(
(seedMessage: string, ids: ThreadIdMap, coord: string) =>
orchestrationModeRef.current === "chain"
? runChainOrchestration(seedMessage, ids, coord)
: runOrchestration(seedMessage, ids, coord),
[runOrchestration, runChainOrchestration],
(seedMessage: string, ids: ThreadIdMap, coord: string) => {
const mode = orchestrationModeRef.current;
// dag:**只在计划尚未跑过时**执行整张 DAG;一旦跑过(用户干预 / 恢复 / 澄清回复),
// 改走 leader 自由派活循环,由总控接着派活,而不是把 DAG 从头重跑一遍。
if (
mode === "dag" &&
!dagPlanExecutedRef.current &&
(orchestrationPlanRef.current?.stages.length ?? 0) > 0
) {
return runDagOrchestration(seedMessage, ids, coord);
}
if (mode === "chain") {
return runChainOrchestration(seedMessage, ids, coord);
}
return runOrchestration(seedMessage, ids, coord);
},
[runOrchestration, runChainOrchestration, runDagOrchestration],
);
// ─── Effects ─────────────────────────────────────────────────────
@@ -1604,7 +2285,7 @@ export function useStep2Orchestration(
content: `💡 (人工指令介入) ${userText}`,
confidence: "最高指令",
});
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
triggerToast("干预指令已记录,会商已自动恢复执行");
}, [appendDialogue, customAgentInput, triggerToast]);
@@ -1631,7 +2312,7 @@ export function useStep2Orchestration(
confidence: "最高指令",
});
const prompt = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
const prompt = `用户人工干预新指令:${userText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
// 确保处于运行状态(同步更新按钮 UI)
setIsRoundTableRunning(true);
@@ -1739,6 +2420,8 @@ export function useStep2Orchestration(
const resetInitGate = useCallback(() => {
step2InitRef.current = false;
// 新一轮研讨:允许 DAG 计划重新执行一遍。
dagPlanExecutedRef.current = false;
setInitNonce((n) => n + 1);
}, []);
@@ -1794,6 +2477,10 @@ export function useStep2Orchestration(
setLastLeaderContent("");
setHasConsensus(false);
setConsensusPercentage(0);
setParallelBatch([]);
setSelectedParallelSeats([]);
setDagNodeStates({});
resetParallelStreams();
setActiveArtifactThreadId(null);
artifactPathsRef.current = [];
setArtifacts([]);
@@ -1801,9 +2488,10 @@ export function useStep2Orchestration(
setSeatStubMessages({});
hasAutoOpenedRef.current = new Set();
pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false;
deselectArtifact();
step2InitRef.current = false;
}, [abortAllInFlight, deselectArtifact, setArtifacts]);
}, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]);
return {
selectedAgents,
@@ -1823,6 +2511,11 @@ export function useStep2Orchestration(
activeSpeakerSet,
customAgentInput,
isUpperPanelCollapsed,
parallelBatch,
selectedParallelSeats,
isParallelRunning: parallelBatch.length > 0,
toggleParallelSeat,
dagNodeStates,
budgetLimit,
agentConfigs,
selectedAgentId,
@@ -5,9 +5,12 @@
* (`/api/roundtable-chains`,按登录用户隔离)。在 Step 2 圆桌研讨里,总控会**按
* 链条顺序逐个派活**(见 `docs/multi-agent-business-chain-dev.md` §6)。
*
* 这里只放纯类型 / 常量,不含 React 状态或请求逻辑(CRUD 在 `api/chains.ts`)。
* 这里放纯类型 / 常量,以及不依赖 React 的纯函数(如 `buildChainSeatSelection`);
* 不含状态或请求逻辑(CRUD 在 `api/chains.ts`)。
*/
import { buildPlanFromStages } from "./dag-types";
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
export interface ChainSeat {
agent_id: string;
@@ -27,27 +30,114 @@ export interface BusinessChain {
description?: string;
/** 有序席位列表。 */
seats: ChainSeat[];
/**
* 可选「分层编排」:把 `seats` 分成若干阶段(stage),每个阶段内的 agent_id
* 并行、阶段之间串行。如 `[["a","b"],["c"]]` = (a‖b) → c。缺省 / null = 线性链
* (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。
*/
stages?: string[][] | null;
/**
* 可选「每阶段目标」:与 `stages` **同下标对齐**(`stageGoals[i]` 是 `stages[i]` 的
* 目标),告诉总控本阶段要产出什么、如何依赖上一阶段产出。仅 dag 模式注入;`stages`
* 为 null(线性)时本字段也为 null。某位为 null / 空 = 该阶段不带目标。
*/
stageGoals?: (string | null)[] | null;
/**
* 可选「总控编排提示」:用户写给**总控(协调)智能体**的一段指引,告诉总控该怎么派活、
* 做什么(整体编排策略 / 风格 / 约束)。**仅 dag 模式生效**,注入总控每阶段派活提示。
* 空 / null = 不注入。
*/
coordinatorPrompt?: string | null;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
isMine?: boolean;
createdAt?: string;
updatedAt?: string;
}
/**
* 推荐弹窗(席位来源选择器)确认后回传给主页面的统一结果。
* 两种模式(智能推荐 / 业务链条)最终都产出**有序**的 `agents` + 一个 `mode`。
* 三种模式(智能推荐 / 业务链条线性 / 业务链条分层 DAG)最终都产出**有序**的
* `agents` + 一个 `mode`。
*/
export interface SeatSelectionResult {
/** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。 */
/** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。 */
agents: Array<{ agent_id: string; name: string }>;
/** 决定 Step 2 走哪种编排:自由派活 vs 按链条顺序派活。 */
mode: "recommend" | "chain";
/** chain 模式带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
/**
* 决定 Step 2 走哪种编排:
* - `recommend` 总控自由派活;
* - `chain` 按链条顺序逐位派活;
* - `dag` 分层并行(来源链条带 `stages` 时)。
*/
mode: "recommend" | "chain" | "dag";
/** chain / dag 模式带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
chain?: Pick<BusinessChain, "id" | "title">;
/** dag 模式带上由链条 `stages` 构造的执行计划(见 `lib/dag-types.ts`)。 */
plan?: import("./dag-types").OrchestrationPlan;
/**
* dag 模式的执行子模式(仅 `mode === "dag"` 有意义):
* - `fast`(快速):同 stage 席位**并行**、主区**双列**显示、**禁写文件**(seat_parallel);
* - `sandbox`(沙箱):同 stage 席位仍**并行**,但主区**单列**显示、**允许写文件**(seat),
* 沙箱跟随当前显示的席位(点头像切换,沙箱内容随之切换/关闭)。
* 缺省 `fast`。
*/
dagExecMode?: "fast" | "sandbox";
}
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
export const CHAIN_MIN_SEATS = 2;
export const CHAIN_MAX_SEATS = 8;
/**
* 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。
*
* 与 `BusinessChainPicker.handleUse` 同源逻辑,抽成纯函数供「自动选链」复用
* (会商自动化串联:从任务页带 chainId 进圆桌时跳过选链弹窗,直接据此进 Step 2):
* - 仅保留候选池里仍存在的存活席位;存活不足 `CHAIN_MIN_SEATS` 返回 null(调用方回退弹窗);
* - 链条带分层、过滤存活后仍含并行 stage / 任一阶段目标 / 总控提示 → 走 `dag`(默认 fast);
* - 否则走线性 `chain`,席位顺序 = 链条顺序。
*
* `candidates` 为 `{ agent_id, name }` 列表(一般来自 `filterRecommendCandidates(listAgents())`)。
*/
export function buildChainSeatSelection(
chain: BusinessChain,
candidates: ReadonlyArray<{ agent_id: string; name: string }>,
): SeatSelectionResult | null {
const existingIds = new Set(candidates.map((c) => c.agent_id));
const nameById = new Map(candidates.map((c) => [c.agent_id, c.name]));
const seatNameById = new Map(chain.seats.map((s) => [s.agent_id, s.name]));
const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id;
const survivingSeats = chain.seats.filter((s) => existingIds.has(s.agent_id));
if (survivingSeats.length < CHAIN_MIN_SEATS) return null;
const chainMeta = { id: chain.id, title: chain.title };
if (chain.stages && chain.stages.length > 0) {
const surviveIds = survivingSeats.map((s) => s.agent_id);
const plan = buildPlanFromStages(
chain.stages,
surviveIds,
true,
chain.stageGoals,
chain.coordinatorPrompt,
);
const hasParallel = plan.stages.some((st) => st.agentIds.length > 1);
const hasGoal = plan.stages.some((st) => (st.goal?.trim() ?? "") !== "");
const hasCoord = (plan.coordinatorPrompt?.trim() ?? "") !== "";
if (hasParallel || hasGoal || hasCoord) {
const agents = plan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id) }));
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast" };
}
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
return { agents, mode: "chain", chain: chainMeta };
}
/**
* 把 `getAvatarTypeFor()` 产出的头像配色(blue/purple/emerald/amber/pink/human)
* 映射成 tag chip 的 tailwind 类(背景 + 文字 + 边框,含深色变体)。
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
addEmptyStage,
addSeatAsNewStage,
addSeatToStage,
chainToStageGroups,
countSeats,
deleteStage,
findStageIndexOfSeat,
moveSeatToStage,
moveStage,
removeSeat,
setStageGoal,
splitSeatToNewStage,
stageGroupsToSeats,
stageGroupsToStageGoals,
stageGroupsToStagePayload,
type StageGroups,
} from "./chain-stages";
const seat = (id: string) => ({ agent_id: id, name: id.toUpperCase(), description: "" });
/** 把席位数组的数组包成 StageGroup[](测试构造便捷)。 */
const grp = (...stages: ReturnType<typeof seat>[][]): StageGroups =>
stages.map((seats) => ({ seats }));
const ids = (groups: StageGroups) => groups.map((g) => g.seats.map((s) => s.agent_id));
// ── chainToStageGroups ────────────────────────────────────────────────────────
test("chainToStageGroups: linear chain (no stages) → one seat per stage", () => {
const groups = chainToStageGroups({ seats: [seat("a"), seat("b"), seat("c")] });
assert.deepEqual(ids(groups), [["a"], ["b"], ["c"]]);
});
test("chainToStageGroups: with stages → grouped, mapping ids to seats", () => {
const groups = chainToStageGroups({
seats: [seat("a"), seat("b"), seat("c")],
stages: [["a", "b"], ["c"]],
});
assert.deepEqual(ids(groups), [["a", "b"], ["c"]]);
});
test("chainToStageGroups: drops stage ids with no matching seat, and empty groups", () => {
const groups = chainToStageGroups({
seats: [seat("a"), seat("c")],
stages: [["a", "ghost"], ["c"], ["onlyghost"]],
});
assert.deepEqual(ids(groups), [["a"], ["c"]]);
});
test("chainToStageGroups: stages that resolve to nothing → linear fallback", () => {
const groups = chainToStageGroups({ seats: [seat("a"), seat("b")], stages: [["ghost"]] });
assert.deepEqual(ids(groups), [["a"], ["b"]]);
});
// ── serialization ─────────────────────────────────────────────────────────────
test("stageGroupsToSeats flattens in order", () => {
const groups = grp([seat("a"), seat("b")], [seat("c")]);
assert.deepEqual(
stageGroupsToSeats(groups).map((s) => s.agent_id),
["a", "b", "c"],
);
});
test("stageGroupsToStagePayload returns null when all singletons AND no goals (linear)", () => {
const groups = grp([seat("a")], [seat("b")]);
assert.equal(stageGroupsToStagePayload(groups), null);
});
test("stageGroupsToStagePayload returns explicit stages when serial but a goal is set", () => {
// 串行单席位 + 任一「本阶段目标」→ 落显式分层(目标必须挂在显式 stages 上,否则丢失)。
const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "分析目的");
assert.deepEqual(stageGroupsToStagePayload(groups), [["a"], ["b"]]);
});
test("stageGroupsToStagePayload forceExplicit lifts a pure-linear chain to explicit stages", () => {
// 无并行无目标,但调用方因「总控编排提示」要求强制显式分层(否则提示在线性链里被忽略)。
const groups = grp([seat("a")], [seat("b")]);
assert.equal(stageGroupsToStagePayload(groups), null);
assert.deepEqual(stageGroupsToStagePayload(groups, true), [["a"], ["b"]]);
assert.deepEqual(stageGroupsToStageGoals(groups, true), [null, null]);
});
test("stageGroupsToStagePayload returns groups when any stage is parallel", () => {
const groups = grp([seat("a"), seat("b")], [seat("c")]);
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]);
});
test("stageGroupsToStagePayload ignores empty stages", () => {
const groups = grp([seat("a"), seat("b")], []);
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"]]);
});
// ── stage goals ─────────────────────────────────────────────────────────────
test("stageGroupsToStageGoals returns null when linear AND no goals", () => {
const groups = grp([seat("a")], [seat("b")]);
assert.equal(stageGroupsToStageGoals(groups), null);
});
test("stageGroupsToStageGoals persists goals on a serial chain when any goal is set", () => {
// 旧行为:串行 → 目标折叠为 null(被丢)。现改为:填了目标的串行链也落库,与显式分层对齐,
// 否则保存 / 回显皆失(用户反馈的 bug)。stage 1 无目标 → null。
const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, " 分析目的 ");
assert.deepEqual(stageGroupsToStageGoals(groups), ["分析目的", null]);
});
test("stageGroupsToStageGoals aligns to surviving stages, trims, nulls empties", () => {
let groups = grp([seat("a"), seat("b")], [seat("c")]);
groups = setStageGoal(groups, 0, " 分析目的 ");
// stage 1 无目标 → null;stage 0 目标 trim。
assert.deepEqual(stageGroupsToStageGoals(groups), ["分析目的", null]);
});
test("setStageGoal is immutable + out-of-range no-op", () => {
const groups = grp([seat("a"), seat("b")], [seat("c")]);
const next = setStageGoal(groups, 1, "g1");
assert.equal(next[1].goal, "g1");
assert.equal(groups[1].goal, undefined); // 原对象不变
assert.equal(setStageGoal(groups, 9, "x"), groups); // 越界返回原引用
});
// ── lookups ───────────────────────────────────────────────────────────────────
test("findStageIndexOfSeat / countSeats", () => {
const groups = grp([seat("a"), seat("b")], [seat("c")]);
assert.equal(findStageIndexOfSeat(groups, "c"), 1);
assert.equal(findStageIndexOfSeat(groups, "zzz"), -1);
assert.equal(countSeats(groups), 3);
});
// ── mutations ─────────────────────────────────────────────────────────────────
test("addSeatAsNewStage appends a singleton stage, dedupes", () => {
let g = grp([seat("a")]);
g = addSeatAsNewStage(g, seat("b"));
assert.deepEqual(ids(g), [["a"], ["b"]]);
g = addSeatAsNewStage(g, seat("a")); // dup → no-op
assert.deepEqual(ids(g), [["a"], ["b"]]);
});
test("addSeatToStage inserts into a stage at index, preserves goal", () => {
const base = setStageGoal(grp([seat("a")], [seat("c")]), 0, "g0");
const g = addSeatToStage(base, seat("b"), 0, 0);
assert.deepEqual(ids(g), [["b", "a"], ["c"]]);
assert.equal(g[0].goal, "g0"); // goal 保留
});
test("removeSeat drops the seat and prunes an emptied stage", () => {
const g = removeSeat(grp([seat("a")], [seat("b"), seat("c")]), "a");
assert.deepEqual(ids(g), [["b", "c"]]);
});
test("moveSeatToStage merges across stages and prunes empty source", () => {
const g = moveSeatToStage(grp([seat("a")], [seat("b")]), "a", 1);
assert.deepEqual(ids(g), [["b", "a"]]);
});
test("moveSeatToStage within same stage reorders", () => {
const g = moveSeatToStage(grp([seat("a"), seat("b"), seat("c")]), "c", 0, 0);
assert.deepEqual(ids(g), [["c", "a", "b"]]);
});
test("splitSeatToNewStage pulls a seat out; original keeps goal, split has none", () => {
const base = setStageGoal(grp([seat("a"), seat("b")], [seat("c")]), 0, "g0");
const g = splitSeatToNewStage(base, "b");
assert.deepEqual(ids(g), [["a"], ["b"], ["c"]]);
assert.equal(g[0].goal, "g0"); // 原 stage 保留目标
assert.equal(g[1].goal, undefined); // 拆出的新单席位 stage 无目标
});
test("splitSeatToNewStage on an already-solo seat is a no-op", () => {
const g = splitSeatToNewStage(grp([seat("a")], [seat("b")]), "a");
assert.deepEqual(ids(g), [["a"], ["b"]]);
});
test("moveStage reorders serial stages, goal travels with the stage", () => {
const base = setStageGoal(grp([seat("a")], [seat("b")], [seat("c")]), 2, "gc");
const g = moveStage(base, 2, -1);
assert.deepEqual(ids(g), [["a"], ["c"], ["b"]]);
assert.equal(g[1].goal, "gc"); // c 的目标随它移到了第 2 位
});
test("addEmptyStage / deleteStage", () => {
let g = addEmptyStage(grp([seat("a")]));
assert.deepEqual(ids(g), [["a"], []]);
g = deleteStage(g, 1);
assert.deepEqual(ids(g), [["a"]]);
});
// round-trip: editor groups → payload → reload preserves structure + goals
test("round-trip: groups → {seats, stages, stageGoals} → chainToStageGroups", () => {
const groups = setStageGoal(grp([seat("a"), seat("b")], [seat("c")]), 1, "依赖上一阶段");
const seats = stageGroupsToSeats(groups);
const stages = stageGroupsToStagePayload(groups);
const stageGoals = stageGroupsToStageGoals(groups);
const restored = chainToStageGroups({ seats, stages, stageGoals });
assert.deepEqual(ids(restored), [["a", "b"], ["c"]]);
assert.equal(restored[0].goal, undefined);
assert.equal(restored[1].goal, "依赖上一阶段");
});
// round-trip on a **serial single-seat chain with a goal** (the reported bug):
// save must persist stages+goals, and reload must show the goal back on its stage.
test("round-trip: serial single-seat chain + goal preserves goal across save/reload", () => {
const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "作战目的分析");
const seats = stageGroupsToSeats(groups);
const stages = stageGroupsToStagePayload(groups);
const stageGoals = stageGroupsToStageGoals(groups);
// 不再折叠为 null —— 串行+目标落显式分层。
assert.deepEqual(stages, [["a"], ["b"]]);
assert.deepEqual(stageGoals, ["作战目的分析", null]);
const restored = chainToStageGroups({ seats, stages, stageGoals });
assert.deepEqual(ids(restored), [["a"], ["b"]]);
assert.equal(restored[0].goal, "作战目的分析");
assert.equal(restored[1].goal, undefined);
});
test("chainToStageGroups: goal drops with a stage whose agents are all gone", () => {
// stages[0] 全是 ghost → 整组(含 g0)丢弃;存活的 ["c"] 应拿到 g1,不是 g0。
const restored = chainToStageGroups({
seats: [seat("c")],
stages: [["ghosta", "ghostb"], ["c"]],
stageGoals: ["g0", "g1"],
});
assert.deepEqual(ids(restored), [["c"]]);
assert.equal(restored[0].goal, "g1");
});
@@ -0,0 +1,227 @@
/**
* 业务链条「分层编排」编辑器的纯数据操作。
*
* 编辑器内部用 `StageGroups`(`StageGroup[]`,每个 `StageGroup` = 一个 stage,含其
* 并行席位 `seats` 与可选「本阶段目标」`goal`;stage 内并行、stage 间串行)作为
* **单一数据源**:
* - 展平得到落库的有序 `seats`({@link stageGroupsToSeats});
* - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload});
* - 折叠得到落库的 `stageGoals`({@link stageGroupsToStageGoals}),与 `stages` 同下标对齐。
*
* `goal` 挂在 stage 对象上,随 stage 在增删改 / 重排中自动跟随,避免与席位错位。
* 这里只放纯函数(无 React / 无副作用),便于单测;组件只做渲染 + 调用这些函数。
* 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。
*/
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
/** 一个并行 stage:含其并行席位与可选「本阶段目标」(注入给总控/席位,仅 dag 模式生效)。 */
export interface StageGroup {
seats: ChainSeat[];
/** 本阶段目标:告诉总控本阶段要产出什么、如何依赖上一阶段产出。空 = 不注入。 */
goal?: string;
}
/** 编辑器内部的分层结构:每个元素是一个并行 stage,外层顺序即串行顺序。 */
export type StageGroups = StageGroup[];
/**
* 从一条业务链条还原分层结构。
* - 链条带 `stages`:按分层分组(agent_id → 对应 seat),丢弃找不到 seat 的 id 与空组;
* `goal` 按**原始 stage 下标**从 `stageGoals` 取,随被丢弃的组一并丢弃;分层全空时回退到线性。
* - 无 `stages`(老链条):线性 —— 每个 seat 独立成一层(无 goal)。
*/
export function chainToStageGroups(
chain: Pick<BusinessChain, "seats" | "stages" | "stageGoals">,
): StageGroups {
const seats = chain.seats ?? [];
const byId = new Map(seats.map((s) => [s.agent_id, s]));
const goals = chain.stageGoals ?? null;
if (chain.stages && chain.stages.length > 0) {
const groups: StageGroups = [];
chain.stages.forEach((group, idx) => {
const mapped = group.map((id) => byId.get(id)).filter((s): s is ChainSeat => Boolean(s));
if (mapped.length === 0) return; // 整组解析不到席位 → 连同其 goal 一并丢弃
const goal = goals?.[idx]?.trim() || undefined;
groups.push(goal ? { seats: mapped, goal } : { seats: mapped });
});
if (groups.length > 0) return groups;
}
return seats.map((s) => ({ seats: [s] }));
}
/** 展平成落库 / 校验用的有序席位列表(stage 顺序 → stage 内顺序)。 */
export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] {
return groups.flatMap((g) => g.seats);
}
/** 一组里是否有人填了「本阶段目标」(trim 后非空)。 */
function hasAnyGoal(groups: StageGroups): boolean {
return groups.some((g) => (g.goal?.trim() ?? "") !== "");
}
/**
* 折叠成落库的 `stages`。返回 null = 线性链(Step 2 走 chain 模式,老行为不变);返回显式
* 分层 = Step 2 走 dag 模式。判定:**只要有一个 stage ≥2 席位(并行),或用户给任一 stage
* 填了「本阶段目标」,就落显式分层**;否则(纯单席位且无目标)落 null。空 stage 已被忽略。
*
* 为什么「填了目标也要落显式分层」:每阶段目标按 stage 下标对齐落库(见
* {@link stageGroupsToStageGoals}),只能挂在显式 `stages` 上。若串行单席位链照旧折叠为
* null,目标就会被一并丢掉——保存丢、回显也丢(正是用户反馈的「本阶段目标没保存上 / 没回显」)。
* 填了目标的串行链因此落成「每席位一层」的显式分层,由总控**逐阶段按目标派活**(dag 模式)。
*
* `forceExplicit`:调用方还有别的「必须走 dag」的理由时(如填了链条级「总控编排提示」,
* 仅 dag 模式注入)传 true,强制落显式分层,避免该提示在纯线性链里被静默忽略。
*/
export function stageGroupsToStagePayload(groups: StageGroups, forceExplicit = false): string[][] | null {
const nonEmpty = groups.filter((g) => g.seats.length > 0);
const hasParallel = nonEmpty.some((g) => g.seats.length > 1);
if (!hasParallel && !hasAnyGoal(nonEmpty) && !forceExplicit) return null;
return nonEmpty.map((g) => g.seats.map((s) => s.agent_id));
}
/**
* 折叠成落库的 `stageGoals`,与 {@link stageGroupsToStagePayload} 做**完全相同的折叠判定**
* (过滤空组;无并行且无目标、且未 `forceExplicit` 时才返回 null),保证两个数组下标一一对应。
* 每个存活组取其 `goal`(trim 后为空则该位为 null)。只要落了 `stages`,就同步落目标。
*/
export function stageGroupsToStageGoals(groups: StageGroups, forceExplicit = false): (string | null)[] | null {
const nonEmpty = groups.filter((g) => g.seats.length > 0);
const hasParallel = nonEmpty.some((g) => g.seats.length > 1);
if (!hasParallel && !hasAnyGoal(nonEmpty) && !forceExplicit) return null;
return nonEmpty.map((g) => g.goal?.trim() || null);
}
/** 该席位所在 stage 下标;不存在返回 -1。 */
export function findStageIndexOfSeat(groups: StageGroups, agentId: string): number {
return groups.findIndex((g) => g.seats.some((s) => s.agent_id === agentId));
}
/** 席位是否已在任意 stage 中。 */
export function seatExistsInGroups(groups: StageGroups, agentId: string): boolean {
return findStageIndexOfSeat(groups, agentId) >= 0;
}
/** 总席位数(用于 2–8 约束)。 */
export function countSeats(groups: StageGroups): number {
return groups.reduce((n, g) => n + g.seats.length, 0);
}
/** 设置某个 stage 的「本阶段目标」(不可变,供编辑器 onChange)。 */
export function setStageGoal(groups: StageGroups, stageIdx: number, goal: string): StageGroups {
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
return groups.map((g, i) => (i === stageIdx ? { ...g, goal } : g));
}
// ── 变换(均返回新结构,不可变)──────────────────────────────────────────────
/** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */
export function addSeatAsNewStage(groups: StageGroups, seat: ChainSeat): StageGroups {
if (seatExistsInGroups(groups, seat.agent_id)) return groups;
return [...groups, { seats: [seat] }];
}
/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。保留目标 stage 的 goal。 */
export function addSeatToStage(
groups: StageGroups,
seat: ChainSeat,
stageIdx: number,
atIndex?: number,
): StageGroups {
if (seatExistsInGroups(groups, seat.agent_id)) return groups;
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
return groups.map((g, i) => {
if (i !== stageIdx) return g;
const next = [...g.seats];
const at = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length));
next.splice(at, 0, seat);
return { ...g, seats: next };
});
}
/** 移除一个席位;该 stage 因此变空则连 stage(含其 goal)一起删掉。 */
export function removeSeat(groups: StageGroups, agentId: string): StageGroups {
return groups
.map((g) => ({ ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) }))
.filter((g) => g.seats.length > 0);
}
/**
* 把席位移动到目标 stage(跨 stage 合并 / stage 内移动)。
* 源 stage 变空则删除;删除会改变下标,这里用 agent_id 与目标 stage 的**身份**而非下标
* 重新定位,保证语义稳定。`atIndex` 为目标 stage 内插入位置。
*/
export function moveSeatToStage(
groups: StageGroups,
agentId: string,
targetStageIdx: number,
atIndex?: number,
): StageGroups {
const from = findStageIndexOfSeat(groups, agentId);
if (from < 0 || targetStageIdx < 0 || targetStageIdx >= groups.length) return groups;
const seat = groups[from].seats.find((s) => s.agent_id === agentId);
if (!seat) return groups;
// 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。goal 始终随 stage 保留。
const targetRef = groups[targetStageIdx];
let next: StageGroups = groups.map((g) =>
g === targetRef ? g : { ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) },
);
next = next.map((g) => {
if (g !== targetRef) return g;
if (g.seats.some((s) => s.agent_id === agentId)) {
// stage 内移动:先抽出再按 atIndex 插回。
const without = g.seats.filter((s) => s.agent_id !== agentId);
const at = atIndex == null ? without.length : Math.max(0, Math.min(atIndex, without.length));
without.splice(at, 0, seat);
return { ...g, seats: without };
}
const merged = [...g.seats];
const at = atIndex == null ? merged.length : Math.max(0, Math.min(atIndex, merged.length));
merged.splice(at, 0, seat);
return { ...g, seats: merged };
});
return next.filter((g) => g.seats.length > 0);
}
/**
* 把席位从当前 stage 抽出,独立成一个**新 stage**(插在原 stage 之后)。
* 原 stage 保留其 `goal`(目标描述的是这一批),拆出的新单席位 stage 无 goal。
*/
export function splitSeatToNewStage(groups: StageGroups, agentId: string): StageGroups {
const from = findStageIndexOfSeat(groups, agentId);
if (from < 0) return groups;
const seat = groups[from].seats.find((s) => s.agent_id === agentId);
if (!seat) return groups;
if (groups[from].seats.length === 1) return groups; // 已经独占一层,无需拆
const out: StageGroups = [];
groups.forEach((g, i) => {
if (i === from) {
out.push({ ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) });
out.push({ seats: [seat] });
} else {
out.push(g);
}
});
return out.filter((g) => g.seats.length > 0);
}
/** 整段 stage 上移 / 下移(改串行顺序)。goal 随 stage 一起移动。 */
export function moveStage(groups: StageGroups, stageIdx: number, dir: -1 | 1): StageGroups {
const target = stageIdx + dir;
if (stageIdx < 0 || stageIdx >= groups.length || target < 0 || target >= groups.length) {
return groups;
}
const next = [...groups];
[next[stageIdx], next[target]] = [next[target], next[stageIdx]];
return next;
}
/** 末尾追加一个空 stage(用户「新建 Stage」后再拖 / 加席位进去)。 */
export function addEmptyStage(groups: StageGroups): StageGroups {
return [...groups, { seats: [] }];
}
/** 删除整个 stage(含其席位)。 */
export function deleteStage(groups: StageGroups, stageIdx: number): StageGroups {
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
return groups.filter((_, i) => i !== stageIdx);
}
@@ -0,0 +1,539 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildDagStageLeaderTask,
buildDagTransitTask,
buildDependencySummary,
buildPlanFromStages,
buildStageTask,
chainToDagPlan,
emptyPlan,
isParallelStage,
PARALLEL_DEGREE_WARN_THRESHOLD,
pickDagSeatTask,
validatePlan,
type OrchestrationPlan,
} from "./dag-types";
// ── chainToDagPlan:链条 → 单节点-stage DAG(§1.3 兼容转换)────────────────────
test("chainToDagPlan turns a linear chain into one-node-per-stage DAG", () => {
const plan = chainToDagPlan(["a", "b", "c"]);
assert.equal(plan.mode, "dag");
assert.equal(plan.finalSynthesis, true);
assert.deepEqual(
plan.stages.map((s) => s.agentIds),
[["a"], ["b"], ["c"]],
);
// stage id 稳定且唯一(diff / 拖拽重排用)。
assert.deepEqual(
plan.stages.map((s) => s.id),
["stage-0", "stage-1", "stage-2"],
);
});
test("chainToDagPlan preserves order and respects finalSynthesis=false", () => {
const plan = chainToDagPlan(["x", "y"], false);
assert.equal(plan.finalSynthesis, false);
assert.deepEqual(
plan.stages.map((s) => s.agentIds[0]),
["x", "y"],
);
});
test("chainToDagPlan on empty chain yields an empty plan", () => {
const plan = chainToDagPlan([]);
assert.deepEqual(plan.stages, []);
});
test("emptyPlan is a valid empty dag plan", () => {
assert.deepEqual(emptyPlan(), { mode: "dag", stages: [], finalSynthesis: true });
});
// ── isParallelStage ──────────────────────────────────────────────────────────
test("isParallelStage is true only for ≥2 seats", () => {
assert.equal(isParallelStage({ id: "s", agentIds: ["a"] }), false);
assert.equal(isParallelStage({ id: "s", agentIds: ["a", "b"] }), true);
});
// ── validatePlan ──────────────────────────────────────────────────────────────
const roster = ["a", "b", "c", "d"];
test("validatePlan accepts a valid mixed serial/parallel plan", () => {
const plan: OrchestrationPlan = {
mode: "dag",
finalSynthesis: true,
stages: [
{ id: "s0", agentIds: ["a"] },
{ id: "s1", agentIds: ["b", "c"] },
{ id: "s2", agentIds: ["d"] },
],
};
const res = validatePlan(plan, roster);
assert.equal(res.ok, true);
assert.deepEqual(res.errors, []);
assert.deepEqual(res.warnings, []);
});
test("validatePlan rejects an empty plan", () => {
const res = validatePlan(emptyPlan(), roster);
assert.equal(res.ok, false);
assert.equal(res.errors.length, 1);
});
test("validatePlan rejects an empty stage", () => {
const plan: OrchestrationPlan = {
mode: "dag",
finalSynthesis: true,
stages: [{ id: "s0", agentIds: ["a"] }, { id: "s1", agentIds: [] }],
};
const res = validatePlan(plan, roster);
assert.equal(res.ok, false);
assert.match(res.errors[0], /第 2 阶段为空/);
});
test("validatePlan rejects a seat not in the roster", () => {
const plan: OrchestrationPlan = {
mode: "dag",
finalSynthesis: true,
stages: [{ id: "s0", agentIds: ["a", "zzz"] }],
};
const res = validatePlan(plan, roster);
assert.equal(res.ok, false);
assert.match(res.errors[0], /未选定的席位/);
});
test("validatePlan rejects the same seat across two stages", () => {
const plan: OrchestrationPlan = {
mode: "dag",
finalSynthesis: true,
stages: [
{ id: "s0", agentIds: ["a"] },
{ id: "s1", agentIds: ["a"] },
],
};
const res = validatePlan(plan, roster);
assert.equal(res.ok, false);
assert.match(res.errors[0], /只能跑一次/);
});
test("validatePlan rejects a duplicate seat within one stage", () => {
const plan: OrchestrationPlan = {
mode: "dag",
finalSynthesis: true,
stages: [{ id: "s0", agentIds: ["a", "a"] }],
};
const res = validatePlan(plan, roster);
assert.equal(res.ok, false);
assert.match(res.errors[0], /重复/);
});
test("validatePlan warns (but allows) when parallel degree exceeds the threshold", () => {
const big = Array.from({ length: PARALLEL_DEGREE_WARN_THRESHOLD + 1 }, (_, i) => `p${i}`);
const plan: OrchestrationPlan = {
mode: "dag",
finalSynthesis: true,
stages: [{ id: "s0", agentIds: big }],
};
const res = validatePlan(plan, big);
assert.equal(res.ok, true);
assert.equal(res.errors.length, 0);
assert.equal(res.warnings.length, 1);
assert.match(res.warnings[0], /并发限流/);
});
// chain→dag round-trip is always valid against its own roster.
test("a chainToDagPlan validates clean against its own roster", () => {
const chain = ["alpha", "beta", "gamma"];
const res = validatePlan(chainToDagPlan(chain), chain);
assert.equal(res.ok, true);
});
// ── buildPlanFromStages (链条 stages → 执行计划) ──────────────────────────────
test("buildPlanFromStages maps groups to stages with stable ids", () => {
const plan = buildPlanFromStages([["a", "b"], ["c"]]);
assert.equal(plan.mode, "dag");
assert.equal(plan.finalSynthesis, true);
assert.deepEqual(
plan.stages.map((s) => s.agentIds),
[["a", "b"], ["c"]],
);
assert.deepEqual(
plan.stages.map((s) => s.id),
["stage-0", "stage-1"],
);
});
test("buildPlanFromStages filters out non-surviving ids and drops emptied stages", () => {
// "b" deleted; stage that becomes empty is dropped.
const plan = buildPlanFromStages([["a", "b"], ["x"]], ["a", "c"]);
assert.deepEqual(
plan.stages.map((s) => s.agentIds),
[["a"]],
);
});
test("buildPlanFromStages on all-filtered yields empty stages (caller falls back)", () => {
const plan = buildPlanFromStages([["x"], ["y"]], ["a"]);
assert.deepEqual(plan.stages, []);
});
test("a chain plan validates clean against its surviving roster", () => {
const plan = buildPlanFromStages([["a", "b"], ["c"]], ["a", "b", "c"]);
const res = validatePlan(plan, ["a", "b", "c"]);
assert.equal(res.ok, true);
});
test("buildPlanFromStages stamps per-stage goals (trimmed), aligned to source index", () => {
const plan = buildPlanFromStages([["a", "b"], ["c"]], undefined, true, [" 目的 ", ""]);
assert.equal(plan.stages[0].goal, "目的"); // trim
assert.equal(plan.stages[1].goal, undefined); // 空 → 无 goal
});
test("buildPlanFromStages drops a filtered-out stage's goal, keeps survivor's by source index", () => {
// stage 0 全被过滤 → 连同 goals[0] 丢弃;存活的 ["c"] 是源下标 1 → 拿 goals[1]。
const plan = buildPlanFromStages([["x"], ["c"]], ["c"], true, ["g0", "g1"]);
assert.deepEqual(plan.stages.map((s) => s.agentIds), [["c"]]);
assert.equal(plan.stages[0].goal, "g1");
});
test("buildPlanFromStages without goals leaves stage.goal undefined (old plans)", () => {
const plan = buildPlanFromStages([["a"], ["b"]]);
assert.equal(plan.stages[0].goal, undefined);
assert.equal(plan.stages[1].goal, undefined);
});
test("buildPlanFromStages stamps trimmed coordinatorPrompt onto the plan", () => {
const plan = buildPlanFromStages([["a"], ["b"]], undefined, true, null, " 按阶段派活 ");
assert.equal(plan.coordinatorPrompt, "按阶段派活");
});
test("buildPlanFromStages leaves coordinatorPrompt undefined when blank/absent", () => {
assert.equal(buildPlanFromStages([["a"], ["b"]]).coordinatorPrompt, undefined);
assert.equal(buildPlanFromStages([["a"]], undefined, true, null, " ").coordinatorPrompt, undefined);
});
// ── buildDependencySummary (§3.1 依赖摘要注入) ─────────────────────────────────
test("buildDependencySummary is empty for no deliveries (first stage)", () => {
assert.equal(buildDependencySummary([]), "");
});
test("buildDependencySummary lists each seat's delivery with its name", () => {
const out = buildDependencySummary([
{ seatName: "情报收集", content: "情报正文" },
{ seatName: "环境评估", content: "评估正文" },
]);
assert.match(out, /【情报收集】/);
assert.match(out, /情报正文/);
assert.match(out, /【环境评估】/);
assert.match(out, /评估正文/);
});
test("buildDependencySummary truncates long content", () => {
const long = "x".repeat(3000);
const out = buildDependencySummary([{ seatName: "A", content: long }], 100);
assert.match(out, /…(已截断)/);
assert.ok(out.length < 3000);
});
test("buildDependencySummary handles empty content gracefully", () => {
const out = buildDependencySummary([{ seatName: "A", content: " " }]);
assert.match(out, /(无文本输出)/);
});
// ── buildStageTask (§3.2 直派提示) ────────────────────────────────────────────
test("buildStageTask for first stage embeds the task background", () => {
const task = buildStageTask({
seedMessage: "做一个容灾方案",
seatName: "情报收集",
priorSummary: "",
isParallel: false,
});
assert.match(task, /任务背景/);
assert.match(task, /做一个容灾方案/);
assert.match(task, /情报收集/);
// serial node — no parallel no-file warning.
assert.doesNotMatch(task, /并行研讨/);
});
test("buildStageTask for a later stage embeds the prior summary, not the seed", () => {
const task = buildStageTask({
seedMessage: "原始任务",
seatName: "方案设计",
priorSummary: "【情报收集】的交付:\n情报正文",
isParallel: false,
});
assert.match(task, /此前各阶段已完成席位的交付摘要/);
assert.match(task, /情报正文/);
assert.doesNotMatch(task, /原始任务/);
});
test("buildStageTask in a parallel stage forbids writing files", () => {
const task = buildStageTask({
seedMessage: "原始任务",
seatName: "方案设计",
priorSummary: "",
isParallel: true,
});
assert.match(task, /并行研讨/);
assert.match(task, /不要写文件/);
});
test("buildStageTask with priorIsBridge frames prior context as the leader's bridge", () => {
const bridge = "总控综合:情报已收集,下一步聚焦架构设计";
const task = buildStageTask({
seedMessage: "原始任务",
seatName: "方案设计",
priorSummary: bridge,
isParallel: false,
priorIsBridge: true,
});
// 措辞切换到「承上启下」综合说明,而非机械「交付摘要」。
assert.match(task, /承上启下/);
assert.match(task, /综合说明/);
assert.match(task, /总控综合/);
assert.doesNotMatch(task, /已完成席位的交付摘要/);
});
// ── buildDagTransitTask (§12.3 Stage 间总控中转 + 广播) ─────────────────────────
test("buildDagTransitTask names completed + next seats and forbids dispatch/closeout", () => {
const task = buildDagTransitTask({
completedSeatNames: ["情报收集", "环境评估"],
nextSeatNames: ["方案设计", "风险审查"],
});
assert.match(task, /中转轮/);
assert.match(task, /情报收集、环境评估/);
assert.match(task, /方案设计、风险审查/);
assert.match(task, /承上启下/);
// 计划钉死下一批 → 总控不自由派活、不收口。
assert.match(task, /不要.*派活/);
assert.match(task, /不要现在收口/);
});
test("buildDagTransitTask falls back gracefully when seat name lists are empty", () => {
const task = buildDagTransitTask({ completedSeatNames: [], nextSeatNames: [] });
assert.match(task, /本批席位/);
assert.match(task, /下一阶段席位/);
});
// ── buildDagStageLeaderTask (§3.2 方案 A:总控驱动每 stage 派活) ─────────────────
test("buildDagStageLeaderTask first stage embeds task background + pins seats", () => {
const task = buildDagStageLeaderTask({
seedMessage: "做一个容灾方案",
stageIndex: 0,
totalStages: 3,
seats: [
{ agentId: "intel", name: "情报收集" },
{ agentId: "env", name: "环境评估" },
],
isFirst: true,
});
assert.match(task, /任务背景/);
assert.match(task, /做一个容灾方案/);
assert.match(task, /第 1\/3 个阶段/);
// 每个钉死席位都要带 agent_name 让总控逐字派活。
assert.match(task, /agent_name: intel/);
assert.match(task, /agent_name: env/);
assert.match(task, /情报收集/);
assert.match(task, /环境评估/);
// 约束:只派给清单内、不收口。
assert.match(task, /不要派给清单外/);
assert.match(task, /不要.*给出最终结论/);
// 多席位 → 并行提示。
assert.match(task, /并行/);
});
test("buildDagStageLeaderTask later stage relies on injected prior deliveries, not seed", () => {
const task = buildDagStageLeaderTask({
seedMessage: "原始任务",
stageIndex: 1,
totalStages: 2,
seats: [{ agentId: "plan", name: "方案设计" }],
isFirst: false,
});
assert.match(task, /承上启下/);
assert.match(task, /第 2\/2 个阶段/);
assert.doesNotMatch(task, /原始任务/);
// 单席位 stage → 不加并行提示。
assert.doesNotMatch(task, /会\*\*并行\*\*执行/);
});
// ── pickDagSeatTask (§3.2 方案 A:取总控派给某席位的 task,多席位不张冠李戴) ────────
test("pickDagSeatTask returns the exact dispatch matched by agent_name (+ role guard)", () => {
const dispatched: [string, string][] = [
["plan", "设计架构"],
["risk", "审查风险"],
];
const riskTask = pickDagSeatTask(dispatched, "risk", "风险审查");
assert.match(riskTask, /审查风险/);
assert.doesNotMatch(riskTask, /设计架构/);
const planTask = pickDagSeatTask(dispatched, "plan", "方案设计");
assert.match(planTask, /设计架构/);
// 角色边界护栏:禁止席位派活/调度。
assert.match(riskTask, /执行席位/);
assert.match(riskTask, /严禁.*派活|严禁.*调度/);
});
test("pickDagSeatTask gives a CLEAN own-role task on miss — never injects the dispatch narrative", () => {
const dispatched: [string, string][] = [["plan", "设计架构"]];
// risk 没被派到 → 既不借 plan 的「设计架构」,也**不**注入总控派活叙述(防模仿派活)。
const task = pickDagSeatTask(dispatched, "risk", "风险审查");
assert.doesNotMatch(task, /设计架构/);
assert.match(task, /风险审查/);
assert.match(task, /独立完成/);
// 角色边界护栏:禁止席位派活/调度/绕道脚本。
assert.match(task, /执行席位/);
assert.match(task, /严禁/);
assert.match(task, /bash|脚本|内部接口/);
});
test("pickDagSeatTask with empty dispatched falls back to clean own-role task", () => {
const task = pickDagSeatTask([], "risk", "风险审查");
assert.match(task, /风险审查/);
assert.match(task, /本阶段/);
assert.match(task, /执行席位/);
});
test("pickDagSeatTask embeds the seed message as task background when provided", () => {
const task = pickDagSeatTask([], "risk", "风险审查", "做一个容灾方案");
assert.match(task, /任务背景/);
assert.match(task, /做一个容灾方案/);
// exact-match 分支也带背景。
const exactTask = pickDagSeatTask([["risk", "审查风险"]], "risk", "风险审查", "做一个容灾方案");
assert.match(exactTask, /做一个容灾方案/);
assert.match(exactTask, /审查风险/);
});
test("pickDagSeatTask mentions read_peer_delivery only when peers exist (§12.9)", () => {
const noPeers = pickDagSeatTask([], "risk", "风险审查", "种子", false);
assert.doesNotMatch(noPeers, /read_peer_delivery/);
const withPeers = pickDagSeatTask([], "risk", "风险审查", "种子", true);
assert.match(withPeers, /read_peer_delivery/);
assert.match(withPeers, /按需取全文|完整交付/);
});
test("pickDagSeatTask (sandbox mode, allowFiles) asks for .md and forbids .py (§12.8)", () => {
// allowFiles=true(沙箱模式):产出 md、禁 .py。
const miss = pickDagSeatTask([], "risk", "风险审查", "", false, true);
const hit = pickDagSeatTask([["risk", "审查风险"]], "risk", "风险审查", "", false, true);
for (const t of [miss, hit]) {
assert.match(t, /Markdown(\.md)文件|\.md/);
assert.match(t, /不要写 \.py|不要 \.py/);
assert.match(t, /产物要求/);
}
});
test("pickDagSeatTask (fast mode, default) keeps output in the message body — no file (§12.10)", () => {
// allowFiles 默认 false(快速模式):产物走正文,不写文件、不写 md。
const t = pickDagSeatTask([], "risk", "风险审查");
assert.match(t, /直接写在回复正文|产物走正文|不要写文件/);
assert.doesNotMatch(t, /Markdown(\.md)文件/);
});
test("buildDagStageLeaderTask insists on actually calling the tool (not narrating)", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 0,
totalStages: 2,
seats: [
{ agentId: "a", name: "甲" },
{ agentId: "b", name: "乙" },
],
isFirst: true,
});
assert.match(task, /真正调用 agent_orchestration 工具/);
assert.match(task, /不要.*只用文字/);
});
// ── per-stage goal injection (本阶段目标) ──────────────────────────────────────
test("buildDagStageLeaderTask injects the stage goal when provided", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 1,
totalStages: 2,
seats: [{ agentId: "actor", name: "行为体分析" }],
isFirst: false,
goal: "根据上一阶段的各个目的,分析出行为体",
});
assert.match(task, /本阶段目标/);
assert.match(task, /分析出行为体/);
assert.match(task, /显式承接上一阶段/);
});
test("buildDagStageLeaderTask omits the goal line when goal is empty/blank", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 0,
totalStages: 1,
seats: [{ agentId: "a", name: "甲" }],
isFirst: true,
goal: " ",
});
assert.doesNotMatch(task, /本阶段目标/);
});
// ── coordinator prompt injection (写给总控的编排提示) ─────────────────────────
test("buildDagStageLeaderTask injects the coordinator prompt as a top-priority guideline", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 0,
totalStages: 2,
seats: [{ agentId: "a", name: "甲" }],
isFirst: true,
coordinatorPrompt: "严格按阶段顺序派活,最终做综合收口",
});
assert.match(task, /总控编排指引/);
assert.match(task, /严格按阶段顺序派活,最终做综合收口/);
});
test("buildDagStageLeaderTask injects the coordinator prompt on later stages too", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 1,
totalStages: 2,
seats: [{ agentId: "b", name: "乙" }],
isFirst: false,
coordinatorPrompt: "聚焦本职、显式承接",
});
assert.match(task, /总控编排指引/);
assert.match(task, /聚焦本职、显式承接/);
});
test("buildDagStageLeaderTask omits the coordinator block when prompt is empty/blank", () => {
const task = buildDagStageLeaderTask({
seedMessage: "x",
stageIndex: 0,
totalStages: 1,
seats: [{ agentId: "a", name: "甲" }],
isFirst: true,
coordinatorPrompt: " ",
});
assert.doesNotMatch(task, /总控编排指引/);
});
test("pickDagSeatTask injects the stage goal in BOTH the exact-match and the miss branches", () => {
const goal = "根据上一阶段的各个目的,分析出行为体";
const hit = pickDagSeatTask([["actor", "干这个"]], "actor", "行为体", "种子", true, false, goal);
const miss = pickDagSeatTask([], "actor", "行为体", "种子", true, false, goal);
for (const t of [hit, miss]) {
assert.match(t, /本阶段目标/);
assert.match(t, /分析出行为体/);
assert.match(t, /显式承接上一阶段/);
}
});
test("pickDagSeatTask omits the goal block when no goal (old plans)", () => {
const t = pickDagSeatTask([], "actor", "行为体", "种子", true);
assert.doesNotMatch(t, /本阶段目标/);
});
@@ -0,0 +1,474 @@
/**
* 圆桌 DAG 编排(分层 Stage 模型)共享类型 + 纯函数。
*
* 对应 `docs/roundtable-dag-orchestration.md` §1「数据模型」。本文件只放纯类型 +
* 可单测的纯函数(校验 / chain→dag 兼容转换),不含任何 React / 网络副作用,
* 后续 Phase 2(编辑器)/ Phase 3(`runDagOrchestration`)会引用这些类型。
*
* 设计要点(§0.3 / §1.1):
* - DAG 用「有序的 stage 列表」表示:stage 内并行、stage 间串行。
* - 线性链是 DAG 的特例(每层 1 个节点),见 {@link chainToDagPlan}。
* - 自由连线画布本期不做,仅在数据模型上预留 {@link DagGraph}(§1.2)避免返工。
*/
// ── 分层(Stage)表示:本期主模型(§1.1)──────────────────────────────────────
/** 一个并行批次:stage 内所有席位并行执行。 */
export interface OrchestrationStage {
/** stage 稳定 id(拖拽重排 / diff 用)。 */
id: string;
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
agentIds: string[];
/**
* 可选「本阶段目标」:来自业务链条 `stageGoals`,注入总控派活提示(主)与席位任务
* (兜底),告诉本阶段要产出什么、如何依赖上一阶段产出。空 = 不注入。
*/
goal?: string;
}
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
export interface OrchestrationPlan {
/** 编排模式标识,持久化进 step2。 */
mode: "dag";
/** 串行执行的批次;每批内并行。 */
stages: OrchestrationStage[];
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
finalSynthesis: boolean;
/**
* 可选「总控编排提示」:来自业务链条 `coordinatorPrompt`,写给总控的整体派活指引
* (怎么派活 / 做什么)。注入总控**每阶段**派活提示({@link buildDagStageLeaderTask})。
* 空 = 不注入。
*/
coordinatorPrompt?: string;
}
// ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)───────────────────────
/** from 完成后才能跑 to。 */
export interface DagEdge {
from: string;
to: string;
}
/** 自由连线画布的图表示。执行前需 toposort → 分层落回 {@link OrchestrationStage}。 */
export interface DagGraph {
nodes: string[];
edges: DagEdge[];
}
// ── 常量 ──────────────────────────────────────────────────────────────────────
/**
* 单 stage 并行度软上限:超过给 warning(远程模型并发限流 + 沙箱压力,§1.4 / §9)。
* 仅提示,不阻断保存。
*/
export const PARALLEL_DEGREE_WARN_THRESHOLD = 6;
// ── 校验(纯函数,可单测,§1.4)──────────────────────────────────────────────
/** {@link validatePlan} 的返回。`errors` 阻断保存,`warnings` 仅提示。 */
export interface PlanValidation {
ok: boolean;
/** 面向用户的中文错误(非空 = 无法保存)。 */
errors: string[];
/** 面向用户的中文提示(不阻断保存)。 */
warnings: string[];
}
/**
* 校验一份编排计划是否可执行 / 可保存。
*
* 校验项(§1.4):
* - 至少 1 个 stage。
* - 不允许空 stage。
* - 每个席位必须在本次选定席位 `selectedAgentIds` 内(防编排不存在的席位)。
* - 同一席位不允许出现在多个 stage(一个席位只跑一次);同一 stage 内也不允许重复。
* - (warning)单 stage 并行度 > {@link PARALLEL_DEGREE_WARN_THRESHOLD}。
*
* @param plan 待校验的计划。
* @param selectedAgentIds 本次选定的席位 agent_id 全集(roster)。
*/
export function validatePlan(
plan: OrchestrationPlan,
selectedAgentIds: string[],
): PlanValidation {
const errors: string[] = [];
const warnings: string[] = [];
const roster = new Set(selectedAgentIds);
if (!plan.stages || plan.stages.length === 0) {
errors.push("编排为空:请至少添加一个阶段(Stage)。");
return { ok: false, errors, warnings };
}
// 记录每个席位首次出现的 stage 序号,用来检测跨 stage 重复。
const seenInStage = new Map<string, number>();
plan.stages.forEach((stage, idx) => {
const human = idx + 1;
if (!stage.agentIds || stage.agentIds.length === 0) {
errors.push(`第 ${human} 阶段为空:请至少放入一个席位,或删除该阶段。`);
return;
}
const seenInThisStage = new Set<string>();
for (const agentId of stage.agentIds) {
if (!roster.has(agentId)) {
errors.push(`第 ${human} 阶段包含未选定的席位「${agentId}」,请先把它加入参与角色。`);
continue;
}
if (seenInThisStage.has(agentId)) {
errors.push(`第 ${human} 阶段内席位「${agentId}」重复,一个席位每阶段只能出现一次。`);
continue;
}
seenInThisStage.add(agentId);
const firstStage = seenInStage.get(agentId);
if (firstStage !== undefined) {
errors.push(
`席位「${agentId}」同时出现在第 ${firstStage + 1} 与第 ${human} 阶段,一个席位只能跑一次。`,
);
} else {
seenInStage.set(agentId, idx);
}
}
if (stage.agentIds.length > PARALLEL_DEGREE_WARN_THRESHOLD) {
warnings.push(
`第 ${human} 阶段并行 ${stage.agentIds.length} 个席位,可能触发远程模型并发限流或沙箱压力,建议拆分。`,
);
}
});
return { ok: errors.length === 0, errors, warnings };
}
// ── 链条 → DAG 兼容转换(§1.3)───────────────────────────────────────────────
/** 生成稳定的 stage id。不依赖随机数,便于单测与 diff(用序号即可,stages 不重排前唯一)。 */
function stageId(index: number): string {
return `stage-${index}`;
}
/**
* 把线性链条(有序席位数组)转换成「每层 1 个节点」的 DAG 计划。
*
* 老 `chain` 模式的数据只是有序的 `selectedAgents`(线性),读取时即时转换为
* 单节点-stage 的 dag,不破坏老 draft(§1.3 兼容层)。
*
* @param orderedAgentIds 链条顺序的席位 agent_id。
* @param finalSynthesis 是否收口综合,默认 true。
*/
export function chainToDagPlan(
orderedAgentIds: string[],
finalSynthesis = true,
): OrchestrationPlan {
return {
mode: "dag",
stages: orderedAgentIds.map((agentId, idx) => ({
id: stageId(idx),
agentIds: [agentId],
})),
finalSynthesis,
};
}
/**
* 构造一个空计划(编辑器初始态 / 兜底)。
*/
export function emptyPlan(finalSynthesis = true): OrchestrationPlan {
return { mode: "dag", stages: [], finalSynthesis };
}
/**
* 把业务链条的 `stages`(agent_id 分组的 string[][])转成执行计划。
*
* - `allowedIds` 提供时,过滤掉不在其中的 agent_id(如链条里有席位被删除),
* 并丢弃过滤后变空的 stage —— 保证选入 Step 2 的计划只含存活席位。
* - 全部为空时返回空 stages(调用方据此判断退回 chain / recommend)。
*
* @param stages 链条分层(每组并行、组间串行)。
* @param allowedIds 仅保留这些 agent_id(存活席位);省略则全保留。
* @param finalSynthesis 是否收口综合,默认 true。
* @param goals 可选「每阶段目标」,与 `stages` **同源下标**对齐;存活 stage 取对应 goal
* (被丢弃的 stage 其 goal 一并丢弃,因为按源下标取)。
* @param coordinatorPrompt 可选「总控编排提示」(写给总控的整体派活指引),挂在计划上,
* 注入总控每阶段派活提示。空 = 不带。
*/
export function buildPlanFromStages(
stages: string[][],
allowedIds?: Iterable<string>,
finalSynthesis = true,
goals?: ReadonlyArray<string | null> | null,
coordinatorPrompt?: string | null,
): OrchestrationPlan {
const allow = allowedIds ? new Set(allowedIds) : null;
const cleaned: OrchestrationStage[] = [];
stages.forEach((group, idx) => {
const ids = allow ? group.filter((id) => allow.has(id)) : group.slice();
if (ids.length === 0) return;
const goal = goals?.[idx]?.trim() || undefined;
cleaned.push(goal ? { id: stageId(idx), agentIds: ids, goal } : { id: stageId(idx), agentIds: ids });
});
const cp = coordinatorPrompt?.trim() || undefined;
return cp
? { mode: "dag", stages: cleaned, finalSynthesis, coordinatorPrompt: cp }
: { mode: "dag", stages: cleaned, finalSynthesis };
}
/** 该 stage 是否真并行(≥2 席位)—— Phase 3 决定是否禁写文件 / Phase 5 决定显示。 */
export function isParallelStage(stage: OrchestrationStage): boolean {
return stage.agentIds.length > 1;
}
// ── 派活提示构造(纯函数,Phase 3 调度用,可单测,§3.1 依赖摘要注入)──────────────
/** 已完成席位的一条交付(喂给后续 stage 当依赖上下文)。 */
export interface StageDelivery {
/** 席位显示名(中文优先)。 */
seatName: string;
/** 该席位本轮交付正文。 */
content: string;
}
/**
* 把前面所有 stage 已交付席位的正文拼成「依赖摘要」,注入下一 stage 的派活提示。
* 每条按 `maxCharsPerSeat` 截断,避免后续席位上下文过长(§3.1:stage 边界拉一次摘要)。
* 无交付(首 stage)返回空串。
*/
export function buildDependencySummary(
deliveries: StageDelivery[],
maxCharsPerSeat = 1200,
): string {
if (deliveries.length === 0) return "";
return deliveries
.map((d) => {
const body = (d.content ?? "").trim();
const clipped =
body.length > maxCharsPerSeat ? `${body.slice(0, maxCharsPerSeat)}…(已截断)` : body;
return `【${d.seatName}】的交付:\n${clipped || "(无文本输出)"}`;
})
.join("\n\n");
}
/**
* 为某 stage 内一个席位构造 special 派活提示(DAG 直派,无 leader,§3.2 方案 B)。
*
* - 首 stage(`priorSummary` 为空):带任务背景。
* - 后续 stage:带前序交付摘要让其承接(§3.1 依赖可见性)。
* - 并行 stage(`isParallel`):明确要求**产出写在正文、禁止写文件 / 沙箱命令**
* (§4.3 / §9:并行禁写文件,降低模型困惑,与后端 `seat_parallel` policy 呼应)。
*/
export function buildStageTask(args: {
seedMessage: string;
seatName: string;
/**
* 上一阶段的上下文:
* - 默认(`priorIsBridge` 假):{@link buildDependencySummary} 拼出的机械交付摘要;
* - `priorIsBridge` 真:总控「承上启下」中转轮({@link buildDagTransitTask})的综合说明。
* 首 stage 传空串。
*/
priorSummary: string;
isParallel: boolean;
/**
* `priorSummary` 是否为总控中转轮(§12.3)产出的「承上启下」说明(而非机械交付摘要)。
* 真时改用对应措辞,让席位知道这是总控综合后的指引。缺省 false(机械摘要)。
*/
priorIsBridge?: boolean;
}): string {
const { seedMessage, seatName, priorSummary, isParallel, priorIsBridge } = args;
const lines: string[] = [];
if (!priorSummary) {
lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, "");
} else if (priorIsBridge) {
lines.push("分层研讨继续。以下是总控对此前各阶段的【承上启下】综合说明,请据此承接:", "", priorSummary, "");
} else {
lines.push("分层研讨继续。以下是此前各阶段已完成席位的交付摘要,供你承接:", "", priorSummary, "");
}
lines.push(`请你作为「${seatName}」,从你的专业职责出发,完成本阶段的分析与交付。`);
if (isParallel) {
lines.push(
"⚠️ 本阶段为并行研讨:请把你的产出**直接写在回复正文里**,不要写文件、不要执行沙箱命令——并行期间禁止文件写入,以免与同批其他席位互相打架。需要落文件的产物会留到后续串行 / 收口阶段单独处理。",
);
}
return lines.join("\n");
}
/**
* 总控「中转轮」派活提示(§12.3 Stage 间经总控中转 + 广播)。
*
* DAG 各 stage 之间插入一轮 leader:综合上一批并行席位的交付,产出一段「承上启下」
* 说明,再作为下一 stage 席位 task 的上下文(见 {@link buildStageTask} 的
* `priorIsBridge`)。下一批席位由编排计划**钉死**(已在 `nextSeatNames` 列出),
* 故提示明确要求总控**不要自己派活、不要收口**——只做综合(前端照计划直派下一批)。
*
* 「传摘要按需传全文」:各席位真实交付由后端 `_collect_delivery_status`(轻量
* `GET /last-ai-message?max_chars=N` 接口)实读后注入总控输入——中转轮默认只注入
* 每席位 ~600 字**摘要**,完整交付正文留到最终收口的 synthesis 轮才按需读取。
* 本提示因此不重复塞交付正文,保持总控上下文精简、响应快。
*/
export function buildDagTransitTask(args: {
/** 刚完成的本批席位显示名(仅用于提示文案,真实交付由后端核对注入)。 */
completedSeatNames: string[];
/** 下一阶段由计划钉死、即将执行的席位显示名。 */
nextSeatNames: string[];
}): string {
const { completedSeatNames, nextSeatNames } = args;
const done = completedSeatNames.join("、") || "本批席位";
const next = nextSeatNames.join("、") || "下一阶段席位";
return [
"【分层 DAG · 总控中转轮】本阶段并行席位已全部完成交付。",
`已完成席位:${done}。系统已把各席位的真实交付(摘要)客观核对后注入到你本轮输入里,以此为准。`,
"请你作为总控,综合这一批的交付,产出一段【承上启下】说明:提炼关键结论与未决问题,并给出对下一阶段的指引。",
`下一阶段将由编排计划**钉死**执行的席位是:${next}。这些席位由系统按计划直接派活,` +
"**你不需要、也不要**通过 agent_orchestration 自行派活,更不要现在收口或输出最终方案。",
"只输出这段承上启下的综合说明即可——它会作为下一阶段席位承接前序结论的上下文。",
].join("\n");
}
// ── 总控驱动的「每 stage 派活轮」(§3.2 方案 A + 批内并行,2026-06-07 改)─────────
//
// 把 DAG 执行从「前端直派」改成「总控驱动」:每个 stage 由总控**真正派活**给该 stage
// 计划钉死的席位(约束总控只派给这些席位、且每个都派到),席位并行执行、完成后交付经
// 后端广播 + `_collect_delivery_status` 回到总控,总控下一 stage 继续派活。与 chain 模式
// 同构(`buildChainLeaderTask` 单席位),区别是一次钉死**一批**席位、批内并行。
/**
* 总控「每 stage 派活轮」提示:约束总控只对本 stage 钉死的若干席位派活(§3.2 方案 A)。
*
* - 首 stage(`isFirst`):带任务背景;后续 stage:靠后端注入的此前交付摘要承上。
* - `seats` 是本 stage 计划钉死的席位(≥2 即并行批次),要求总控对**每个**席位都
* 通过 agent_orchestration 派一个聚焦其职责的 task,不要派给清单外席位、不要自答 / 收口。
*
* 「传摘要按需传全文」:此处不塞各席位交付正文——后端 `_build_seat_status_block` 已把
* 此前各席位的真实交付(摘要)注入总控本轮输入;完整正文留到末尾收口的 synthesis 轮。
*/
export function buildDagStageLeaderTask(args: {
seedMessage: string;
/** 0-based stage 序号。 */
stageIndex: number;
totalStages: number;
/** 本 stage 计划钉死的席位。 */
seats: Array<{ agentId: string; name: string }>;
isFirst: boolean;
/** 可选「本阶段目标」:用户在业务链条里为本阶段写的产出 / 依赖说明。空 = 不注入。 */
goal?: string;
/**
* 可选「总控编排提示」:用户在业务链条里写给总控的整体派活指引(怎么派活 / 做什么 /
* 风格 / 约束)。每个 stage 的派活轮都注入,作为总控的高优先编排原则。空 = 不注入。
*/
coordinatorPrompt?: string;
}): string {
const { seedMessage, stageIndex, totalStages, seats, isFirst, goal, coordinatorPrompt } = args;
const ordinal = `${stageIndex + 1}/${totalStages}`;
const seatList = seats
.map((s) => `「${s.name}」(agent_name: ${s.agentId})`)
.join("、");
const isParallel = seats.length > 1;
const goalText = goal?.trim();
const coordText = coordinatorPrompt?.trim();
const lines: string[] = [];
if (isFirst) {
lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, "");
} else {
lines.push(
"分层研讨继续。系统已把此前各阶段席位的真实交付(摘要)客观核对后注入到你本轮输入里,请据此承上启下。",
"",
);
}
if (coordText) {
lines.push(
"【总控编排指引(用户预设,最高优先)】以下是本次业务链条为你(总控)预设的整体派活原则,请在每一阶段派活时都严格遵循:",
coordText,
"",
);
}
lines.push(
`本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`,
`本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`,
);
if (goalText) {
lines.push(
`【本阶段目标】${goalText}`,
"请据此目标为每个席位拆解任务,并要求他们**显式承接上一阶段的产出**来完成本阶段目标。",
);
}
lines.push(
`请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景${goalText ? "、本阶段目标" : ""}与此前交付,为每个席位分配聚焦其职责的本阶段任务。`,
"只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。",
"⚠️ 必须**真正调用 agent_orchestration 工具**来派活(每个席位对应一次工具调用);**不要**只用文字罗列「席位1:…席位2:…」这样的任务清单——只写文字而不调用工具,系统无法把任务下发给席位。",
);
if (isParallel) {
lines.push(
`注意:本阶段这 ${seats.length} 个席位会**并行**执行,请确保给每个席位的 task 都能独立完成,不要让它们互相等待。`,
);
}
return lines.join("\n");
}
/**
* 从总控「每 stage 派活轮」的终态里,取出派给**某个**钉死席位的 task 文本:
* 1) 命中 agent_name === seatAgentId 的结构化派活(总控真调了 agent_orchestration 工具)
* → 用它的 task(正常路径);
* 2) 总控**没用工具、只写了文字**(弱模型常见,dispatched 为空 / 没命中该席位)→ **绝不**把
* 总控的整段派活叙述喂给席位 —— 否则席位会把「我来调度…席位1/2/3」当成自己的任务照抄、
* 模仿派活,甚至绕道 bash/python 调内部接口去派活(已在生产观测到)。改给一个**干净的本职
* 任务**:任务背景与此前各阶段交付已由后端广播进席位线程上下文,席位据此承接即可。
*
* 所有分支都追加「角色边界护栏」,明确席位**不是总控、不许派活**(席位本就被 run policy 禁了
* agent_orchestration,但弱模型会空转叙述 / 绕道,故再从提示层钉死)。
*/
export function pickDagSeatTask(
dispatched: ReadonlyArray<readonly [string, string]>,
seatAgentId: string,
seatName: string,
seedMessage = "",
/** 是否已有前序席位交付(决定是否提示 read_peer_delivery 工具按需取全文,§12.9)。 */
hasPeers = false,
/**
* 是否允许写文件(§12.10):
* - `true`(**沙箱模式**):要求把交付**写成 .md 文件** + 正文摘要,禁 .py;
* - `false`(**快速模式**,默认):产物**直接写在正文**,不写文件(fast 模式产物走正文)。
*/
allowFiles = false,
/**
* 可选「本阶段目标」(兜底注入):总控派活质量差 / 没派到时,席位仍据此知道本阶段要产出
* 什么、如何依赖上一阶段产出。空 = 不注入。
*/
goal = "",
): string {
const guard =
`\n\n【角色边界·必读】你是**执行席位**「${seatName}」,不是总控协调者,本轮**只**对你自己的交付负责。**严禁**任何形式的派活 / 调度:不要调用 agent_orchestration 或 skill_list,不要用 python / 脚本去访问内部接口(如 /api/agents、agent_orchestration)来调度,不要罗列或给其他席位分配任务清单、不要复述「我来调度各智能体」这类总控口吻。`;
// 产物要求(§12.8 / §12.10):沙箱模式产出 .md 文件(禁 .py);快速模式产物走正文、不写文件。
const fileRule = allowFiles
? `\n\n【产物要求】请把你的**完整交付写成一个 Markdown(.md)文件**(用 write_file 写到 outputs 目录,文件名体现你的职责),并在回复正文里给出要点摘要。**不要写 .py 或任何可执行脚本文件**(不要写脚本去生成产物);直接产出成品文档,.md / .html / .txt 等其它格式都可以,唯独不要 .py。`
: `\n\n【产物要求】本轮为**快速并行**模式:请把你的产物**直接写在回复正文里**,**不要写文件、不要执行脚本/命令**(需要落文件的产物留给沙箱模式或后续阶段)。`;
// 任务背景:DAG 派活轮抑制了 leader 预广播(防席位模仿派活),所以席位 thread 里可能没有
// 用户原始消息 —— 把任务背景直接写进席位 task,确保它有上下文(此前各阶段交付仍由后端
// 席位间 detached 广播注入,故只补背景)。
const bg = seedMessage.trim() ? `本次研讨的任务背景:\n${seedMessage.trim()}\n\n` : "";
// §12.9:此前各阶段席位的交付,你默认只看到**摘要**(广播进来的);若某个前序席位的摘要不够、
// 你需要它的**完整交付**,调用 read_peer_delivery('<席位名>') 按需取全文(平时不必调,优先用摘要)。
const peerRule = hasPeers
? `\n\n【按需取全文】此前各阶段席位的交付你默认只看到**摘要**;若需要某个前序席位的**完整交付**,调用工具 read_peer_delivery('<该席位名称>') 取回全文(摘要够用就不必调)。`
: "";
// 本阶段目标(兜底):用户为本阶段写的产出/依赖说明,即便总控没派好,席位也据此对齐本阶段意图。
const goalRule = goal.trim()
? `\n\n【本阶段目标】${goal.trim()}\n请确保你的交付服务于该目标,并显式承接上一阶段的产出。`
: "";
const wanted = seatAgentId.toLowerCase();
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
if (exact && exact[1]?.trim()) {
return `${bg}请你作为「${seatName}」,完成总控派给你的本阶段任务:\n${exact[1].trim()}${goalRule}${fileRule}${peerRule}${guard}`;
}
// dispatched 为空 / 未命中 → 干净本职任务,**不**注入总控派活叙述(根治「席位模仿派活」)。
return (
`${bg}请你作为「${seatName}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。` +
`此前各阶段席位的交付已在你的会话上下文里,请据此承接。` +
goalRule +
fileRule +
peerRule +
guard
);
}
File diff suppressed because it is too large. Load diff
@@ -23,7 +23,7 @@
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import {
Activity,
Check,
@@ -51,6 +51,7 @@ import {
import { useTheme } from "@/contexts/ThemeContext";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { useModels } from "@/core/models/hooks";
@@ -83,13 +84,44 @@ import {
type RoleEntry,
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
import {
buildChainSeatSelection,
type SeatSelectionResult,
} from "@/roundtable-planning/lib/business-chain";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
import { getChain } from "@/roundtable-planning/api/chains";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
/** 从任务页带进来的预填(经 navigation state 传递)。见会商自动化串联(模块 C)。 */
interface RoundtablePrefill {
/** 任务名称(注入 Step 1 首条用户消息)。 */
taskTitle?: string;
/** 任务描述(注入 Step 1 首条用户消息)。 */
taskGoal?: string;
/** 来源业务码(如 "6BF"):据此查业务映射拿到要自动选用的业务链。 */
businessCode?: string;
/** 是否进入即自动开跑 Step 1 意图分析。 */
autoStart?: boolean;
}
export default function RoundtablePlanningPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
// ─── 会商自动化串联(模块 C):任务页带进来的预填 ─────────────────
// 只在首次渲染读一次 navigation state;随后清掉 history state,避免刷新/后退重放。
const prefillRef = useRef<RoundtablePrefill | null>(
((location.state as { roundtablePrefill?: RoundtablePrefill } | null)?.roundtablePrefill) ?? null,
);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
const autoStartedRef = useRef(false);
const prefillClearedRef = useRef(false);
const { models: availableModels, isLoading: modelsLoading } = useModels();
@@ -104,13 +136,19 @@ export default function RoundtablePlanningPage() {
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活;
// dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
const [orchestrationMode, setOrchestrationMode] = useState<"recommend" | "chain">(
const [orchestrationMode, setOrchestrationMode] = useState<"recommend" | "chain" | "dag">(
"recommend",
);
// chain 模式来源链条(仅展示/持久化用)。
const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null);
// dag 模式的分层编排计划(Phase 2 编辑器产出;持久化进 step2.orchestrationPlan)。
const [orchestrationPlan, setOrchestrationPlan] = useState<OrchestrationPlan | null>(null);
// dag 执行子模式:fast(并行双列禁写)/ sandbox(并行单列可写、沙箱跟随)。
// 由推荐弹窗的「执行方式」选择决定(仅 dag 有意义)。
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
@@ -121,6 +159,9 @@ export default function RoundtablePlanningPage() {
const [step3SavedTick, setStep3SavedTick] = useState<number>(0);
const step2RunsRef = useRef<DraftStep2RunSnapshot[]>([]);
const activeStep2RunIdRef = useRef<string | null>(null);
// 并行执行时收起左侧栏 —— 全局工作区侧栏(最左)走 useSidebarSafe(与「打开沙箱」同一路径,
// 见 artifacts/context.tsx 的 sidebar.setOpen(false)),圆桌「参与角色」栏走 step2SidebarExpanded。
const sidebar = useSidebarSafe();
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
@@ -194,6 +235,8 @@ export default function RoundtablePlanningPage() {
currentStep,
selectedModel,
orchestrationMode,
orchestrationPlan,
dagExecMode,
buildInitialIntent,
triggerToast,
setRoles,
@@ -243,10 +286,14 @@ export default function RoundtablePlanningPage() {
seatStubMessages: step2.seatStubMessages,
orchestrationMode,
chain: chainMeta,
orchestrationPlan,
dagExecMode,
}),
[
chainMeta,
orchestrationMode,
orchestrationPlan,
dagExecMode,
step2.budgetLimit,
step2.consensusPercentage,
step2.coordinatorName,
@@ -279,6 +326,8 @@ export default function RoundtablePlanningPage() {
seatStubMessages: snapshot.seatStubMessages ?? {},
orchestrationMode: snapshot.orchestrationMode,
chain: snapshot.chain ?? null,
orchestrationPlan: snapshot.orchestrationPlan ?? null,
dagExecMode: snapshot.dagExecMode,
step3: step3Report,
}),
[step3Report],
@@ -362,6 +411,8 @@ export default function RoundtablePlanningPage() {
(run: DraftStep2RunSnapshot) => {
setOrchestrationMode(run.orchestrationMode ?? "recommend");
setChainMeta(run.chain ?? null);
setOrchestrationPlan(run.orchestrationPlan ?? null);
setDagExecMode(run.dagExecMode ?? "fast");
setStep3Report(run.step3 ?? null);
step2.hydrateFromDraft(run as unknown as Step2Snapshot);
},
@@ -421,6 +472,8 @@ export default function RoundtablePlanningPage() {
}
setOrchestrationMode(snapshot.orchestrationMode ?? "recommend");
setChainMeta(snapshot.chain ?? null);
setOrchestrationPlan(snapshot.orchestrationPlan ?? null);
setDagExecMode(snapshot.dagExecMode ?? "fast");
step2.hydrateFromDraft(snapshot as unknown as Step2Snapshot);
},
cancelAllStreams: () => {
@@ -504,6 +557,19 @@ export default function RoundtablePlanningPage() {
drafts.loadedFromDraftRef,
]);
// 并行执行 → 收起左侧栏腾出横向空间放两列(和「打开沙箱」一样的收起体验):
// - 全局工作区侧栏(最左):sidebar.setOpen(false)(与 artifacts 打开沙箱同一路径);
// - 圆桌「参与角色」栏:setStep2SidebarExpanded(false)。
// **不自动复原**:并行结束后再进入单智能体回答时保持收起(用户需要时手动展开),
// 与沙箱收起后不自动回弹一致。
useEffect(() => {
if (step2.isParallelRunning) {
sidebar?.setOpen(false);
setStep2SidebarExpanded(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step2.isParallelRunning]);
// ─── 步骤间转场 handler ───────────────────────────────────
const handleConfirmAgents = useCallback(
(result: SeatSelectionResult) => {
@@ -537,7 +603,10 @@ export default function RoundtablePlanningPage() {
budgetLimit: step2.budgetLimit,
seatStubMessages: {},
orchestrationMode: result.mode,
chain: result.mode === "chain" ? result.chain ?? null : null,
// chain / dag 都来自业务链条,保留来源链名;recommend 无。
chain: result.mode === "recommend" ? null : result.chain ?? null,
orchestrationPlan: result.mode === "dag" ? result.plan ?? null : null,
dagExecMode: result.mode === "dag" ? result.dagExecMode ?? "fast" : undefined,
step3: null,
};
setStep2Runs([...syncedRuns, newRun]);
@@ -546,13 +615,18 @@ export default function RoundtablePlanningPage() {
step2.resetStep2State();
step2.setSelectedAgents(normalized);
step2.resetInitGate();
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活。
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活;
// dag = 分层并行(来源链条带 stages,携带 result.plan)。
setOrchestrationMode(result.mode);
setChainMeta(result.mode === "chain" ? result.chain ?? null : null);
setChainMeta(result.mode === "recommend" ? null : result.chain ?? null);
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast");
setIsRecommendDialogOpen(false);
setCurrentStep(2);
triggerToast(
result.mode === "chain"
result.mode === "dag"
? `成功确认!将按业务链条「${result.chain?.title ?? ""}」分层并行研讨`
: result.mode === "chain"
? `成功确认!将按业务链条「${result.chain?.title ?? ""}」顺序研讨`
: "成功确认!已切换至第二阶段圆桌研讨系统",
);
@@ -560,6 +634,69 @@ export default function RoundtablePlanningPage() {
[buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast],
);
// ─── 会商自动化串联(模块 C) ──────────────────────────────────
// 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。
useEffect(() => {
if (prefillClearedRef.current || !prefillRef.current) return;
prefillClearedRef.current = true;
try {
window.history.replaceState({ ...window.history.state, usr: null }, "");
} catch {
/* noop */
}
}, []);
// C2:带 autoStart 时,等 Step 1 意图线程就绪后自动把「任务名称/描述」作为首条用户消息发出,
// 触发任务理解智能体自动分析(只发一次)。
useEffect(() => {
const prefill = prefillRef.current;
if (!prefill?.autoStart || autoStartedRef.current) return;
if (currentStep !== 1) return;
if (!step1.intentThreadId || step1.intentReady) return;
const title = prefill.taskTitle?.trim();
const goal = prefill.taskGoal?.trim();
const text = [title ? `任务名称:${title}` : "", goal ? `任务描述:${goal}` : ""]
.filter(Boolean)
.join("\n");
if (!text) return;
autoStartedRef.current = true;
void step1.sendIntentMessage(text);
}, [currentStep, step1.intentThreadId, step1.intentReady, step1.sendIntentMessage]);
// C3:据 prefill.businessCode 查业务映射 → 取 chainId → 拉公共链 + 候选智能体 → 构造自动选链结果。
// 解析成功后「进入研讨」会跳过选链弹窗直接据此进 Step 2;解析失败(未配置 / 链不存在 / 席位不足)
// 静默回退到弹窗手选。
// 不加「已启动」永久闸门 —— StrictMode 的 mount→unmount→remount 会让首次 run 在 cleanup
// 里被 cancelled,闸门又让 remount 跳过重启,导致 autoChainResult 永远设不上(与
// useStep1Intent 同款坑)。改为:每次 setup 都重跑,靠 cancelled 丢弃过期的首次结果,
// 末次 setup 正常落地(生产无 StrictMode 只跑一次)。
useEffect(() => {
const prefill = prefillRef.current;
if (!prefill?.businessCode) return;
let cancelled = false;
void (async () => {
try {
const mappings = await listBusinessMappings();
const row = mappings.find((m) => m.businessCode === prefill.businessCode);
const chainId = row?.chainId;
if (!chainId) return;
const [chain, agents] = await Promise.all([
getChain(chainId),
listAgents().catch(() => []),
]);
if (cancelled || !chain) return;
const candidates = filterRecommendCandidates(agents);
const result = buildChainSeatSelection(chain, candidates);
if (!cancelled && result) setAutoChainResult(result);
} catch {
/* 解析失败 → 保持 null,回退弹窗手选 */
}
})();
return () => {
cancelled = true;
};
}, []);
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
// 在历史记录里可见)。
@@ -570,6 +707,8 @@ export default function RoundtablePlanningPage() {
setActiveStep2RunId(null);
setOrchestrationMode("recommend");
setChainMeta(null);
setOrchestrationPlan(null);
setDagExecMode("fast");
setStep3Report(null);
step1.stopIntentStream();
drafts.loadedFromDraftRef.current = false;
@@ -609,7 +748,9 @@ export default function RoundtablePlanningPage() {
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
orchestrationMode,
// 后台作业引擎暂不支持 dag(分层 DAG 只在前端 runDagOrchestration 跑);
// 挂起到后台时降级为 recommend(总控自由派活)。
orchestrationMode: orchestrationMode === "dag" ? "recommend" : orchestrationMode,
chain: chainMeta,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。
@@ -855,6 +996,13 @@ export default function RoundtablePlanningPage() {
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
if (autoChainResult) {
const result = autoChainResult;
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
@@ -878,6 +1026,13 @@ export default function RoundtablePlanningPage() {
agentConfigs={step2.agentConfigs}
selectedAgentId={step2.selectedAgentId}
roles={roles}
parallelBatch={step2.parallelBatch}
selectedParallelSeats={step2.selectedParallelSeats}
isParallelRunning={step2.isParallelRunning}
onToggleParallelSeat={step2.toggleParallelSeat}
orchestrationMode={orchestrationMode}
orchestrationPlan={orchestrationPlan}
dagNodeStates={step2.dagNodeStates}
agentNameToRole={step2.agentNameToRole}
selectedModel={selectedModel}
availableModels={availableModels}
@@ -0,0 +1,87 @@
/**
* 全局业务映射(业务码 → 智能体 / 业务链)API 客户端。
*
* 后端:`/api/business-mapping`(见 app/gateway/routers/business_mapping.py)。
* 全局共享、无用户隔离;只读列表 + 更新 agent / chain 两列(业务行由后端 seed 固定)。
* snake_case ⇄ camelCase 在此边界收敛,调用方保持干净。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface BusinessMapping {
businessCode: string;
label: string;
agentId: string | null;
agentName: string | null;
chainId: string | null;
chainTitle: string | null;
sortOrder: number;
}
/** 部分更新 —— 只发改动的字段;显式传 null 清空该字段。 */
export interface BusinessMappingPatch {
agentId?: string | null;
agentName?: string | null;
chainId?: string | null;
chainTitle?: string | null;
}
const BASE = () => `${getBackendBaseURL()}/api/business-mapping`;
interface ServerMapping {
business_code: string;
label: string;
agent_id: string | null;
agent_name: string | null;
chain_id: string | null;
chain_title: string | null;
sort_order: number;
}
function toMapping(s: ServerMapping): BusinessMapping {
return {
businessCode: s.business_code,
label: s.label ?? "",
agentId: s.agent_id ?? null,
agentName: s.agent_name ?? null,
chainId: s.chain_id ?? null,
chainTitle: s.chain_title ?? null,
sortOrder: s.sort_order ?? 0,
};
}
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;
}
/** 列出全部业务映射(按展示顺序)。 */
export async function listBusinessMappings(): Promise<BusinessMapping[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ mappings: ServerMapping[] }>(res, "list business mappings");
return (data.mappings ?? []).map(toMapping);
}
/** 更新某业务行的智能体 / 业务链选择。404 返回 null。 */
export async function updateBusinessMapping(
businessCode: string,
patch: BusinessMappingPatch,
): Promise<BusinessMapping | null> {
const body: Record<string, unknown> = {};
if (patch.agentId !== undefined) body.agent_id = patch.agentId;
if (patch.agentName !== undefined) body.agent_name = patch.agentName;
if (patch.chainId !== undefined) body.chain_id = patch.chainId;
if (patch.chainTitle !== undefined) body.chain_title = patch.chainTitle;
const res = await apiFetch(`${BASE()}/${encodeURIComponent(businessCode)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.status === 404) return null;
const data = await readJson<ServerMapping>(res, "update business mapping");
return toMapping(data);
}
@@ -4,9 +4,10 @@ import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizonta
import React, { useEffect, useRef, useState } from 'react';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Col, Empty, Loading, Pagination, Progress, Row } from 'tdesign-react';
import { Col, Loading, Pagination, Progress, Row } from 'tdesign-react';
import { requestCrudService } from '@/service/request-axios-service';
import { ActionColumn } from './ActionColumn';
import { resolveStatusIcon } from './icons';
interface ActionAnalyzesProps {
TaskType?: string;
@@ -78,7 +79,6 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
setTaskLoading(true);
const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param);
setTaskLoading(false);
console.log('任务搜索', res);
const result = res.data;
result.forEach((item: TaskListItem) => {
item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比
@@ -86,7 +86,6 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
})
setTaskList(result || []); // 设置任务列表页
const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数
console.log('totalRes', totalRes)
setTotal(totalRes.data || 0); // 设置总数
if (result.length > 0) {
setTaskId(result[0].id);
@@ -101,7 +100,6 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
}
setActionLoading(true);
const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param);
console.log('行动查询', res);
setActionLoading(false);
const result = res.data;
setActionColumnList([
@@ -151,7 +149,7 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
<div className="text-[var(--text-color)] text-[17px] mb-[5px] font-[500]">{item.taskName}</div>
<div className="text-[#7f7f7f] mb-[5px]">{format(new Date(item.taskStartDate), 'yyyy-MM-dd HH:mm:ss')}</div>
<div className="flex items-center mb-[8px]">
<img src={`/icon/${item?.taskStatusData?.icon}`} alt="" />
<img src={resolveStatusIcon(item?.taskStatusData?.icon)} className="w-[20px]" alt="" />
<span className="ml-[5px] leading-[16px]">{item?.taskStatusData?.label}</span>
</div>
<div className="pb-[10px] pt-[10px]">
@@ -195,7 +193,11 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
))
}
{
actionColumnList.length === 0 && <Empty/>
actionColumnList.length === 0 && (
<div className={`col-span-4 flex items-center justify-center py-[60px] text-sm ${isDark ? 'text-slate-500' : 'text-[#bfbfbf]'}`}>
暂无数据
</div>
)
}
</div>
</Loading>
@@ -4,8 +4,9 @@ import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizonta
import React, { useEffect, useRef, useState } from 'react';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Col, Empty, Pagination, Progress, Row } from 'tdesign-react';
import { Col, Pagination, Progress, Row } from 'tdesign-react';
import { requestCrudService } from '@/service/request-axios-service';
import { resolveStatusIcon } from './icons';
interface ActionColumnProps {
TaskType?: string;
@@ -66,7 +67,7 @@ export const ActionColumn: React.FC<ActionColumnProps> = ({ title, category, sub
<div className={`h-[calc(100vh-100px)] p-[10px] flex-1 ${isDark? 'dark-action-bg' : `bg-white action-bg${columnIndex} shadow-[2px_0px_6px_rgba(0,0,0,0.12)]`} rounded-[4px]`}>
<div className="mb-[10px]">
<div className="flex items-center mb-[20px]">
<img src={`/icon/${icon}`} className="w-[24px] mr-[10px]" alt="" />
<img src={resolveStatusIcon(icon)} className="w-[24px] mr-[10px]" alt="" />
<span className="text-[#222222] text-[18px] font-bold mr-[20px]">{columnName}</span>
<span className="text-[#222222] text-[18px] font-bold mr-[20px]">{actionCount}</span>
</div>
@@ -99,13 +100,11 @@ export const ActionColumn: React.FC<ActionColumnProps> = ({ title, category, sub
))
}
{
actionList.length === 0 && <Empty image={
<img
src="/icon/empty-list.png"
alt="empty"
className="w-[135px]"
/>
} />
actionList.length === 0 && (
<div className={`flex items-center justify-center py-[48px] text-sm ${isDark ? 'text-slate-500' : 'text-[#bfbfbf]'}`}>
暂无数据
</div>
)
}
</div>
</div>
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ChevronRight, FileText } from 'lucide-react';
import { FileText } from 'lucide-react';
import { Pagination } from 'tdesign-react';
import { useTheme } from '@/contexts/ThemeContext';
import { TaskListItem } from './TaskManagement';
@@ -11,8 +11,8 @@ interface KanbanColumnProps {
color: string;
tasks: TaskListItem[];
taskType: string;
TaskDataType?: string;
pageIndexSearch: (taskType: string, pageIndex: number) => void;
taskDataType?: string;
pageIndexSearch: (taskType: string, pageIndex: number) => void | Promise<void>;
handleStartAnalysis: (task: TaskListItem) => void;
}
@@ -22,7 +22,7 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
color,
tasks,
taskType,
TaskDataType = 'task',
taskDataType = 'task',
pageIndexSearch,
handleStartAnalysis,
}) => {
@@ -30,49 +30,40 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
const isDark = theme === 'dark';
const [pageNum, setPageNum] = useState(1);
const pageIndexChange = async (value: number) => {
const handlePageChange = async (value: number) => {
setPageNum(value);
await pageIndexSearch(taskType, value);
};
const columnClass = isDark
? 'border border-[rgba(67,89,124,0.46)] bg-[linear-gradient(180deg,rgba(18,36,67,0.96),rgba(12,28,54,0.96))] shadow-[0_18px_40px_rgba(3,10,22,0.18)]'
: 'border border-[#E8E8E8] bg-[linear-gradient(180deg,#FFFFFF_0%,#FFF9F9_100%)] shadow-[0_14px_28px_rgba(245,34,45,0.06)]';
return (
<div
className={`theme-transition flex h-full min-h-0 flex-col rounded-[28px] border p-4 md:p-5 ${
isDark
? 'theme-card border-[rgba(110,136,176,0.24)] shadow-[0_18px_50px_rgba(2,8,20,0.24)]'
: 'border-slate-200 bg-white shadow-sm'
<section className={`flex h-full flex-col rounded-[28px] p-4 ${columnClass}`}>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${color}`} />
<h2
className={`whitespace-nowrap text-[15px] font-semibold leading-none ${
isDark ? 'text-white' : 'text-[#222222]'
}`}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className={`mt-1 h-2.5 w-2.5 rounded-full ${color}`} />
<div>
<div className="flex items-center gap-2">
<h2 className={`text-base font-semibold ${isDark ? 'text-slate-50' : 'text-slate-900'}`}>{title}</h2>
{title}
</h2>
<span
className={`rounded-full px-2 py-0.5 text-xs ${
isDark ? 'bg-[rgba(20,47,86,0.92)] text-slate-300' : 'bg-slate-100 text-slate-600'
className={`inline-flex rounded-full px-2 py-0.5 text-xs ${
isDark ? 'bg-[rgba(77,141,255,0.14)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'
}`}
>
{count}
</span>
</div>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
按时间倒序展示,支持分页切换。
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div
className={`hidden rounded-xl p-2 lg:flex ${
isDark ? 'bg-[rgba(10,30,54,0.82)] text-slate-300' : 'bg-slate-50 text-slate-500'
}`}
>
<ChevronRight className="h-4 w-4" />
</div>
<Pagination
className="sentiment-pagination"
className="sentiment-pagination shrink-0"
total={count}
size="small"
theme="simple"
@@ -81,43 +72,37 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
maxPageBtn={3}
showPageSize={false}
totalContent={false}
onCurrentChange={(page) => pageIndexChange(Number(page))}
onCurrentChange={(page) => handlePageChange(Number(page))}
/>
</div>
</div>
<div className="scrollbar-thin flex-1 space-y-3 overflow-y-auto pr-1">
<p className={`mt-2 text-xs leading-6 ${isDark ? 'text-slate-400' : 'text-[#8C8C8C]'}`}>
按时间倒序展示,支持分页切换。
</p>
<div className="mt-4 flex-1 space-y-4">
{tasks.length === 0 ? (
<div
className={`flex h-full min-h-[260px] flex-col items-center justify-center rounded-2xl border border-dashed ${
className={`flex h-full min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed ${
isDark
? 'border-[rgba(110,136,176,0.24)] bg-[rgba(9,25,46,0.38)]'
: 'border-slate-200 bg-slate-50'
? 'border-[rgba(87,111,149,0.42)] bg-[rgba(12,24,44,0.46)] text-slate-400'
: 'border-[#F3C4C8] bg-[#FFF7F7] text-[#A6A6A6]'
}`}
>
<div
className={`mb-3 flex h-12 w-12 items-center justify-center rounded-2xl ${
isDark ? 'bg-[rgba(13,42,78,0.92)] text-slate-300' : 'bg-white text-slate-500'
}`}
>
<FileText className="h-5 w-5" />
</div>
<div className={`text-sm ${isDark ? 'text-slate-300' : 'text-slate-600'}`}>暂无任务</div>
<div className={`mt-1 text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
可以尝试调整筛选条件或切换数据模式。
</div>
<FileText className="h-8 w-8 opacity-70" />
<p className="mt-3 text-sm">暂无任务</p>
</div>
) : (
tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
TaskType={TaskDataType}
taskDataType={taskDataType}
handleStartAnalysis={handleStartAnalysis}
/>
))
)}
</div>
</div>
</section>
);
};
@@ -1,13 +1,14 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowRight, Bot, Clock3, Layers, Search, Sparkles, Users, X } from 'lucide-react';
import { DateRangePicker, DateRangeValue, Loading } from 'tdesign-react';
import { ArrowRight, Bot, Layers, Plus, Search, Users, X } from 'lucide-react';
import dayjs from 'dayjs';
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@/contexts/ThemeContext';
import { useChatContext } from '@/contexts/ChatContext';
import { requestCrudService } from '@/service/request-axios-service';
import { toast } from 'sonner';
import { KanbanColumn } from './KanbanColumn';
import { TaskListItem } from './TaskManagement';
import { listBusinessMappings } from '@/strategy-components/api/business-mapping';
interface TaskListPageProps {
title: string;
@@ -15,189 +16,27 @@ interface TaskListPageProps {
subCategory: string;
}
type TaskType = 'single' | 'consultation' | 'scene' | 'multi-agent' | 'task-search';
type ColumnType = 'unanalyzed' | 'analyzing' | 'completed';
interface StateItem {
startTime: string;
endTime: string;
task: TaskListItem | null;
}
type ColumnType = 'unanalyzed' | 'running' | 'completed';
const service = requestCrudService;
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const ENABLE_SENTIMENT_DEMO_BY_DEFAULT = import.meta.env.DEV;
const buildMockTask = (
id: string,
name: string,
overview: string,
column: ColumnType,
type: TaskType,
priority: 1 | 2 | 3,
progress: number,
createTime: string,
updateTime: string,
owner: string,
): TaskListItem => ({
id,
taskName: name,
overview,
column,
type,
priority,
progress,
createTime,
updateTime,
owner,
creator: owner,
executor: owner,
scene: JSON.stringify({
taskJson: JSON.stringify({
task_title: name,
task_goal: overview,
}),
}),
});
const MOCK_TASKS: Record<ColumnType, TaskListItem[]> = {
unanalyzed: [
buildMockTask(
'demo-u-1',
'新能源价格舆情波动分析',
'聚焦近 7 日新能源整车与动力电池价格讨论热度,识别社媒与媒体端异常扩散信号。',
'unanalyzed',
'single',
2,
0,
'2026-06-05 09:10:00',
'2026-06-05 09:10:00',
'王晓楠',
),
buildMockTask(
'demo-u-2',
'重点项目负面反馈聚类',
'从任务评论、审批意见和业务群消息中抽取高频情绪主题,辅助判断风险聚焦点。',
'unanalyzed',
'consultation',
3,
0,
'2026-06-05 08:40:00',
'2026-06-05 08:40:00',
'刘志诚',
),
buildMockTask(
'demo-u-3',
'竞品发布会用户情绪追踪',
'围绕发布会直播、短视频评论与论坛帖子,建立情绪转折时间线并识别主要触发事件。',
'unanalyzed',
'scene',
1,
0,
'2026-06-04 18:20:00',
'2026-06-04 18:20:00',
'陈思雨',
),
buildMockTask(
'demo-u-4',
'区域渠道满意度扫描',
'基于周报、工单和售后回访文本,发现华东区域渠道服务满意度下行趋势。',
'unanalyzed',
'task-search',
2,
0,
'2026-06-04 15:35:00',
'2026-06-04 15:35:00',
'赵明宇',
),
],
analyzing: [
buildMockTask(
'demo-a-1',
'新车上市口碑监测会商',
'正在联合多个智能体汇总平台评价、媒体测评与用户问答,生成阶段性情绪画像。',
'analyzing',
'multi-agent',
3,
56,
'2026-06-05 07:50:00',
'2026-06-05 10:05:00',
'张雨桐',
),
buildMockTask(
'demo-a-2',
'投诉热点归因拆解',
'拆分售后投诉中关于续航、交付和门店服务的负面占比,分析主要归因与蔓延路径。',
'analyzing',
'consultation',
2,
78,
'2026-06-04 20:10:00',
'2026-06-05 09:48:00',
'何嘉成',
),
],
completed: [
buildMockTask(
'demo-c-1',
'车展传播效果情绪复盘',
'已输出传播节点热度变化、核心评论主题以及 KOL 内容对情绪曲线的影响分析。',
'completed',
'single',
1,
100,
'2026-06-03 14:20:00',
'2026-06-04 17:30:00',
'孙怡宁',
),
buildMockTask(
'demo-c-2',
'智能驾驶争议议题扫描',
'已完成争议议题聚类、情绪倾向分层和外部平台扩散链路分析,可直接查看结论。',
'completed',
'scene',
3,
100,
'2026-06-03 09:00:00',
'2026-06-04 11:45:00',
'李承泽',
),
buildMockTask(
'demo-c-3',
'品牌联名活动舆情总结',
'已完成用户态度分布统计与正负面声音对比,附带评论样本摘要和建议动作。',
'completed',
'task-search',
2,
100,
'2026-06-02 16:50:00',
'2026-06-03 19:12:00',
'周清越',
),
],
};
const paginateTasks = (tasks: TaskListItem[], pageIndex: number, pageSize: number) => {
const start = (pageIndex - 1) * pageSize;
return tasks.slice(start, start + pageSize);
};
/** 本页对应的业务码(3QFX = 3Q);用于查业务映射拿到要自动选用的业务链 / 单智能体。 */
const BUSINESS_CODE = '3Q';
const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory }) => {
const { theme } = useTheme();
const { setStartConfig } = useChatContext();
const navigate = useNavigate();
const isDark = theme === 'dark';
const shellClass = isDark
? 'strategy-data min-h-full bg-[radial-gradient(circle_at_top_left,rgba(27,94,168,0.28),transparent_32%),linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100'
: 'strategy-data min-h-full bg-[#f8fafc] text-slate-900';
const metricCardClass = isDark
? 'border border-[rgba(110,136,176,0.24)] bg-[linear-gradient(180deg,rgba(12,32,58,0.96),rgba(9,22,39,0.96))] shadow-[0_14px_42px_rgba(2,8,20,0.30)]'
: 'border border-slate-200 bg-white shadow-sm';
const inputClass = isDark
? 'border-[rgba(110,136,176,0.3)] bg-[rgba(8,23,42,0.92)] text-slate-100 placeholder:text-slate-500'
: 'border-slate-200 bg-white text-slate-900 placeholder:text-slate-400';
const panelGlowClass = isDark
? 'before:pointer-events-none before:absolute before:inset-x-8 before:top-0 before:h-px before:bg-[linear-gradient(90deg,transparent,rgba(96,165,250,0.45),transparent)]'
: '';
const [searchQuery, setSearchQuery] = useState('');
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState<TaskListItem | null>(null);
const [useDemoData, setUseDemoData] = useState(ENABLE_SENTIMENT_DEMO_BY_DEFAULT);
const [selectedTask, setSelectedTask] = useState<TaskListItem | null>(null);
const [unanalyzedTotal, setUnanalyzedTotal] = useState(0);
const [runningTotal, setRunningTotal] = useState(0);
const [completedTotal, setCompletedTotal] = useState(0);
@@ -207,378 +46,399 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
const [unanalyzedLoading, setUnanalyzedLoading] = useState(false);
const [runningLoading, setRunningLoading] = useState(false);
const [completedLoading, setCompletedLoading] = useState(false);
const [dateRange, setDateRange] = useState<DateRangeValue>([
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().endOf('day').format('YYYY-MM-DD'),
]);
const [range, setRange] = useState<DateRangeValue>([]);
const pageSizeRef = useRef(10);
const filteredMock = useMemo(() => {
const keyword = searchQuery.trim().toLowerCase();
const filterItems = (items: TaskListItem[]) => {
if (!keyword) return items;
return items.filter((item) => {
const text = `${item.taskName} ${item.overview} ${item.owner || ''}`.toLowerCase();
return text.includes(keyword);
const stateRef = useRef<StateItem>({
startTime: '',
endTime: '',
task: null,
});
const presets = useMemo<DateRangePickerProps['presets']>(
() => ({
最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()],
最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()],
今天: [dayjs().toDate(), dayjs().toDate()],
}),
[],
);
const taskStatusMap: Record<ColumnType, number[]> = {
unanalyzed: [0, 10],
running: [20],
completed: [25, 30, 40, 60, 70, 80, 90, 100, 110],
};
return {
unanalyzed: filterItems(MOCK_TASKS.unanalyzed),
analyzing: filterItems(MOCK_TASKS.analyzing),
completed: filterItems(MOCK_TASKS.completed),
};
}, [searchQuery]);
const setRangeChange = async (value: DateRangeValue) => {
setRange(value);
stateRef.current.startTime = '';
stateRef.current.endTime = '';
const applyMockColumn = (column: ColumnType, pageIndex = 1) => {
const source = filteredMock[column];
const pageItems = paginateTasks(source, pageIndex, pageSizeRef.current);
if (column === 'unanalyzed') {
setUnanalyzedTotal(source.length);
setUnanalyzedList(pageItems);
setUnanalyzedLoading(false);
} else if (column === 'analyzing') {
setRunningTotal(source.length);
setRunningList(pageItems);
setRunningLoading(false);
} else {
setCompletedTotal(source.length);
setCompletedList(pageItems);
setCompletedLoading(false);
if (Array.isArray(value) && value.length > 1) {
stateRef.current.startTime = String(value[0] || '');
stateRef.current.endTime = String(value[1] || '');
}
await taskDataChange();
};
const taskListSearch = async (
taskStatus: ColumnType,
pageIndex = 1,
pageSize = pageSizeRef.current,
taskType: ColumnType,
pageNum = 1,
pageSize = 10,
) => {
pageSizeRef.current = pageSize;
const setLoadingByType = (loading: boolean) => {
if (taskStatus === 'unanalyzed') setUnanalyzedLoading(loading);
if (taskStatus === 'analyzing') setRunningLoading(loading);
if (taskStatus === 'completed') setCompletedLoading(loading);
};
setLoadingByType(true);
if (useDemoData) {
applyMockColumn(taskStatus, pageIndex);
return;
}
if (taskType === 'unanalyzed') setUnanalyzedLoading(true);
if (taskType === 'running') setRunningLoading(true);
if (taskType === 'completed') setCompletedLoading(true);
try {
const [startTime, endTime] = Array.isArray(dateRange) ? dateRange : ['', ''];
const payload = {
pageNum: pageIndex,
const response = await service.post(`${zncmUrl}/three/cop-three-tasks`, {
content: searchQuery,
pageNum,
pageSize,
taskStatus,
taskCategoryName: category,
taskTypeName: subCategory,
searchKey: searchQuery.trim() || undefined,
startTime,
endTime,
};
taskStatus: taskStatusMap[taskType],
startTime: stateRef.current.startTime,
endTime: stateRef.current.endTime,
taskDirection: '',
});
const response = await service.post(`${zncmUrl}/three/cop-three-tasks`, payload);
const rows = response?.rows || response?.data?.rows || [];
const total = response?.total || response?.data?.total || 0;
const mappedList = rows.map((item: TaskListItem) => ({
const result = response?.data ?? {};
const records = Array.isArray(result.records) ? result.records : [];
const normalized = records.map((item: TaskListItem) => ({
...item,
column: taskStatus === 'analyzing' ? 'running' : taskStatus,
column: taskType,
taskLevel: item.taskLevel || 1,
}));
const total = Number(result.total ?? normalized.length);
if (taskStatus === 'unanalyzed') {
setUnanalyzedList(mappedList);
if (taskType === 'unanalyzed') {
setUnanalyzedTotal(total);
} else if (taskStatus === 'analyzing') {
setRunningList(mappedList);
setUnanalyzedList(normalized);
} else if (taskType === 'running') {
setRunningTotal(total);
setRunningList(normalized);
} else {
setCompletedList(mappedList);
setCompletedTotal(total);
setCompletedList(normalized);
}
} catch (error) {
console.error('sentiment task list fetch failed', error);
applyMockColumn(taskStatus, pageIndex);
setUseDemoData(true);
} finally {
setLoadingByType(false);
if (taskType === 'unanalyzed') setUnanalyzedLoading(false);
if (taskType === 'running') setRunningLoading(false);
if (taskType === 'completed') setCompletedLoading(false);
}
};
const refreshAllColumns = async () => {
const pageIndexChange = async (type: string, pageIndex: number) => {
await taskListSearch(type as ColumnType, pageIndex);
};
const taskDataChange = async (pageNum = 1, pageSize = 10) => {
await Promise.all([
taskListSearch('unanalyzed', 1),
taskListSearch('analyzing', 1),
taskListSearch('completed', 1),
taskListSearch('unanalyzed', pageNum, pageSize),
taskListSearch('running', pageNum, pageSize),
taskListSearch('completed', pageNum, pageSize),
]);
};
useEffect(() => {
refreshAllColumns();
}, [useDemoData]);
useEffect(() => {
const timer = window.setTimeout(() => {
refreshAllColumns();
}, useDemoData ? 120 : 260);
return () => window.clearTimeout(timer);
}, [searchQuery, dateRange, useDemoData]);
const metrics = [
{
label: '全部任务',
value: unanalyzedTotal + runningTotal + completedTotal,
icon: Layers,
accent: 'from-[#4D8DFF] to-[#76C2FF]',
},
{
label: '未分析',
value: unanalyzedTotal,
icon: Clock3,
accent: 'from-[#38BDF8] to-[#7DD3FC]',
},
{
label: '分析中',
value: runningTotal,
icon: Sparkles,
accent: 'from-[#2DD4BF] to-[#67E8F9]',
},
{
label: '已完成',
value: completedTotal,
icon: Bot,
accent: 'from-[#60A5FA] to-[#A78BFA]',
},
];
const handleStartAnalysis = (task: TaskListItem) => {
setSelectedTaskForAnalysis(task);
setSelectedTask(task);
stateRef.current.task = task;
setShowAnalysisModal(true);
};
const handleViewTaskDetail = () => {
if (!selectedTaskForAnalysis) return;
setStartConfig({
task_title: selectedTaskForAnalysis.taskName,
task_goal: selectedTaskForAnalysis.overview,
task_context: selectedTaskForAnalysis.overview,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
});
const handleOpenTaskDetail = () => {
if (!stateRef.current.task) return;
window.open(`https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-info-search?id=${stateRef.current.task.id}`);
setShowAnalysisModal(false);
navigate('/qa-center/mope-chat');
};
const handleMultiAgentDiscussion = () => {
if (!selectedTaskForAnalysis) return;
setStartConfig({
task_title: selectedTaskForAnalysis.taskName,
task_goal: selectedTaskForAnalysis.overview,
task_context: selectedTaskForAnalysis.overview,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
const handleOpenConsultation = () => {
const task = stateRef.current.task;
if (!task) return;
// 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(3Q=三情分析)。
// 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 3Q 配置的业务链(跳过选链弹窗)。
navigate('/page/strategy/qa/multi-agent-roundtable', {
state: {
roundtablePrefill: {
taskTitle: task.taskName,
taskGoal: task.overview,
businessCode: BUSINESS_CODE,
autoStart: true,
},
},
});
setShowAnalysisModal(false);
navigate('/page/workspace/roundtable/planning');
};
// 单智能体分析:用「业务链条管理」里为本业务配置的智能体,跳转到其问答页(新会话),
// 把任务名称/简介作为开场消息暂存,由 AgentChatPage 读取 sessionStorage.pendingAgentMessage
// 自动发出并开始问答。未配置智能体则提示去配置。
const handleSingleAgentAnalysis = async () => {
const task = stateRef.current.task;
if (!task) return;
let agentId: string | null = null;
try {
const mappings = await listBusinessMappings();
agentId = mappings.find((m) => m.businessCode === BUSINESS_CODE)?.agentId ?? null;
} catch {
/* ignore → 下面按未配置处理 */
}
if (!agentId) {
toast.error('该业务尚未在「业务链条管理」中配置单智能体');
return;
}
const text = [
task.taskName ? `任务名称:${task.taskName}` : '',
task.overview ? `任务简介:${task.overview}` : '',
]
.filter(Boolean)
.join('\n');
try {
sessionStorage.setItem(
'pendingAgentMessage',
JSON.stringify({ agentId, text }),
);
} catch {
/* noop */
}
navigate(`/page/strategy/agents/${agentId}/chats/new`);
setShowAnalysisModal(false);
};
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
if ((event.metaKey || event.ctrlKey || event.shiftKey) && event.key === 'Enter') {
event.preventDefault();
} else if (event.key === 'Enter') {
event.preventDefault();
await taskDataChange();
} else if (event.key === '/') {
event.preventDefault();
}
};
useEffect(() => {
taskDataChange();
}, []);
const shellClass = isDark
? 'strategy-data h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)]'
: 'strategy-data h-full overflow-y-auto bg-[linear-gradient(180deg,#FFFDFD_0%,#FFF7F7_46%,#FAFAFA_100%)]';
const toolbarClass = isDark
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(10,23,42,0.94)] text-white shadow-[0_16px_36px_rgba(3,10,22,0.2)]'
: 'border border-[#E8E8E8] bg-white text-[#222222] shadow-[0_14px_30px_rgba(245,34,45,0.06)]';
const metricClass = isDark
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)]'
: 'border border-[#E8E8E8] bg-white';
const inputClass = isDark
? 'border-[rgba(67,89,124,0.46)] bg-[rgba(8,19,35,0.9)] text-white placeholder:text-slate-500'
: 'border-[#E8E8E8] bg-white text-[#222222] placeholder:text-[#A6A6A6]';
const metrics = [
{ label: '全部任务', value: unanalyzedTotal + runningTotal + completedTotal },
{ label: '未分析', value: unanalyzedTotal },
{ label: '分析中', value: runningTotal },
{ label: '已完成', value: completedTotal },
];
return (
<div className={shellClass}>
<div className={`relative mx-auto flex h-full max-w-[1680px] flex-col gap-5 px-6 py-6 lg:px-8 ${panelGlowClass}`}>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="mx-auto flex min-h-full max-w-[1680px] flex-col gap-4 px-6 py-6 lg:px-8">
<section className={`rounded-[28px] p-5 ${toolbarClass}`}>
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-[rgba(96,165,250,0.28)] bg-[linear-gradient(180deg,rgba(22,119,255,0.28),rgba(14,63,135,0.18))] shadow-[0_10px_30px_rgba(14,63,135,0.24)]">
<Bot className="h-5 w-5 text-[#7CC9FF]" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-[26px] font-semibold tracking-[0.02em] text-slate-50">{title}</h1>
{useDemoData ? (
<span className="rounded-full border border-[rgba(125,211,252,0.28)] bg-[rgba(56,189,248,0.14)] px-2.5 py-1 text-xs text-[#8EDCFF]">
演示数据
<div className="flex items-center gap-2 text-xs">
<span className={`rounded-full px-3 py-1 ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}>
{category}
</span>
<ArrowRight className={`h-3.5 w-3.5 ${isDark ? 'text-slate-500' : 'text-[#A6A6A6]'}`} />
<span className={`rounded-full px-3 py-1 ${isDark ? 'bg-[rgba(34,197,94,0.14)] text-[#89E8B8]' : 'bg-[#FFF6E8] text-[#D46B08]'}`}>
{subCategory}
</span>
) : null}
</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-300/80' : 'text-slate-500'}`}>
<div>
<h1 className={`text-[28px] font-semibold ${isDark ? 'text-white' : 'text-[#222222]'}`}>{title}</h1>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
面向规划任务的情绪与倾向分析看板,支持快速检索、会商启动与结果回看。
</p>
</div>
</div>
<div className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs ${isDark ? 'border-[rgba(110,136,176,0.28)] bg-[rgba(9,25,46,0.72)] text-slate-300' : 'border-slate-200 bg-white text-slate-600'}`}>
<span>{category}</span>
<ArrowRight className="h-3.5 w-3.5 opacity-70" />
<span>{subCategory}</span>
</div>
</div>
<div className="flex flex-col gap-3 xl:items-end">
<div className="flex flex-wrap items-center gap-3">
<label className={`flex h-11 min-w-[320px] items-center gap-3 rounded-2xl border px-4 ${inputClass}`}>
<Search className="h-4 w-4 text-[#7CC9FF]" />
<label className={`flex h-11 min-w-[320px] items-center gap-3 rounded-xl border px-4 ${inputClass}`}>
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#A6A6A6]'}`} />
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={handleKeyDown}
className="w-full bg-transparent text-sm outline-none"
placeholder="搜索任务名称、摘要或负责人"
placeholder="搜索任务名称"
/>
</label>
<DateRangePicker
className="sentiment-date-range"
value={dateRange}
enableTimePicker={false}
clearable={false}
value={range}
presets={presets}
onChange={setRangeChange}
enableTimePicker
clearable
popupProps={{ overlayClassName: 'sentiment-date-range-popup' }}
rangeInputProps={{
inputProps: [
{ inputClass: 'sentiment-date-range-input' },
{ inputClass: 'sentiment-date-range-input' },
],
}}
onChange={(value) => setDateRange(value)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setUseDemoData(true)}
className={`rounded-xl px-4 py-2 text-sm transition ${useDemoData ? 'bg-[#1677FF] text-white shadow-[0_8px_22px_rgba(22,119,255,0.28)]' : isDark ? 'bg-[rgba(10,30,54,0.9)] text-slate-300 hover:bg-[rgba(13,42,78,0.95)]' : 'bg-white text-slate-700 hover:bg-slate-50'}`}
>
演示数据
</button>
<button
type="button"
onClick={() => setUseDemoData(false)}
className={`rounded-xl px-4 py-2 text-sm transition ${!useDemoData ? 'bg-[#0EA5E9] text-white shadow-[0_8px_22px_rgba(14,165,233,0.24)]' : isDark ? 'bg-[rgba(10,30,54,0.9)] text-slate-300 hover:bg-[rgba(13,42,78,0.95)]' : 'bg-white text-slate-700 hover:bg-slate-50'}`}
>
真实数据
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{metrics.map(({ label, value, icon: Icon, accent }) => (
<div key={label} className={`rounded-3xl p-5 ${metricCardClass}`}>
<button
type="button"
className={`inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm ${isDark ? 'bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}
>
<Plus className="h-4 w-4" />
新建任务
</button>
</div>
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{metrics.map((item, index) => (
<div key={item.label} className={`rounded-[24px] p-5 ${metricClass}`}>
<div className="flex items-center justify-between">
<div>
<p className={`text-sm ${isDark ? 'text-slate-300/78' : 'text-slate-500'}`}>{label}</p>
<p className="mt-3 text-3xl font-semibold tracking-[0.03em] text-white">{value}</p>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#8C8C8C]'}`}>{item.label}</p>
<p className={`mt-3 text-[36px] font-semibold ${isDark ? 'text-white' : index === 0 ? 'text-[#222222]' : 'text-[#F5222D]'}`}>
{item.value}
</p>
</div>
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br ${accent} shadow-[inset_0_1px_0_rgba(255,255,255,0.18)]`}>
<Icon className="h-5 w-5 text-white" />
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl ${isDark ? 'bg-[rgba(77,141,255,0.18)] text-[#9CCBFF]' : 'bg-[#FFF1F0] text-[#CF1322]'}`}>
<Layers className="h-5 w-5" />
</div>
</div>
</div>
))}
</div>
</section>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 xl:grid-cols-3">
<div className="min-h-[420px]">
<Loading loading={unanalyzedLoading} showOverlay={false}>
<section className="grid grid-cols-1 items-stretch gap-4 xl:grid-cols-3">
<div className="h-full">
<Loading className="block h-full" loading={unanalyzedLoading} showOverlay={false}>
<div className="h-full">
<KanbanColumn
title="未分析"
count={unanalyzedTotal}
color="bg-[#38BDF8]"
color={isDark ? 'bg-[#7CC9FF]' : 'bg-[#F5222D]'}
tasks={unanalyzedList}
taskType="unanalyzed"
TaskDataType="sentiment"
pageIndexSearch={(taskType, pageIndex) => taskListSearch(taskType as ColumnType, pageIndex)}
taskDataType="sentiment"
pageIndexSearch={pageIndexChange}
handleStartAnalysis={handleStartAnalysis}
/>
</div>
</Loading>
</div>
<div className="min-h-[420px]">
<Loading loading={runningLoading} showOverlay={false}>
<div className="h-full">
<Loading className="block h-full" loading={runningLoading} showOverlay={false}>
<div className="h-full">
<KanbanColumn
title="分析中"
count={runningTotal}
color="bg-[#2DD4BF]"
color={isDark ? 'bg-[#5EEAD4]' : 'bg-[#1677FF]'}
tasks={runningList}
taskType="analyzing"
TaskDataType="sentiment"
pageIndexSearch={(taskType, pageIndex) => taskListSearch(taskType as ColumnType, pageIndex)}
taskType="running"
taskDataType="sentiment"
pageIndexSearch={pageIndexChange}
handleStartAnalysis={handleStartAnalysis}
/>
</div>
</Loading>
</div>
<div className="min-h-[420px]">
<Loading loading={completedLoading} showOverlay={false}>
<div className="h-full">
<Loading className="block h-full" loading={completedLoading} showOverlay={false}>
<div className="h-full">
<KanbanColumn
title="已完成"
count={completedTotal}
color="bg-[#60A5FA]"
color={isDark ? 'bg-[#67E8A5]' : 'bg-[#52C41A]'}
tasks={completedList}
taskType="completed"
TaskDataType="sentiment"
pageIndexSearch={(taskType, pageIndex) => taskListSearch(taskType as ColumnType, pageIndex)}
taskDataType="sentiment"
pageIndexSearch={pageIndexChange}
handleStartAnalysis={handleStartAnalysis}
/>
</div>
</Loading>
</div>
</div>
</section>
</div>
{showAnalysisModal && selectedTaskForAnalysis ? (
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-[rgba(3,10,22,0.62)] px-4 backdrop-blur-sm">
<div className={`w-full max-w-[560px] rounded-[28px] border p-6 ${isDark ? 'border-[rgba(110,136,176,0.26)] bg-[linear-gradient(180deg,rgba(10,29,54,0.98),rgba(8,20,37,0.98))] text-slate-100 shadow-[0_28px_80px_rgba(2,8,20,0.42)]' : 'border-slate-200 bg-white text-slate-900 shadow-xl'}`}>
{showAnalysisModal && selectedTask ? (
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-black/45 px-4">
<div className={`w-full max-w-[560px] rounded-[28px] border p-6 ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(10,23,42,0.98)] text-white' : 'border-[#E8E8E8] bg-white text-[#222222] shadow-[0_18px_36px_rgba(0,0,0,0.12)]'}`}>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-semibold">选择分析模式</h3>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300/78' : 'text-slate-500'}`}>
当前任务:{selectedTaskForAnalysis.taskName}
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
为任务“{selectedTask.taskName}”选择分析方式
</p>
</div>
<button
type="button"
onClick={() => setShowAnalysisModal(false)}
className={`rounded-xl p-2 transition ${isDark ? 'hover:bg-[rgba(15,41,74,0.88)]' : 'hover:bg-slate-100'}`}
className={`rounded-xl p-2 ${isDark ? 'hover:bg-[rgba(21,42,72,0.9)]' : 'hover:bg-[#FFF1F0]'}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-6 grid gap-3">
<div className="mt-6 space-y-3">
<button
type="button"
onClick={handleViewTaskDetail}
className={`group flex items-center justify-between rounded-2xl border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(96,165,250,0.48)] hover:bg-[rgba(12,38,69,0.96)]' : 'border-slate-200 bg-slate-50 hover:border-sky-200 hover:bg-sky-50'}`}
onClick={handleOpenTaskDetail}
className={`flex w-full items-center justify-between rounded-[22px] border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(110,168,255,0.46)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
>
<div className="flex items-center gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,#1677FF,#0EA5E9)]">
<Layers className="h-5 w-5 text-white" />
</div>
<div>
<div className="font-medium">查看任务详情</div>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-slate-500'}`}>进入单任务分析视图,查看步骤、结论和上下文信息。</div>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
进入 B1 系统查看任务详情。
</div>
</div>
<ArrowRight className="h-4 w-4 opacity-70 transition group-hover:translate-x-0.5" />
<ArrowRight className="h-4 w-4 opacity-70" />
</button>
<button
type="button"
onClick={handleMultiAgentDiscussion}
className={`group flex items-center justify-between rounded-2xl border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(45,212,191,0.48)] hover:bg-[rgba(10,42,58,0.96)]' : 'border-slate-200 bg-slate-50 hover:border-emerald-200 hover:bg-emerald-50'}`}
onClick={handleOpenConsultation}
className={`flex w-full items-center justify-between rounded-[22px] border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(103,232,165,0.42)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
>
<div className="flex items-center gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,#14B8A6,#22C55E)]">
<Users className="h-5 w-5 text-white" />
</div>
<div className="flex items-start gap-3">
<Users className={`mt-0.5 h-5 w-5 ${isDark ? 'text-[#89E8B8]' : 'text-[#CF1322]'}`} />
<div>
<div className="font-medium">多智能体会商</div>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-slate-500'}`}>启动会商模式,由多个智能体并行分析情绪走势与影响因素。</div>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
多个 AI 智能体从不同视角开展讨论并形成共识。
</div>
</div>
<ArrowRight className="h-4 w-4 opacity-70 transition group-hover:translate-x-0.5" />
</div>
<ArrowRight className="h-4 w-4 opacity-70" />
</button>
<button
type="button"
onClick={handleSingleAgentAnalysis}
className={`flex w-full items-center justify-between rounded-[22px] border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(67,89,124,0.46)] bg-[rgba(15,33,60,0.92)] hover:border-[rgba(167,139,250,0.46)]' : 'border-[#E8E8E8] bg-[#FFF9F9] hover:border-[#F6B0B4]'}`}
>
<div className="flex items-start gap-3">
<Bot className={`mt-0.5 h-5 w-5 ${isDark ? 'text-[#C4B5FD]' : 'text-[#7C3AED]'}`} />
<div>
<div className="font-medium">单智能体分析</div>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
由单个 AI 智能体直接对该任务进行分析问答。
</div>
</div>
</div>
<ArrowRight className="h-4 w-4 opacity-70" />
</button>
</div>
</div>
File diff suppressed because it is too large. Load diff
@@ -1,20 +1,54 @@
import React from 'react';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Bot, Eye, Layers, Play, User, Users, Workflow } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@/contexts/ThemeContext';
import { useChatContext } from '@/contexts/ChatContext';
import { TaskListItem } from './TaskManagement';
interface TaskCardProps {
TaskType?: string;
task: TaskListItem;
taskDataType?: string;
handleStartAnalysis: (task: TaskListItem) => void;
}
const getTaskTypeConfig = (taskType?: number) => {
switch (taskType) {
case 2:
return { label: '场景', icon: Workflow };
case 3:
return { label: '会商', icon: Users };
case 4:
return { label: '多智能体', icon: Layers };
case 5:
return { label: '检索', icon: Layers };
case 1:
default:
return { label: '单任务', icon: Bot };
}
};
const getTaskLevelLabel = (taskLevel?: number) => {
switch (taskLevel) {
case 2:
return '紧急';
case 3:
return '加急';
case 1:
default:
return '一般';
}
};
const getColumnLabel = (column?: string) => {
if (column === 'running' || column === 'analyzing') return '分析中';
if (column === 'completed') return '已完成';
return '待分析';
};
export const TaskCard: React.FC<TaskCardProps> = ({
task,
TaskType = 'task',
taskDataType = 'task',
handleStartAnalysis,
}) => {
const { theme } = useTheme();
@@ -22,209 +56,159 @@ export const TaskCard: React.FC<TaskCardProps> = ({
const navigate = useNavigate();
const isDark = theme === 'dark';
const sceneDataChange = (currentTask: TaskListItem) => {
if (!currentTask.scene) return;
const typeConfig = getTaskTypeConfig(task.taskType);
const TypeIcon = typeConfig.icon;
const isCompleted = task.column === 'completed';
const isRunning = task.column === 'running' || task.column === 'analyzing';
const openConversation = () => {
try {
const sceneItem = task.scene ? JSON.parse(task.scene) : {};
const taskJson = sceneItem?.taskJson ? JSON.parse(sceneItem.taskJson) : {};
const sceneItem = JSON.parse(currentTask.scene);
if (sceneItem.taskJson) {
sceneItem.taskJson = JSON.parse(sceneItem.taskJson);
setStartConfig({
...sceneItem.taskJson,
...taskJson,
task_title: task.taskName,
task_goal: task.overview,
task_context: task.overview,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
});
} catch (error) {
setStartConfig({
task_title: task.taskName,
task_goal: task.overview,
task_context: task.overview,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
task_context: currentTask.overview,
});
navigate('/qa-center/mope-chat');
}
navigate('/qa-center/mope-chat');
};
const handleTaskClick = (currentTask: TaskListItem) => {
if (TaskType === 'sentiment') {
sceneDataChange(currentTask);
const handleCardClick = () => {
if (taskDataType === 'sentiment') {
openConversation();
return;
}
if (currentTask.column === 'completed') {
if (isCompleted) {
navigate('/strategy-result');
return;
}
if (currentTask.column === 'running' || currentTask.column === 'analyzing') {
sceneDataChange(currentTask);
if (isRunning) {
openConversation();
}
};
const handleStartChange = (currentTask: TaskListItem) => {
handleStartAnalysis(currentTask);
};
const handleActionClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
const getTypeConfig = (type: string) => {
const common = isDark ? 'border border-white/10 text-slate-200' : 'border border-slate-200 text-slate-700';
switch (type) {
case 'single':
return {
label: '单任务',
icon: Layers,
className: `${common} ${isDark ? 'bg-[rgba(22,119,255,0.14)]' : 'bg-sky-50'}`,
};
case 'consultation':
return {
label: '会商',
icon: Users,
className: `${common} ${isDark ? 'bg-[rgba(20,184,166,0.14)]' : 'bg-emerald-50'}`,
};
case 'scene':
return {
label: '场景',
icon: Workflow,
className: `${common} ${isDark ? 'bg-[rgba(168,85,247,0.14)]' : 'bg-violet-50'}`,
};
case 'multi-agent':
return {
label: '多智能体',
icon: Bot,
className: `${common} ${isDark ? 'bg-[rgba(99,102,241,0.16)]' : 'bg-indigo-50'}`,
};
default:
return {
label: '检索',
icon: Layers,
className: `${common} ${isDark ? 'bg-[rgba(59,130,246,0.12)]' : 'bg-slate-100'}`,
};
if (isCompleted) {
handleCardClick();
return;
}
handleStartAnalysis(task);
};
const getLevel = (priority: number = 1) => {
if (priority >= 3) return '加急';
if (priority === 2) return '紧急';
return '一般';
};
const panelClass = isDark
? 'border border-[rgba(67,89,124,0.46)] bg-[rgba(19,39,72,0.9)] shadow-[0_14px_34px_rgba(3,10,22,0.2)] hover:border-[rgba(110,168,255,0.52)]'
: 'border border-[#E8E8E8] bg-white shadow-[0_10px_24px_rgba(245,34,45,0.06)] hover:border-[#F6B0B4] hover:shadow-[0_16px_32px_rgba(245,34,45,0.12)]';
const getStatusConfig = (column: string) => {
switch (column) {
case 'completed':
return {
label: '已完成',
className: isDark
? 'bg-[rgba(34,197,94,0.16)] text-emerald-300 border border-emerald-400/20'
: 'bg-emerald-50 text-emerald-600 border border-emerald-100',
};
case 'running':
case 'analyzing':
return {
label: '分析中',
className: isDark
? 'bg-[rgba(14,165,233,0.16)] text-sky-300 border border-sky-400/20'
: 'bg-sky-50 text-sky-600 border border-sky-100',
};
default:
return {
label: '待分析',
className: isDark
? 'bg-[rgba(96,165,250,0.14)] text-blue-200 border border-blue-300/15'
: 'bg-blue-50 text-blue-600 border border-blue-100',
};
}
};
const typeBadgeClass = isDark
? 'bg-[rgba(77,141,255,0.14)] text-[#A9D2FF] border border-[rgba(108,161,255,0.24)]'
: 'bg-[#FFF1F0] text-[#CF1322] border border-[#FFD6D6]';
const typeConfig = getTypeConfig(task.type || 'single');
const statusConfig = getStatusConfig(task.column || 'unanalyzed');
const TypeIcon = typeConfig.icon;
const progressValue = Math.max(0, Math.min(task.progress || 0, 100));
const levelClass = isDark
? 'text-slate-400'
: 'text-[#8C8C8C]';
const statusClass =
task.column === 'completed'
? isDark
? 'bg-[rgba(18,184,134,0.16)] text-[#59E2B1] border border-[rgba(89,226,177,0.2)]'
: 'bg-[#EEFCF7] text-[#0F9D6C] border border-[#B7F0D5]'
: task.column === 'running' || task.column === 'analyzing'
? isDark
? 'bg-[rgba(34,197,94,0.12)] text-[#67E8A5] border border-[rgba(103,232,165,0.2)]'
: 'bg-[#EAF4FF] text-[#1677FF] border border-[#CDE2FF]'
: isDark
? 'bg-[rgba(96,165,250,0.12)] text-[#8FD1FF] border border-[rgba(143,209,255,0.2)]'
: 'bg-[#FFF1F0] text-[#F5222D] border border-[#FFD6D6]';
const actionClass = isCompleted
? isDark
? 'bg-[rgba(12,48,82,0.95)] text-white hover:bg-[rgba(15,60,104,0.98)]'
: 'bg-[#FFF1F0] text-[#CF1322] hover:bg-[#FFE4E6]'
: isDark
? 'bg-[linear-gradient(90deg,#1677FF,#22A8F0)] text-white hover:brightness-110'
: 'bg-[linear-gradient(90deg,#F5222D,#FF6B73)] text-white hover:brightness-105';
return (
<div
className={`theme-card theme-card-hover theme-transition rounded-[24px] border p-4 ${
isDark
? 'border-[rgba(110,136,176,0.2)] shadow-[0_12px_30px_rgba(2,8,20,0.20)]'
: 'border-slate-200 bg-white shadow-sm'
}`}
<article
className={`group rounded-[24px] p-4 transition-all duration-200 ${panelClass}`}
onClick={handleCardClick}
>
<div className="flex items-start justify-between gap-3">
<div className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs ${typeConfig.className}`}>
<TypeIcon className="h-3.5 w-3.5" />
<span>{typeConfig.label}</span>
</div>
<span className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs ${statusConfig.className}`}>
{statusConfig.label}
</span>
</div>
<button
type="button"
onClick={() => handleTaskClick(task)}
className="mt-4 block w-full text-left"
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${typeBadgeClass}`}
>
<div className={`line-clamp-2 text-[15px] font-semibold leading-6 ${isDark ? 'text-slate-50' : 'text-slate-900'}`}>
{task.taskName}
</div>
<p className={`mt-2 line-clamp-3 text-sm leading-6 ${isDark ? 'text-slate-300/78' : 'text-slate-500'}`}>
{task.overview || '暂无任务摘要'}
</p>
</button>
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between text-xs">
<div className={`flex items-center gap-1.5 ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
<User className="h-3.5 w-3.5" />
<span>{task.owner || task.creator || '未分配'}</span>
</div>
<div className={`${isDark ? 'text-slate-500' : 'text-slate-400'}`}>优先级:{getLevel(task.priority)}</div>
</div>
<div>
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>分析进度</span>
<span className={isDark ? 'text-slate-300' : 'text-slate-700'}>{progressValue}%</span>
</div>
<div className={`h-2 overflow-hidden rounded-full ${isDark ? 'bg-[rgba(255,255,255,0.08)]' : 'bg-slate-100'}`}>
<div
className="h-full rounded-full bg-[linear-gradient(90deg,#1677FF,#5EEAD4)]"
style={{ width: `${progressValue}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
创建于 {task.createTime ? format(new Date(task.createTime), 'MM-dd HH:mm') : '--'}
<TypeIcon className="h-3.5 w-3.5" />
{typeConfig.label}
</span>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
更新于 {task.updateTime ? format(new Date(task.updateTime), 'MM-dd HH:mm') : '--'}
<span
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${statusClass}`}
>
{getColumnLabel(task.column)}
</span>
</div>
</div>
<div className="mt-4 flex gap-2">
{task.column === 'completed' ? (
<button
type="button"
onClick={() => handleTaskClick(task)}
className={`flex-1 rounded-xl px-3 py-2 text-sm transition ${
isDark
? 'bg-[rgba(11,40,71,0.95)] text-slate-100 hover:bg-[rgba(15,49,87,0.98)]'
: 'bg-slate-900 text-white hover:bg-slate-800'
<h3
className={`mt-5 text-[16px] font-semibold leading-7 ${
isDark ? 'text-white' : 'text-[#222222]'
}`}
>
<span className="inline-flex items-center gap-2">
<Eye className="h-4 w-4" />
查看结果
</span>
</button>
) : (
{task.taskName}
</h3>
<p
className={`mt-3 min-h-[56px] text-sm leading-7 ${
isDark ? 'text-slate-300' : 'text-[#575757]'
}`}
>
{task.overview || '暂无任务摘要'}
</p>
<div className="mt-4 flex items-center justify-between gap-3 text-sm">
<div className={`flex items-center gap-2 ${isDark ? 'text-slate-300' : 'text-[#575757]'}`}>
<User className="h-4 w-4" />
<span>{task.taskDirection || '未分配'}</span>
</div>
<div className={levelClass}>优先级:{getTaskLevelLabel(task.taskLevel)}</div>
</div>
<div
className={`mt-4 grid grid-cols-2 gap-3 text-xs ${
isDark ? 'text-slate-400' : 'text-[#8C8C8C]'
}`}
>
<div>创建于 {format(new Date(task.createTime), 'MM-dd HH:mm')}</div>
<div className="text-right">更新于 {format(new Date(task.updateTime), 'MM-dd HH:mm')}</div>
</div>
<button
type="button"
onClick={() => handleStartChange(task)}
className="flex-1 rounded-xl bg-[linear-gradient(90deg,#1677FF,#0EA5E9)] px-3 py-2 text-sm text-white shadow-[0_10px_24px_rgba(22,119,255,0.28)] transition hover:brightness-110"
onClick={handleActionClick}
className={`mt-5 flex h-12 w-full items-center justify-center gap-2 rounded-full text-sm font-medium transition ${actionClass}`}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
开始分析
</span>
{isCompleted ? <Eye className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{isCompleted ? '查看结果' : '开始分析'}
</button>
)}
</div>
</div>
</article>
);
};
@@ -77,28 +77,16 @@
border-radius: 3px;
}
.strategy-data::-webkit-scrollbar,
.strategy-data *::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.strategy-data::-webkit-scrollbar-thumb,
.strategy-data *::-webkit-scrollbar-thumb {
background: rgba(203, 213, 225, 0.75);
border-radius: 999px;
}
.strategy-data::-webkit-scrollbar-track,
.strategy-data *::-webkit-scrollbar-track {
background: rgba(241, 245, 249, 0.9);
border-radius: 999px;
}
.dark .strategy-data .scrollbar-thin {
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
}
.dark .strategy-data::-webkit-scrollbar,
.dark .strategy-data *::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark .strategy-data,
.dark .strategy-data * {
scrollbar-width: thin;
@@ -262,27 +250,39 @@
min-width: 320px;
}
.sentiment-date-range-popup .t-date-picker__panel,
.sentiment-date-range-popup .t-date-range-picker__panel,
.sentiment-date-range-popup .t-date-picker__panel-body,
.sentiment-date-range-popup .t-date-picker__panel-content,
.sentiment-date-range-popup .t-date-picker__table,
.sentiment-date-range-popup .t-time-picker__panel {
html.dark .sentiment-date-range-popup .t-date-picker__panel,
html.dark .sentiment-date-range-popup .t-date-range-picker__panel,
html.dark .sentiment-date-range-popup .t-date-picker__panel-body,
html.dark .sentiment-date-range-popup .t-date-picker__panel-content,
html.dark .sentiment-date-range-popup .t-date-picker__table,
html.dark .sentiment-date-range-popup .t-time-picker__panel,
html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__panel,
html[theme-mode='dark'] .sentiment-date-range-popup .t-date-range-picker__panel,
html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__panel-body,
html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__panel-content,
html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__table,
html[theme-mode='dark'] .sentiment-date-range-popup .t-time-picker__panel {
background: #0d1d33 !important;
color: #e6f4ff !important;
}
.sentiment-date-range-popup .t-popup__content {
html.dark .sentiment-date-range-popup .t-popup__content,
html[theme-mode='dark'] .sentiment-date-range-popup .t-popup__content {
background: #0d1d33 !important;
border: 1px solid rgba(110, 136, 176, 0.24);
box-shadow: 0 24px 60px rgba(2, 8, 20, 0.42);
}
.sentiment-date-range-popup .t-date-picker__cell,
.sentiment-date-range-popup .t-date-picker__cell-inner,
.sentiment-date-range-popup .t-date-picker__header,
.sentiment-date-range-popup .t-date-picker__text,
.sentiment-date-range-popup .t-button {
html.dark .sentiment-date-range-popup .t-date-picker__cell,
html.dark .sentiment-date-range-popup .t-date-picker__cell-inner,
html.dark .sentiment-date-range-popup .t-date-picker__header,
html.dark .sentiment-date-range-popup .t-date-picker__text,
html.dark .sentiment-date-range-popup .t-button,
html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__cell,
html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__cell-inner,
html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__header,
html[theme-mode='dark'] .sentiment-date-range-popup .t-date-picker__text,
html[theme-mode='dark'] .sentiment-date-range-popup .t-button {
color: #e6f4ff !important;
}
@@ -296,7 +296,7 @@
color: #ffffff;
}
.strategy-data .dark-theme:hover {
.dark .strategy-data .dark-theme:hover {
background: #0062d966;
color: #ffffff;
border-color: #0062d9 !important;
@@ -307,8 +307,8 @@
border-color: var(--card-border);
}
.strategy-data .dark-theme.task-active,
.strategy-data .dark-theme .task-active {
.dark .strategy-data .dark-theme.task-active,
.dark .strategy-data .dark-theme .task-active {
border-color: #0062d9 !important;
}
@@ -351,20 +351,20 @@
background: linear-gradient(180deg, #f5f5f5, #fff 17.89% 100%);
}
.strategy-data .dark-action-bg {
.dark .strategy-data .dark-action-bg {
background: rgba(3, 38, 80, 0.6) !important;
border: 1px solid rgba(43, 143, 254, 0.6) !important;
border-radius: 8px !important;
box-shadow: 0 1px 16px rgba(0, 0, 0, 0.08) !important;
}
.strategy-data .dark-action-item {
.dark .strategy-data .dark-action-item {
background: #0062d91a;
border: 1px solid #2b8ffe99;
border-radius: 8px;
}
.strategy-data .dark-action-item:hover {
.dark .strategy-data .dark-action-item:hover {
background: #0062d966;
border-color: #0062d9;
}
@@ -373,6 +373,16 @@
.strategy-data .t-loading,
.strategy-data .t-loading__parent {
min-height: 0;
height: 100%;
}
.strategy-data .t-loading {
display: block;
}
.strategy-data .t-loading__parent {
display: flex;
flex-direction: column;
}
.strategy-data .t-loading--full {
@@ -0,0 +1,30 @@
/**
* 行动 / 任务状态图标。
*
* 用 `import` 引入 SVG,让 Vite 把它们打进产物并生成带 hash 的最终 URL,
* 这样无论部署到子路径(base)还是带指纹的静态资源服务都能正确访问 ——
* 不能再用 `/icon/xxx.svg` 这种运行时绝对路径(部署后会 404)。
*
* key 沿用后端 / taskStatusList 里给的中文文件名,方便按名取用。
*/
import daiQianShou from "./待签收.svg";
import daiCEH from "./待CEH.svg";
import jinXingZhong from "./进行中.svg";
import wanCheng from "./完成.svg";
import weiTongGuo from "./未通过.svg";
import yiZhongZhi from "./已终止.svg";
export const statusIconMap: Record<string, string> = {
"待签收.svg": daiQianShou,
"待CEH.svg": daiCEH,
"进行中.svg": jinXingZhong,
"完成.svg": wanCheng,
"未通过.svg": weiTongGuo,
"已终止.svg": yiZhongZhi,
};
/** 按文件名取打包后的图标 URL;取不到返回 undefined(<img> 不渲染 broken icon)。 */
export function resolveStatusIcon(name?: string | null): string | undefined {
if (!name) return undefined;
return statusIconMap[name];
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#1eb07f"/>
<path d="M7.5 12.3 10.5 15.3 16.5 9" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#8c8c8c"/>
<rect x="8.7" y="8.7" width="6.6" height="6.6" rx="1.4" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#2f9acc"/>
<circle cx="11" cy="11" r="3.2" stroke="#fff" stroke-width="1.8" fill="none"/>
<path d="M13.4 13.4 16.2 16.2" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#0062d9"/>
<rect x="7.6" y="7.8" width="8.8" height="9.6" rx="1.6" stroke="#fff" stroke-width="1.5" fill="none"/>
<path d="M9.8 7.8v-.5a1 1 0 0 1 1-1h2.4a1 1 0 0 1 1 1v.5" stroke="#fff" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.8 11h4.4M9.8 13.6h2.8" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#cf1322"/>
<path d="M8.8 8.8 15.2 15.2M15.2 8.8 8.8 15.2" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#d97b00"/>
<path d="M10.2 8.4 15.4 12l-5.2 3.6z" fill="#fff" stroke="#fff" stroke-width="1.2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 264 B

+1 -1
View File
@@ -17,7 +17,7 @@ SERPER_API_KEY=your-serper-api-key
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5183,http://localhost:5174,http://127.0.0.1:5174,http://localhost:4200
GATEWAY_CORS_ALLOW_ALL=0
GATEWAY_ENABLE_DOCS=true
GATEWAY_WORKERS=2
+760
View File
@@ -0,0 +1,760 @@
# This file was autogenerated by uv via the following command:
# uv export --no-hashes --no-emit-project --no-dev -o _reqs.txt
-e ./packages/harness
# via deer-flow
agent-client-protocol==0.10.1
# via deerflow-harness
agent-sandbox==0.0.30
# via deerflow-harness
aiohappyeyeballs==2.6.2
# via aiohttp
aiohttp==3.14.1
# via
# aiohttp-retry
# dingtalk-stream
# firecrawl-py
# hindsight-client
# kubernetes
# wecom-aibot-python-sdk
aiohttp-retry==2.9.1
# via hindsight-client
aiosignal==1.4.0
# via aiohttp
aiosqlite==0.22.1
# via
# deerflow-harness
# langgraph-checkpoint-sqlite
alembic==1.18.4
# via deerflow-harness
annotated-doc==0.0.4
# via fastapi
annotated-types==0.7.0
# via pydantic
anthropic==0.107.1
# via langchain-anthropic
anyio==4.13.0
# via
# anthropic
# google-genai
# httpx
# mcp
# openai
# sse-starlette
# starlette
# watchfiles
asyncmy==0.2.11
# via deer-flow
attrs==26.1.0
# via
# aiohttp
# jsonschema
# referencing
audioop-lts==0.2.2 ; python_full_version >= '3.13'
# via
# speechrecognition
# standard-aifc
azure-ai-documentintelligence==1.0.2
# via markitdown
azure-core==1.41.0
# via
# azure-ai-documentintelligence
# azure-identity
azure-identity==1.25.3
# via markitdown
backoff==2.2.1
# via langfuse
bcrypt==5.0.0
# via deer-flow
beautifulsoup4==4.15.0
# via
# markdownify
# markitdown
# readabilipy
blockbuster==1.5.26
# via langgraph-runtime-inmem
brotli==1.2.0 ; platform_python_implementation == 'CPython'
# via httpx
brotlicffi==1.2.0.1 ; platform_python_implementation != 'CPython'
# via httpx
certifi==2026.5.20
# via
# httpcore
# httpx
# kubernetes
# requests
# volcengine-python-sdk
# wecom-aibot-python-sdk
cffi==2.0.0
# via
# brotlicffi
# cryptography
charset-normalizer==3.4.7
# via
# markitdown
# pdfminer-six
# requests
click==8.4.1
# via
# ddgs
# langgraph-cli
# magika
# uvicorn
cloudpickle==3.1.2
# via langgraph-api
cobble==0.1.4
# via mammoth
colorama==0.4.6 ; sys_platform == 'win32'
# via
# click
# tqdm
# uvicorn
coloredlogs==15.0.1
# via onnxruntime
croniter==6.2.2
# via
# deer-flow
# langgraph-runtime-inmem
cryptography==48.0.0
# via
# azure-identity
# google-auth
# langgraph-api
# msal
# pdfminer-six
# pyjwt
# wecom-aibot-python-sdk
ddgs==9.14.4
# via deerflow-harness
defusedxml==0.7.1
# via
# markitdown
# youtube-transcript-api
dingtalk-stream==0.24.3
# via deer-flow
distro==1.9.0
# via
# anthropic
# google-genai
# openai
dnspython==2.8.0
# via email-validator
docstring-parser==0.18.0
# via anthropic
dotenv==0.9.9
# via deerflow-harness
duckdb==1.5.3
# via deerflow-harness
durationpy==0.10
# via kubernetes
email-validator==2.3.0
# via deer-flow
et-xmlfile==2.0.0
# via openpyxl
exa-py==2.13.0
# via deerflow-harness
fake-useragent==2.2.0
# via ddgs
fastapi==0.136.3
# via deer-flow
filetype==1.2.0
# via langchain-google-genai
firecrawl-py==4.28.2
# via deerflow-harness
flatbuffers==25.12.19
# via onnxruntime
forbiddenfruit==0.1.4 ; implementation_name == 'cpython'
# via blockbuster
frozenlist==1.8.0
# via
# aiohttp
# aiosignal
google-auth==2.53.0
# via google-genai
google-genai==2.8.0
# via langchain-google-genai
googleapis-common-protos==1.75.0
# via opentelemetry-exporter-otlp-proto-http
greenlet==3.5.1
# via sqlalchemy
grpcio==1.80.0
# via
# grpcio-health-checking
# grpcio-tools
# langgraph-api
grpcio-health-checking==1.80.0
# via langgraph-api
grpcio-tools==1.80.0
# via langgraph-api
h11==0.16.0
# via
# httpcore
# uvicorn
h2==4.3.0
# via httpx
hindsight-client==0.7.2
# via deer-flow
hpack==4.1.0
# via h2
html5lib==1.1
# via readabilipy
httpcore==1.0.9
# via
# exa-py
# httpx
# python-telegram-bot
httptools==0.8.0
# via
# langgraph-api
# uvicorn
httpx==0.28.1
# via
# agent-sandbox
# anthropic
# ddgs
# deer-flow
# deerflow-harness
# exa-py
# firecrawl-py
# google-genai
# langfuse
# langgraph-api
# langgraph-cli
# langgraph-sdk
# langsmith
# lark-oapi
# mcp
# openai
# python-telegram-bot
# tavily-python
httpx-sse==0.4.3
# via mcp
humanfriendly==10.0
# via coloredlogs
hyperframe==6.1.0
# via h2
idna==3.18
# via
# anyio
# email-validator
# httpx
# requests
# yarl
isodate==0.7.2
# via azure-ai-documentintelligence
jiter==0.15.0
# via
# anthropic
# openai
jsonpatch==1.33
# via langchain-core
jsonpointer==3.1.1
# via jsonpatch
jsonschema==4.26.0
# via mcp
jsonschema-rs==0.44.1
# via langgraph-api
jsonschema-specifications==2025.9.1
# via jsonschema
kubernetes==36.0.2
# via deerflow-harness
langchain==1.3.4
# via deerflow-harness
langchain-anthropic==1.4.4
# via deerflow-harness
langchain-core==1.4.1
# via
# langchain
# langchain-anthropic
# langchain-deepseek
# langchain-google-genai
# langchain-mcp-adapters
# langchain-openai
# langgraph
# langgraph-api
# langgraph-checkpoint
# langgraph-prebuilt
# langgraph-sdk
langchain-deepseek==1.1.0
# via deerflow-harness
langchain-google-genai==4.2.4
# via deerflow-harness
langchain-mcp-adapters==0.2.2
# via deerflow-harness
langchain-openai==1.2.2
# via
# deerflow-harness
# langchain-deepseek
langchain-protocol==0.0.16
# via
# langchain-core
# langgraph-api
# langgraph-sdk
langfuse==4.7.1
# via deerflow-harness
langgraph==1.2.4
# via
# deerflow-harness
# langchain
# langgraph-api
# langgraph-runtime-inmem
langgraph-api==0.9.0
# via deerflow-harness
langgraph-checkpoint==4.1.1
# via
# langgraph
# langgraph-api
# langgraph-checkpoint-postgres
# langgraph-checkpoint-sqlite
# langgraph-prebuilt
# langgraph-runtime-inmem
langgraph-checkpoint-postgres==3.1.0
# via deerflow-harness
langgraph-checkpoint-sqlite==3.1.0
# via deerflow-harness
langgraph-cli==0.4.27
# via deerflow-harness
langgraph-prebuilt==1.1.0
# via langgraph
langgraph-runtime-inmem==0.29.0
# via
# deerflow-harness
# langgraph-api
langgraph-sdk==0.4.2
# via
# deer-flow
# deerflow-harness
# langgraph
# langgraph-api
# langgraph-cli
langsmith==0.8.9
# via
# langchain-core
# langgraph-api
lark-oapi==1.6.8
# via deer-flow
lxml==6.1.1
# via
# ddgs
# markitdown
# python-pptx
# readabilipy
magika==0.6.3
# via markitdown
mako==1.3.12
# via alembic
mammoth==1.11.0
# via markitdown
markdown-to-mrkdwn==0.3.3
# via deer-flow
markdownify==1.2.2
# via
# deerflow-harness
# markitdown
markitdown==0.1.5
# via deerflow-harness
markupsafe==3.0.3
# via mako
mcp==1.27.2
# via langchain-mcp-adapters
mpmath==1.3.0
# via sympy
msal==1.37.0
# via
# azure-identity
# msal-extensions
msal-extensions==1.3.1
# via azure-identity
multidict==6.7.1
# via
# aiohttp
# yarl
nest-asyncio==1.6.0
# via firecrawl-py
numpy==2.4.6
# via
# magika
# onnxruntime
# pandas
oauthlib==3.3.1
# via requests-oauthlib
olefile==0.47
# via markitdown
onnxruntime==1.20.1
# via magika
openai==2.41.0
# via
# exa-py
# langchain-openai
openpyxl==3.1.5
# via markitdown
opentelemetry-api==1.42.1
# via
# langfuse
# langgraph-api
# langsmith
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
opentelemetry-exporter-otlp-proto-common==1.42.1
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http==1.42.1
# via
# langfuse
# langgraph-api
# langsmith
opentelemetry-proto==1.42.1
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.42.1
# via
# langfuse
# langgraph-api
# langsmith
# opentelemetry-exporter-otlp-proto-http
opentelemetry-semantic-conventions==0.63b1
# via opentelemetry-sdk
orjson==3.11.9
# via
# langgraph-api
# langgraph-checkpoint-postgres
# langgraph-sdk
# langsmith
ormsgpack==1.12.2
# via langgraph-checkpoint
packaging==26.2
# via
# langchain-core
# langfuse
# langsmith
# onnxruntime
pandas==3.0.3
# via markitdown
pathspec==1.1.1
# via langgraph-cli
pdfminer-six==20251230
# via
# markitdown
# pdfplumber
pdfplumber==0.11.9
# via markitdown
pillow==12.2.0
# via
# pdfplumber
# python-pptx
primp==1.3.1
# via ddgs
propcache==0.5.2
# via
# aiohttp
# yarl
protobuf==6.33.6
# via
# googleapis-common-protos
# grpcio-health-checking
# grpcio-tools
# langgraph-api
# onnxruntime
# opentelemetry-proto
psycopg==3.3.4
# via
# deerflow-harness
# langgraph-checkpoint-postgres
psycopg-binary==3.3.4 ; implementation_name != 'pypy'
# via psycopg
psycopg-pool==3.3.1
# via
# deerflow-harness
# langgraph-checkpoint-postgres
pyasn1==0.6.3
# via pyasn1-modules
pyasn1-modules==0.4.2
# via google-auth
pycparser==3.0 ; implementation_name != 'PyPy'
# via cffi
pycryptodome==3.23.0
# via lark-oapi
pydantic==2.13.4
# via
# agent-client-protocol
# agent-sandbox
# anthropic
# deerflow-harness
# exa-py
# fastapi
# firecrawl-py
# google-genai
# hindsight-client
# langchain
# langchain-anthropic
# langchain-core
# langchain-google-genai
# langfuse
# langgraph
# langsmith
# mcp
# openai
# pydantic-settings
pydantic-core==2.46.4
# via pydantic
pydantic-settings==2.14.1
# via mcp
pydub==0.25.1
# via markitdown
pyee==13.0.1
# via wecom-aibot-python-sdk
pyjwt==2.13.0
# via
# deer-flow
# langgraph-api
# mcp
# msal
pypdfium2==5.9.0
# via pdfplumber
pyreadline3==3.5.6 ; sys_platform == 'win32'
# via humanfriendly
python-dateutil==2.9.0.post0
# via
# croniter
# hindsight-client
# kubernetes
# pandas
# volcengine-python-sdk
python-dotenv==1.2.2
# via
# dotenv
# exa-py
# firecrawl-py
# langgraph-cli
# magika
# pydantic-settings
# uvicorn
python-multipart==0.0.32
# via
# deer-flow
# mcp
python-pptx==1.0.2
# via markitdown
python-telegram-bot==22.7
# via deer-flow
pywin32==312 ; sys_platform == 'win32'
# via mcp
pyyaml==6.0.3
# via
# deerflow-harness
# kubernetes
# langchain-core
# uvicorn
readabilipy==0.3.0
# via deerflow-harness
referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
regex==2026.5.9
# via
# readabilipy
# tiktoken
requests==2.34.2
# via
# azure-core
# dingtalk-stream
# exa-py
# firecrawl-py
# google-auth
# google-genai
# kubernetes
# langsmith
# lark-oapi
# markitdown
# msal
# opentelemetry-exporter-otlp-proto-http
# requests-oauthlib
# requests-toolbelt
# tavily-python
# tiktoken
# youtube-transcript-api
requests-oauthlib==2.0.0
# via kubernetes
requests-toolbelt==1.0.0
# via
# langsmith
# lark-oapi
rpds-py==2026.5.1
# via
# jsonschema
# referencing
setuptools==82.0.1
# via grpcio-tools
six==1.17.0
# via
# html5lib
# kubernetes
# markdownify
# python-dateutil
# volcengine-python-sdk
slack-sdk==3.42.0
# via deer-flow
sniffio==1.3.1
# via
# anthropic
# google-genai
# openai
socksio==1.0.0
# via httpx
soupsieve==2.8.4
# via beautifulsoup4
speechrecognition==3.16.1
# via markitdown
sqlalchemy==2.0.50
# via
# alembic
# deerflow-harness
sqlite-vec==0.1.9
# via langgraph-checkpoint-sqlite
sse-starlette==3.3.4
# via
# deer-flow
# langgraph-api
# langgraph-runtime-inmem
# mcp
standard-aifc==3.13.0 ; python_full_version >= '3.13'
# via speechrecognition
standard-chunk==3.13.0 ; python_full_version >= '3.13'
# via standard-aifc
starlette==1.2.1
# via
# fastapi
# langgraph-api
# langgraph-runtime-inmem
# mcp
# sse-starlette
structlog==25.5.0
# via
# langgraph-api
# langgraph-runtime-inmem
sympy==1.14.0
# via onnxruntime
tavily-python==0.7.25
# via deerflow-harness
tenacity==9.1.4
# via
# google-genai
# langchain-core
# langgraph-api
tiktoken==0.13.0
# via
# deerflow-harness
# langchain-openai
# tavily-python
tqdm==4.68.1
# via openai
truststore==0.10.4
# via langgraph-api
typing-extensions==4.15.0
# via
# aiohttp
# aiosignal
# alembic
# anthropic
# anyio
# azure-ai-documentintelligence
# azure-core
# azure-identity
# beautifulsoup4
# exa-py
# fastapi
# google-genai
# grpcio
# hindsight-client
# langchain-core
# langchain-mcp-adapters
# langchain-protocol
# mcp
# openai
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# psycopg
# psycopg-pool
# pydantic
# pydantic-core
# pyee
# python-pptx
# referencing
# speechrecognition
# sqlalchemy
# starlette
# typing-inspection
typing-inspection==0.4.2
# via
# fastapi
# mcp
# pydantic
# pydantic-settings
tzdata==2026.2 ; sys_platform == 'emscripten' or sys_platform == 'win32'
# via
# pandas
# psycopg
urllib3==2.7.0
# via
# hindsight-client
# kubernetes
# requests
# volcengine-python-sdk
uuid-utils==0.16.0
# via
# langchain-core
# langgraph-api
# langsmith
uvicorn==0.49.0
# via
# deer-flow
# langgraph-api
# mcp
uvloop==0.22.1 ; sys_platform != 'win32'
# via
# langgraph-api
# uvicorn
volcengine-python-sdk==5.0.33
# via agent-sandbox
watchfiles==1.2.0
# via
# langgraph-api
# uvicorn
webencodings==0.5.1
# via html5lib
websocket-client==1.9.0
# via kubernetes
websockets==15.0.1
# via
# dingtalk-stream
# firecrawl-py
# google-genai
# langgraph-api
# langgraph-sdk
# langsmith
# lark-oapi
# uvicorn
# wecom-aibot-python-sdk
wecom-aibot-python-sdk==1.0.2
# via deer-flow
wrapt==1.17.3
# via langfuse
xlrd==2.0.2
# via markitdown
xlsxwriter==3.2.9
# via python-pptx
xxhash==3.7.0
# via
# langgraph
# langsmith
yarl==1.24.2
# via aiohttp
youtube-transcript-api==1.0.3
# via markitdown
zstandard==0.25.0
# via
# langgraph-api
# langsmith
@@ -33,6 +33,7 @@ from app.gateway.routers import (
assistants_compat,
auth,
browser_context,
business_mapping,
channels,
checkpoint_migration,
feedback,
@@ -45,6 +46,8 @@ from app.gateway.routers import (
models,
multi_agent,
notifications,
parallel_agent_tasks,
parallel_agents,
public_embed,
public_leaderboard,
public_scheduled_tasks,
@@ -865,6 +868,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"Roundtable functional agent seeding failed at startup (non-fatal)"
)
# 业务链条管理:幂等补建固定业务行(3Q/6BF/7BF/8BF),agent/chain 初始为空。
try:
business_mapping_store = getattr(app.state, "business_mapping_store", None)
if business_mapping_store is not None:
inserted = await business_mapping_store.ensure_seed_defaults()
if inserted:
logger.info("Seeded %d business mapping rows", inserted)
except Exception:
logger.exception("Business mapping seeding failed at startup (non-fatal)")
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
@@ -1226,6 +1239,13 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Global business → agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router)
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router)
# Roundtable-planning background jobs at /api/roundtable-jobs
app.include_router(roundtable_jobs.router)
@@ -47,6 +47,8 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
"/api/v1/auth/initialize",
# 并行多智能体面板专用的「绕过常规登录」取 token 端点(内网部署限制了标准登录时用)。
"/api/parallel-agents/auth/token",
}
)
@@ -59,6 +59,8 @@ _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
"/api/v1/auth/logout",
"/api/v1/auth/register",
"/api/v1/auth/initialize",
# 并行多智能体面板专用取 token 端点(公共 + CSRF 豁免)。
"/api/parallel-agents/auth/token",
}
)
@@ -200,6 +200,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
from deerflow.persistence.business_mapping import make_business_mapping_store
app.state.business_mapping_store = make_business_mapping_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
from deerflow.persistence.roundtable_jobs import (
make_roundtable_job_store,
)
@@ -183,6 +183,9 @@ async def _run_agent_turn(
"subagent_enabled": False,
"thread_id": thread_id,
"memory_recall_disabled": True,
# 与 multi_agent._run_payload 对齐:关 builtin 记忆注入,避免单例总控/席位
# 被上轮会话 memory.json 带偏(臆造席位 / 直接干活)。详见该处注释。
"memory_injection_enabled": False,
},
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
@@ -29,6 +29,15 @@ from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
# ⚠️ 临时禁用「后台挂起」的 Step3 结果绘制(报告 HTML 生成)。
# 进程内驱动报告 agent 在真模型环境下仍无法稳定写出 HTML(前端手动调
# /api/multi-agent/report/init + run/stream 可以,但后台 start_run 路径不行,
# 根因尚未定位)。临时方案:后台只跑完 Step2 研讨即收尾,不自动画报告——用户需要
# 报告时可在前端手动跑 Step3(那条路是通的)。
# 待修复后把本开关改回 True 即恢复后台自动报告。
# 详见 frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md「后台报告 HTML」。
ROUNDTABLE_BACKGROUND_REPORT_ENABLED = False
class _JobUser:
"""最小 CurrentUser:满足 user_context 的 ``.id`` 结构协议。"""
@@ -164,6 +173,9 @@ class StartParams:
is_resume: bool = False
# chain 模式来源链条(写进草稿 run 用,展示)。
chain: dict[str, Any] | None = None
# 是否生成 Step3 报告。默认沿用全局开关(当前临时禁用,见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。
# 测试可显式传 True 覆盖以验证报告链路。
enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED
# 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。
@@ -229,6 +241,7 @@ class RoundtableJobExecutor:
seed_message=params.seed_message,
mode=params.mode,
intent_text=params.intent_text,
enable_report=params.enable_report,
)
# 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。
await self._write_run_to_draft(
@@ -320,8 +333,11 @@ class RoundtableJobExecutor:
# 顶层 draft.step3 也写一份(同 Step2 把对话写进 run 的同一次落库一并带上)。
# 前端 hydrateStep3 用**顶层** draft.step3 重建报告气泡(结果绘制的聊天记录 + HTML 预览);
# 只存 run 内 step3 时,加载会被 hydrateStep3(draft.step3=None) 覆盖成空 → 报告聊天记录丢失。
update_kwargs: dict[str, Any] = {"step2": step2, "furthest_step": 3 if status == "done" else 2}
if effective_step3 is not None:
# furthest_step 跟随**是否真有 step3**:done 且有报告→3(落到结果绘制);
# 否则停在 2(Step2 研讨)。后台禁用报告时 step3=None → 停在 Step2,用户可在前端手动画报告。
has_report = effective_step3 is not None
update_kwargs: dict[str, Any] = {"step2": step2, "furthest_step": 3 if (status == "done" and has_report) else 2}
if has_report:
update_kwargs["step3"] = effective_step3
try:
await self._draft_store.update_draft(params.draft_id, params.user_id, **update_kwargs)
@@ -10,15 +10,33 @@
角色(``role``):
- ``leader`` —— 总控派活:短决策,关 thinking + low reasoning(首字节快);
只禁 ``web_search``;并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration``
调用后停图,便于网关从 AIMessage.tool_calls 解析派活列表。
- ``seat`` —— 子智能体交付:开 thinking + medium reasoning;禁派活 /
澄清 / 展示文件 / 看图 / 联网搜索(与前端 ``_special_run`` 一致)。
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``bash`` /
**只保留 ``agent_orchestration`` / ``ask_clarification`` / ``present_files``**,
其余「干活类」工具(``write_file`` / ``bash`` / ``str_replace`` / ``read_file`` /
``ls`` / ``view_image`` / ``task`` / ``write_todos`` / ``memory`` / ``hindsight_*``)
一律禁用——否则弱模型(如 deepseek-chat)会概率性地**自己写文件、跑 bash 干活**
而不是派活(已在生产日志里观测到总控 ``wc -l outputs/*.md`` 自检自己写的报告)。
总控的唯一职责是「派活 / 澄清 / 收口呈现」,没有任何理由动文件系统或执行命令。
并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration`` 调用后停图,便于网关从
AIMessage.tool_calls 解析派活列表。
- ``seat`` —— 子智能体交付:开 thinking + medium reasoning;**可写文件**
(``write_file`` / ``str_replace`` 产出 md 等成品文档),但禁派活 / 澄清 /
展示文件 / 看图 / 联网搜索 / **``bash``**。禁 ``bash`` 的原因:圆桌席位是
「文档产出者」,弱模型爱写 ``.py`` 脚本再跑出产物(乱且抓不到预览),与
``report`` 角色禁 ``bash`` 同理;让它**直接 write_file 写成品文档**。
- ``seat_parallel`` —— **DAG 快速(fast)模式的并行席位(方案 A,2026-06)**:**禁写文件**
(``write_file`` / ``str_replace``,产物走正文)+ 禁 ``bash`` + 禁「打断 / 越权」类,
**放开 ``web_search`` 与技能**(搜索不碰 sandbox;技能靠 ``read_file`` SKILL.md)——
否则并行席位几乎调不动工具、产生不了步骤(步骤条不显示)。
「写文件产出 md」归 **沙箱(sandbox)模式**(走 ``seat`` policy,单列 + 沙箱跟随预览);
fast 模式快、双列、产物走正文 —— 用户明确要求 fast 不生成文件。详见
``docs/roundtable-dag-orchestration.md`` §4 / §12.1 / §12.8 / §12.10。
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
``seat_parallel`` **放开搜索**(本期诉求)。
- ``report`` —— Step3 报告绘制:在 ``seat``(已禁 ``bash``)基础上**额外禁
``str_replace``**:
* 禁 ``bash`` → 杜绝它写 Python 脚本再跑出 HTML(脚本产物落在 outputs,
但前端虚拟预览只抓 ``write_file`` 的 ``args.content``,抓不到脚本产物 →
预览停在半截 ``write_file``,正是「没写完就去生成 py 文件」的现象);
* ``bash`` 已在 ``seat`` 禁 → 杜绝写 Python 脚本再跑出 HTML(脚本产物落在
outputs,但前端虚拟预览只抓 ``write_file`` 的 ``args.content``,抓不到脚本
产物 → 预览停在半截 ``write_file``,正是「没写完就去生成 py 文件」的现象);
* 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写
(``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined
而清空沙箱)。
@@ -31,10 +49,58 @@ from dataclasses import dataclass
# ── 每角色基础工具裁剪(唯一定义处)─────────────────────────────────────────────
#
# 注意:``seat`` / ``report`` 都包含 ``web_search`` —— 圆桌研讨里联网搜索一律禁用
# (与前端 ``_special_run`` 的 excluded_tools 对齐)。leader 也禁 ``web_search``。
_LEADER_EXCLUDED: list[str] = ["web_search"]
_SEAT_EXCLUDED: list[str] = ["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"]
_REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "bash", "str_replace"]
# (与前端 ``_special_run`` 的 excluded_tools 对齐)。
#
# ``leader`` 用「白名单」思路反推黑名单:总控**只允许** agent_orchestration(派活)、
# ask_clarification(澄清)、present_files(收口呈现子智能体产出的文件)三件事,
# 因此把其余所有工具全部禁掉。关键是 write_file / bash / str_replace —— 留着它们,
# 弱模型会绕过派活直接自己写报告(生产日志已实锤)。memory / hindsight_* 一并禁,
# 配合 ``memory_injection_enabled=False`` 让单例总控不被上轮会话的记忆带偏席位。
_LEADER_EXCLUDED: list[str] = [
"web_search",
"view_image",
"bash",
"ls",
"read_file",
"write_file",
"str_replace",
"task",
"write_todos",
"memory",
"hindsight_recall",
"hindsight_reflect",
"hindsight_retain",
"read_peer_delivery", # 总控靠 _collect_delivery_status 直读各席位,不需要这个席位侧工具
]
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,但**禁 bash** ——
# 圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 脚本再跑生成产物,既乱又
# 抓不到产物预览,与 report 角色禁 bash 同理)。禁 web_search(圆桌串行一律禁搜索,旧决策)。
_SEAT_EXCLUDED: list[str] = [
"web_search",
"ask_clarification",
"present_files",
"view_image",
"agent_orchestration",
"bash",
]
# 并行席位 = **快速(fast)模式**(双列):**禁写文件**(产物走正文)+ 禁跑命令 +「打断/越权」类。
# **禁 web_search** —— 离线内网部署无外网,席位检索一律走「技能(skill)」(靠 read_file 读
# SKILL.md,不联网),与 leader / seat / report 三角色保持一致(2026-06 离线部署决策)。
# 「写文件」归 **沙箱(sandbox)模式**(seat policy,单列 + 沙箱跟随预览);fast 模式快、双列、
# 产物走正文 —— 用户明确要求 fast 不生成文件(2026-06 §12.10)。
_SEAT_PARALLEL_EXCLUDED: list[str] = [
"web_search", # 离线部署无外网,检索走技能(skill),不联网
"ask_clarification", # 并行批次不打断问澄清
"present_files", # 产物走正文,不呈现文件
"view_image",
"agent_orchestration", # 席位不派活
"bash", # 禁跑脚本(防 .py 脚本生成产物)
"write_file", # fast 模式产物走正文,不写文件(写文件归 sandbox 模式)
"str_replace",
]
# report:在 seat 基础上额外禁 str_replace(bash 已在 seat 禁)——强制整文件 write_file 重写,
# 让前端虚拟预览能拿到完整 args.content。report 是 Step3 单独绘制,不读前序席位 → 禁 read_peer_delivery。
_REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"]
# leader 内禀的 skill_stop:``agent_orchestration`` 调用后停图。调用方可在此基础上
# 再叠加自己的 skill_stop(见 ``merge_skill_stop_names``)。
@@ -56,7 +122,7 @@ class RoundtableRunPolicy:
def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
"""返回某角色(``leader`` / ``seat`` / ``report``)的单轮 run 参数。
"""返回某角色(``leader`` / ``seat`` / ``seat_parallel`` / ``report``)的单轮 run 参数。
返回值里的列表是**新副本**,调用方可安全地再 append/merge 而不污染本模块常量。
未知角色抛 ``ValueError``(早失败,避免悄悄退化到错误的工具面)。
@@ -75,6 +141,14 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
thinking_enabled=True,
reasoning_effort="medium",
)
if role == "seat_parallel":
# 同 seat(thinking + medium),但额外禁写文件 / 跑命令(防并行沙箱打架)。
return RoundtableRunPolicy(
excluded_tools=list(_SEAT_PARALLEL_EXCLUDED),
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
)
if role == "report":
return RoundtableRunPolicy(
excluded_tools=list(_REPORT_EXCLUDED),
@@ -82,7 +156,7 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
thinking_enabled=True,
reasoning_effort="medium",
)
raise ValueError(f"unknown roundtable role: {role!r} (expected leader/seat/report)")
raise ValueError(f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report)")
def merge_skill_stop_names(policy_names: list[str], caller_names: list[str] | None) -> list[str]:
@@ -11,15 +11,18 @@
通过 `agent_orchestration` 技能调用其他智能体干活,使用技能时传入 `agent_name` 和需要做的活。
**⚠️ 关于 `agent_name` 的硬性规则**:
- 必须严格使用**本次圆桌已上线席位的英文 id**(例如 `roundtable-intelligence`,或形如 `02714cbe6e64406a8a4659f0e3d9c0b8` 这样的 UUID)
- 本次可调度的席位清单会以「【圆桌系统初始化】本次可调度席位:...」这条人类消息的形式出现在你的对话历史最前面——**只能从这份清单里挑**
- 不要把括号里的中文显示名拼进 `agent_name`;不要派给清单上没有的 id
- `agent_name` 必须**逐字符照抄**本次席位清单里给出的 id 原文(可能是 `roundtable-intelligence` 这样的可读 id,也可能是一串 32 位十六进制字符)
- 本次可调度的席位清单会以「【圆桌系统初始化】本次可调度席位:...」这条人类消息的形式出现在你的对话历史最前面——**只能从这份清单里逐字复制 id**
- **绝对禁止凭记忆、猜测,或参考本说明里的任何示例文字自行拼造 id**;清单里没有的 id 一律不准派
- 不要把括号里的中文显示名拼进 `agent_name`
## 关键约束(必读)
1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,如果某个子智能体已经针对你正要派的任务完成过交付(看到形如「子智能体[名称]完成XX工作,交付内容为:...」的 human 消息),就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题
2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),让讨论收敛
3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论
4. **数量与节奏**:每轮派活 1-3 个席位最佳;不需要每个席位都派一次
3. **⛔ 严禁臆造交付**:你**只能**把对话历史里**确实出现过**的「子智能体[名称]完成XX工作,交付内容为:...」当作已交付。**绝对禁止**在没有看到这条消息的情况下声称某席位「已交付」、编造它的交付内容、或假装研讨已完成。**没看到该席位的真实交付,就一律视为「未交付」**——该派活就派活,绝不跳过直接收口。宁可多派一轮,也不许凭空说「三方已交付」。
4. **用户点名某席位就去派活**:当用户(含人工干预指令)明确希望由某个席位来回答/产出(例如"我想看前端实现,席位里有前端实现智能体"),你**必须**通过 `agent_orchestration` 把活派给那个真实席位,**不要**自己代答、也不要直接给最终结论搪塞。
5. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论
6. **数量与节奏**:每轮派活 1-3 个席位最佳;不需要每个席位都派一次
## 终止条件
当满足以下任一条件,**直接输出最终结论文本**(不调用任何工具),让本次圆桌收敛:
@@ -40,3 +43,5 @@
- ❌ 派活给本次清单**没有**的 agent_name
- ❌ 把括号里的中文显示名拼进 `agent_name`
- ❌ 已经达成共识后仍然继续派活
- ❌ **臆造 / 假装某席位已交付**——没在对话历史里看到它的「完成…交付内容为:…」就不许说它交付了
- ❌ 用户点名要某席位回答时,自己代答或直接收口而**不去派活**
@@ -0,0 +1,87 @@
"""CRUD API for the global business → agent / chain mapping (业务链条管理).
Binds a fixed business code (3Q / 6BF / 7BF / 8BF) to an optional 智能体 and an
optional 业务链条 (a **public** ``roundtable_chains`` row). Used to auto-pick the
business chain when a task page launches the multi-agent roundtable.
Global / shared — there is no user scoping; all accounts see and edit the same
rows. The four business rows are seeded on startup; this API only lists them and
updates the agent / chain columns (no create/delete).
Routes (prefix ``/api/business-mapping``):
GET "" list all business mappings (display order)
PUT "/{business_code}" update one row's agent / chain selection
"""
from __future__ import annotations
import logging
from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"])
# ── schemas ────────────────────────────────────────────────────────────────
class BusinessMappingResponse(BaseModel):
business_code: str
label: str = ""
agent_id: str | None = None
agent_name: str | None = None
chain_id: str | None = None
chain_title: str | None = None
sort_order: int = 0
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class BusinessMappingListResponse(BaseModel):
mappings: list[BusinessMappingResponse]
class BusinessMappingUpdateRequest(BaseModel):
# All optional → callers send only what changed; explicit null clears a field.
agent_id: str | None = Field(default=None, max_length=64)
agent_name: str | None = Field(default=None, max_length=191)
chain_id: str | None = Field(default=None, max_length=64)
chain_title: str | None = Field(default=None, max_length=512)
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request):
store = getattr(request.app.state, "business_mapping_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Business mapping store not available")
return store
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=BusinessMappingListResponse)
async def list_mappings(request: Request) -> BusinessMappingListResponse:
store = _get_store(request)
rows = await store.list_mappings()
return BusinessMappingListResponse(
mappings=[BusinessMappingResponse(**r) for r in rows]
)
@router.put("/{business_code}", response_model=BusinessMappingResponse)
async def update_mapping(
request: Request, business_code: str, body: BusinessMappingUpdateRequest
) -> BusinessMappingResponse:
store = _get_store(request)
# Only forward fields the caller explicitly sent (distinguish omitted vs null).
fields = body.model_dump(exclude_unset=True)
row = await store.update_mapping(business_code, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Business mapping not found")
return BusinessMappingResponse(**row)
@@ -47,6 +47,7 @@ Leader 终态帧(前端 ``isFinalStatusFrame``)
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
@@ -468,22 +469,42 @@ async def _append_thread_message(
失败只打 warning,不阻断主流程——广播是 best-effort,席位真正干活仍靠
前端对该席位 thread 发起的 ``/run/stream``。
走**轻量追加**接口(``POST /messages/append``):只传一条消息,服务端读 checkpoint+追加+写回,
省掉原先 GET /state(下载全量)+POST /state(上传全量)把几万字状态经 loopback 传两遍的开销。
"""
state_url = f"{base}/api/threads/{thread_id}/state"
get_resp = await client.get(state_url, headers=headers)
if get_resp.status_code != 200:
logger.warning("get state %s failed: %s %s", thread_id, get_resp.status_code, get_resp.text[:200])
return
state = get_resp.json()
values = state.setdefault("values", {})
messages = values.setdefault("messages", [])
if not isinstance(messages, list):
messages = []
values["messages"] = messages
messages.append({"type": "human", "content": content})
post_resp = await client.post(state_url, json=state, headers=headers)
if post_resp.status_code >= 400:
logger.warning("update state %s failed: %s %s", thread_id, post_resp.status_code, post_resp.text[:200])
resp = await client.post(
f"{base}/api/threads/{thread_id}/messages/append",
json={"content": content, "type": "human"},
headers=headers,
)
if resp.status_code >= 400:
logger.warning("append message %s failed: %s %s", thread_id, resp.status_code, resp.text[:200])
def _spawn_detached_broadcast(
base: str,
headers: dict[str, str],
message: str,
agent_threads: dict[str, str],
skip: list[str],
) -> None:
"""后台 fire-and-forget 广播,**用独立 client**,即使发起它的请求 SSE 已结束也能跑完。
广播是 best-effort 的「上下文播报」(告知其它席位某人交付了什么),原本在总控 yield 派活
帧**之前**被 ``await``,导致每个席位都要等几十秒(广播要读写各席位的巨大 thread 状态)
才开始执行 —— 实测 ``dispatch_broadcasts_await`` 飙到 27s。改成 detached 后台任务后,总控
立即 yield 派活、前端立即启动席位,广播在后台慢慢落地,彻底移出关键路径。失败只记日志。
"""
async def _run() -> None:
try:
async with httpx.AsyncClient(timeout=None, trust_env=False) as bg:
await _broadcast(bg, base, headers, message, agent_threads, skip)
except Exception as exc: # noqa: BLE001
logger.warning("detached broadcast failed: %s", exc)
# create_task 后不持有引用:任务挂在事件循环上,独立于本请求生命周期跑完。
asyncio.ensure_future(_run())
async def _broadcast(
@@ -506,6 +527,29 @@ async def _broadcast(
_TOOL_CALL_PATTERN = re.compile(r'\.py\s+"([^"]*)"\s+"([^"]*)"')
# 子智能体交付的「后广播」里,正文广播给**其它席位**时的截断上限。
# 背景:席位交付动辄几万字;原实现把整篇交付广播进**每一个**其它席位 thread,导致
# 每个 thread 状态滚雪球,之后每次状态读/写(广播、交付状态核对)都要搬运巨量数据,
# 延迟复利式恶化(实测 dispatch_broadcasts_await 飙到 30s)。其它席位只需"知道某席位
# 交付了什么"的摘要即可;**完整交付仍原样发给总控 thread**(总控做最终综合要看全文),
# 也仍通过 SSE 完整给到前端。仅截断"广播给兄弟席位"这一路。
_BROADCAST_CONTENT_MAX_CHARS = 1500
def _truncate_broadcast_content(prefix: str, content: str) -> str:
"""构造广播给**兄弟席位**的交付摘要消息:超长正文截断到上限并标注省略。
``content`` 未超限时原样拼接(等价于完整版),超限时截断 + 追加省略说明。
总控那一路不走本函数,始终用完整正文(见 ``_special_run`` 的 ``full_msg``)。
"""
if len(content) <= _BROADCAST_CONTENT_MAX_CHARS:
return prefix + content
return (
prefix
+ content[:_BROADCAST_CONTENT_MAX_CHARS]
+ f"\n…(完整交付约 {len(content)} 字,已省略;以该席位自身交付为准)"
)
# Mirrors the upstream agent-id validator in the LangGraph runtime. Reject early
# so callers get a clear 400 here instead of a stream that aborts with HTTP 422
# from the loopback /api/threads/.../runs/stream endpoint.
@@ -592,6 +636,8 @@ def _run_payload(
skill_stop_names: list[str],
thinking_enabled: bool = True,
reasoning_effort: str = "medium",
multitask_strategy: str = "reject",
extra_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
@@ -604,7 +650,7 @@ def _run_payload(
应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐
reasoning tokens 才开口的时间,显著降低前端"调度中"首字节延迟。
"""
return {
payload: dict[str, Any] = {
"input": {
"messages": [
{
@@ -627,18 +673,36 @@ def _run_payload(
# 圆桌四个 router(intent / recommend / leader / special)都是临时任务,
# 用户个人记忆与本次研讨无关。MemoryMiddleware 的 Hindsight recall 是
# 同步阻塞 HTTP 调用(单次 timeout 15s),如果记忆服务慢/不通会让前端
# "调度中"卡很久;这里默认跳过,builtin USER.md/MEMORY.md 仍通过系统
# 提示注入不受影响。
# "调度中"卡很久;这里默认跳过。
"memory_recall_disabled": True,
# ⚠️ 同时关掉 builtin 记忆**注入**:圆桌四个角色(intent / recommend /
# leader / special)都是固定单例 agent,其 memory.json 跨所有会话累积。
# 总控尤其危险——上一个任务里它臆造的席位名(如 data-specialist)会被存进
# memory.json,下一个新任务又被注入系统提示而召回 → 派给根本没初始化的
# 席位 → run/stream 报 400 "agent_name ... missing from d_agent_thread_id"。
# 圆桌是一次性研讨,本就不该吃历史会话记忆,这里统一关注入根治该污染。
"memory_injection_enabled": False,
},
"stream_mode": ["messages-tuple", "values"],
"stream_subgraphs": True,
"stream_resumable": True,
"assistant_id": "lead_agent",
"on_disconnect": "continue",
# ⚠️ 圆桌 run 必须能被「下一次同 thread 的 run」抢占。圆桌每个 thread 同一时刻
# 只应有一个活跃 run,但 on_disconnect="continue" 让前端 abort SSE 后**后端 run
# 仍在跑**(为可恢复);此时用户「人工干预」立刻发起新 leader run 打到同一总控
# thread,默认 reject 策略就会 409「already has an active run」→ 被 _stream_upstream
# 包成 502。调用方(leader / special)传 multitask_strategy="rollback",让新 run 先
# 取消该 thread 上没结束的旧 run 再继续 —— 这正是「最新一轮/干预指令优先」的语义。
"multitask_strategy": multitask_strategy,
"excluded_tools": excluded_tools,
"skill_stop_names": skill_stop_names,
}
# 额外注入 run context(如圆桌席位的 roundtable_peer_deliveries:前序席位完整交付,
# 供 read_peer_delivery 工具按需读取)。只进 context dict,不进 prompt → 不调工具就不耗 token。
if extra_context:
payload["context"].update(extra_context)
return payload
# ---------------------------------------------------------------------------
@@ -902,16 +966,23 @@ async def _stream_upstream(
yield None, captured
def _parse_last_messages(captured: list[str]) -> list[dict[str, Any]]:
def _parse_last_messages(captured: list[str]) -> list[dict[str, Any]] | None:
"""Return the full ``messages`` list from the most recent ``values`` snapshot.
The upstream sends the final ``values`` frame second-to-last (the very last
line is the ``end`` event with ``null``). We tolerate trailing non-JSON
frames (e.g. ``data: null``) by scanning backwards for the first frame that
successfully parses as a state object with a ``messages`` key.
Returns ``None`` when the stream carried **no parseable ``values`` frame**
(empty capture, or only ``data: null`` / token deltas). 这通常**不是真正的
上游错误**,而是该 run 被 ``multitask_strategy=rollback`` 抢占 / 客户端断开 /
中途 abort —— 流被截断,没来得及发出最终 values 快照。调用方(leader/special)据此
**静默收尾**,绝不能把它当 502 抛给前端:抢占场景下这条 SSE 多半已被前端
``abortAllInFlight`` 放弃,硬报 502 只会在 UI 上凭空冒出一条错误。
"""
if not captured:
raise HTTPException(status_code=502, detail="upstream produced no data lines")
return None
for raw_line in reversed(captured):
raw = raw_line[5:].strip() if raw_line.startswith("data:") else raw_line.strip()
@@ -926,7 +997,7 @@ def _parse_last_messages(captured: list[str]) -> list[dict[str, Any]]:
if isinstance(messages, list):
return messages
raise HTTPException(status_code=502, detail="no parseable values frame in upstream stream")
return None
def _find_trailing_ai_text(messages: list[dict[str, Any]], *, exclude_text: str = "") -> str:
@@ -983,6 +1054,198 @@ def _find_dispatch_message(messages: list[dict[str, Any]]) -> dict[str, Any]:
return {}
def _partition_dispatch(
tool_calls: list[dict[str, Any]],
valid_seat_ids: set[str],
) -> tuple[list[tuple[str, str]], list[str]]:
"""把总控的 agent_orchestration 派活拆成「合法 / 非法」两份。
返回 ``(valid, invalid)``:
- ``valid`` —— ``[(seat_id, task), ...]``,``seat_id`` 确实是本轮已初始化的席位;
- ``invalid`` —— ``[seat_id, ...]``,模型臆造 / 不在本轮 roster 内的 id。
弱模型(deepseek-chat)会把 SOUL 里的示例 hex id 当真照抄、或臆造同款 32 位 hex,
这些 id 不在 ``agent_threads`` 里;若不拦截会漏给前端,到 ``/run/stream`` 才报 400
把整个 Step 2 打挂。校验放在派活解析处,只丢非法、保留同轮合法席位。
"""
valid: list[tuple[str, str]] = []
invalid: list[str] = []
for tool_call in tool_calls:
sub_name, task_text = _extract_dispatch(tool_call)
if not sub_name:
continue
if sub_name in valid_seat_ids:
valid.append((sub_name, task_text))
else:
invalid.append(sub_name)
return valid, invalid
def _build_dispatch_correction(invalid_names: list[str], valid_seat_ids: set[str]) -> str:
"""整轮派活全非法时,回灌给总控 thread 的系统纠正消息文本。
列出真实席位 id,要求模型**逐字符复制**重派,杜绝「凭示例/记忆构造 id」。
"""
bad = "、".join(invalid_names)
roster = "、".join(sorted(valid_seat_ids))
return (
"【系统纠正】你刚才通过 agent_orchestration 派活的 agent_name("
f"{bad})不在本次圆桌的席位清单里,无法执行。请**只**从下面这份真实席位清单里"
"逐字符复制 agent_name 重新派活,不要凭记忆、示例或猜测自行构造 id:\n"
f"{roster}"
)
def _build_seat_roster_block(seats: list[tuple[str, dict[str, Any]]]) -> str:
"""构造注入到总控**本轮输入最前面**的「真实席位清单」文本。
``seats`` 为 ``[(seat_id, {name, description}), ...]``。
为什么要每轮把清单塞进输入,而不是只靠 init 时 _append_thread_message 注入历史:
圆桌席位清单原本只在 init 时以一条 human 消息异步写进总控 thread 历史,总控靠"读
历史"看到它 —— 这条链路脆弱(best-effort 写盘、跨 thread 持久化),一旦没落地,弱模型
(deepseek-chat)面对空历史就会**从任务语义臆造**不存在的席位(已观测到 architecture-
specialist、02714cbe… 这类假 id)。网关在每次 leader run 时本就握有真实席位 id,这里
直接把清单摆到总控本轮输入最前面并要求逐字复制,从源头根治臆造;与事后 _partition_dispatch
校验构成「源头 + 兜底」双保险。
"""
if not seats:
return ""
lines: list[str] = []
for sid, meta in seats:
name = str((meta or {}).get("name") or sid)
desc = str((meta or {}).get("description") or "").strip()
lines.append(f"- {sid}({name}):{desc}" if desc else f"- {sid}({name})")
roster = "\n".join(lines)
return (
"【本次圆桌可调度席位清单】派活时 agent_orchestration 的 agent_name 必须从下面"
"逐字符复制一个 id,严禁自行拼造、翻译角色名或凭记忆构造:\n"
f"{roster}"
)
async def _collect_delivery_status(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
seat_threads: dict[str, str],
*,
max_chars: int = 0,
) -> dict[str, tuple[bool, str, int]]:
"""读各席位 thread 的真实状态,客观判定「是否已交付 + 交付摘要」。
返回 ``{seat_id: (delivered, text, full_len)}``。``text`` 是该席位**最后一条 AI 交付**(按
``max_chars`` 截断:派活轮取 600 字摘要、综合轮 ``max_chars<=0`` 取全文);``full_len`` 是交付
全文长度(供调用方判断 text 是否被截断、要不要加省略号)。判定依据:席位 thread 是否存在非空
AI 交付,只有 broadcast 进来的 human 消息不算。
走**轻量读**接口(``GET /last-ai-message?max_chars=N``):服务端只回最后一条 AI 消息(可截断),
不把整份 state 序列化经 loopback 传回 —— 派活轮每席位只取 600 字,大幅压低原先 ~11s 的
``payload_built``。任一席位读失败按「未交付」保守处理,绝不阻断。
这是「确定性双保险」的数据源:总控弱模型会臆造「三方已交付」,这里由网关**客观核对**每个席位
thread 的实际产出,再把结论喂回总控输入,总控以系统核对为准,不靠自己读历史猜。
"""
async def _one(sid: str, tid: str) -> tuple[str, tuple[bool, str, int]]:
try:
resp = await client.get(
f"{base}/api/threads/{tid}/last-ai-message",
params={"max_chars": max_chars},
headers=headers,
)
if resp.status_code != 200:
return sid, (False, "", 0)
data = resp.json()
content = str(data.get("content") or "")
full_len = int(data.get("length") or len(content))
return sid, (bool(data.get("delivered")), content, full_len)
except Exception:
return sid, (False, "", 0)
results = await asyncio.gather(*(_one(sid, tid) for sid, tid in seat_threads.items()))
return dict(results)
# 交付状态摘要块里,每个席位「已交付摘要」截断到的字符数(让总控有足够信息做派活决策,
# 又远小于动辄几万字的全文)。
_STATUS_EXCERPT_CHARS = 600
def _build_seat_status_block(
seats: list[tuple[str, dict[str, Any]]],
delivery: dict[str, tuple[bool, str, int]],
*,
undelivered_count: int = 0,
) -> str:
"""席位清单 + **系统客观核对的真实交付状态**,注入总控本轮输入最前面。
在 ``_build_seat_roster_block``(只给 id+名称)基础上,叠加每个席位的 ✅已交付 / ⬜未交付
标记(来自 ``_collect_delivery_status`` 对席位 thread 的实读),并明确告诉总控「以这份为准、
不要臆造」「用户点名的 ⬜ 席位必须派活」。这是消除「臆造三方已交付直接收口」的确定性手段。
``undelivered_count`` > 0 时额外加一句硬约束:还有席位未交付,**不得**急于收口。
"""
if not seats:
return ""
lines: list[str] = []
for sid, meta in seats:
name = str((meta or {}).get("name") or sid)
delivered, text, full_len = delivery.get(sid, (False, "", 0))
if delivered:
# text 已是服务端按 max_chars 截好的摘要;full_len > len(text) 说明被截断,加省略号。
ellipsis = "…" if full_len > len(text) else ""
tail = f" —— 交付摘要:{text}{ellipsis}" if text else ""
lines.append(f"- {sid}({name}):✅ 已交付{tail}")
else:
lines.append(f"- {sid}({name}):⬜ 未交付")
roster = "\n".join(lines)
pending_rule = (
(
f"\n⚠️ 当前还有 {undelivered_count} 个 ⬜ 未交付席位。**信息尚未集齐,严禁现在收口/输出最终方案**,"
"也不得声称「各席位均已发言」。请**优先继续派活**给这些 ⬜ 席位,把它们的真实交付拿到手再说综合。"
)
if undelivered_count > 0
else ""
)
return (
"【本次圆桌席位与真实交付状态(系统客观核对,以此为准,严禁臆造)】\n"
"派活时 agent_orchestration 的 agent_name 必须从下面逐字符复制一个 id;"
"只有标 ✅ 的才算已交付,标 ⬜ 的就是还没交付——\n"
"若用户想要的席位是 ⬜,你**必须**派活给它拿到真实交付,**不得**跳过它直接收口或代答;"
"也**不得**声称任何 ⬜ 席位已交付。下面只是**摘要**,做最终综合时以系统额外提供的"
"「完整交付」为准:\n"
f"{roster}"
f"{pending_rule}"
)
def _build_full_deliverables_block(
seats: list[tuple[str, dict[str, Any]]],
delivery: dict[str, tuple[bool, str, int]],
) -> str:
"""综合轮专用:把各已交付席位的**完整交付正文**拼成一块,供总控写最终方案。
只在前端标记的「综合轮」(synthesis_mode)注入,平时不进总控输入 —— 这样派活决策轮
的上下文保持精简、响应快,只有真要收口时才承担一次全文的体量。未交付席位不列入。
"""
sections: list[str] = []
for sid, meta in seats:
delivered, full_text, _ = delivery.get(sid, (False, "", 0))
if not delivered or not full_text:
continue
name = str((meta or {}).get("name") or sid)
sections.append(f"━━ {name}({sid})的完整交付 ━━\n{full_text}")
if not sections:
return ""
body = "\n\n".join(sections)
return (
"【各席位完整交付正文(综合专用,做最终方案时以此为准)】\n"
"请基于下列各席位的真实完整交付,综合输出结构化的最终方案,不要遗漏关键内容,也不要臆造:\n"
f"{body}"
)
async def _leader_run(
client: httpx.AsyncClient,
base: str,
@@ -992,6 +1255,9 @@ async def _leader_run(
new_message: str,
skill_stop_names: list[str],
model_name: str,
seat_meta: dict[str, dict[str, Any]] | None = None,
synthesis_mode: bool = False,
suppress_pre_broadcast: bool = False,
) -> AsyncIterator[str | dict[str, Any]]:
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
@@ -1002,13 +1268,78 @@ async def _leader_run(
4. 若有派活,**派活广播**后再 yield ``{status: dispatched, ...}``。
``skill_stop_names`` 会与 ``agent_orchestration`` 合并,确保 middleware 在工具调用后停图。
``seat_meta``:``{seat_id: {name, description}}``。非空时,本函数会在跑总控前**实读各席位
thread 的真实交付状态**,把「席位清单 + ✅已交付/⬜未交付 + 摘要」拼到总控本轮输入最前面
(见 ``_collect_delivery_status`` / ``_build_seat_status_block``)。这是消除「总控臆造
三方已交付、一交付就收口」的确定性双保险:总控以系统客观核对为准,不靠自己读历史猜。
``synthesis_mode``:前端在「派活后让总控判断继续/综合」的轮次置真。为真时**额外**把各
已交付席位的**完整交付正文**注入本轮输入(``_build_full_deliverables_block``),供总控写
最终方案;平时(派活决策轮)只给摘要,让总控 thread 与每轮上下文保持精简、响应快。
"""
t0 = time.perf_counter()
# 实读各席位 thread 的真实交付状态,构造「席位清单 + 客观交付状态(摘要)」块;综合轮再额外
# 拼上各已交付席位的完整交付正文。失败/无 seat_meta 时退化为不注入(leader_input ==
# new_message),绝不阻断 leader run。这些只进总控**本轮 run 输入**,不广播给席位。
seat_status_block = ""
full_deliverables_block = ""
if seat_meta:
seat_ids = [name for name in agent_threads if name != agent_name]
if seat_ids:
# 第 1 阶段:**轻量**读各席位最后一条 AI 交付,只取 ≤_STATUS_EXCERPT_CHARS 字 ——
# 派活轮只需 ✅/⬜ + 摘要,绝不下载几万字全文,把 payload_built 从 ~11s 压下来。
delivery: dict[str, tuple[bool, str]] = {}
try:
delivery = await _collect_delivery_status(
client, base, headers, {sid: agent_threads[sid] for sid in seat_ids},
max_chars=_STATUS_EXCERPT_CHARS,
)
except Exception:
logger.warning("[leader-roster] 收集席位交付状态失败,退化为仅席位清单", exc_info=True)
undelivered = [sid for sid in seat_ids if not delivery.get(sid, (False, ""))[0]]
seats_meta_list = [(sid, seat_meta.get(sid, {})) for sid in seat_ids]
seat_status_block = _build_seat_status_block(seats_meta_list, delivery, undelivered_count=len(undelivered))
# 第 2 阶段(仅「综合轮 且 所有席位都已交付」):再**全文**取各已交付席位的交付,拼综合块。
# 还有 ⬜ 未交付席位时不注入 —— 否则"综合输出最终方案"的指令会怂恿弱模型在席位没集齐时
# 就提前收口(实测:3/5 交付就臆称"三方已发言"草草收口)。全文读只在这一轮才付出。
if synthesis_mode and not undelivered:
try:
full_delivery = await _collect_delivery_status(
client, base, headers,
{sid: agent_threads[sid] for sid in seat_ids if delivery.get(sid, (False, ""))[0]},
max_chars=0,
)
full_deliverables_block = _build_full_deliverables_block(seats_meta_list, full_delivery)
except Exception:
logger.warning("[leader-roster] 综合轮取全文交付失败,退化为仅摘要", exc_info=True)
logger.info(
"[leader-roster] %s 注入席位交付状态:%s synthesis_mode=%s undelivered=%d full_deliverables=%s",
agent_name,
{sid: delivery.get(sid, (False, ""))[0] for sid in seat_ids},
synthesis_mode,
len(undelivered),
bool(full_deliverables_block),
)
leader_input = "\n\n".join(
part for part in (seat_status_block, full_deliverables_block, new_message) if part
)
# 与上游 LLM 并行,减少「总控已开始想但席位 thread 里还没有用户消息」的窗口。
pre_broadcast_task = asyncio.create_task(
#
# ``suppress_pre_broadcast``:DAG「每 stage 派活轮」(方案 A)置真——此时 ``new_message`` 是
# 「请你作为总控…通过 agent_orchestration 派活给席位…」的**派活 prompt**,若广播进各席位
# thread,弱模型(deepseek)会从历史里捡到「派活」意图,**席位跟着模仿派活 / 绕道 bash 调
# 内部接口**(已观测 bug-1~4)。DAG 下席位的任务由前端 ``pickDagSeatTask`` 直接、干净地下发
# (含任务背景),此处不需要、也不应把总控的派活 prompt 播给席位。recommend/chain 不传该 flag,
# 行为不变。
pre_broadcast_task = (
None
if suppress_pre_broadcast
else asyncio.create_task(
_broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name])
)
)
thread_id = agent_threads[agent_name]
# 角色参数取自单一数据源 roundtable_run_policy(leader:禁 web_search、停
# agent_orchestration 图、关 thinking + low reasoning——短决策、首字节快)。
@@ -1019,11 +1350,14 @@ async def _leader_run(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
# 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。
new_message=leader_input,
excluded_tools=policy.excluded_tools,
skill_stop_names=effective_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
# 人工干预/恢复会重发 leader run 到同一总控 thread;抢占掉没结束的旧 run,避免 409。
multitask_strategy="rollback",
)
t_payload_built = time.perf_counter()
logger.info(
@@ -1032,6 +1366,17 @@ async def _leader_run(
t_payload_built - t0,
)
# 单例总控是弱模型(deepseek-chat),已观测到它把 SOUL 里的示例 id 当真实席位
# 照抄、或臆造同款 32 位 hex —— 这些 id 不在 agent_threads 里,直接漏给前端会让
# /run/stream 报 400 把整个 Step 2 打挂。这里把「本次真实可派席位」作为 roster
# (agent_threads 去掉总控自身),派活前就地校验:非法 id 丢弃 + 记日志;若**整轮
# 全是非法 id**,向总控 thread 回灌一条系统纠正消息(列出真实 id),再重跑一轮让它
# 自纠(最多重试 1 次,避免弱模型反复臆造导致死循环)。
valid_seat_ids: set[str] = {name for name in agent_threads if name != agent_name}
_MAX_DISPATCH_RETRIES = 1
attempt = 0
while True:
captured: list[str] = []
first_line_ts: float | None = None
line_count = 0
@@ -1059,21 +1404,38 @@ async def _leader_run(
)
messages = _parse_last_messages(captured)
if messages is None:
# 流被截断(多半被 multitask_strategy=rollback 抢占 / 客户端断开 / abort)。
# 这不是真正的上游错误——静默收尾,不向前端抛 502。该 run 对应的 SSE 在抢占
# 场景下通常已被前端 abortAllInFlight 放弃;即便没被放弃,前端拿到"无终态帧"
# 也只会当作空轮次,不会冒出错误条。
# WARNING 级:正常应是被 rollback 抢占;但若上游 run 因别的原因(如 DB 连接断、
# LLM 报错)截断流也会走这里 → 静默收尾会把它表现成前端"(无文本输出)"。留个
# 显眼日志,便于把"真异常被静默吞掉"和"正常抢占"区分开排查。
logger.warning(
"[leader-timing] %s upstream truncated (no values frame) — ending quietly "
"(expected on rollback preemption; otherwise check the upstream run's server-side error)",
agent_name,
)
if pre_broadcast_task is not None:
with contextlib.suppress(Exception):
await pre_broadcast_task
return
# Pre-broadcast must finish before we yield the final status frame on any
# branch — the seats that get dispatched next rely on having seen the
# original user message. The upstream LLM stream usually takes several
# seconds, so the broadcast has typically already completed and this
# await is effectively free.
# original user message. Only await it once (first attempt); the rare
# self-correction retry reuses the same already-awaited broadcast.
if attempt == 0 and pre_broadcast_task is not None:
t_before_broadcast_await = time.perf_counter()
try:
await pre_broadcast_task
except Exception as exc:
logger.warning("pre-broadcast failed: %s", exc)
t_after_broadcast_await = time.perf_counter()
logger.info(
"[leader-timing] %s pre_broadcast_await=%.3fs (>0 means broadcast was still in flight after LLM finished)",
agent_name,
t_after_broadcast_await - t_before_broadcast_await,
time.perf_counter() - t_before_broadcast_await,
)
dispatch_msg = _find_dispatch_message(messages)
@@ -1108,41 +1470,53 @@ async def _leader_run(
yield {"status": [], "content": leader_text, "agent_name": agent_name}
return
# 派活广播:告知各席位「总控给 X 派了什么活」,与即将发起的 special run 配合。
# sets and we don't care about ordering, so serializing them just
# multiplied latency before the frontend could start a sub-agent run.
dispatched: list[list[str]] = []
dispatch_broadcasts: list[asyncio.Task[None]] = []
for tool_call in tool_calls:
# 派活前按真实 roster 校验:合法保留、非法丢弃(只丢非法,不殃及同轮合法席位)。
valid_dispatch, invalid_names = _partition_dispatch(tool_calls, valid_seat_ids)
if invalid_names:
logger.warning(
"[leader-dispatch] %s 派活到 %d 个不存在的席位 id %s;真实席位=%s(attempt=%d)",
agent_name,
len(invalid_names),
invalid_names,
sorted(valid_seat_ids),
attempt,
)
# 整轮全是非法 id 且还有重试预算 → 回灌纠正消息 + 重跑一轮让总控自纠。
if invalid_names and not valid_dispatch and attempt < _MAX_DISPATCH_RETRIES:
try:
sub_name, task_text = _extract_dispatch(tool_call)
if not sub_name:
continue
dispatch_broadcasts.append(
asyncio.create_task(
_broadcast(
await _append_thread_message(
client,
base,
headers,
thread_id,
_build_dispatch_correction(invalid_names, valid_seat_ids),
)
except Exception as exc:
logger.warning("[leader-dispatch] 回灌纠正消息失败: %s", exc)
attempt += 1
continue
# 非法 id 已被丢弃;若 roster 校验后无合法派活(且重试已用尽),退化为空共识,
# 而不是把假 id 漏给前端再报 400。
dispatched: list[list[str]] = [[sub_name, task_text] for sub_name, task_text in valid_dispatch]
# 关键路径优先:**先 yield 派活帧**,让前端立刻启动席位 special run;派活广播(告知
# 其它席位"总控给 X 派了什么活")改成 detached 后台任务,不再 await 阻塞 —— 否则广播
# 读写各席位巨大 thread 状态会卡几十秒,白白拖慢席位启动(见 _spawn_detached_broadcast)。
yield {"status": dispatched, "content": leader_text, "agent_name": agent_name}
# DAG 派活轮(suppress_pre_broadcast)同样**不**把「总控给 X 派活」广播给兄弟席位 ——
# 否则弱模型从历史里看到派活语句又会模仿派活(席位任务由前端 pickDagSeatTask 干净下发,
# 跨席位承上靠各 special run 的"完成交付"后广播,不需要这条派活广播)。recommend/chain 照旧。
if not suppress_pre_broadcast:
for sub_name, task_text in valid_dispatch:
_spawn_detached_broadcast(
base,
headers,
f"总控智能体给{sub_name}派活,内容为:" + task_text,
agent_threads,
[agent_name, sub_name],
)
)
)
dispatched.append([sub_name, task_text])
except Exception as exc:
logger.warning("dispatch failed: %s", exc)
if dispatch_broadcasts:
t_before_dispatch = time.perf_counter()
await asyncio.gather(*dispatch_broadcasts, return_exceptions=True)
logger.info(
"[leader-timing] %s dispatch_broadcasts_await=%.3fs count=%d (gates how soon the next sub agent can start)",
agent_name,
time.perf_counter() - t_before_dispatch,
len(dispatch_broadcasts),
)
yield {"status": dispatched, "content": leader_text, "agent_name": agent_name}
return
async def _special_run(
@@ -1156,6 +1530,8 @@ async def _special_run(
model_name: str,
*,
display_name: str = "",
no_file: bool = False,
peer_deliveries: list[dict[str, Any]] | None = None,
) -> AsyncIterator[str | dict[str, Any]]:
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
@@ -1163,13 +1539,26 @@ async def _special_run(
- 终态 ``status`` 为描述性字符串(非数组),``content`` 为交付正文;
- **后广播**在 yield 终态帧之后 await,保证其它席位 state 里有完成摘要,
同时尽量让前端先收到结果关闭 loading。
``no_file``:DAG 并行批次里的席位置真 —— 用 ``seat_parallel`` policy 额外禁
write_file / bash / str_replace,避免并行席位同时写沙箱打架(§4)。
``peer_deliveries``:前序席位的**完整交付** ``[{name, content}, ...]``。注入到
run context 的 ``roundtable_peer_deliveries``,供席位用 ``read_peer_delivery`` 工具
**按需**取全文(平时只看广播摘要,§12.9 传摘要按需传全文)。不进 prompt,不耗 token。
"""
t0 = time.perf_counter()
thread_id = agent_threads[agent_name]
# 角色参数取自单一数据源 roundtable_run_policy(seat / report)。report 在 seat
# 基础上额外禁 bash / str_replace(理由见 roundtable_run_policy 模块注释)。
# 角色参数取自单一数据源 roundtable_run_policy(seat / seat_parallel / report)。
# report 在 seat 基础上额外禁 bash / str_replace;seat_parallel 额外禁
# write_file / bash / str_replace(理由见 roundtable_run_policy 模块注释)。
# 后台进程内网关用同一份 policy,保证「后台挂起」与「前端手动调用」传参一致。
role = "report" if agent_name == REPORT_AGENT_ID else "seat"
if agent_name == REPORT_AGENT_ID:
role = "report"
elif no_file:
role = "seat_parallel"
else:
role = "seat"
policy = roundtable_run_policy(role)
body = _run_payload(
agent_name=agent_name,
@@ -1180,6 +1569,12 @@ async def _special_run(
skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
# 干预/重新派活会把同一席位 thread 再跑一次;抢占掉没结束的旧 run,避免 409。
multitask_strategy="rollback",
# 前序席位完整交付 → run context,供 read_peer_delivery 工具按需取全文(§12.9)。
extra_context=(
{"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None
),
)
t_payload_built = time.perf_counter()
logger.info(
@@ -1215,6 +1610,15 @@ async def _special_run(
)
messages = _parse_last_messages(captured)
if messages is None:
# 与 _leader_run 同理:席位 run 被抢占/断开导致流截断,不抛 502,静默收尾。
# WARNING 级:正常是 rollback 抢占;否则查上游 run 的服务端报错(如 DB 连接断)。
logger.warning(
"[special-timing] %s upstream truncated (no values frame) — ending quietly "
"(expected on rollback preemption; otherwise check the upstream run's server-side error)",
agent_name,
)
return
# Sub-agent's final deliverable is the latest AIMessage's text content.
last_msg: dict[str, Any] = {}
for msg in reversed(messages):
@@ -1222,41 +1626,22 @@ async def _special_run(
last_msg = msg
break
content = _flatten_content(last_msg.get("content", ""))
# Kick the "子智能体完成" broadcast off in the background and yield the
# status frame first — the next leader cycle that depends on this
# broadcast having landed only starts after a fresh /run/stream request
# from the frontend, which is many seconds away (full leader LLM call).
# Awaiting after the yield ensures the task finishes before the AsyncClient
# closes; the SSE connection stays open a bit longer but the frontend has
# already consumed the result.
t_before_post = time.perf_counter()
# 优先用 display_name(中文友好,例如"情报收集")提示 leader,id 放括号备查;
# display_name 缺失时退化为仅 agent_name —— 与旧行为一致。
speaker_label = f"{display_name}({agent_name})" if display_name else agent_name
post_broadcast_task = asyncio.create_task(
_broadcast(
client,
base,
headers,
f"子智能体{speaker_label}完成{new_message}工作,交付内容为:" + content,
agent_threads,
[agent_name],
)
)
_prefix = f"子智能体{speaker_label}完成{new_message}工作,交付内容为:"
# 广播给**所有**席位(含总控)的都只是**截断摘要** —— 总控 thread 不再囤积各席位全文,
# 每轮 leader 的上下文因此保持小、响应快。总控做最终综合需要全文时,由 _leader_run 在
# 「综合轮」按需现读各席位 thread 注入(见 _build_full_deliverables_block),不靠历史囤积。
brief_msg = _truncate_broadcast_content(_prefix, content)
# 先 yield 交付结果让前端立即收口本席位;后广播改 detached,不再 await 阻塞 ——
# 否则它读写各席位大状态会让 SSE 多挂十几秒,把下一轮 leader 的启动也一起拖慢。
yield {
"status": f"子智能体{speaker_label}完成{new_message}工作",
"content": content,
"agent_name": agent_name,
}
try:
await post_broadcast_task
except Exception as exc:
logger.warning("post-broadcast failed: %s", exc)
logger.info(
"[special-timing] %s post_broadcast_total=%.3fs (broadcast time after yield — keeps SSE open this long)",
agent_name,
time.perf_counter() - t_before_post,
)
_spawn_detached_broadcast(base, headers, brief_msg, agent_threads, [agent_name])
# ---------------------------------------------------------------------------
@@ -1292,6 +1677,29 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
display_name: str = str(payload.get("display_name") or "").strip()
new_message: str = str(payload.get("new_message") or "")
skill_stop_names: list[str] = payload.get("skill_stop_names") or []
# 综合轮标记(仅 leader 用):前端在「派活后让总控判断继续/综合」的轮次置真,网关据此
# 额外把各已交付席位的完整交付正文注入总控输入,供其写最终方案。缺省 False(派活决策轮
# 只给摘要,保持上下文精简)。旧前端不传 → False,行为安全退化。
synthesis_mode: bool = bool(payload.get("synthesis_mode") or False)
# 并行禁写文件标记(仅 special 用):前端 runDagOrchestration 在真并行 stage(≥2 席位)
# 置真,网关据此用 seat_parallel policy 额外禁 write_file/bash/str_replace(§4)。
# 兼容 no_file 别名;缺省 False → 普通 seat,旧前端不传也不破坏。
no_file: bool = bool(payload.get("parallel_no_file") or payload.get("no_file") or False)
# 抑制 leader 预广播(仅 leader 用):DAG「每 stage 派活轮」置真,避免把总控的派活 prompt
# 广播进席位 thread 让弱模型模仿派活(详见 _leader_run 的 suppress_pre_broadcast 注释)。
# 缺省 False,recommend/chain 行为不变。
suppress_pre_broadcast: bool = bool(payload.get("suppress_pre_broadcast") or False)
# 前序席位完整交付(仅 special 用):前端 DAG stage>0 席位带上此前各阶段的全文,网关注入
# run context,供 read_peer_delivery 工具按需取全文(平时只看广播摘要,§12.9)。规整成
# [{name, content}] 且过滤空项;缺省 None。
peer_deliveries: list[dict[str, Any]] | None = None
_pd_raw = payload.get("peer_deliveries")
if isinstance(_pd_raw, list):
peer_deliveries = [
{"name": str(p.get("name") or ""), "content": str(p.get("content") or "")}
for p in _pd_raw
if isinstance(p, dict) and str(p.get("name") or "").strip() and str(p.get("content") or "").strip()
] or None
# 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致。
model_name: str = str(payload.get("model") or "")
@@ -1308,6 +1716,20 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
base = _loopback_base(request)
headers = _auth_headers(request)
# 总控派活前,取本次各席位的元数据(id+名称+描述);_leader_run 会据此**实读各席位
# thread 的真实交付状态**,把「席位清单 + ✅/⬜交付状态」拼进总控本轮输入最前面 ——
# 既让弱模型每轮都能逐字复制真实 id,又以系统客观核对的交付状态压制臆造。元数据走同进程
# 批量查;任一席位查不到/不可见就退化为仅 id 清单,绝不因此阻断 leader run。
seat_meta: dict[str, dict[str, Any]] | None = None
if agent_type == "leader":
seat_ids = [k for k in agent_threads if k != agent_name]
if seat_ids:
try:
seat_meta = await _get_agents_batch_direct(request, seat_ids)
except Exception:
logger.warning("[leader-roster] 取席位元数据失败,退化为仅 id 清单", exc_info=True)
seat_meta = {sid: {"id": sid, "name": sid, "description": ""} for sid in seat_ids}
async def stream_generator() -> AsyncIterator[str]:
# TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
# trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
@@ -1316,11 +1738,16 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
if agent_type == "leader":
gen = _leader_run(
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
seat_meta=seat_meta,
synthesis_mode=synthesis_mode,
suppress_pre_broadcast=suppress_pre_broadcast,
)
else:
gen = _special_run(
client, base, headers, agent_threads, agent_name, new_message, skill_stop_names, model_name,
display_name=display_name,
no_file=no_file,
peer_deliveries=peer_deliveries,
)
async for chunk in gen:
if isinstance(chunk, dict):
@@ -0,0 +1,148 @@
"""CRUD API for parallel multi-agent task records (并行多智能体任务).
A task = one saved run of the standalone parallel multi-agent panel: a task name
+ brief dispatched to several agents in parallel, plus the resulting message list.
Bound to the requesting user (not the browser), so it follows the account across
devices/sessions — same model as roundtable drafts.
Routes (prefix ``/api/parallel-agent-tasks``):
GET "" list current user's tasks (lightweight meta, no messages)
POST "" create a task
GET "/{task_id}" one task (full, incl. messages)
PUT "/{task_id}" partial update (title / brief / agents / messages)
DELETE "/{task_id}" delete
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
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/parallel-agent-tasks", tags=["parallel-agent-tasks"])
# ── schemas ────────────────────────────────────────────────────────────────
class TaskAgentSchema(BaseModel):
agent_id: str = Field(min_length=1, max_length=128)
name: str = ""
avatarType: str | None = None
class TaskResponse(BaseModel):
id: str
title: str = ""
brief: str | None = None
agents: list[TaskAgentSchema] = []
# messages is an opaque dialogue list (mirrors the frontend Dialogue[] shape);
# absent on list responses (lightweight), present on get.
messages: list[dict[str, Any]] | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class TaskListResponse(BaseModel):
tasks: list[TaskResponse]
class TaskCreateRequest(BaseModel):
id: str | None = Field(default=None, max_length=64)
title: str = Field(default="", max_length=512)
brief: str | None = None
agents: list[TaskAgentSchema] = []
messages: list[dict[str, Any]] = []
class TaskUpdateRequest(BaseModel):
# All optional → callers send only what changed.
title: str | None = Field(default=None, max_length=512)
brief: str | None = None
agents: list[TaskAgentSchema] | None = None
messages: list[dict[str, Any]] | 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, "parallel_agent_task_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Parallel agent task store not available")
return store
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=TaskListResponse)
async def list_tasks(request: Request) -> TaskListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_tasks(user_id)
return TaskListResponse(tasks=[TaskResponse(**r) for r in rows])
@router.post("", response_model=TaskResponse, status_code=201)
async def create_task(request: Request, body: TaskCreateRequest) -> TaskResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_task(
user_id,
{
"id": (body.id or uuid4().hex),
"title": body.title.strip(),
"brief": body.brief,
"agents": [a.model_dump() for a in body.agents],
"messages": body.messages,
},
)
return TaskResponse(**row)
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(request: Request, task_id: str) -> TaskResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_task(task_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse(**row)
@router.put("/{task_id}", response_model=TaskResponse)
async def update_task(request: Request, task_id: str, body: TaskUpdateRequest) -> TaskResponse:
store = _get_store(request)
user_id = _current_user_id(request)
fields = body.model_dump(exclude_unset=True)
# Normalize agents (pydantic models) to plain dicts for JSON storage.
if "agents" in fields and fields["agents"] is not None:
fields["agents"] = [a if isinstance(a, dict) else a.model_dump() for a in body.agents or []]
row = await store.update_task(task_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse(**row)
@router.delete("/{task_id}", status_code=204)
async def delete_task(request: Request, task_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete_task(task_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Task not found")
@@ -0,0 +1,440 @@
"""Server-side parallel fan-out for the standalone parallel multi-agent panel.
The Vue embeddable panel sends a single request — ``{task_name, brief, agent_ids}``
— and this endpoint **fans out** to every agent in parallel, then **merges** their
LangGraph streams into ONE SSE tagged per ``agent_id``, so the client only consumes
one stream and never parses LangGraph internals.
Each fan-out agent runs as a pure **executing seat**: ``excluded_tools`` =
``_EXCLUDED_TOOLS`` (``write_file`` / ``str_replace`` / ``agent_orchestration`` /
``bash``) so a seat can neither write files (parallel writes would clash) nor
dispatch / run scripts (a weak model fed "总控 / 业务链条" framing would otherwise
mimic the controller and "派活"). Read-only tools + ``ask_clarification`` stay on,
so the step bar and clarification cards still work.
Unified event protocol (one JSON object per ``data:`` frame)::
{"type": "agent_start", "agent_id": "..."}
{"type": "delta", "agent_id": "...", "text": "..."} # AI text increment
{"type": "step", "agent_id": "...", "key": "...", "label": "...", "args": {...}}
{"type": "agent_done", "agent_id": "...", "content": "..."}
{"type": "agent_error", "agent_id": "...", "message": "..."}
{"type": "all_done"}
The frame-normalisation (LangGraph ``messages-tuple`` → delta / tool step) mirrors
the frontend parser in ``frontend-web/src/roundtable-planning/api/multi-agent.ts``
(``extractAiTextDelta`` + ``tool_call_chunks`` accumulation).
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, AsyncIterator
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_flatten_content,
_loopback_base,
_make_loopback_client,
_parse_last_messages,
_run_payload,
_stream_upstream,
_validate_agent_id,
)
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
# Soft cap on parallel degree — protects remote model concurrency + sandbox.
MAX_PARALLEL_AGENTS = 8
# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
# 链条」语气的任务后,会概率性地用 agent_orchestration 真去派活,或绕道 bash 跑脚本
# 调内部接口模仿总控调度(bug:单步单智能体链条里子智能体输出「总控调度指令…」并用
# glob / bash 翻找派活脚本)。物理禁掉这两类即可根治。其余只读工具(ls / read_file /
# 检索)与 ask_clarification(协作卡片)保留:既不互相打架,又能正常出步骤条 / 要澄清。
_EXCLUDED_TOOLS: list[str] = [
"write_file", # 并行不互相写文件打架
"str_replace",
"agent_orchestration", # 子智能体不派活 / 不调度(防模仿总控)
"bash", # 禁跑脚本(防绕道脚本调内部接口模仿派活)
]
# ── 专用「绕过常规登录」取 token ──────────────────────────────────────────────
# 内网部署对标准 /api/v1/auth/login/username 有额外操作限制(口令门 / 网关裁剪等),
# 嵌入方拿不到 token。这里给并行多智能体面板单开一个**公共**端点:传用户名直接签发 token
# (账号不存在则自动注册,与 username 登录同一套逻辑)。已在 auth_middleware / csrf_middleware
# 的白名单里放行(公共 + CSRF 豁免)。⚠️ 这是有意的鉴权旁路,仅供内网部署的本功能使用。
class _TokenRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
class _TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
@router.post("/auth/token", response_model=_TokenResponse)
async def issue_token(body: _TokenRequest) -> _TokenResponse:
"""传用户名 → 直接签发 access_token(绕过常规登录,公共端点)。"""
# 懒加载,避免与 auth 路由的模块级循环依赖。
from app.gateway.auth import create_access_token
from app.gateway.routers.auth import _get_or_create_user_by_username
user, _created = await _get_or_create_user_by_username(body.username.strip())
token = create_access_token(str(user.id), token_version=user.token_version)
return _TokenResponse(access_token=token, token_type="bearer", user_id=str(user.id))
# ── frame normalisation (mirrors multi-agent.ts) ─────────────────────────────
def _extract_ai_text(chunk: dict[str, Any]) -> str:
"""AI text delta from a ``messages-tuple`` chunk obj; "" when not AI text."""
type_ = str(chunk.get("type") or "").lower()
if type_ not in ("ai", "aimessagechunk", "aimessage"):
return ""
content = chunk.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
out: list[str] = []
for item in content:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict):
t = item.get("text")
if isinstance(t, str):
out.append(t)
return "".join(out)
return ""
def _ingest_tool_chunks(chunks: Any, acc: dict[int, dict[str, Any]]) -> None:
"""累积 ``tool_call_chunks`` 的增量(name + args 字符串),按 index 归并
(mirror multi-agent.ts ``ingestToolCallChunks``)。"""
if not isinstance(chunks, list):
return
for c in chunks:
if not isinstance(c, dict):
continue
idx = c.get("index") if isinstance(c.get("index"), int) else 0
entry = acc.setdefault(idx, {"id": None, "name": "", "argsStr": ""})
cid = c.get("id")
if isinstance(cid, str) and cid:
if entry["id"] and entry["id"] != cid: # 同 index 换了新调用 → 重置
entry["name"] = ""
entry["argsStr"] = ""
entry["id"] = cid
name = c.get("name")
if isinstance(name, str) and name:
entry["name"] = name
a = c.get("args")
if isinstance(a, str):
entry["argsStr"] += a
def _synthetic_tool_calls(acc: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
"""把累积的 argsStr 尽力 parse 成完整 args,产出 ``{id,name,args}``(未成完整 JSON 则跳过)。"""
out: list[dict[str, Any]] = []
for entry in acc.values():
if not entry["name"]:
continue
s = str(entry["argsStr"]).strip()
if not s:
continue
try:
args = json.loads(s)
except Exception:
continue
if isinstance(args, dict):
out.append({"id": entry["id"], "name": entry["name"], "args": args})
return out
def _clarification_from_messages(messages: list[dict[str, Any]]) -> dict[str, Any] | None:
"""从最终 messages 里找最后一个 ``ask_clarification`` 工具调用,抽出结构化澄清载荷
(字段对齐 multi_agent leader 的 clarification 帧)。"""
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
continue
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and (tc.get("name") or "").lower() == "ask_clarification":
cargs = tc.get("args") if isinstance(tc.get("args"), dict) else {}
return {
"question": str(cargs.get("question") or ""),
"clarification_type": str(cargs.get("clarification_type") or ""),
"clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
}
return None
async def _run_one_agent(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
agent_id: str,
thread_id: str,
new_message: str,
model_name: str,
) -> AsyncIterator[str | dict[str, Any]]:
"""跑一个智能体一轮(禁写文件 + 禁派活 / 脚本,其余只读工具放开)。
与 ``multi_agent._special_run`` 同形(yield 原始上游 SSE 行 + 末尾一条终态 dict),
但 ``excluded_tools`` = ``_EXCLUDED_TOOLS``(write_file / str_replace /
agent_orchestration / bash),且不做 post-broadcast(独立面板各智能体互不广播)。
"""
body = _run_payload(
agent_name=agent_id,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=list(_EXCLUDED_TOOLS),
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
multitask_strategy="rollback",
)
captured: list[str] = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield line
elif done is not None:
captured = done
messages = _parse_last_messages(captured)
if messages is None:
# 流被抢占/截断,没有终态 values 帧 —— 静默收尾(调用方据无终态帧兜底)。
return
last_ai: dict[str, Any] = {}
for msg in reversed(messages):
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
last_ai = msg
break
content = _flatten_content(last_ai.get("content", ""))
# 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤
# (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。
final_tool_calls: list[dict[str, Any]] = []
for msg in messages:
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
final_tool_calls.append(tc)
clarification = _clarification_from_messages(messages)
if clarification is not None:
# 智能体在向用户要澄清 —— 交给前端渲染「协作卡片」(对齐圆桌第一步)。
yield {
"type": "clarification",
"content": content,
"clarification": clarification,
"tool_calls": final_tool_calls,
}
else:
yield {
"status": f"子智能体 {agent_id} 完成",
"content": content,
"agent_name": agent_id,
"tool_calls": final_tool_calls,
}
def _build_task_message(task_name: str, brief: str) -> str:
parts: list[str] = []
if task_name.strip():
parts.append(f"任务名称:{task_name.strip()}")
if brief.strip():
parts.append(f"任务简介:\n{brief.strip()}")
parts.append(
"请你从你的专业职责出发,针对以上任务给出你的分析与交付。"
"本轮为**并行**研讨:请把你的产出**直接写在回复正文里**(本轮禁止写文件)。"
)
# 角色边界护栏:本面板里每个智能体都是**执行席位**,不是总控 / 指挥。即便任务里带有
# 「总控 / 业务链条 / 调度 / 派活」字样,也只完成属于自己的那份交付,严禁模仿总控派活。
parts.append(
"【角色边界】你是本轮的**执行席位**,不是总控 / 指挥。即使任务里出现「总控 / 业务链条 / 调度 / "
"派活」等字样,你也只需完成**属于你自己**的那份分析与交付;严禁模仿总控口吻输出「总控调度指令 / "
"调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用"
"任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。"
)
return "\n\n".join(parts)
# ── endpoint ─────────────────────────────────────────────────────────────────
@router.post("/run/stream")
async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
"""并行扇出 N 个智能体,合并成一条带 ``agent_id`` 标记的 SSE。文件写入固定禁用。"""
task_name: str = str(payload.get("task_name") or "").strip()
brief: str = str(payload.get("brief") or "").strip()
raw_ids = payload.get("agent_ids") or []
model_name: str = str(payload.get("model") or "")
if not isinstance(raw_ids, list) or not raw_ids:
raise HTTPException(status_code=400, detail="agent_ids 不能为空")
agent_ids: list[str] = []
for aid in raw_ids:
sid = str(aid or "").strip().lower()
_validate_agent_id(sid, "agent_ids")
if sid not in agent_ids:
agent_ids.append(sid)
if len(agent_ids) > MAX_PARALLEL_AGENTS:
raise HTTPException(
status_code=400,
detail=f"并行智能体数量上限 {MAX_PARALLEL_AGENTS}(当前 {len(agent_ids)})",
)
if not task_name and not brief:
raise HTTPException(status_code=400, detail="任务名称与简介至少填写其一")
base = _loopback_base(request)
headers = _auth_headers(request)
new_message = _build_task_message(task_name, brief)
async def _consume_agent(
client: Any,
agent_id: str,
queue: asyncio.Queue[dict[str, Any]],
) -> None:
await queue.put({"type": "agent_start", "agent_id": agent_id})
last_event = ""
emitted_tools: set[str] = set() # 已发过 step 的 tool_call id(去重)
tool_acc: dict[int, dict[str, Any]] = {} # tool_call_chunks 累积
buffer: list[str] = []
done_sent = False
async def _emit_tool(tc: Any, allow_empty: bool = False) -> None:
"""一个 tool_call(含完整 args)→ 一条 step 事件;按 id 去重。
``allow_empty=False``(流式期间):args 为空先不发 —— 流式帧里的 tool_calls 常先给
空 ``{}`` 占位、真实 args 走 tool_call_chunks 后到,过早发会把空 args 钉死。等累积出
完整 args(synthetic)或终态全量 tool_calls(``allow_empty=True``)再发。
"""
if not isinstance(tc, dict):
return
name = tc.get("name")
if not isinstance(name, str) or not name:
return
args = tc.get("args") if isinstance(tc.get("args"), dict) else None
if args is None:
return
if not args and not allow_empty: # 空 args 先不发,等 chunks / 终态补全
return
key_id = str(tc.get("id") or name)
if key_id in emitted_tools:
return
emitted_tools.add(key_id)
await queue.put(
{"type": "step", "agent_id": agent_id, "key": f"tool_calling:{name}", "label": name, "args": args}
)
try:
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
gen = _run_one_agent( # 只禁文件写入,其余工具放开(保留步骤条)
client, base, headers, agent_id, thread_id, new_message, model_name
)
async for chunk in gen:
if isinstance(chunk, dict):
# 终态:先用最终 tool_calls(全量 args)补发漏掉的 step,再发收口/澄清。
for tc in chunk.get("tool_calls") or []:
await _emit_tool(tc, allow_empty=True)
if chunk.get("type") == "clarification":
clar = chunk.get("clarification") or {}
await queue.put(
{
"type": "agent_clarification",
"agent_id": agent_id,
"content": str(chunk.get("content") or ""),
**clar,
}
)
else:
await queue.put(
{"type": "agent_done", "agent_id": agent_id, "content": str(chunk.get("content") or "")}
)
done_sent = True
continue
line = chunk.strip() if isinstance(chunk, str) else ""
if line.startswith("event:"):
last_event = line[6:].strip()
continue
if not line.startswith("data:"):
continue
raw = line[5:].strip()
if not raw:
continue
try:
parsed = json.loads(raw)
except Exception:
continue
if last_event == "messages" and isinstance(parsed, list) and parsed:
obj = parsed[0]
if isinstance(obj, dict):
text = _extract_ai_text(obj)
if text:
buffer.append(text)
await queue.put({"type": "delta", "agent_id": agent_id, "text": text})
# 完整 tool_calls(部分模型直接给)+ 累积 chunks 后的合成调用,都尝试发 step。
for tc in obj.get("tool_calls") or []:
await _emit_tool(tc)
_ingest_tool_chunks(obj.get("tool_call_chunks"), tool_acc)
for tc in _synthetic_tool_calls(tool_acc):
await _emit_tool(tc)
if not done_sent:
# 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。
await queue.put(
{"type": "agent_done", "agent_id": agent_id, "content": "".join(buffer)}
)
except Exception as exc: # noqa: BLE001 — 单个 agent 失败不拖垮其它
logger.warning("[parallel-agents] %s failed: %r", agent_id, exc)
await queue.put({"type": "agent_error", "agent_id": agent_id, "message": str(exc)})
finally:
await queue.put({"type": "__agent_end__", "agent_id": agent_id})
async def stream_generator() -> AsyncIterator[str]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async with _make_loopback_client(request) as client:
tasks = [asyncio.create_task(_consume_agent(client, aid, queue)) for aid in agent_ids]
ended = 0
try:
while ended < len(agent_ids):
event = await queue.get()
if event.get("type") == "__agent_end__":
ended += 1
continue
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'all_done'}, ensure_ascii=False)}\n\n"
finally:
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@@ -46,6 +46,17 @@ class ChainResponse(BaseModel):
title: str = ""
description: str | None = None
seats: list[ChainSeatSchema] = []
# Optional layered orchestration: list of stages, each a list of agent_ids
# run in parallel (stages run serially). None/absent = linear chain.
stages: list[list[str]] | None = None
# Optional per-stage goals aligned to ``stages`` (dag-only). None = no goals.
stage_goals: list[str | None] | None = None
# 写给总控的「编排提示」(dag-only):告诉总控怎么派活、做什么。None/空 = 不注入。
coordinator_prompt: str | None = None
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -61,6 +72,9 @@ class ChainCreateRequest(BaseModel):
title: str = Field(default="", max_length=512)
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] = []
stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
class ChainUpdateRequest(BaseModel):
@@ -68,6 +82,11 @@ class ChainUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=512)
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] | None = None
stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None
# ── helpers ──────────────────────────────────────────────────────────────
@@ -101,20 +120,101 @@ def _validate_seats(seats: list[ChainSeatSchema]) -> None:
seen.add(s.agent_id)
def _validate_stages(seats: list[ChainSeatSchema], stages: list[list[str]] | None) -> None:
"""Validate the optional layering. ``stages`` must partition the seats.
Rules (None = linear chain, always valid):
- at least one stage, no empty stage;
- every agent_id in a stage must be one of the chain's seats;
- no agent_id appears twice (within or across stages);
- every seat must appear in exactly one stage (full coverage), so the
stored layering and the seat list never drift apart.
"""
if stages is None:
return
if len(stages) == 0:
raise HTTPException(status_code=400, detail="编排分层不能为空(至少一个阶段)")
seat_ids = {s.agent_id for s in seats}
seen: set[str] = set()
for idx, stage in enumerate(stages):
if not stage:
raise HTTPException(status_code=400, detail=f"第 {idx + 1} 阶段为空,请删除或放入席位")
for agent_id in stage:
if agent_id not in seat_ids:
raise HTTPException(
status_code=400,
detail=f"编排分层包含不在席位列表中的智能体: {agent_id}",
)
if agent_id in seen:
raise HTTPException(
status_code=400,
detail=f"智能体 {agent_id} 在编排分层中重复出现,每个席位只能跑一次",
)
seen.add(agent_id)
if seen != seat_ids:
missing = seat_ids - seen
raise HTTPException(
status_code=400,
detail=f"编排分层未覆盖全部席位,缺少: {', '.join(sorted(missing))}",
)
# Per-goal length cap to keep injected prompts bounded.
MAX_STAGE_GOAL_CHARS = 1000
def _validate_stage_goals(
stages: list[list[str]] | None, stage_goals: list[str | None] | None
) -> None:
"""Validate the optional per-stage goals (None = no goals, always valid).
Rules:
- goals only make sense alongside a non-null layering (dag-only): reject
goals sent without ``stages``;
- length must match ``stages`` exactly (positional alignment);
- each goal (when present) must be ≤ ``MAX_STAGE_GOAL_CHARS`` chars.
"""
if stage_goals is None:
return
if stages is None:
raise HTTPException(status_code=400, detail="阶段目标需与编排分层一起提交(线性链不支持阶段目标)")
if len(stage_goals) != len(stages):
raise HTTPException(
status_code=400,
detail=f"阶段目标数量({len(stage_goals)})与编排分层阶段数({len(stages)})不一致",
)
for idx, goal in enumerate(stage_goals):
if goal is not None and len(goal) > MAX_STAGE_GOAL_CHARS:
raise HTTPException(
status_code=400,
detail=f"第 {idx + 1} 阶段目标过长(上限 {MAX_STAGE_GOAL_CHARS} 字)",
)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=ChainListResponse)
async def list_chains(request: Request) -> ChainListResponse:
async def list_chains(request: Request, scope: str = "mine") -> ChainListResponse:
"""列出业务链条。
``scope`` 查询参数(筛选):
- ``mine``(默认):仅当前用户自己的链条;
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨)。
其余值按 ``mine`` 处理。
"""
normalized_scope = scope if scope in ("mine", "public") else "mine"
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id)
rows = await store.list_chains(user_id, scope=normalized_scope)
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)
_validate_stages(body.seats, body.stages)
_validate_stage_goals(body.stages, body.stage_goals)
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_chain(
@@ -124,6 +224,9 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"title": body.title.strip(),
"description": body.description,
"seats": [s.model_dump() for s in body.seats],
"stages": body.stages,
"stage_goals": body.stage_goals,
"coordinator_prompt": body.coordinator_prompt,
},
)
return ChainResponse(**row)
@@ -143,6 +246,14 @@ async def get_chain(request: Request, chain_id: str) -> ChainResponse:
async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest) -> ChainResponse:
if body.seats is not None:
_validate_seats(body.seats)
# A non-null stages layering can only be validated against seats; the editor
# always saves both together. Reject a layering update that omits seats.
if body.stages is not None:
if body.seats is None:
raise HTTPException(status_code=400, detail="更新编排分层时必须同时提交席位列表")
_validate_stages(body.seats, body.stages)
# Goals align to stages; reject goals sent without a layering, enforce length/size.
_validate_stage_goals(body.stages, body.stage_goals)
store = _get_store(request)
user_id = _current_user_id(request)
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
@@ -20,7 +20,7 @@ import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from langgraph.checkpoint.base import empty_checkpoint
from langgraph.checkpoint.base import empty_checkpoint, uuid6
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
@@ -1009,6 +1009,138 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re
)
# ---------------------------------------------------------------------------
# Lightweight message append / last-AI read
#
# 圆桌广播与「席位交付状态」核对原本都走 GET /state + POST /state,要把动辄几万字的整份
# thread 状态经 loopback 传一两遍 —— 实测让总控每轮 payload_built 飙到 ~11s、广播也很慢。
# 下面两个轻量接口把重活留在服务端,loopback 只传一条小消息 / 截断后的摘要:
# • POST /{id}/messages/append —— 只传 1 条消息,服务端读 checkpoint+追加+写回;
# • GET /{id}/last-ai-message —— 只回最后一条 AI 交付(可 max_chars 截断),核对交付状态用。
# 注意:Postgres checkpoint 的 aput/aget 本身仍是 O(state) 的(快照模型),这里省掉的是
# 「整份 state 的 JSON 序列化 + loopback 往返」,这是可避免的那部分开销。
# ---------------------------------------------------------------------------
class AppendMessageRequest(BaseModel):
"""Request body for the lightweight single-message append."""
content: str = Field(..., description="Message text to append")
type: str = Field(default="human", description="Message role: human/ai/system")
def _flatten_msg_content(content: Any) -> str:
"""把 message.content(str / 结构化 block 列表)拍平成纯文本。"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
parts.append(str(item.get("text", "")))
return "".join(parts)
return str(content) if content else ""
def _extract_last_ai_text(messages: list[Any]) -> str:
"""返回最后一条 AI 消息的纯文本;没有则空串。兼容 message 对象与序列化 dict 两种存储形态。"""
for msg in reversed(messages or []):
if isinstance(msg, dict):
mtype = str(msg.get("type") or "").lower()
content = msg.get("content", "")
else:
mtype = str(getattr(msg, "type", "") or "").lower()
content = getattr(msg, "content", "")
if mtype in ("ai", "aimessage", "aimessagechunk"):
text = _flatten_msg_content(content)
if text.strip():
return text
return ""
async def _append_message_to_checkpoint(checkpointer: Any, thread_id: str, content: str, type_: str) -> int | None:
"""把一条消息追加进 thread 最新 checkpoint 的 ``messages`` 通道,返回新消息总数。
``None`` 表示线程不存在(无 checkpoint)。纯粹围绕 checkpointer 的 aget/aput,便于单测。
语义与 ``update_thread_state`` 一致:仅替换 ``messages`` 通道为追加后的列表,其它通道原样保留。
"""
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(config)
if ckpt_tuple is None:
return None
checkpoint: dict[str, Any] = dict(getattr(ckpt_tuple, "checkpoint", {}) or {})
metadata: dict[str, Any] = dict(getattr(ckpt_tuple, "metadata", {}) or {})
channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {}))
messages = list(channel_values.get("messages") or [])
messages.append({"type": type_, "content": content})
channel_values["messages"] = messages
checkpoint["channel_values"] = channel_values
# 生成**新的** checkpoint 快照 id + ts —— 每次追加都落成一个新快照(uuid6 时间可排序,新 id
# 天然成为最新),而不是复用旧 id 原地覆盖(后端语义不一致,会让连续追加互相丢失)。
checkpoint["id"] = str(uuid6(clock_seq=-2))
checkpoint["ts"] = now_iso()
# **关键**:checkpointer 用「版本化 blob」存 channel —— 必须 bump ``messages`` 的版本并把它
# 放进 aput 的 new_versions,aput 才会真正写入该 channel 的新内容、aget 才能读回。否则(像原
# GET/POST /state 那样只改 channel_values、new_versions 传 {})对没有现成 messages 版本的线程
# 不生效。
versions = dict(checkpoint.get("channel_versions", {}))
new_ver = checkpointer.get_next_version(versions.get("messages"), None)
versions["messages"] = new_ver
checkpoint["channel_versions"] = versions
metadata["updated_at"] = now_iso()
await checkpointer.aput(config, checkpoint, metadata, {"messages": new_ver})
return len(messages)
@router.post("/{thread_id}/messages/append", response_model=None)
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def append_thread_message(thread_id: str, body: AppendMessageRequest, request: Request) -> dict[str, Any]:
"""轻量追加一条消息到 thread checkpoint —— 调用方只传一条消息,不必 GET+POST 整份 state。
语义与 ``update_thread_state`` 的 messages 写入一致:直接追加到 ``messages`` 通道,下次图
运行时 ``add_messages`` 做规范化/按 id 去重。失败抛 5xx 由调用方按 best-effort 处理。
"""
checkpointer = get_checkpointer(request)
try:
count = await _append_message_to_checkpoint(checkpointer, thread_id, body.content, body.type)
except Exception:
logger.exception("Failed to append message on thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to append thread message")
if count is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
return {"ok": True, "message_count": count}
@router.get("/{thread_id}/last-ai-message", response_model=None)
@require_permission("threads", "read", owner_check=True)
async def get_thread_last_ai_message(thread_id: str, request: Request, max_chars: int = 0) -> dict[str, Any]:
"""轻量读取 thread 最后一条 AI 消息(可截断),供圆桌核对席位交付状态。
返回 ``{delivered, content, length}``:``delivered`` = 是否存在非空 AI 交付;``max_chars>0``
时 ``content`` 截断到该长度(派活轮取摘要),``<=0`` 取全文(综合轮)。``length`` 是全文长度。
线程不存在时返回 ``delivered=False``(而非 404),方便调用方把"还没建好/没交付"统一当未交付。
"""
checkpointer = get_checkpointer(request)
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
ckpt_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.exception("Failed to read last-ai-message on thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to get thread state")
if ckpt_tuple is None:
return {"delivered": False, "content": "", "length": 0}
channel_values = (getattr(ckpt_tuple, "checkpoint", {}) or {}).get("channel_values", {}) or {}
messages = channel_values.get("messages") or []
text = _extract_last_ai_text(messages if isinstance(messages, list) else [])
full_len = len(text)
if max_chars and max_chars > 0:
text = text[:max_chars]
return {"delivered": full_len > 0, "content": text, "length": full_len}
@router.delete("/{thread_id}/messages/{message_id}")
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def delete_thread_message(thread_id: str, message_id: str, request: Request) -> dict[str, Any]:
@@ -224,6 +224,11 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"excluded_tools",
"skill_stop_names",
"skill_stop_message",
# 记忆开关:必须在白名单里才会从 body.context 透传到 run config,
# 否则 agent 端 cfg.get(...) 永远取默认值。圆桌等临时任务靠这两个键
# 关掉个人记忆召回 / builtin 记忆注入(见 multi_agent._run_payload)。
"memory_recall_disabled",
"memory_injection_enabled",
}
)
@@ -72,6 +72,7 @@ async def run_orchestration(
seed_message: str,
mode: str = "recommend",
intent_text: str = "",
enable_report: bool = True,
) -> OrchestrationResult:
"""运行一次完整编排(循环 → 共识 → 报告绘制)。
@@ -85,6 +86,9 @@ async def run_orchestration(
seed_message: 第一轮 leader 的 prompt(启动=初始意图;resume=用户答案续轮)。
mode: "recommend" | "chain"。
intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。
enable_report: 是否在收口后执行 Step3 报告绘制。False 时只跑完 Step2 研讨即收尾、
不生成报告(step3=None)。当前「后台挂起」临时禁用报告(进程内驱动报告 agent
在真模型下仍无法稳定写出 HTML,详见 multi-agent-background-run-遗留问题与下次继续.md)。
"""
intent = (intent_text or seed_message or "").strip()
try:
@@ -104,8 +108,10 @@ async def run_orchestration(
dialogues=progress.dialogues(),
)
# consensus 或 max_cycles:均收口 → 报告绘制
# consensus 或 max_cycles:均收口。报告绘制按 enable_report 开关执行。
await progress.consensus(100)
step3 = None
if enable_report:
step3 = await _run_report(
gateway,
progress,
@@ -0,0 +1,18 @@
"""Global business → agent / chain mapping persistence (业务链条管理)."""
from __future__ import annotations
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.business_mapping.sql import BusinessMappingRepository
__all__ = [
"BusinessMappingRepository",
"BusinessMappingRow",
"make_business_mapping_store",
]
def make_business_mapping_store(session_factory):
if session_factory is None:
return None
return BusinessMappingRepository(session_factory)
@@ -0,0 +1,53 @@
"""ORM model for the global business → agent / chain mapping (业务链条管理).
A "business mapping" row binds a fixed business code (3Q / 6BF / 7BF / 8BF) to an
optional 智能体 (``agent_id``) and an optional 业务链条 (``chain_id`` → a
``roundtable_chains`` row, which must be **public** so every account can resolve
it). Used to auto-pick the business chain when a task page launches the
multi-agent roundtable.
Unlike ``roundtable_chains`` (per-user), this mapping is **global / shared** —
there is no ``user_id`` column; all accounts see and use the same configuration.
The fixed business rows are seeded on startup; users only fill in the
agent / chain columns from the management page.
``agent_name`` / ``chain_title`` are denormalized display snapshots so the table
renders without cross-referencing and survives a later agent/chain rename or
delete (the picked id still drives the auto-flow).
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class BusinessMappingRow(Base):
__tablename__ = "business_mapping"
# Business code is the natural primary key (e.g. "3Q", "6BF"). Global, no user scope.
business_code: Mapped[str] = mapped_column(String(32), primary_key=True)
# Display label shown in the 业务 column (e.g. "任务FX").
label: Mapped[str] = mapped_column(String(128), nullable=False, default="")
# Picked 智能体 (暂存不用,自动流程当前不消费此字段). NULL = 未配置.
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_name: Mapped[str | None] = mapped_column(String(191), nullable=True)
# Picked 业务链条 → roundtable_chains.id (须为 public). NULL = 未配置.
chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
chain_title: Mapped[str | None] = mapped_column(String(512), nullable=True)
# Fixed display order for the management table.
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
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),
)
@@ -0,0 +1,120 @@
"""SQLAlchemy-backed global business → agent / chain mapping store.
Global / shared (no ``user_id`` scoping): every account reads and writes the same
rows. The four business rows are seeded once on startup (``ensure_seed_defaults``)
with empty agent/chain; users fill those in from the management page via
``update_mapping``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.business_mapping.model import BusinessMappingRow
_UNSET = object()
# Fixed business rows (业务码 → 展示名). Seeded on first boot; agent/chain left empty.
# 8BF 页面尚未建好,先占位保留行。顺序即管理表的展示顺序。
DEFAULT_BUSINESS_ROWS: list[dict[str, Any]] = [
{"business_code": "3Q", "label": "3QFX"},
{"business_code": "6BF", "label": "任务FX"},
{"business_code": "7BF", "label": "XDFX"},
{"business_code": "8BF", "label": "8BF"},
]
class BusinessMappingRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_dict(row: BusinessMappingRow) -> dict[str, Any]:
return {
"business_code": row.business_code,
"label": row.label,
"agent_id": row.agent_id,
"agent_name": row.agent_name,
"chain_id": row.chain_id,
"chain_title": row.chain_title,
"sort_order": row.sort_order,
"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_mappings(self) -> list[dict[str, Any]]:
stmt = select(BusinessMappingRow).order_by(
BusinessMappingRow.sort_order.asc(), BusinessMappingRow.business_code.asc()
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_dict(row) for row in result.scalars()]
async def get_mapping(self, business_code: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(BusinessMappingRow, business_code)
return self._to_dict(row) if row is not None else None
async def update_mapping(
self,
business_code: str,
*,
agent_id=_UNSET,
agent_name=_UNSET,
chain_id=_UNSET,
chain_title=_UNSET,
) -> dict[str, Any] | None:
"""Update an existing (seeded) business row. Returns None if the code is unknown.
Only forwarded fields are touched; explicit ``None`` clears that field.
"""
async with self._sf() as session:
row = await session.get(BusinessMappingRow, business_code)
if row is None:
return None
if agent_id is not _UNSET:
row.agent_id = agent_id or None
if agent_name is not _UNSET:
row.agent_name = agent_name or None
if chain_id is not _UNSET:
row.chain_id = chain_id or None
if chain_title is not _UNSET:
row.chain_title = chain_title or None
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def ensure_seed_defaults(self) -> int:
"""Idempotently insert the fixed business rows if missing. Returns inserted count.
Never overwrites an existing row (respects user-configured agent/chain).
"""
inserted = 0
now = datetime.now(UTC)
async with self._sf() as session:
for idx, seed in enumerate(DEFAULT_BUSINESS_ROWS):
existing = await session.get(BusinessMappingRow, seed["business_code"])
if existing is not None:
continue
session.add(
BusinessMappingRow(
business_code=seed["business_code"],
label=seed.get("label", ""),
agent_id=None,
agent_name=None,
chain_id=None,
chain_title=None,
sort_order=idx,
created_at=now,
updated_at=now,
)
)
inserted += 1
if inserted:
await session.commit()
return inserted
@@ -0,0 +1,49 @@
"""Add stages column to roundtable_chains.
Layered orchestration (分层编排) for business chains: an optional list-of-lists of
agent_ids grouping the chain's seats into parallel stages (stages run serially).
NULL = linear chain. The column is LONGTEXT on MySQL (mirrors ``seats`` headroom)
and plain TEXT elsewhere — matching ``PortableLongText`` on
``RoundtableChainRow.stages``. ``create_all`` adds the column for brand-new DBs;
this revision patches DBs that already have the table.
Revision ID: 20260607_03
Revises: 20260607_02
Create Date: 2026-06-07
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260607_03"
down_revision = "20260607_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "stages"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_chains", sa.Column("stages", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_chains", "stages"):
op.drop_column("roundtable_chains", "stages")
@@ -0,0 +1,27 @@
"""Merge chain_stages and admin_leaderboard_daily_stats migration heads.
Two independent migrations diverged from ``20260607_02``: ``20260607_03``
(roundtable_chains.stages) and ``20260608_01`` (admin_leaderboard_daily_stats +
analytics indexes). This no-op merge revision joins both lineages so
``upgrade head`` succeeds.
Revision ID: 20260608_02
Revises: 20260607_03, 20260608_01
Create Date: 2026-06-08
"""
from __future__ import annotations
revision = "20260608_02"
down_revision = ("20260607_03", "20260608_01")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,58 @@
"""Add is_public column to roundtable_chains.
「发布」business chains: ``is_public=True`` makes a chain visible to **all
accounts** under the "公共" filter (read + select-into-roundtable only; edit /
delete stay owner-only). Defaults to ``0`` (private). ``create_all`` adds the
column for brand-new DBs; this revision patches DBs that already have the table.
Chained after the merge head ``20260608_02`` (chain_stages + leaderboard) so the
revision graph stays single-headed — otherwise ``alembic upgrade head`` aborts
with "multiple heads" and the auto-migrate on startup silently falls back to the
create_all schema (which lacks this column → 500 on list).
Revision ID: 20260608_03
Revises: 20260608_02
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260608_03"
down_revision = "20260608_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "is_public"):
op.add_column(
"roundtable_chains",
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="0"),
)
op.create_index(
"ix_roundtable_chains_is_public", "roundtable_chains", ["is_public"]
)
def downgrade() -> None:
if _has_column("roundtable_chains", "is_public"):
with op.batch_alter_table("roundtable_chains") as batch:
batch.drop_index("ix_roundtable_chains_is_public")
op.drop_column("roundtable_chains", "is_public")
@@ -0,0 +1,56 @@
"""Add stage_goals column to roundtable_chains.
Per-stage goals (每阶段目标) for business chains: an optional list aligned by index
to ``stages`` where ``stage_goals[i]`` is the「本阶段目标」for ``stages[i]`` — what
that serial stage should produce / how it depends on the previous stage's output.
Injected to the coordinator dispatch prompt (and seat tasks as fallback) in dag-mode
only. NULL = no goals. The column is LONGTEXT on MySQL (mirrors ``stages`` headroom)
and plain TEXT elsewhere — matching ``PortableLongText`` on
``RoundtableChainRow.stage_goals``. ``create_all`` adds the column for brand-new DBs;
this revision patches DBs that already have the table.
Chained after ``20260608_03`` (chain is_public) so the revision graph stays
single-headed — otherwise ``alembic upgrade head`` aborts with "multiple heads" and
the auto-migrate on startup silently falls back to the create_all schema (which lacks
this column → 500 on list).
Revision ID: 20260608_04
Revises: 20260608_03
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260608_04"
down_revision = "20260608_03"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "stage_goals"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_chains", sa.Column("stage_goals", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_chains", "stage_goals"):
op.drop_column("roundtable_chains", "stage_goals")
@@ -0,0 +1,61 @@
"""Create parallel_agent_tasks table.
Saved runs of the standalone parallel multi-agent panel (Vue embeddable
component): task name + brief + agents snapshot + the resulting message list,
bound per user. ``brief`` / ``agents`` / ``messages`` are LONGTEXT on MySQL
(mirror ``roundtable_drafts`` headroom) and plain TEXT elsewhere — matching
``PortableLongText``. ``create_all`` builds the table for brand-new DBs; this
revision patches DBs that already exist.
Chained after ``20260608_04`` (chain stage_goals) so the revision graph stays
single-headed — otherwise ``alembic upgrade head`` aborts with "multiple heads"
and the startup auto-migrate silently falls back to the create_all schema.
Revision ID: 20260608_05
Revises: 20260608_04
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260608_05"
down_revision = "20260608_04"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if _has_table("parallel_agent_tasks"):
return
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.create_table(
"parallel_agent_tasks",
sa.Column("id", sa.String(length=64), primary_key=True, nullable=False),
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=512), nullable=False, server_default=""),
sa.Column("brief", long_text, nullable=True),
sa.Column("agents", long_text, nullable=True),
sa.Column("messages", long_text, nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(
"ix_parallel_agent_tasks_user_id", "parallel_agent_tasks", ["user_id"]
)
def downgrade() -> None:
if _has_table("parallel_agent_tasks"):
op.drop_index("ix_parallel_agent_tasks_user_id", table_name="parallel_agent_tasks")
op.drop_table("parallel_agent_tasks")
@@ -0,0 +1,56 @@
"""Add coordinator_prompt column to roundtable_chains.
Per-chain「总控编排提示」: an optional free-text instruction the user writes for the
**coordinator (总控) agent** of a business chain — telling it how to dispatch / what to
do (overall orchestration strategy / style / constraints). Injected into the coordinator
per-stage dispatch prompt (``buildDagStageLeaderTask``) in **dag-mode only**. NULL = no
prompt. The column is LONGTEXT on MySQL (mirrors ``stage_goals`` headroom) and plain TEXT
elsewhere — matching ``PortableLongText`` on ``RoundtableChainRow.coordinator_prompt``.
``create_all`` adds the column for brand-new DBs; this revision patches DBs that already
have the table.
Chained after ``20260608_05`` (parallel_agent_tasks) so the revision graph stays
single-headed — otherwise ``alembic upgrade head`` aborts with "multiple heads" and the
startup auto-migrate silently falls back to the create_all schema (which lacks this
column → 500 on list).
Revision ID: 20260608_06
Revises: 20260608_05
Create Date: 2026-06-09
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260608_06"
down_revision = "20260608_05"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "coordinator_prompt"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_chains", sa.Column("coordinator_prompt", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_chains", "coordinator_prompt"):
op.drop_column("roundtable_chains", "coordinator_prompt")
@@ -22,6 +22,7 @@ from deerflow.persistence.agents.model import (
AgentSkillRow,
)
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.business_mapping.model import BusinessMappingRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.knowledge.models import (
@@ -37,6 +38,7 @@ from deerflow.knowledge.models import (
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.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
@@ -70,6 +72,7 @@ __all__ = [
"AgentRow",
"AgentSkillRow",
"AdminLeaderboardDailyStatRow",
"BusinessMappingRow",
"KnowledgeEmbeddingRow",
"KnowledgeEntityRow",
"KnowledgeNoteRow",
@@ -80,6 +83,7 @@ __all__ = [
"KnowledgeTagRow",
"LlmCallMetricRow",
"NotificationRow",
"ParallelAgentTaskRow",
"RecommendedQuestionRow",
"RoundtableChainRow",
"RoundtableDraftRow",
@@ -0,0 +1,18 @@
"""Parallel multi-agent task persistence."""
from __future__ import annotations
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.parallel_agent_tasks.sql import ParallelAgentTaskRepository
__all__ = [
"ParallelAgentTaskRepository",
"ParallelAgentTaskRow",
"make_parallel_agent_task_store",
]
def make_parallel_agent_task_store(session_factory):
if session_factory is None:
return None
return ParallelAgentTaskRepository(session_factory)
@@ -0,0 +1,46 @@
"""ORM model for parallel multi-agent task records.
A "parallel-agent task" is one saved run of the standalone *parallel multi-agent*
panel (the Vue3 embeddable component): a task name + brief dispatched to several
agents in parallel, plus the resulting chat/message list. Persisted per user so it
follows the account across devices/sessions — same model as ``roundtable_drafts``.
``agents`` and ``messages`` are stored as JSON serialized into ``PortableLongText``
(MySQL ``LONGTEXT``) rather than ``PortableJSON`` (MySQL ``TEXT``, 64 KB cap),
because the parallel transcript can carry several agents' full deliveries that
easily exceed 64 KB — mirroring ``roundtable_drafts``.
"""
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 ParallelAgentTaskRow(Base):
__tablename__ = "parallel_agent_tasks"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Task name (用户输入的任务名称) shown on the history list.
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
# Task brief / 简介 — the free-text task description.
brief: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# JSON: snapshot of the agents dispatched, [{"agent_id", "name", "avatarType"?}].
agents: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# JSON: the full message/dialogue list (mirrors the frontend Dialogue[] shape).
messages: 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),
)
@@ -0,0 +1,131 @@
"""SQLAlchemy-backed store for parallel multi-agent task records.
``agents`` / ``messages`` JSON columns are stored as serialized strings in
``PortableLongText`` and round-tripped here with ``json.dumps`` / ``json.loads``
— same approach as ``roundtable_drafts`` / ``roundtable_chains``.
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.parallel_agent_tasks.model import ParallelAgentTaskRow
_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 ParallelAgentTaskRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _to_dict(row: ParallelAgentTaskRow, *, with_messages: bool = True) -> dict[str, Any]:
data: dict[str, Any] = {
"id": row.id,
"title": row.title,
"brief": row.brief,
"agents": _loads(row.agents) 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,
}
if with_messages:
data["messages"] = _loads(row.messages) or []
return data
async def list_tasks(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
"""列出当前用户的任务(轻量元信息,不含 messages,供历史列表)。"""
stmt = (
select(ParallelAgentTaskRow)
.where(ParallelAgentTaskRow.user_id == user_id)
.order_by(ParallelAgentTaskRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_dict(row, with_messages=False) for row in result.scalars()]
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ParallelAgentTaskRow, task_id)
if row is None or row.user_id != user_id:
return None
return self._to_dict(row)
async def create_task(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = ParallelAgentTaskRow(
id=data["id"],
user_id=user_id,
title=(data.get("title") or "").strip(),
brief=(data.get("brief") or None),
agents=_dumps(data.get("agents") or []),
messages=_dumps(data.get("messages") 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_task(
self,
task_id: str,
user_id: str,
*,
title=_UNSET,
brief=_UNSET,
agents=_UNSET,
messages=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ParallelAgentTaskRow, task_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 brief is not _UNSET:
row.brief = brief or None
if agents is not _UNSET:
row.agents = _dumps(agents)
if messages is not _UNSET:
row.messages = _dumps(messages)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
async def delete_task(self, task_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(ParallelAgentTaskRow, task_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
@@ -14,7 +14,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -33,6 +33,27 @@ class RoundtableChainRow(Base):
# 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)
# Optional JSON: layered orchestration grouping over ``seats`` — a list of
# stages, each a list of agent_ids run in parallel; stages run serially in
# order. e.g. [["a","b"],["c"]] = (a‖b) → c. NULL/absent = linear chain
# (each seat its own stage). Drives Step 2 dag-mode orchestration; see
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
stages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# Optional JSON: per-stage goals aligned by index to ``stages`` — a list where
# ``stage_goals[i]`` is the「本阶段目标」for ``stages[i]`` (what that serial stage
# should produce / how it depends on the previous stage's output). Injected to the
# coordinator dispatch prompt (and seat tasks as fallback) in dag-mode only. NULL =
# no goals (and always NULL when ``stages`` is NULL/linear). Stored as JSON string.
stage_goals: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# 可选「总控编排提示」:用户为本链条写给**总控(协调)智能体**的一段指引,告诉总控该
# 怎么派活、做什么(整体编排策略 / 风格 / 约束)。**仅分层 DAG 模式生效**:注入到总控
# 每阶段派活提示(``buildDagStageLeaderTask``)。NULL = 不注入。纯文本,存为 LONGTEXT。
coordinator_prompt: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
is_public: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0", index=True
)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
@@ -44,33 +44,58 @@ class RoundtableChainRepository:
self._sf = session_factory
@staticmethod
def _to_dict(row: RoundtableChainRow) -> dict[str, Any]:
def _to_dict(row: RoundtableChainRow, current_user_id: str | None = None) -> dict[str, Any]:
return {
"id": row.id,
"title": row.title,
"description": row.description,
"seats": _loads(row.seats) or [],
# None when absent → frontend reads it as "linear chain" (no layering).
"stages": _loads(row.stages),
# Per-stage goals aligned to ``stages``; None when absent.
"stage_goals": _loads(getattr(row, "stage_goals", None)),
# 总控编排提示(写给总控的派活指引,dag-only);None/空 = 不注入。纯文本,不 json。
"coordinator_prompt": getattr(row, "coordinator_prompt", None),
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
"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]]:
async def list_chains(
self, user_id: str, *, scope: str = "mine", limit: int = 200
) -> list[dict[str, Any]]:
"""列出链条。
``scope``:
- ``"mine"``(默认):仅当前用户自己的链条;
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的。
其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。
"""
if scope == "public":
cond = RoundtableChainRow.is_public.is_(True)
else:
cond = RoundtableChainRow.user_id == user_id
stmt = (
select(RoundtableChainRow)
.where(RoundtableChainRow.user_id == user_id)
.where(cond)
.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()]
return [self._to_dict(row, user_id) for row in result.scalars()]
async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None:
"""取一条链条:owner 可见,**已发布的公共链条任何人可见**(供选入研讨)。"""
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
if row is None or row.user_id != user_id:
if row is None:
return None
return self._to_dict(row)
if row.user_id != user_id and not bool(getattr(row, "is_public", False)):
return None
return self._to_dict(row, user_id)
async def create_chain(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
@@ -80,6 +105,10 @@ class RoundtableChainRepository:
title=(data.get("title") or "").strip(),
description=(data.get("description") or None),
seats=_dumps(data.get("seats") or []),
stages=_dumps(data.get("stages")),
stage_goals=_dumps(data.get("stage_goals")),
coordinator_prompt=(data.get("coordinator_prompt") or None),
is_public=bool(data.get("is_public") or False),
created_at=now,
updated_at=now,
)
@@ -87,7 +116,7 @@ class RoundtableChainRepository:
session.add(row)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
return self._to_dict(row, user_id)
async def update_chain(
self,
@@ -97,6 +126,10 @@ class RoundtableChainRepository:
title=_UNSET,
description=_UNSET,
seats=_UNSET,
stages=_UNSET,
stage_goals=_UNSET,
coordinator_prompt=_UNSET,
is_public=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
@@ -108,10 +141,18 @@ class RoundtableChainRepository:
row.description = description or None
if seats is not _UNSET:
row.seats = _dumps(seats)
if stages is not _UNSET:
row.stages = _dumps(stages)
if stage_goals is not _UNSET:
row.stage_goals = _dumps(stage_goals)
if coordinator_prompt is not _UNSET:
row.coordinator_prompt = coordinator_prompt or None
if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
return self._to_dict(row, user_id)
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
async with self._sf() as session:
@@ -0,0 +1,82 @@
"""read_peer_delivery —— 圆桌席位「按需读取前序席位**完整交付**」的只读工具。
背景:圆桌每个席位是独立 thread(独立 sandbox),彼此隔离。默认情况下,一个席位只能
从跨席位广播里看到前序席位的**截断摘要**(≤1500 字)。当席位识别到自己需要某个前序
席位的**全文**时,调用本工具按席位名取回完整交付。
数据来源:全文由**网关层**(``app.gateway``)在发起本席位 run 时,注入到
``runtime.context["roundtable_peer_deliveries"]`` —— 一个
``[{"name": <席位显示名>, "content": <完整交付正文>}, ...]`` 列表。本工具**只读该上下文
dict**,不跨 thread、不碰 sandbox、不发网络请求,因此完全在 ``deerflow.*`` 内(不违反
harness→app 导入防火墙)。非圆桌场景下该上下文为空,工具返回「无」。
「传摘要按需传全文」:完整交付**只在席位真正调用本工具时**才进入它的 LLM 上下文,平时
只有摘要,既省 token 又满足「需要时能拿全文」。详见
``frontend-web/docs/roundtable-dag-orchestration.md`` §12.9。
"""
from __future__ import annotations
import logging
from langchain.tools import ToolRuntime, tool
logger = logging.getLogger(__name__)
_CTX_KEY = "roundtable_peer_deliveries"
def _peer_deliveries(runtime: ToolRuntime | None) -> list[dict]:
"""从 runtime.context 取出网关注入的前序席位完整交付列表(缺失/非法时返回 [])。"""
if runtime is None:
return []
ctx = getattr(runtime, "context", None)
if not ctx:
return []
try:
peers = ctx.get(_CTX_KEY)
except Exception:
return []
if not isinstance(peers, list):
return []
return [p for p in peers if isinstance(p, dict)]
@tool("read_peer_delivery", parse_docstring=True)
def read_peer_delivery_tool(runtime: ToolRuntime, seat_name: str = "") -> str:
"""Read another roundtable seat's FULL deliverable on demand.
In a multi-agent roundtable you (a seat) normally only see **truncated
summaries** of earlier seats' deliverables. When you decide you genuinely
need the **complete text** of a specific earlier seat (e.g. to build on its
detailed data/conclusions), call this tool with that seat's name to fetch
its full deliverable. Prefer the summaries you already have; only call this
when the summary is insufficient.
Args:
seat_name: The earlier seat's display name (the Chinese name shown in
the summaries, e.g. "情报收集"). Leave empty to list which peers'
full deliverables are available to read.
"""
peers = _peer_deliveries(runtime)
if not peers:
return "(当前没有可读取的前序席位完整交付。请直接基于你已有的上下文与摘要完成交付。)"
names = [str(p.get("name") or "") for p in peers if p.get("name")]
query = (seat_name or "").strip()
if not query:
return (
"可读取**完整交付**的前序席位:" + "、".join(names) + "。\n"
"需要哪个席位的全文,就再次调用 read_peer_delivery 并把它的名称传给 seat_name。"
)
ql = query.lower()
# 精确匹配(忽略大小写)优先,其次子串互含(容忍模型给「情报收集席位」这类带后缀的名)。
for p in peers:
if str(p.get("name") or "").lower() == ql:
return str(p.get("content") or "(该席位交付为空。)")
for p in peers:
nm = str(p.get("name") or "")
if nm and (ql in nm.lower() or nm.lower() in ql):
return str(p.get("content") or "(该席位交付为空。)")
return f"未找到名为「{query}」的前序席位。可读取的有:" + "、".join(names) + "。"
@@ -139,6 +139,13 @@ def get_available_tools(
builtin_tools.extend([skill_list_tool, skill_view_tool])
# read_peer_delivery —— 圆桌席位「按需读取前序席位完整交付」的只读工具(从 run context
# 取网关注入的全文,非圆桌场景为空 → 工具自述「无」)。始终提供(只读、无副作用);圆桌
# 总控/报告角色由 run policy 显式排除,避免干扰它们。
from deerflow.tools.builtins.roundtable_peers_tool import read_peer_delivery_tool
builtin_tools.append(read_peer_delivery_tool)
# skill_manage 能创建/修改/删除技能,属于"自我进化"写能力,按 skill_evolution.enabled
# 门控。该开关用户可在 per-user 配置里覆盖,所以按"生效配置(含 per-user 覆盖)"判断 ——
# Gateway 每次 run 重建 agent,用户改了配置下次 run 即生效;解析失败退回全局 config.yaml。
+79
View File
@@ -0,0 +1,79 @@
# parallel-agents-vue
并行多智能体面板 —— Vue3 可嵌入组件。输入「任务名称 + 简介」,**并行调用多个智能体**
(智能体清单由宿主传入,运行期间后端固定**禁止写文件**),消息列表照搬 DeerFlow 圆桌
研讨第二步并行多席位的**深色样式与完整结构**(步骤条 / 并行双列 / 席位状态条),支持
**保存聊天记录 + 回显**与**重新分析**。
## 开发调试
```bash
pnpm install
pnpm dev # http://localhost:5183 ,填后端 baseUrl / token / 智能体 id 即可联调
```
后端需要一个跑着的 DeerFlow 网关(默认 `http://localhost:8001`),它提供:
- `POST /api/parallel-agents/run/stream` —— 并行扇出,合并 SSE(事件带 `agent_id`);
- `/api/parallel-agent-tasks` —— 任务保存 / 回显 CRUD。
## 构建为组件库
```bash
pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs} + dist/parallel-agents-vue.css
```
## 嵌入其它 Vue 项目
```ts
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
```
```vue
<!-- 放进你自己的抽屉 / 弹窗里,面板会自适应容器(h-full w-full)。
接口地址走配置文件默认(src/config.ts),登录只需把用户名传进来(免密自动登录)。 -->
<YourDrawer v-model="open">
<div style="height: 80vh">
<ParallelAgentsPanel
:username="currentUsername"
:agents="[
{ agent_id: 'roundtable-intelligence', name: '情报收集' },
{ agent_id: 'roundtable-risk-review', name: '风险审查' },
]"
@saved="onSaved"
/>
</div>
</YourDrawer>
```
### 接口地址 & 登录
- **接口地址**写在配置文件 `src/config.ts` 的 `DEFAULT_BASE_URL`(dev 可用 `VITE_PA_BASE_URL` 覆盖);
也可用 `:base-url` prop 覆盖。
- **登录**:把宿主侧的**用户名**通过 `:username` 传进来即可,组件会自动走 DeerFlow 的
**免密「用户名登录」**端点(`POST /api/v1/auth/login/username`,公共 + CSRF 豁免)换取 token
并缓存、自动续期(401 重登)。**不需要宿主自己管 token**。
- 若你已有 token,也可直接传 `:token` / `:get-token`(优先级高于 username)。
- 若后端开了口令门(`auth_login.username_login.require_password=true`),再传 `:login-password`。
### Props
| prop | 类型 | 说明 |
| --------------- | -------------------------------------- | ----------------------------------------------------- |
| `agents` | `{agent_id,name,avatarType?}[]`(必填)| 要并行调用的智能体 |
| `username` | `string` | 免密自动登录用户名(推荐方式,宿主自己传) |
| `baseUrl` | `string` | 后端地址;不传用 `config.ts` 默认 |
| `token` | `string` | 已有 Bearer token(与 `username` 二选一,优先) |
| `getToken` | `() => string \| Promise<string>` | 动态取 token |
| `loginPassword` | `string` | 仅当后端开启口令门时需要 |
| `taskId` | `string` | 传入则挂载时自动加载并回显该已保存任务 |
| `model` | `string` | 可选模型名(不传用 `config.ts` 默认 / 后端回退) |
| `dark` | `boolean`(默认 `true`) | 深色面板(自带成色,不依赖宿主主题) |
### 事件
- `@saved="(task) => ..."` —— 保存成功(含新建/更新)后回传 `SavedTask`。
- `@update:taskId="(id) => ..."` —— 当前任务 id 变化(保存 / 加载历史时)。
> 样式作用域全部包在根类 `.parallel-agents` 下,且**不引入 Tailwind preflight**,不会污染宿主全局样式。
@@ -0,0 +1,48 @@
# 并行多智能体面板 · 测试用例总览
本目录是 `parallel-agents-vue` 的**手工测试用例**(Markdown)。覆盖:并行调用与流式、文件写入禁用、
保存与回显、重新分析、免密登录与鉴权、嵌入与深色样式。
## 用例清单
| 文件 | 主题 |
| -------------------------------------------- | ------------------------------------- |
| [01-并行调用与流式.md](./01-并行调用与流式.md) | 并行扇出、流式输出、步骤条、席位状态条、禁写文件 |
| [02-保存与回显.md](./02-保存与回显.md) | 保存聊天记录、历史列表、加载回显、更新保存 |
| [03-重新分析.md](./03-重新分析.md) | 重新分析(覆盖重跑)、停止、空输入校验 |
| [04-免密登录与鉴权.md](./04-免密登录与鉴权.md) | username 免密登录、token 优先级、401 重登、口令门 |
| [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 |
| [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 |
| [07-使用说明.md](./07-使用说明.md) | **在其它 Vue 项目中引入哪些组件、如何使用**(props/事件/嵌入) |
| [08-开发文档.md](./08-开发文档.md) | **二次开发**:架构/数据流/事件协议/目录职责/后端契约/扩展指南 |
## 测试环境
| 组件 | 地址 / 命令 |
| --------------- | ------------------------------------------------------ |
| DeerFlow 后端 | `http://localhost:8001`(`make dev`,需含本功能的新路由)|
| Vue dev demo | `http://localhost:5183`(`pnpm dev`) |
| 接口地址配置 | `src/config.ts` 的 `DEFAULT_BASE_URL` |
## 前置准备(所有用例通用)
1. 后端已启动,且包含本功能:
- `POST /api/parallel-agents/run/stream`(并行扇出,合并 SSE)
- `GET/POST/PUT/DELETE /api/parallel-agent-tasks`(任务保存/回显)
- `POST /api/v1/auth/login/username`(免密登录,公共)
- 数据表 `parallel_agent_tasks` 已建(`alembic upgrade head` 或 `create_all`)。
2. 至少准备 **2 个真实存在的 agent_id**(必须是后端注册过的真实 id,不能只写名字)。
本环境可直接用:`roundtable-solution-design`(方案设计)、`roundtable-risk-review`(风险审查)等
—— 完整可复制清单见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
3. 打开 dev demo `http://localhost:5183`,填:
- 后端 baseUrl:`http://localhost:8001`
- 用户名:`demo`(免密自动登录)
- 智能体:每行 `agent_id, 显示名`
点「打开面板(弹窗内)」。
## 用例字段约定
- **用例ID**:`PA-<模块>-<序号>`
- **优先级**:P0(核心,必过)/ P1(重要)/ P2(边界)
- **前置条件 → 操作步骤 → 预期结果**:逐条核对,全部命中才算通过。
- **结果记录**:建议在「实际结果 / 通过?」列里回填。
@@ -0,0 +1,105 @@
# 测试用例 · 并行调用与流式
覆盖:并行扇出多个智能体、流式逐字输出、步骤条(工具调用)、点头像切换显示(单选)、席位状态条、
运行期间**禁止写文件**。
---
## PA-RUN-01 · 并行调用多个智能体(P0)
**前置条件**
- 面板已打开,填入任务名「竞品调研」、简介「对比 A、B 两款产品的核心功能与定价」。
- 传入 2 个智能体(情报收集、风险审查)。
**操作步骤**
1. 点击「开始分析」。
**预期结果**
- 顶部出现**席位状态条**,显示「并行 2 席」,两个席位头像角标先后转为「进行中」(转圈)。
- 两个智能体**同时**开始执行(并行,而非一个跑完再跑下一个)——可在状态条上看到都在「进行中」。
- 会话区每次显示**一个**席位的消息列表(默认第 1 个),点头像可切换查看另一个(见 PA-RUN-06)。
- 气泡头部显示:头像、智能体名、状态徽标「并行执行中」、时间。
---
## PA-RUN-02 · 流式逐字输出 + 光标(P0)
**前置条件**:PA-RUN-01 正在运行中。
**操作步骤**
1. 观察当前显示席位的气泡正文(点头像可切换看其它席位)。
**预期结果**
- 正文**逐步增量**出现(流式),不是一次性整段刷出。
- 正在输出的气泡末尾有**闪烁光标**(`.pa-cursor`)。
- 内容还没来时,显示 loading 占位(转圈 + 跳动圆点 + 「并行执行中」文案)。
- Markdown 能正确渲染(标题/列表/代码块/表格按 `.pa-md` 样式)。
---
## PA-RUN-03 · 步骤条(思维链 / 工具调用)(P1)
**前置条件**:智能体会调用工具(如检索类技能)。
**操作步骤**
1. 运行一轮,观察气泡顶部的「执行步骤」折叠卡。
**预期结果**
- 出现「执行步骤(N)」折叠卡(`MessageStepsCard`),点击可展开/收起。
- 每条步骤显示:工具标签(如「调用工具 xxx」)、参数摘要(如有 `path` 优先显示)、时间。
- 同一工具调用**不重复**出条目(按 key 去重)。
- 若智能体本轮不调工具,则不出现步骤卡(属正常)。
---
## PA-RUN-04 · 席位状态流转(P1)
**操作步骤**
1. 运行一轮,全程观察席位状态条各头像角标。
**预期结果**
- 角标状态依次:灰(pending)→ 蓝色转圈(running)→ 绿色对勾(done)。
- 某智能体失败时其角标变**红色叉**(error),且对应气泡显示「该智能体执行出错:…」、状态徽标「出错」。
- 单个智能体失败**不影响**其它智能体继续完成(互不拖累)。
- 全部结束后,运行按钮从「停止」恢复为「重新分析」。
---
## PA-RUN-05 · 运行期间禁止写文件(P0)
**目的**:核对后端 `seat_parallel` 策略生效——并行席位被禁用 `write_file` / `str_replace` / `bash` 等。
**操作步骤**
1. 简介里**故意要求写文件**,例如:「请把结果保存成一个 .md 文件」。
2. 点击「开始分析」,等待完成。
**预期结果**
- 智能体**不会**真正落地文件,产出**直接呈现在气泡正文**里。
- 步骤条里**不出现** `write_file` / `bash` / `str_replace` 工具调用(被策略排除)。
- 可旁证:后端日志中该席位 `role=seat_parallel`。
---
## PA-RUN-06 · 点头像切换显示(单选)(P1)
**前置条件**:传入 ≥2 个智能体并运行。
**操作步骤**
1. 点击席位状态条上的某个头像。
**预期结果**
- 主区**切换为只显示**该智能体的消息列表(**单选**,每次显示一个)。
- 被选中的头像高亮(外发光描边);再点别的头像即切到那个。
- 切换不影响后台并行执行:未显示的智能体仍在跑/已完成,其增量在切回时能看到。
- 默认显示第 1 个席位。
---
## PA-RUN-07 · 后端不可达 / 智能体 id 非法(P2)
**操作步骤**
1. 把 baseUrl 改成一个打不通的地址,或传入不存在的 agent_id,点「开始分析」。
**预期结果**
- 顶部出现红色错误提示(请求失败信息),不白屏、不卡死。
- 非法 agent_id 时,对应席位以 error 角标 + 出错气泡呈现,其它正常席位不受影响。
@@ -0,0 +1,94 @@
# 测试用例 · 保存与回显
覆盖:保存聊天记录(新建/更新)、历史列表、加载已存任务并回显、`taskId` 自动加载、跨刷新持久化。
---
## PA-SAVE-01 · 首次保存(新建)(P0)
**前置条件**:已跑完一轮、会话区有结果。任务名「竞品调研」。
**操作步骤**
1. 点击「保存」。
**预期结果**
- 按钮短暂显示「保存中…」,成功后变为「更新保存」(说明已绑定 taskId)。
- 触发 `@saved` 事件,回传 `SavedTask`(含 id、title、agents、messages)。
- 「历史」下拉里出现这条记录(标题 + 更新时间)。
- 后端 `parallel_agent_tasks` 表新增一行(owner = 当前登录用户)。
---
## PA-SAVE-02 · 再次保存(更新覆盖)(P0)
**前置条件**:PA-SAVE-01 已保存、当前仍是同一任务。
**操作步骤**
1. 修改任务名 / 简介,或重新分析得到新结果。
2. 点击「更新保存」。
**预期结果**
- **不新增**记录,而是更新原 taskId 那条(title / brief / agents / messages 被覆盖为最新)。
- 历史列表里该条的更新时间刷新、排到最前。
---
## PA-SAVE-03 · 历史加载并回显(P0)
**前置条件**:历史里至少有 1 条已保存任务。
**操作步骤**
1. 点击「历史 ▾」展开列表。
2. 选择一条历史任务。
**预期结果**
- 任务名、简介**回填**为该任务保存时的值。
- 会话区**完整回显**保存时的消息列表(各智能体气泡、正文、步骤条),且**均为非流式终态**(无光标、无 loading)。
- 席位状态条据消息重建:正常席位显示 done(绿勾),出错的显示 error(红叉)。
- 当前 taskId 切换为该任务(再点「保存」是「更新保存」该条)。
---
## PA-SAVE-04 · taskId 进入即自动回显(P1)
**操作步骤**
1. 以 `:task-id="<已存在任务id>"` 方式挂载面板(嵌入场景)。
**预期结果**
- 面板挂载后**自动加载**该任务并回显(等同于 PA-SAVE-03 的结果),无需手动点历史。
- `@update:taskId` 随之触发。
---
## PA-SAVE-05 · 跨刷新持久化(P1)
**操作步骤**
1. 保存一条任务后,刷新浏览器(或重开宿主页面)。
2. 用相同用户名进入,打开历史。
**预期结果**
- 历史里仍能看到之前保存的任务(数据存在后端、按用户隔离,非浏览器 localStorage)。
- 加载后能正常回显。
---
## PA-SAVE-06 · 无结果时禁止保存(P2)
**操作步骤**
1. 新开面板、尚未运行任何分析。
**预期结果**
- 「保存」按钮**禁用**(无可保存内容)。
- 运行中也不应因保存导致状态错乱(保存按钮在有结果后才可用)。
---
## PA-SAVE-07 · 用户隔离(P1)
**操作步骤**
1. 用户名 A 保存一条任务。
2. 切换用户名为 B(重新进入面板),打开历史。
**预期结果**
- B 的历史里**看不到** A 的任务。
- B 直接用 A 的 taskId 加载应失败(404 / 无内容),不报错崩溃。
@@ -0,0 +1,77 @@
# 测试用例 · 重新分析与运行控制
覆盖:重新分析(覆盖重跑)、运行中停止、空输入校验、运行态按钮切换。
---
## PA-RE-01 · 重新分析(覆盖重跑)(P0)
**前置条件**:已跑完一轮、会话区有结果(按钮显示「重新分析」)。
**操作步骤**
1. (可选)修改任务名 / 简介。
2. 点击「重新分析」。
**预期结果**
- **清空**上一轮的所有气泡与席位状态,按当前输入**重新并行**调用各智能体。
- 不会和上一轮结果混在一起(旧消息被替换,不是追加)。
- 重跑过程与首跑一致(流式、单列消息 + 点头像切换、步骤条、席位状态流转)。
---
## PA-RE-02 · 运行中按钮为「停止」(P1)
**操作步骤**
1. 开始分析,运行未结束时观察操作区。
**预期结果**
- 运行期间,主按钮显示「停止」(不再是「开始/重新分析」)。
- 「保存」按钮在运行中可点(保存当前已产出的内容)或按实现禁用——以不报错为准。
---
## PA-RE-03 · 停止当前运行(P0)
**前置条件**:正在运行中。
**操作步骤**
1. 点击「停止」。
**预期结果**
- 进行中的请求被取消(AbortController),不再有新增量进来。
- 仍在 streaming 的气泡被收尾为终态(去掉光标,徽标置「已完成」)。
- 按钮恢复为「重新分析」,可立即再次发起。
- 控制台不应出现未捕获的 AbortError 报错(已被吞掉)。
---
## PA-RE-04 · 空输入校验(P1)
**操作步骤**
1. 任务名与简介**都留空**,点击「开始分析」。
**预期结果**
- 按钮处于**禁用**态,点击无效(`canStart` 为假需任务名或简介至少其一 + 至少一个智能体)。
- 只填任务名或只填简介,按钮可用。
---
## PA-RE-05 · 未传智能体(P2)
**操作步骤**
1. `:agents="[]"`(空数组)挂载面板。
**预期结果**
- 操作区显示提示「⚠️ 未传入任何智能体(agents 为空)」。
- 「开始分析」禁用,不会发起空请求。
---
## PA-RE-06 · 连续多次重新分析(P2)
**操作步骤**
1. 连续点击「重新分析」若干次(每次等上一轮结束或中途停止再点)。
**预期结果**
- 每次都干净重置 + 重跑,无残留旧气泡、无席位状态错乱。
- 不出现多份 SSE 叠加导致的内容串列 / 重复。
@@ -0,0 +1,106 @@
# 测试用例 · 免密登录与鉴权
覆盖:username 免密自动登录、token 优先级、token 缓存与 401 重登、口令门、CSRF 绕过。
> 背景:面板优先用宿主传入的 token;没有 token 时,用宿主传入的 **username** 走 DeerFlow 公共免密端点
> `POST /api/v1/auth/login/username` 换取 `access_token`,以 `Authorization: Bearer` 发起后续请求
> (带 Bearer 会绕过 CSRF)。token 解析优先级:`token` > `getToken` > 缓存 > `username` 登录。
---
## PA-AUTH-01 · username 免密自动登录(P0)
**前置条件**:后端 `auth_login.username_login.require_password = false`(默认)。
**操作步骤**
1. 只传 `:username="demo"`(不传 token),打开面板。
2. 开始分析 / 打开历史。
**预期结果**
- 面板能正常拉历史、能发起并行流式——说明已用 username 换到 token 并附带在请求头。
- 旁证(手工 curl):
```bash
curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"demo"}' http://localhost:8001/api/v1/auth/login/username
# → 返回 {"access_token":"...","token_type":"bearer",...}
```
---
## PA-AUTH-02 · token 优先级高于 username(P1)
**操作步骤**
1. 同时传 `:token="<有效token>"` 和 `:username="demo"`。
2. 触发请求(开始分析 / 历史)。
**预期结果**
- 直接用传入的 `token`(不会再去调登录端点)。
- `:get-token` 同理优先于 username(动态取 token)。
---
## PA-AUTH-03 · token 缓存(不重复登录)(P1)
**操作步骤**
1. 只传 username,先后触发多次请求(拉历史 → 开始分析 → 保存)。
**预期结果**
- 登录端点**只调用一次**(首个请求),后续请求复用缓存 token(按 `baseUrl::username` 缓存)。
- 可在浏览器 Network 面板核对 `login/username` 只出现一次。
---
## PA-AUTH-04 · 401 自动重登重试(P1)
**前置条件**:能制造 token 失效(例如等待过期,或后端清理)。
**操作步骤**
1. 用 username 登录并缓存 token 后,使其失效,再触发一次 CRUD 请求。
**预期结果**
- 首次请求收到 401 → 面板**清缓存并用 username 重登一次**,再自动重试该请求。
- 用户无感知(不需手动重新登录),请求最终成功。
---
## PA-AUTH-05 · 口令门(require_password=true)(P2)
**前置条件**:后端把 `auth_login.username_login.require_password` 设为 `true`、配置 `password`。
**操作步骤**
1. 只传 username、不传 `loginPassword` → 触发请求。
2. 再传上正确的 `:login-password="<口令>"` → 触发请求。
**预期结果**
- 不传口令时:登录返回 401「口令错误」,面板顶部红色错误提示,不崩溃。
- 传对口令时:登录成功,后续请求正常。
---
## PA-AUTH-06 · Bearer 绕过 CSRF(P1)
**目的**:核对带 Bearer 的 POST 不被 CSRF 拦截。
**操作步骤(手工 curl 旁证)**
```bash
TOKEN=... # 上面登录拿到的 access_token
# 带 Bearer 的 POST:参数非法应为 400/422,而非 403(403=CSRF 拦截)
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{}' http://localhost:8001/api/parallel-agents/run/stream
```
**预期结果**
- 返回 **400/422**(业务参数校验),**不是 403**——证明 Bearer 已绕过 CSRF。
- 不带任何鉴权时该 POST 返回 401/403(被拦截),属正常。
---
## PA-AUTH-07 · 未传 token 且未传 username(P2)
**操作步骤**
1. 既不传 token / getToken,也不传 username,触发需要鉴权的请求。
**预期结果**
- 请求因缺少鉴权失败(401/403),面板给出可读的错误提示(不白屏)。
- 控制台/提示能反映「缺少 token 且未提供 username,无法登录」之类信息。
@@ -0,0 +1,105 @@
# 测试用例 · 嵌入与深色样式
覆盖:接口地址走配置文件、组件库构建产物、嵌入其它 Vue 项目、放进抽屉/弹窗自适应、深色成色、
不污染宿主全局样式。
---
## PA-EMBED-01 · 接口地址走配置文件(P0)
**操作步骤**
1. 不传 `:base-url`,只改 `src/config.ts` 的 `DEFAULT_BASE_URL` 为目标后端地址。
2. 运行面板触发请求。
**预期结果**
- 请求打到配置文件里的地址。
- 传 `:base-url` prop 时**优先**用 prop(覆盖配置默认)。
- dev 下设 `VITE_PA_BASE_URL` 环境变量也能覆盖默认。
---
## PA-EMBED-02 · 组件库构建产物(P0)
**操作步骤**
```bash
pnpm build:lib
```
**预期结果**
- `dist/` 产出:`parallel-agents-vue.js`(ESM)、`parallel-agents-vue.umd.cjs`(UMD)、`parallel-agents-vue.css`。
- `vue` 被 external(不打进库,由宿主提供)。
- 构建无报错、无「named + default exports」警告。
---
## PA-EMBED-03 · 嵌入其它 Vue 项目(P0)
**操作步骤**
1. 在另一个 Vue3 项目里:
```ts
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
```
2. 模板中放置:
```vue
<ParallelAgentsPanel :username="user" :agents="agents" />
```
**预期结果**
- 组件正常渲染、可交互(深色面板,与 demo 一致)。
- 只需 `:username` + `:agents` 两个 prop 即可跑起来(接口地址走配置)。
- 也支持 `app.use(ParallelAgentsVue)` 全局注册后用 `<ParallelAgentsPanel/>`。
---
## PA-EMBED-04 · 放进抽屉 / 弹窗自适应(P0)
**操作步骤**
1. 把面板放进宿主的抽屉 / 对话框,外层给一个固定高度容器:
```vue
<YourDrawer v-model="open">
<div style="height: 80vh"><ParallelAgentsPanel :username="user" :agents="agents" /></div>
</YourDrawer>
```
2. 改变容器尺寸 / 视口大小。
**预期结果**
- 面板**充满容器**(根元素 `h-full w-full flex flex-col min-h-0`),不溢出、不塌缩。
- 会话区内部独立滚动,输入区与席位条固定不被挤掉。
- 窄容器下消息列表正常显示、内部滚动,不溢出。
---
## PA-EMBED-05 · 深色成色自带、与宿主主题无关(P1)
**操作步骤**
1. 分别在**浅色**宿主页和**深色**宿主页里嵌入面板。
**预期结果**
- 面板始终是深蓝深色(自带 `--pa-*` token,作用域在 `.parallel-agents`),不随宿主明暗变化。
- 文案、徽标、步骤卡、并行列底、滚动条等观感与圆桌 Step2 深色一致。
- 传 `:dark="false"` 时按实现切换(如未实现浅色则保持深色,至少不报错)。
---
## PA-EMBED-06 · 不污染宿主全局样式(P0)
**目的**:核对**未引入 Tailwind preflight**(不重置宿主全局标签样式)。
**操作步骤**
1. 在一个有自己样式的宿主页里嵌入面板(页面有 `h1/p/button/ul` 等默认样式)。
**预期结果**
- 嵌入后,宿主页面**自身**的 `h1/p/button/列表/边距` 等样式**不被改变**(没有被 Tailwind 的 reset 抹平)。
- 面板内部样式正常(不依赖宿主提供 Tailwind)。
- 类名都在 `.parallel-agents` 作用域下,不与宿主类名冲突。
---
## PA-EMBED-07 · 多实例并存(P2)
**操作步骤**
1. 同一页面放置 2 个面板实例(不同 agents / 不同 username)。
**预期结果**
- 两个实例各自独立运行、各自保存/回显,互不串流、互不串 token 缓存(缓存按 `baseUrl::username` 区分)。
@@ -0,0 +1,73 @@
# 测试用 · 可用智能体清单(可直接复制粘贴)
测试时面板需要传入**真实存在的 `agent_id`**(后端真正拿去跑的标识,不能只写名字)。
下面是当前后端里**可直接用于测试**的内置智能体(已排除 intent / recommender / coordinator 这些系统角色)。
> 注:`name` 只是显示用、可省略;`agent_id` 必须照抄。下列为本环境实测可用的内置智能体;
> 你部署里的智能体可能不同,用文末方法自查。
## 可用智能体
| agent_id | 显示名 |
| --------------------------- | -------- |
| `roundtable-solution-design`| 方案设计 |
| `roundtable-risk-review` | 风险审查 |
| `roundtable-execution-plan` | 执行规划 |
| `roundtable-report` | 方案总结 |
| `demo-expert` | 演示专家 |
## A. 粘贴进 dev demo 页「智能体」输入框(每行 `agent_id, 显示名`)
推荐至少 2 个以便点头像切换查看不同智能体:
```
roundtable-solution-design, 方案设计
roundtable-risk-review, 风险审查
```
想多看几列就多贴几行:
```
roundtable-solution-design, 方案设计
roundtable-risk-review, 风险审查
roundtable-execution-plan, 执行规划
```
## B. 粘贴进组件 `:agents` prop(嵌入场景)
```ts
const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
];
```
```vue
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
```
## C. 配一个示例任务(直接粘贴到面板)
- **任务名称**:`新功能上线方案评估`
- **任务简介**:
```
我们要给 App 上线「扫码点餐」功能。请从你的专业角度,评估方案可行性、列出关键风险、
并给出落地步骤与里程碑。
```
## 自查:列出你部署里所有可用的 agent_id
```bash
# 1) 免密登录拿 token(用户名换成你的)
TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"demo"}' http://localhost:8001/api/v1/auth/login/username \
| python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
# 2) 列出 agent_id <TAB> name
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8001/api/agents -o agents.json
python -c "import json,io;[print(a['id'],'\t',a['name']) for a in json.load(io.open('agents.json',encoding='utf-8')).get('agents',[])]"
```
第一列 `id` 才是要填的 `agent_id`。挑你需要的智能体即可(避开 `roundtable-intent` /
`roundtable-recommender` / `roundtable-coordinator*` 这些系统角色——它们不是用来当并行席位的)。
+345
View File
@@ -0,0 +1,345 @@
# 使用说明 · 在其它 Vue 项目中引入
本组件库只对外暴露**一个**组件 `ParallelAgentsPanel`(其余 `MessageBubble` / `MessageStepsCard` /
`ParallelSeatStrip` / `Avatar` / `MarkdownContent` 都是它的内部子组件,**无需也不要单独引入**)。
宿主只管把它放进自己的页面 / 抽屉 / 弹窗,并传几个 prop。
---
## 一、要引入的东西(就这两样)
1. **组件**:`ParallelAgentsPanel`
2. **样式**:`parallel-agents-vue/style.css`(一次性引入即可,样式作用域全在 `.parallel-agents` 下)
> 依赖说明:`vue ^3.4`(宿主提供,库不打包 vue);`markdown-it` 已打进库内,无需宿主安装。
---
## 二、两种引入方式
### 方式 A:构建成库后按包引入(推荐)
1. 在本项目构建:
```bash
pnpm install
pnpm build:lib # 产出 dist/parallel-agents-vue.{js,umd.cjs,css}
```
2. 宿主项目里安装(任选其一):
- 把本目录作为本地依赖:`"parallel-agents-vue": "file:../parallel-agents-vue"`;
- 或发到你们的私有 npm 后正常 `pnpm add parallel-agents-vue`。
3. 代码里引入:
```ts
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
```
### 方式 B:直接拷贝源码(无需发包)
把本项目 `src/` 下这些拷进宿主项目(保持相对结构),按相对路径引入 `ParallelAgentsPanel.vue` +
`styles/parallel-agents.css` 即可:
```
src/
components/ ParallelAgentsPanel.vue · MessageBubble.vue · MessageStepsCard.vue
ParallelSeatStrip.vue · Avatar.vue · MarkdownContent.vue
composables/ useParallelRun.ts
lib/ avatar-constants.ts
styles/ parallel-agents.css
api.ts · config.ts · types.ts
```
宿主需有 `markdown-it` 依赖(`pnpm add markdown-it`),并保证构建支持 `.vue`(Vite/@vitejs/plugin-vue)。
---
## 三、注册与使用
### 局部引入(推荐)
```vue
<script setup lang="ts">
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
];
</script>
<template>
<div style="height: 80vh">
<ParallelAgentsPanel :username="'demo'" :agents="agents" />
</div>
</template>
```
### 全局注册(可选)
```ts
import ParallelAgentsVue from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
app.use(ParallelAgentsVue); // 之后任意模板可直接用 <ParallelAgentsPanel/>
```
---
## 四、放进抽屉 / 弹窗
组件根元素是 `h-full w-full flex flex-col min-h-0`,会**充满父容器**。所以**父容器要给一个高度**
(弹窗/抽屉本身有高度即可):
```vue
<YourDrawer v-model="open" size="large">
<div style="height: 86vh">
<ParallelAgentsPanel :username="currentUsername" :agents="agents" @saved="onSaved" />
</div>
</YourDrawer>
```
---
## 五、接口地址与登录
- **接口地址**:默认读 `src/config.ts` 的 `DEFAULT_BASE_URL`(可改源码,或 dev 设 `VITE_PA_BASE_URL`);
也可用 `:base-url` prop 临时覆盖。
- **登录**:把宿主的**用户名**用 `:username` 传进来即可——组件自动走本面板**专用**的「绕过常规登录」
公共端点 `POST /api/parallel-agents/auth/token` 换 token、缓存、401 自动重登。**宿主不用管 token**。
若已有 token,可改传 `:token` / `:get-token`(优先级更高)。
- 为什么专用端点:内网部署对标准 `/api/v1/auth/login/username` 有额外限制(口令门/网关裁剪等),拿不到
token;该端点传用户名直接签发(账号不存在自动注册),已在 auth/csrf 中间件白名单里放行。
- token 缓存按 `baseUrl::username` 维度,多个实例/多用户互不串。
---
## 六、Props
| prop | 类型 | 必填 | 说明 |
| --------------- | --------------------------------------- | :--: | ----------------------------------------------- |
| `agents` | `{ agent_id, name, avatarType? }[]` | ✔ | 要并行调用的智能体(`agent_id` 必须是真实 id) |
| `username` | `string` | | 免密自动登录用户名(推荐方式) |
| `baseUrl` | `string` | | 后端地址;不传用 `config.ts` 默认 |
| `token` | `string` | | 已有 Bearer token(优先于 username) |
| `getToken` | `() => string \| Promise<string>` | | 动态取 token |
| `loginPassword` | `string` | | 仅当后端开启口令门时需要 |
| `taskId` | `string` | | 传入则挂载时自动加载并回显该任务 |
| `model` | `string` | | 可选模型名(不传走默认 / 后端回退) |
| `dark` | `boolean`(默认 `true`) | | 深色面板(自带成色,不依赖宿主主题) |
`AgentInput`/`SavedTask`/`AgentResult` 等类型也从包里导出:
`import type { AgentInput, SavedTask, AgentResult } from "parallel-agents-vue"`。
---
## 六·一、每个参数怎么传(逐个举例)
### `agents`(必填)——「智能体参数」怎么传
一个数组,每项 `{ agent_id, name?, avatarType? }`:
```ts
const agents = [
{ agent_id: "roundtable-solution-design", name: "方案设计" }, // 最常用:id + 显示名
{ agent_id: "roundtable-risk-review", name: "风险审查", avatarType: "amber" }, // 顺带指定头像配色
{ agent_id: "demo-expert" }, // 只给 id(显示名回退成 id)
];
```
```vue
<ParallelAgentsPanel :agents="agents" :username="'demo'" />
```
- `agent_id`**必须是后端真实注册的 id**(只写名字无效);可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
- `name`:席位/气泡显示名,可省(省了用 id)。
- `avatarType`:可选 `blue|purple|emerald|amber|pink|human`;不传按顺序自动轮转配色。
### `username`——免密登录(推荐)
```vue
<ParallelAgentsPanel :username="currentUser.name" :agents="agents" />
```
### `baseUrl`——后端地址(不传用 config 默认)
```vue
<ParallelAgentsPanel :base-url="'http://10.0.0.5:8001'" :username="'demo'" :agents="agents" />
```
### `token` / `getToken`——已有登录态时(优先于 username)
```vue
<!-- 直接给字符串 token -->
<ParallelAgentsPanel :token="hostToken" :agents="agents" />
<!-- 或给一个(异步)取 token 的函数 -->
<ParallelAgentsPanel :get-token="() => authStore.getAccessToken()" :agents="agents" />
```
### `model`——指定模型(不传走默认/后端回退;也可在面板右上角下拉切)
```vue
<ParallelAgentsPanel :model="'deepseek-chat'" :username="'demo'" :agents="agents" />
```
### `dark`——深色(默认 true,自带成色)
```vue
<ParallelAgentsPanel :dark="true" :username="'demo'" :agents="agents" />
```
### `taskId`——存储主键 + 回显(见 §七·二)
```vue
<ParallelAgentsPanel
:task-id="taskId"
:username="'demo'"
:agents="agents"
@update:taskId="(id) => (taskId = id)"
/>
```
### 全量示例(所有参数一起)
```vue
<script setup lang="ts">
import { ref } from "vue";
import { ParallelAgentsPanel } from "parallel-agents-vue";
import "parallel-agents-vue/style.css";
import type { AgentInput, AgentResult, SavedTask } from "parallel-agents-vue";
const agents: AgentInput[] = [
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
];
const taskId = ref("task-001");
function onSaved(t: SavedTask) {
console.log("已保存", t.id);
}
function onCompleted(results: AgentResult[]) {
console.log("各智能体返回内容", results);
}
</script>
<template>
<div style="height: 86vh">
<ParallelAgentsPanel
:base-url="'http://localhost:8001'"
:username="'demo'"
:agents="agents"
:task-id="taskId"
:model="'deepseek-chat'"
:dark="true"
@saved="onSaved"
@update:taskId="(id) => (taskId = id)"
@completed="onCompleted"
/>
</div>
</template>
```
## 七、事件
| 事件 | 载荷 | 触发时机 |
| ----------------- | ------------------ | --------------------------------------- |
| `saved` | `SavedTask` | 保存成功(新建或更新)后 |
| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) |
| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 |
```vue
<ParallelAgentsPanel
:username="user"
:agents="agents"
:task-id="taskId"
@saved="(t) => console.log('saved', t.id)"
@update:taskId="(id) => (taskId = id)"
@completed="(results) => console.log('各智能体返回:', results)"
/>
```
## 七·一、如何获取模型返回的内容
每个智能体(模型)返回的正文是 `AgentResult.content`。两种方式,任选其一:
**A. 事件**:监听 `@completed`,一轮分析结束时自动回传 `AgentResult[]`:
```vue
<ParallelAgentsPanel
:username="'demo'"
:agents="agents"
@completed="(results) => {
// results: [{ agent_id, name, content, status }]
for (const r of results) console.log(r.name, '→', r.content);
}"
/>
```
**B. 方法(模板 ref)**:随时主动取(不必等事件):
```vue
<script setup lang="ts">
import { ref } from "vue";
import { ParallelAgentsPanel } from "parallel-agents-vue";
import type { AgentResult } from "parallel-agents-vue";
const panelRef = ref<InstanceType<typeof ParallelAgentsPanel> | null>(null);
function readAll() {
const all: AgentResult[] = panelRef.value!.getResults();
// [{ agent_id, name, content, status }]
}
function readOne(id: string) {
const one = panelRef.value!.getResult(id); // AgentResult | null
}
</script>
<template>
<ParallelAgentsPanel ref="panelRef" :username="user" :agents="agents" />
</template>
```
`AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。
`defineExpose` 还暴露了 `start()` / `cancel()`,可由宿主代替按钮触发开始 / 停止。
## 七·二、如何保存 + 回显聊天记录
**保存**:面板里点「保存」即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。
传 `:task-id` 时**以它为存储主键**做 upsert(已存在→更新;不存在→用该 id 新建);不传则首次保存由后端
生成 id(通过 `@update:taskId` 回传,下次再保存即更新这条)。
**回显(两种方式)**:
```vue
<!-- 方式 1:挂载时传一个已存在的 taskId → 自动加载并回显该任务的聊天记录 -->
<ParallelAgentsPanel :task-id="'task-001'" :username="'demo'" :agents="agents" />
```
```ts
// 方式 1 变体:运行中改 taskId(双向绑定),面板会自动切到并回显那条任务
const taskId = ref<string>(""); // 留空=新任务
// <ParallelAgentsPanel :task-id="taskId" @update:taskId="id => taskId = id" ... />
taskId.value = "task-001"; // 改了之后面板自动回显 task-001
```
> 方式 2:面板**右上角内置「历史」下拉**——点开选一条历史任务即回显,**无需写任何代码**。
要点:先**保存过**、再用**同一个 taskId**(或从历史里选)才会有内容回显;没存过的新 id 第一次进去是空白
(后台会探测一次 `GET /…/{id}` 返回 404,属正常,面板静默处理)。
---
## 八、交互要点(与圆桌 Step2 一致)
- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出。运行期间禁用**写文件**(`write_file`/
`str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,防子智能体模仿总控调度);检索、查看、
`ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常显示**,每个智能体只做自己那份分析。
- 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。
- 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。
- 操作区右侧有**模型切换**下拉(拉取后端 `/api/models`);选「默认」则用 `model` prop / 配置 / 后端回退。
切换作用于**下一次**「开始分析 / 重新分析」,运行中禁用。
- 「保存」存档;「历史」里选回**回显**;「重新分析」清空重跑。
---
## 九、注意事项
- **不会污染宿主全局样式**:库未引入 Tailwind preflight,类名都在 `.parallel-agents` 作用域内。
- **深色自带成色**:面板始终深蓝深色,不随宿主明暗切换(与圆桌 Step2 观感一致)。
- **跨域**:若宿主与后端不同源,后端需允许该来源的 CORS + 带 `credentials`;登录端点公共且 CSRF 豁免,
业务请求用 Bearer 头已绕过 CSRF。
- **agent_id 必须真实**:见 [06-可用智能体清单.md](./06-可用智能体清单.md) 获取可用 id 与自查方法。
+226
View File
@@ -0,0 +1,226 @@
# 开发文档 · parallel-agents-vue
面向**维护 / 二次开发**这个前端的同学。讲清楚:它是什么、架构与数据流、目录与各文件职责、
对接的后端契约、样式系统、以及常见扩展怎么改。使用 / 嵌入请看 [07-使用说明.md](./07-使用说明.md)。
---
## 1. 它是什么
一个**独立、可嵌入**的 Vue3 组件:输入「任务名称 + 简介」→ **并行**调用宿主指定的多个智能体 →
深色消息列表(**1:1 复刻圆桌 Step2**:步骤条 / 协作卡片 / 席位状态条 / 点头像切换单显)→
支持**保存 / 回显 / 重新分析**。只对外暴露一个组件 `ParallelAgentsPanel`。
- 后端是同仓库的 DeerFlow 网关(`offline-backend-20260512/backend`),本前端只消费它的几个 HTTP 接口。
- 并行**编排在后端**:前端发一条请求,后端扇出 N 个智能体、合并成**一条 SSE**回来;前端只解析这一条流。
---
## 2. 技术栈
| 方面 | 选型 |
| -------- | ----------------------------------------------------------------- |
| 框架 | Vue 3(`<script setup>` + Composition API) |
| 构建 | Vite 6(dev app + `build:lib` 组件库双产物) |
| 语言 | TypeScript(`strict` + `noUnusedLocals/Parameters`) |
| 样式 | TailwindCSS v4(**仅 utilities,不引 preflight**)+ 作用域 `.pa-*` token |
| Markdown | `markdown-it`(打进库,宿主无需安装) |
| peer dep | `vue ^3.4`(宿主提供,库不打包 vue) |
---
## 3. 目录结构与各文件职责
```
src/
├─ index.ts 库入口:导出 ParallelAgentsPanel + install 插件 + 类型;import 样式
├─ main.ts / App.vue dev demo(仅本地调试用,不进库)
├─ config.ts 配置:DEFAULT_BASE_URL / DEFAULT_MODEL / TOKEN_PATH
├─ types.ts 共享类型:Dialogue / SeatView / DialogueStep / ClarificationPayload
│ / AgentInput / AgentResult / SavedTask / StreamEvent(事件协议)
├─ api.ts 后端访问层:鉴权(取 token+缓存+401 重登) / SSE 流 / 任务 CRUD / 模型列表
├─ composables/
│ └─ useParallelRun.ts 运行编排(前端态核心):消费合并 SSE → 维护 dialogues/seats;
│ start / cancel / hydrate(回显) / reset / rerunAgent(单席位重跑)
├─ lib/
│ ├─ avatar-constants.ts 头像配色 + getAvatarTypeFor(轮转)
│ └─ step-display.ts 步骤条:getStepDisplay(图标+中文文案) / getStepArgs(参数子节点)
├─ styles/
│ └─ parallel-agents.css 作用域深色 token(--pa-*) + .pa-* 语义类 + Markdown 样式(无 preflight)
└─ components/
├─ ParallelAgentsPanel.vue 主组件:输入区/操作区/模型切换/历史/席位条/会话区 + 全部状态编排
├─ ParallelSeatStrip.vue 席位状态条(头像 + running/done/error 角标,点击切换单显)
├─ MessageBubble.vue 单条气泡:头像/发言人/徽标/时间/步骤条/正文/光标/协作卡片
├─ MessageStepsCard.vue 步骤条(ChainOfThought 结构,1:1 对齐 Step2)
├─ StepIcon.vue 步骤图标(lucide 风格 inline SVG,按 IconKey)
├─ ClarificationCard.vue 协作 / 澄清卡片(1:1 对齐圆桌第一步 IntentClarificationCard)
├─ MarkdownContent.vue markdown-it 渲染
└─ Avatar.vue 机器人 / 真人 SVG 头像
```
---
## 4. 架构与数据流
一次「开始分析」的端到端链路:
```
用户输入(任务名+简介) ──onStart──> useParallelRun.start(cfg, {taskName, brief, agents, model})
│ 为每个 agent 建一个 Dialogue(流式气泡) + SeatView(席位)
▼
api.streamParallelAgents(cfg, body, handlers, signal)
│ POST {baseUrl}/api/parallel-agents/run/stream (Authorization: Bearer <token>)
▼
后端扇出:每个 agent 各跑一轮 LangGraph,合并成「一条带 agent_id 的 SSE」
▼ 逐条 StreamEvent(见 §5)
useParallelRun.handleEvent(ev) 按 agent_id 路由:
delta→气泡正文(rAF 节流) · step→步骤条 · agent_clarification→协作卡片
agent_done→收口 · agent_error→错误 · all_done→收尾
▼ 响应式 dialogues / seats 变化
组件渲染:ParallelSeatStrip + 当前选中席位的 MessageBubble(单显,点头像切换)
▼ run 结束
panel emit "completed"(AgentResult[]);可点「保存」(按 taskId upsert) / 「重新分析」(重跑)
```
**关键不变量**
- 主区**单选**显示一个席位的消息(`selected=[agentId]`,点头像切换);后台 N 个智能体仍并行执行。
- delta 用 `requestAnimationFrame` 合并刷新,避免逐 token reflow。
- `bubbleByAgent: Map<agentId, bubbleId>` 把事件路由到对应气泡;hydrate/start 时重建。
---
## 5. 统一事件协议(前后端唯一契约)
后端 `/run/stream` 每条 `data:` 是一个 JSON(`StreamEvent`,定义在 `types.ts`):
| type | 字段 | 前端处理 |
| --------------------- | --------------------------------------------------------------------------------------------- | --------------------------------- |
| `agent_start` | `agent_id` | 该席位置 running |
| `delta` | `agent_id, text` | 追加正文(节流) |
| `step` | `agent_id, key(=`tool_calling:<tool>`), label, args` | 步骤条加一步(含完整 args) |
| `agent_clarification` | `agent_id, content, question, clarification_type, clarification_context, options, allow_*` | 设 `dialogue.clarification` → 卡片 |
| `agent_done` | `agent_id, content` | 收口,正文以终态为准 |
| `agent_error` | `agent_id, message` | 该席位置 error |
| `all_done` | — | 全部结束 |
**核心数据模型**(`types.ts`):
- `Dialogue { id, agentId, sender, avatarType, content, time, confidence, streaming?, steps?, clarification? }`
- `DialogueStep { id, key, label, ts, args? }`(`key` 形如 `tool_calling:web_search`)
- `SeatView { agentId, name, avatarType, status: pending|running|done|error, bubbleId }`
- `ClarificationPayload { question, clarificationType, context, options, allowCustom, allowMultiple }`
- `AgentResult { agent_id, name, content, status }`(宿主读取用)
> 改协议时:`types.ts` 的 `StreamEvent` 与后端 `parallel_agents.py` 的事件 dict **必须同步**。
---
## 6. 组件树
```
ParallelAgentsPanel
├─ ParallelSeatStrip ── Avatar (席位条,点头像切换单显)
└─ MessageBubble (当前选中席位)
├─ Avatar
├─ MessageStepsCard ── StepIcon (步骤条;getStepDisplay/getStepArgs 翻译 key+args)
├─ MarkdownContent (markdown-it)
└─ ClarificationCard (msg.clarification 存在时;提交→panel.onClarify→rerunAgent)
```
`ParallelAgentsPanel` 的对外接口(详见 [07-使用说明.md](./07-使用说明.md)):
- props:`agents`(必填) / `username` / `baseUrl` / `token` / `getToken` / `taskId` / `model` / `dark`
- emits:`saved(SavedTask)` / `update:taskId(string)` / `completed(AgentResult[])`
- `defineExpose`:`getResults()` / `getResult(id)` / `start()` / `cancel()`
---
## 7. 鉴权
`api.ts` 的 token 解析优先级:`token` > `getToken()` > 缓存 > 用 `username` 取 token。
- **取 token**:`POST {baseUrl}{TOKEN_PATH}`(`TOKEN_PATH=/api/parallel-agents/auth/token`)—— 本面板
**专用的「绕过常规登录」公共端点**(内网部署限制了标准 `/login/username`)。传 `{username}` 直接签发。
- 取到的 token 按 `baseUrl::username` **缓存**;业务请求带 `Authorization: Bearer`(顺带绕过 CSRF)。
- `authedFetch` 在 **401** 时清缓存、用 username 重登并**重试一次**。
- 后端该端点在 `auth_middleware._PUBLIC_EXACT_PATHS` + `csrf_middleware._AUTH_EXEMPT_PATHS` 白名单里。
---
## 8. 对接的后端契约
后端实现:`offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py`
(任务存储:`parallel_agent_tasks.py` + `deerflow/persistence/parallel_agent_tasks/`)。
| 用途 | 接口 |
| ---------- | -------------------------------------------------------------------- |
| 取 token | `POST /api/parallel-agents/auth/token` `{username}` |
| 并行流式 | `POST /api/parallel-agents/run/stream` `{task_name, brief, agent_ids[], model?}` → SSE |
| 任务 CRUD | `GET/POST/GET{id}/PUT{id}/DELETE{id} /api/parallel-agent-tasks` |
| 模型列表 | `GET /api/models` → `{models:[{name, display_name}]}` |
**后端运行策略(重要)**:每个并行席位是「执行席位」,不是总控。
- **禁用工具** `_EXCLUDED_TOOLS = [write_file, str_replace, agent_orchestration, bash]`:①禁写文件(并行
不互相打架);②禁 `agent_orchestration`/`bash`(防弱模型模仿总控**派活**或绕道脚本逃逸)。
- **保留** 只读 / 检索工具 + `ask_clarification` → 步骤条、协作卡片正常。
- 任务消息带**角色边界护栏**(「执行席位、不要模仿总控调度 / 派活」)。
- 步骤 `args` 通过累积 `tool_call_chunks` + 终态全量 `tool_calls` 补全(流式期间空 args 不发,防钉死)。
- `ask_clarification` 从终态 messages 抽成结构化 `agent_clarification` 事件。
---
## 9. 样式系统(嵌入安全)
`styles/parallel-agents.css`:
- **不引 Tailwind preflight**(`@import "tailwindcss/theme.css"` + `utilities.css`),避免重置宿主全局样式。
- 颜色全用作用域内的 `--pa-*` 深蓝 token(取自《深蓝 UI 规范》,与圆桌 Step2 深色一致),全部包在
根类 `.parallel-agents` 下,**不污染宿主**。
- 语义类 `.pa-card / .pa-surface / .pa-border / .pa-text(-muted/-primary) / .pa-input / .pa-btn-* /
.pa-badge / .pa-md(Markdown)` —— 组件**上色用这些类**,布局用 Tailwind 工具类。
- 面板**自带深色成色**,不随宿主明暗变化;根元素 `h-full w-full flex flex-col min-h-0` 自适应容器。
---
## 10. 步骤条 / 协作卡片(与圆桌的对应)
- **步骤条** `MessageStepsCard.vue` ← 圆桌 `MessageStepsCard.tsx` + `ChainOfThought`:图标列(带竖向连接线)
+ 中文文案 + 时间 + 参数子节点。文案/图标映射在 `lib/step-display.ts`(`getStepDisplay`),参数子节点
在 `getStepArgs`(chip / code / text / kv)。加新工具图标:在 `step-display.ts` 的 switch + `StepIcon.vue`
各加一条。
- **协作卡片** `ClarificationCard.vue` ← 圆桌第一步 `IntentClarificationCard.tsx`:标题/类型标签/背景/选项
chip(单选点击即发、多选可勾)/自定义输入/发送。提交回传格式化回答 → 面板 `onClarify` → `rerunAgent`
带「【用户补充】…」**重跑该单个智能体**。
---
## 11. 构建与产物
```bash
pnpm install
pnpm dev # dev demo(连本地后端,App.vue 里填 baseUrl/username/agents)
pnpm typecheck # vue-tsc --noEmit
pnpm build:lib # 组件库:dist/parallel-agents-vue.{js,umd.cjs,css}
```
- `vite.config.ts`:`--mode lib` 走 `build.lib`,`external:['vue']`,`output.exports:'named'`,CSS 不拆分。
- `package.json` `exports`:`.`→ESM/UMD + types;`./style.css`→样式。宿主 `import "parallel-agents-vue/style.css"`。
---
## 12. 常见扩展怎么改
| 需求 | 改哪里 |
| ---------------------------- | --------------------------------------------------------------------------------------- |
| 新增一个 SSE 事件类型 | `types.ts` 的 `StreamEvent` + `useParallelRun.handleEvent` + 后端 `parallel_agents.py` |
| 步骤条支持新工具的图标/文案 | `lib/step-display.ts`(`getStepDisplay`/`getStepArgs`)+ `StepIcon.vue` |
| 改深色配色 / 加浅色主题 | `styles/parallel-agents.css` 的 `--pa-*`(浅色可在 `.parallel-agents--light` 下覆盖) |
| 改默认后端地址 / 模型 | `config.ts`(`DEFAULT_BASE_URL` / `DEFAULT_MODEL`) |
| 多席位并排显示(取消单选) | `ParallelAgentsPanel.vue` 的 `selected` / `visibleSeats` / `toggleSeat` 逻辑 |
| 宿主自定义存储(不走后端) | 用 `@completed` + `getResults()` 取数据,自行存;不调用 `createTask/updateTask` |
| 改鉴权方式 | `api.ts` 的 `resolveToken` / `loginByUsername` / `authedFetch` + `config.ts` 的 `TOKEN_PATH` |
---
## 13. 注意事项 / 已知约束
- **agent_id 必须真实**(后端注册过的 id),只填名字不行;可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。
- 并行席位**禁派活/脚本/写文件**:本面板里每个智能体只做自己那份分析,产出走正文。
- 协作卡片回答是**重跑单个智能体**(新线程 + 把回答拼进任务),不是 LangGraph 原地 resume。
- token 取自公共端点(无门槛旁路)—— 适合内网;若网关要暴露公网,建议给取 token 端点加共享密钥校验。
- 跨域:宿主与后端不同源时,后端需放开该来源 CORS + `credentials`。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>并行多智能体面板 · Dev</title>
</head>
<body style="margin: 0">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
{
"name": "parallel-agents-vue",
"version": "0.1.0",
"description": "并行多智能体面板 —— Vue3 可嵌入组件(输入任务名+简介,并行调用多个智能体,深色消息列表 + 步骤条 + 保存回显 + 重新分析)",
"type": "module",
"main": "./dist/parallel-agents-vue.umd.cjs",
"module": "./dist/parallel-agents-vue.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/parallel-agents-vue.js",
"require": "./dist/parallel-agents-vue.umd.cjs"
},
"./style.css": "./dist/parallel-agents-vue.css"
},
"files": ["dist"],
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:lib": "vite build --mode lib",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"peerDependencies": {
"vue": "^3.4.0"
},
"dependencies": {
"markdown-it": "^14.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.2.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vue": "^3.5.0",
"vue-tsc": "^2.1.10"
}
}
+1389
View File
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
/** Dev demo 调试页:填后端地址 / token / 智能体 id,把面板塞进一个弹窗,验证嵌入与流式。 */
import { reactive, ref } from "vue";
import ParallelAgentsPanel from "./components/ParallelAgentsPanel.vue";
import type { AgentInput, AgentResult } from "./types";
import { DEFAULT_BASE_URL } from "./config";
const cfg = reactive({
baseUrl: localStorage.getItem("pa.baseUrl") || DEFAULT_BASE_URL,
username: localStorage.getItem("pa.username") || "demo",
taskId: localStorage.getItem("pa.taskId") || "",
agentsRaw: localStorage.getItem("pa.agents") || "",
});
const open = ref(false);
const agents = ref<AgentInput[]>([]);
// 面板保存 / 生成 id 时回填,方便复制下来下次填进去测「按 taskId 回显」。
function onTaskId(id: string) {
cfg.taskId = id;
localStorage.setItem("pa.taskId", id);
}
function onCompleted(results: AgentResult[]) {
console.log("[demo] completed → 各智能体返回内容:", results);
}
function parseAgents(): AgentInput[] {
// 每行一个:`agent_id, 显示名` 或仅 `agent_id`。
return cfg.agentsRaw
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const [id, name] = l.split(",").map((x) => x.trim());
return { agent_id: id, name: name || id };
});
}
function launch() {
localStorage.setItem("pa.baseUrl", cfg.baseUrl);
localStorage.setItem("pa.username", cfg.username);
localStorage.setItem("pa.taskId", cfg.taskId);
localStorage.setItem("pa.agents", cfg.agentsRaw);
agents.value = parseAgents();
open.value = true;
}
</script>
<template>
<div style="min-height: 100vh; background: #0a1020; color: #cbd5e1; padding: 24px; font-family: system-ui">
<h1 style="font-size: 18px; font-weight: 700; margin-bottom: 12px">并行多智能体面板 · Dev Demo</h1>
<div style="display: flex; flex-direction: column; gap: 8px; max-width: 640px">
<label>后端 baseUrl
<input v-model="cfg.baseUrl" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<label>用户名(免密自动登录,由你自己传)
<input v-model="cfg.username" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<label>taskId(存储主键,留空则首次保存自动生成;填已存在的 id 会自动回显)
<input v-model="cfg.taskId" placeholder="例如 my-task-001,留空也行" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<label>智能体(每行一个:agent_id, 显示名)
<textarea v-model="cfg.agentsRaw" rows="4" placeholder="roundtable-solution-design, 方案设计&#10;roundtable-risk-review, 风险审查" style="width: 100%; padding: 6px; background: #111c33; color: #e5edf7; border: 1px solid #2d4473; border-radius: 6px" />
</label>
<button @click="launch" style="align-self: flex-start; padding: 8px 16px; background: #1677ff; color: #fff; border: 0; border-radius: 6px; cursor: pointer">
打开面板(弹窗内)
</button>
</div>
<!-- 模拟宿主弹窗:面板自适应容器 -->
<div
v-if="open"
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
@click.self="open = false"
>
<div style="width: 92vw; max-width: 1080px; height: 86vh; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.5)">
<ParallelAgentsPanel
:base-url="cfg.baseUrl"
:username="cfg.username || undefined"
:task-id="cfg.taskId || undefined"
:agents="agents"
@update:taskId="onTaskId"
@completed="onCompleted"
/>
</div>
</div>
</div>
</template>
+234
View File
@@ -0,0 +1,234 @@
/**
* 后端访问层:鉴权(宿主传入 token)+ 并行扇出 SSE + 任务 CRUD。
*
* 全部打到 DeerFlow 网关(宿主传入 `baseUrl`,如 http://localhost:8001):
* - `POST {baseUrl}/api/parallel-agents/run/stream` —— 并行扇出,合并 SSE;
* - `{baseUrl}/api/parallel-agent-tasks` —— 任务保存/回显 CRUD。
*/
import type { AgentInput, Dialogue, SavedTask, StreamEvent } from "./types";
import { TOKEN_PATH } from "./config";
export interface ApiConfig {
baseUrl: string;
/** 已有登录态 token(Bearer)。可传字符串或一个(异步)取 token 的函数。 */
token?: string;
getToken?: () => string | Promise<string>;
/**
* 免密自动登录用户名:不传 token 时,组件用它走「用户名登录」(公共端点)换取 token
* 并缓存。用户名由宿主自己传。
*/
username?: string;
/** 仅当后端 `auth_login.username_login.require_password=true` 时需要的口令。 */
loginPassword?: string;
}
function base(cfg: ApiConfig): string {
return cfg.baseUrl.replace(/\/$/, "");
}
// 免密登录拿到的 token 按 baseUrl::username 缓存,避免每个请求都登一次。
const _tokenCache = new Map<string, string>();
const cacheKey = (cfg: ApiConfig) => `${base(cfg)}::${cfg.username ?? ""}`;
/** 用 username 走本面板**专用**的「绕过常规登录」公共端点换 access_token(CSRF 豁免)。 */
async function loginByUsername(cfg: ApiConfig): Promise<string> {
if (!cfg.username) throw new Error("缺少 token 且未提供 username,无法登录");
const res = await fetch(`${base(cfg)}${TOKEN_PATH}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: cfg.username }),
credentials: "include",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`get token failed (${res.status}): ${text}`);
}
const data = (await res.json()) as { access_token: string; token_type?: string };
const token = data.access_token;
_tokenCache.set(cacheKey(cfg), token);
return token;
}
/** token 解析优先级:显式 token > getToken > 缓存 > 用 username 免密登录。 */
async function resolveToken(cfg: ApiConfig): Promise<string | undefined> {
if (cfg.token) return cfg.token;
if (cfg.getToken) return await cfg.getToken();
const cached = _tokenCache.get(cacheKey(cfg));
if (cached) return cached;
if (cfg.username) return await loginByUsername(cfg);
return undefined;
}
/** 清掉缓存 token(401 时强制下次重登)。 */
function invalidateToken(cfg: ApiConfig): void {
_tokenCache.delete(cacheKey(cfg));
}
async function authHeaders(cfg: ApiConfig, json = true): Promise<Record<string, string>> {
const h: Record<string, string> = {};
if (json) h["Content-Type"] = "application/json";
const token = await resolveToken(cfg);
if (token) h["Authorization"] = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
return h;
}
/** 带鉴权的 fetch;若 401 且用 username 免密登录,则清缓存重登并重试一次。 */
async function authedFetch(
cfg: ApiConfig,
url: string,
init: RequestInit & { json?: boolean } = {},
): Promise<Response> {
const { json = true, headers, ...rest } = init;
const run = async () =>
fetch(url, {
...rest,
headers: { ...(await authHeaders(cfg, json)), ...(headers as Record<string, string> | undefined) },
credentials: "include",
});
let res = await run();
if (res.status === 401 && !cfg.token && !cfg.getToken && cfg.username) {
invalidateToken(cfg);
res = await run();
}
return res;
}
// ── 并行扇出 SSE ──────────────────────────────────────────────────────────────
export interface StreamHandlers {
onEvent: (ev: StreamEvent) => void;
onError?: (err: unknown) => void;
}
/**
* 打开 `/api/parallel-agents/run/stream` 一条合并 SSE,逐条把统一事件回调出去。
* 返回的 Promise 在流结束(all_done / 连接关闭)后 resolve。用 `signal` 取消。
*/
export async function streamParallelAgents(
cfg: ApiConfig,
body: { task_name: string; brief: string; agent_ids: string[]; model?: string },
handlers: StreamHandlers,
signal?: AbortSignal,
): Promise<void> {
const res = await authedFetch(cfg, `${base(cfg)}/api/parallel-agents/run/stream`, {
method: "POST",
body: JSON.stringify(body),
signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`run/stream failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
try {
handlers.onEvent(JSON.parse(payload) as StreamEvent);
} catch {
/* 忽略坏帧 */
}
}
}
} catch (err) {
if ((err as { name?: string })?.name !== "AbortError") handlers.onError?.(err);
} finally {
reader.releaseLock();
}
}
// ── 任务 CRUD(保存 / 回显)─────────────────────────────────────────────────
interface ServerTask {
id: string;
title: string;
brief: string | null;
agents: AgentInput[];
messages?: Dialogue[];
created_at: string | null;
updated_at: string | null;
}
const TASKS = (cfg: ApiConfig) => `${base(cfg)}/api/parallel-agent-tasks`;
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;
}
export async function listTasks(cfg: ApiConfig): Promise<SavedTask[]> {
const res = await authedFetch(cfg, TASKS(cfg), { json: false });
const data = await readJson<{ tasks: ServerTask[] }>(res, "list tasks");
return data.tasks ?? [];
}
export async function getTask(cfg: ApiConfig, id: string): Promise<SavedTask | null> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, { json: false });
if (res.status === 404) return null;
return readJson<SavedTask>(res, "get task");
}
export async function createTask(
cfg: ApiConfig,
payload: { id?: string; title: string; brief?: string; agents: AgentInput[]; messages: Dialogue[] },
): Promise<SavedTask> {
const res = await authedFetch(cfg, TASKS(cfg), { method: "POST", body: JSON.stringify(payload) });
return readJson<SavedTask>(res, "create task");
}
export async function updateTask(
cfg: ApiConfig,
id: string,
patch: { title?: string; brief?: string; agents?: AgentInput[]; messages?: Dialogue[] },
): Promise<SavedTask | null> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(patch),
});
if (res.status === 404) return null;
return readJson<SavedTask>(res, "update task");
}
// ── 模型列表 ──────────────────────────────────────────────────────────────
export interface ModelItem {
name: string;
display_name?: string;
}
/** 列出后端可用模型(GET /api/models)。失败返回空数组(不阻断主流程)。 */
export async function listModels(cfg: ApiConfig): Promise<ModelItem[]> {
try {
const res = await authedFetch(cfg, `${base(cfg)}/api/models`, { json: false });
if (!res.ok) return [];
const data = (await res.json()) as { models?: ModelItem[] };
return data.models ?? [];
} catch {
return [];
}
}
export async function deleteTask(cfg: ApiConfig, id: string): Promise<void> {
const res = await authedFetch(cfg, `${TASKS(cfg)}/${encodeURIComponent(id)}`, {
method: "DELETE",
json: false,
});
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete task failed (${res.status}): ${text}`);
}
}
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from "vue";
import { avatarColor, isHuman } from "@/lib/avatar-constants";
const props = withDefaults(defineProps<{ type: string; size?: number }>(), { size: 36 });
const color = computed(() => avatarColor(props.type));
const human = computed(() => isHuman(props.type));
</script>
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-xl"
:style="{ width: `${size}px`, height: `${size}px`, backgroundColor: color.bg }"
>
<!-- 真人 -->
<svg
v-if="human"
:width="size * 0.58"
:height="size * 0.58"
viewBox="0 0 24 24"
fill="none"
:stroke="color.fg"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="8" r="4" />
<path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
</svg>
<!-- 机器人 -->
<svg
v-else
:width="size * 0.6"
:height="size * 0.6"
viewBox="0 0 24 24"
fill="none"
:stroke="color.fg"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="4" y="8" width="16" height="11" rx="3" />
<path d="M12 8V4" />
<circle cx="12" cy="3" r="1" :fill="color.fg" />
<circle cx="9" cy="13" r="1.2" :fill="color.fg" stroke="none" />
<circle cx="15" cy="13" r="1.2" :fill="color.fg" stroke="none" />
<path d="M9 16.5h6" />
</svg>
</span>
</template>
@@ -0,0 +1,152 @@
<script setup lang="ts">
/**
* 协作 / 澄清卡片 —— 对齐圆桌第一步的 IntentClarificationCard 样式与交互:
* 「需要你的确认」标题 + 类型标签 + 背景说明 + 选项 chip(单选点击即发 / 多选可勾)+
* 自定义输入 + 发送按钮。提交时回传格式化好的回答文本。
*/
import { computed, reactive } from "vue";
import type { ClarificationPayload, ClarOption } from "@/types";
const props = defineProps<{ payload: ClarificationPayload; submitting?: boolean }>();
const emit = defineEmits<{ (e: "submit", answer: string): void }>();
interface NormOption {
id: string;
label: string;
description?: string;
}
function norm(opts: ClarOption[]): NormOption[] {
return (opts || []).map((o, i) =>
typeof o === "string"
? { id: o, label: o }
: { id: o.id || o.label || String(i), label: o.label || o.id || "", description: o.description },
);
}
const options = computed(() => norm(props.payload.options));
const allowMultiple = computed(() => !!props.payload.allowMultiple);
const allowCustom = computed(() => props.payload.allowCustom !== false);
const deferSubmit = computed(() => allowCustom.value); // 允许自定义时不在单击即发,给机会补充
const showSubmit = computed(() => allowMultiple.value || allowCustom.value || options.value.length === 0);
const sel = reactive({ ids: [] as string[], custom: "" });
function labelOf(id: string): string {
return options.value.find((o) => o.id === id)?.label || id;
}
function buildAnswer(ids: string[]): string | null {
const labels = ids.map(labelOf);
const custom = sel.custom.trim();
if (labels.length && custom) return `我选择:${labels.join("、")};补充:${custom}`;
if (labels.length) return `我选择:${labels.join("、")}`;
if (custom) return custom;
return null;
}
function clickOption(id: string) {
if (props.submitting) return;
if (!allowMultiple.value) {
if (deferSubmit.value) {
sel.ids = [id];
return;
}
emit("submit", buildAnswer([id]) ?? labelOf(id));
return;
}
sel.ids = sel.ids.includes(id) ? sel.ids.filter((x) => x !== id) : [...sel.ids, id];
}
const canSubmit = computed(() => sel.ids.length > 0 || sel.custom.trim().length > 0);
function submit() {
if (props.submitting || !canSubmit.value) return;
const ans = buildAnswer(sel.ids);
if (ans) emit("submit", ans);
}
</script>
<template>
<div class="pa-card pa-border mt-2 rounded-xl border p-3 shadow-sm">
<!-- 头部 -->
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<span class="text-[10px] font-bold uppercase tracking-wide pa-text-primary">需要你的确认</span>
<span
v-if="payload.clarificationType"
class="pa-badge rounded-full"
style="text-transform: none"
>
{{ payload.clarificationType }}
</span>
</div>
<p v-if="payload.context" class="text-[11px] italic leading-relaxed pa-text-muted">
{{ payload.context }}
</p>
<div class="text-xs font-medium leading-relaxed pa-text">{{ payload.question }}</div>
</div>
<!-- 选项 -->
<div v-if="options.length" class="mt-3 grid gap-2">
<div class="text-[10px] pa-text-muted">
{{ allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)" }}
</div>
<button
v-for="opt in options"
:key="opt.id"
type="button"
:disabled="submitting"
class="flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition"
:class="
sel.ids.includes(opt.id)
? 'pa-opt-selected'
: 'pa-border pa-surface-soft pa-text hover:opacity-90'
"
@click="clickOption(opt.id)"
>
<span class="flex min-w-0 flex-1 flex-col items-start gap-1">
<span class="text-[11px] font-bold">{{ opt.label }}</span>
<span v-if="opt.description" class="text-[10px] leading-snug" :class="sel.ids.includes(opt.id) ? '' : 'pa-text-muted'">
{{ opt.description }}
</span>
</span>
<span v-if="sel.ids.includes(opt.id)" class="mt-0.5 shrink-0">✓</span>
</button>
</div>
<!-- 自定义输入 -->
<div v-if="allowCustom" class="mt-3 flex flex-col gap-2">
<textarea
v-model="sel.custom"
:disabled="submitting"
rows="2"
class="pa-input min-h-16 resize-none px-2.5 py-1.5 text-xs"
:placeholder="
options.length
? allowMultiple
? '可选:补充说明(与所选项一起发送),或填写自定义回答'
: '如果以上选项都不合适,可在这里输入自定义回答'
: '请输入你的回答'
"
/>
</div>
<!-- 发送 -->
<div v-if="showSubmit" class="mt-3 flex justify-end">
<button
type="button"
class="pa-btn-primary h-8 rounded-xl px-4 text-xs font-bold"
:disabled="submitting || !canSubmit"
@click="submit"
>
{{ submitting ? "发送中…" : "发送回答" }}
</button>
</div>
</div>
</template>
<style scoped>
.pa-opt-selected {
background-color: var(--pa-primary);
color: #fff;
border: 1px solid var(--pa-primary);
}
</style>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { computed } from "vue";
import MarkdownIt from "markdown-it";
const props = defineProps<{ source: string }>();
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
const html = computed(() => md.render(props.source ?? ""));
</script>
<template>
<div class="pa-md" v-html="html" />
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
/** 单条消息气泡(对齐 Step2 MessageBubble + Step2Message 结构):头像 / 发言人 / 状态徽标 /
* 时间 / 步骤条 / Markdown 正文 / 流式光标 / loading 占位。 */
import { computed } from "vue";
import type { Dialogue } from "@/types";
import Avatar from "./Avatar.vue";
import MessageStepsCard from "./MessageStepsCard.vue";
import MarkdownContent from "./MarkdownContent.vue";
import ClarificationCard from "./ClarificationCard.vue";
const props = defineProps<{ msg: Dialogue; submitting?: boolean }>();
const emit = defineEmits<{ (e: "clarify", agentId: string, answer: string): void }>();
const hasContent = computed(() => (props.msg.content ?? "").trim().length > 0);
</script>
<template>
<div class="flex items-start gap-3">
<Avatar :type="msg.avatarType" :size="36" />
<div class="min-w-0 flex-1 space-y-1">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span class="text-sm font-extrabold pa-text">{{ msg.sender }}</span>
<span v-if="msg.confidence" class="pa-badge">{{ msg.confidence }}</span>
</div>
<span class="shrink-0 font-mono text-[11px] pa-text-muted">{{ msg.time }}</span>
</div>
<MessageStepsCard
v-if="msg.steps && msg.steps.length"
:steps="msg.steps"
:streaming="msg.streaming"
/>
<div class="pa-text break-words">
<div
v-if="msg.streaming && !hasContent"
class="flex items-center gap-2 py-1 text-sm pa-text-muted"
>
<span class="pa-spin inline-block">◜</span>
<span>{{ msg.confidence || "正在生成…" }}</span>
<span class="inline-flex items-center gap-0.5">
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary)" />
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.15s" />
<span class="pa-bounce inline-block size-1 rounded-full" style="background: var(--pa-primary); animation-delay: 0.3s" />
</span>
</div>
<MarkdownContent v-if="hasContent" :source="msg.content" />
<span v-if="msg.streaming && hasContent" class="pa-cursor" />
</div>
<!-- 协作 / 澄清卡片(对齐圆桌第一步) -->
<ClarificationCard
v-if="msg.clarification"
:payload="msg.clarification"
:submitting="submitting"
@submit="(answer) => emit('clarify', msg.agentId, answer)"
/>
</div>
</div>
</template>
@@ -0,0 +1,95 @@
<script setup lang="ts">
/**
* 步骤条 / 思维链卡 —— 1:1 复刻圆桌 Step2 的 MessageStepsCard + ChainOfThought:
* slate 边框卡 + 每步「图标列(带竖向连接线) + 中文文案 + 时间 + 参数子节点」。
*/
import { computed, ref } from "vue";
import type { DialogueStep } from "@/types";
import { getStepArgs, getStepDisplay } from "@/lib/step-display";
import StepIcon from "./StepIcon.vue";
const props = defineProps<{ steps: DialogueStep[]; streaming?: boolean }>();
const open = ref(true);
const rows = computed(() =>
props.steps.map((s, i) => {
const display = getStepDisplay(s.key, s.label, s.args);
return {
step: s,
display,
args: getStepArgs(s.key, s.args),
// 最后一步在 streaming 时为「进行中」(前景色),其余为已完成(次级色)。
active: props.streaming && i === props.steps.length - 1,
isLast: i === props.steps.length - 1,
};
}),
);
</script>
<template>
<div class="pa-surface pa-border mb-2 w-full gap-1 rounded-lg border p-0.5">
<button
type="button"
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-[11px] font-semibold pa-text-muted"
@click="open = !open"
>
<span class="inline-block transition-transform" :class="open ? 'rotate-90' : ''">▶</span>
<span>执行步骤({{ steps.length }})</span>
</button>
<div v-show="open" class="space-y-2 px-3 pb-2 pt-0.5">
<div
v-for="row in rows"
:key="row.step.id"
class="flex gap-2 text-sm"
:class="row.active ? 'pa-text' : 'pa-text-muted'"
>
<!-- 图标列 + 竖向连接线 -->
<div class="relative mt-0.5">
<StepIcon :name="row.display.icon" :size="16" />
<div
v-if="!row.isLast"
class="absolute left-1/2 -mx-px w-px"
style="top: 26px; bottom: -8px; background: var(--pa-border)"
/>
</div>
<!-- 文案 + 时间 + 参数 -->
<div class="min-w-0 flex-1 space-y-1 overflow-hidden">
<div class="leading-snug">
<span class="font-medium pa-text">{{ row.display.label }}</span>
<span class="ml-2 font-mono text-[11px] pa-text-muted">{{ row.step.ts }}</span>
</div>
<!-- 参数子节点(按 kind 渲染,对齐 renderStepArgs) -->
<template v-if="row.args">
<span
v-if="row.args.kind === 'chip'"
class="pa-surface pa-border inline-block max-w-full truncate rounded border px-1.5 py-0.5 text-[11px] pa-text-muted"
>
{{ row.args.text }}
</span>
<pre
v-else-if="row.args.kind === 'code'"
class="pa-md max-h-60 overflow-auto rounded-md p-2 text-[11px]"
><code>{{ row.args.text }}</code></pre>
<span
v-else-if="row.args.kind === 'text'"
class="line-clamp-2 block text-[11px] leading-snug pa-text-muted"
>
{{ row.args.text }}
</span>
<span v-else-if="row.args.kind === 'kv'" class="flex flex-wrap gap-1">
<span
v-for="p in row.args.pairs"
:key="p.k"
class="pa-surface pa-border inline-block rounded border px-1.5 py-0.5 text-[11px] pa-text-muted"
>
<span class="opacity-60">{{ p.k }}:</span> {{ p.v }}
</span>
</span>
</template>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,375 @@
<script setup lang="ts">
/**
* 并行多智能体面板(主组件,可嵌入)。
*
* 输入「任务名称 + 简介」,并行调用宿主传入的多个智能体(后端固定禁写文件),消息列表
* 照搬圆桌 Step2 并行多席位的深色样式与结构(步骤条 / 并行双列 / 席位状态条),支持保存
* 聊天记录 + 回显 + 重新分析。
*
* 自适应容器:根元素 `h-full w-full flex flex-col min-h-0`,宿主直接塞进抽屉 / 弹窗即可。
*/
import { computed, onMounted, reactive, ref, watch } from "vue";
import type { AgentInput, AgentResult, Dialogue, SavedTask, SeatView } from "@/types";
import { useParallelRun } from "@/composables/useParallelRun";
import { createTask, getTask, listModels, listTasks, updateTask, type ApiConfig, type ModelItem } from "@/api";
import { DEFAULT_BASE_URL, DEFAULT_MODEL } from "@/config";
import ParallelSeatStrip from "./ParallelSeatStrip.vue";
import MessageBubble from "./MessageBubble.vue";
const props = withDefaults(
defineProps<{
/** 后端地址;不传用配置文件默认(config.ts 的 DEFAULT_BASE_URL)。 */
baseUrl?: string;
/** 已有 token(Bearer),与 username 二选一;都不传走配置默认地址 + 免密登录失败提示。 */
token?: string;
getToken?: () => string | Promise<string>;
/** 免密自动登录用户名(由宿主自己传)。不传 token 时用它登录换 token。 */
username?: string;
/** 仅当后端开启口令门时需要。 */
loginPassword?: string;
agents: AgentInput[];
taskId?: string;
model?: string;
dark?: boolean;
}>(),
{ dark: true },
);
const emit = defineEmits<{
(e: "saved", task: SavedTask): void;
(e: "update:taskId", id: string): void;
/** 一轮并行分析结束时回传各智能体的返回内容。 */
(e: "completed", results: AgentResult[]): void;
}>();
const cfg = computed<ApiConfig>(() => ({
baseUrl: props.baseUrl || DEFAULT_BASE_URL,
token: props.token,
getToken: props.getToken,
username: props.username,
loginPassword: props.loginPassword,
}));
const run = useParallelRun();
const { dialogues, seats, running } = run;
const form = reactive({ taskName: "", brief: "" });
const currentTaskId = ref<string | null>(props.taskId ?? null);
const selected = ref<string[]>([]);
const saving = ref(false);
const history = ref<SavedTask[]>([]);
const showHistory = ref(false);
const errorMsg = ref<string | null>(null);
// 模型切换:拉后端模型列表,空字符串 = 用默认(prop/config/后端回退)。
const models = ref<ModelItem[]>([]);
const selectedModel = ref<string>(props.model ?? DEFAULT_MODEL ?? "");
const dialogueById = computed(() => {
const m = new Map<number, Dialogue>();
for (const d of dialogues.value) m.set(d.id, d);
return m;
});
const hasResults = computed(() => dialogues.value.length > 0);
const canStart = computed(
() => !running.value && props.agents.length > 0 && (form.taskName.trim() || form.brief.trim()),
);
// 单选:主区每次只显示**一个**席位的消息列表,点头像切换。
watch(
seats,
(s) => {
if (!s.length) {
selected.value = [];
return;
}
// 保留仍存在的当前选中;首次 / 失效时默认显示第 1 个席位。
const cur = selected.value[0];
const keep = cur && s.some((x) => x.agentId === cur) ? cur : s[0].agentId;
selected.value = [keep];
},
{ immediate: true },
);
// 宿主传入 / 改变 taskId:作为存储主键,并尝试加载回显该任务(不存在则只设 id)。
watch(
() => props.taskId,
(id) => {
currentTaskId.value = id ?? null;
if (id) void loadTask(id);
},
);
/** 点头像切换显示:单选,始终切到点击的那个席位。 */
function toggleSeat(agentId: string) {
selected.value = [agentId];
}
const visibleSeats = computed(() => {
const id = selected.value[0] ?? seats.value[0]?.agentId;
const seat = seats.value.find((s) => s.agentId === id);
return seat ? [seat] : [];
});
// ── 给宿主读取「每个智能体模型返回的内容」的简单方法 ──────────────────────────
function _statusOf(agentId: string, streaming?: boolean): SeatView["status"] {
return seats.value.find((s) => s.agentId === agentId)?.status ?? (streaming ? "running" : "done");
}
/** 取所有智能体的返回内容(按席位顺序)。宿主:`panelRef.value.getResults()`。 */
function getResults(): AgentResult[] {
return dialogues.value.map((d) => ({
agent_id: d.agentId,
name: d.sender,
content: d.content,
status: _statusOf(d.agentId, d.streaming),
}));
}
/** 取某个智能体的返回内容;不存在返回 null。 */
function getResult(agentId: string): AgentResult | null {
return getResults().find((r) => r.agent_id === agentId) ?? null;
}
// 模板 ref 用:`<ParallelAgentsPanel ref="panelRef" />` → panelRef.value.getResults()
defineExpose({ getResults, getResult, start: () => onStart(), cancel: () => run.cancel() });
/** 用户在某智能体的协作卡片里提交了回答 → 带上回答重跑该智能体。 */
async function onClarify(agentId: string, answer: string) {
errorMsg.value = null;
const brief = `${form.brief}\n\n【用户补充】${answer}`.trim();
try {
await run.rerunAgent(cfg.value, {
taskName: form.taskName,
brief,
agentId,
model: selectedModel.value || undefined,
});
emit("completed", getResults());
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function onStart() {
if (!canStart.value) return;
errorMsg.value = null;
try {
await run.start(cfg.value, {
taskName: form.taskName,
brief: form.brief,
agents: props.agents,
model: selectedModel.value || undefined,
});
// 一轮结束:回传各智能体返回内容(宿主也可用 ref 调 getResults())。
emit("completed", getResults());
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function onSave() {
if (saving.value || !hasResults.value) return;
saving.value = true;
errorMsg.value = null;
try {
const title = form.taskName.trim() || "未命名任务";
const agentsSnapshot: AgentInput[] = props.agents.map((a) => ({ ...a }));
const payload = { title, brief: form.brief, agents: agentsSnapshot, messages: dialogues.value };
// 按宿主传入的 taskId **upsert**:有 id 先更新,不存在(404)再用该 id 创建;无 id 则新建。
let saved: SavedTask | null;
const id = currentTaskId.value;
if (id) {
saved = await updateTask(cfg.value, id, payload);
if (!saved) saved = await createTask(cfg.value, { id, ...payload });
} else {
saved = await createTask(cfg.value, payload);
}
if (saved) {
currentTaskId.value = saved.id;
emit("update:taskId", saved.id);
emit("saved", saved);
await refreshHistory();
}
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
async function refreshHistory() {
try {
history.value = await listTasks(cfg.value);
} catch {
/* 历史拉取失败不阻断主流程 */
}
}
async function loadTask(id: string) {
showHistory.value = false;
errorMsg.value = null;
try {
const task = await getTask(cfg.value, id);
if (!task) return;
currentTaskId.value = task.id;
form.taskName = task.title ?? "";
form.brief = task.brief ?? "";
selected.value = [];
run.hydrate(task.messages ?? []);
emit("update:taskId", task.id);
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function refreshModels() {
const list = await listModels(cfg.value);
models.value = list;
// 当前选择不在列表里(且非空)时,回退为默认(空 = 后端回退)。
if (selectedModel.value && !list.some((m) => m.name === selectedModel.value)) {
selectedModel.value = "";
}
}
onMounted(() => {
void refreshHistory();
void refreshModels();
if (props.taskId) void loadTask(props.taskId);
});
</script>
<template>
<div
class="parallel-agents pa-bg flex h-full w-full min-h-0 flex-col"
:class="dark ? 'parallel-agents--dark' : ''"
>
<!-- 顶部:任务名 + 操作 -->
<div class="pa-border flex shrink-0 flex-col gap-2 border-b px-4 py-3">
<div class="flex items-center gap-2">
<input
v-model="form.taskName"
class="pa-input min-w-0 flex-1 px-3 py-1.5 text-sm"
placeholder="任务名称"
/>
<div class="relative shrink-0">
<button
type="button"
class="pa-btn-ghost px-2.5 py-1.5 text-xs"
@click="showHistory = !showHistory"
>
历史 ▾
</button>
<div
v-if="showHistory"
class="pa-card pa-border absolute right-0 z-20 mt-1 max-h-72 w-72 overflow-auto rounded-lg border p-1 shadow-lg"
>
<div v-if="!history.length" class="px-3 py-4 text-center text-xs pa-text-muted">
暂无保存的任务
</div>
<button
v-for="t in history"
:key="t.id"
type="button"
class="pa-btn-ghost block w-full truncate px-2.5 py-1.5 text-left text-xs"
style="border: 0"
@click="loadTask(t.id)"
>
<span class="font-semibold">{{ t.title || "未命名任务" }}</span>
<span class="pa-text-muted"> · {{ (t.updated_at || "").slice(0, 16).replace("T", " ") }}</span>
</button>
</div>
</div>
</div>
<textarea
v-model="form.brief"
rows="2"
class="pa-input w-full resize-y px-3 py-1.5 text-sm"
placeholder="任务简介:把背景、目标、约束写清楚,会并行下发给各智能体"
/>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="!running"
type="button"
class="pa-btn-primary px-3.5 py-1.5 text-sm font-semibold"
:disabled="!canStart"
@click="onStart"
>
{{ hasResults ? "重新分析" : "开始分析" }}
</button>
<button
v-else
type="button"
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
@click="run.cancel()"
>
停止
</button>
<button
type="button"
class="pa-btn-ghost px-3.5 py-1.5 text-sm font-semibold"
:disabled="saving || !hasResults"
@click="onSave"
>
{{ saving ? "保存中…" : currentTaskId ? "更新保存" : "保存" }}
</button>
<!-- 模型切换:作用于下一次「开始分析 / 重新分析」 -->
<label class="ml-auto flex items-center gap-1.5 text-xs pa-text-muted">
模型
<select
v-model="selectedModel"
:disabled="running"
class="pa-input px-2 py-1 text-xs"
title="选择本次并行分析使用的模型"
>
<option value="">默认</option>
<option v-for="m in models" :key="m.name" :value="m.name">
{{ m.display_name || m.name }}
</option>
</select>
</label>
<span v-if="props.agents.length === 0" class="text-xs pa-text-muted">
⚠️ 未传入任何智能体(agents 为空)
</span>
</div>
<p v-if="errorMsg" class="text-xs" style="color: var(--pa-rose)">{{ errorMsg }}</p>
</div>
<!-- 席位状态条 -->
<div v-if="seats.length" class="pa-border shrink-0 border-b px-4">
<ParallelSeatStrip :seats="seats" :selected="selected" @toggle="toggleSeat" />
</div>
<!-- 会话区:并行双列 -->
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<div
v-if="hasResults"
class="grid gap-4"
:class="visibleSeats.length > 1 ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'"
>
<div
v-for="seat in visibleSeats"
:key="seat.agentId"
class="pa-surface-soft pa-border flex min-w-0 flex-col rounded-xl border p-3"
>
<MessageBubble
v-if="dialogueById.get(seat.bubbleId)"
:msg="dialogueById.get(seat.bubbleId)!"
:submitting="running"
@clarify="onClarify"
/>
<div v-else class="py-6 text-center text-xs pa-text-muted">
{{ seat.name }} · 等待输出…
</div>
</div>
</div>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-2 text-center"
>
<p class="text-sm pa-text-muted">填写任务名称与简介,点「开始分析」并行调用各智能体。</p>
<p class="text-xs pa-text-muted">并行运行期间禁止写文件,产出直接呈现在正文。</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
/** 并行席位状态条(对齐 Step2 ParallelSeatStrip):席位头像 + running/done/error 角标,
* 点击可切换主区显示哪两列。 */
import { computed } from "vue";
import type { SeatView } from "@/types";
import Avatar from "./Avatar.vue";
const props = defineProps<{ seats: SeatView[]; selected: string[] }>();
const emit = defineEmits<{ (e: "toggle", agentId: string): void }>();
const runningCount = computed(() => props.seats.filter((s) => s.status === "running").length);
</script>
<template>
<div class="flex items-center gap-2 overflow-x-auto py-1">
<span class="shrink-0 text-[11px] font-bold pa-text-muted">
并行 {{ seats.length }} 席
<span v-if="runningCount > 0" class="pa-text-primary">({{ runningCount }} 进行中)</span>
<span v-if="seats.length > 1" class="font-normal pa-text-muted">· 点头像切换显示</span>
</span>
<div class="flex items-center gap-2">
<button
v-for="seat in seats"
:key="seat.agentId"
type="button"
class="relative shrink-0 rounded-full transition"
:style="
selected.includes(seat.agentId)
? 'box-shadow: 0 0 0 2px var(--pa-primary)'
: ''
"
:title="seat.name"
@click="emit('toggle', seat.agentId)"
>
<span
v-if="seat.status === 'running'"
class="pa-ping absolute inset-0 rounded-full"
style="background: rgba(22, 119, 255, 0.3)"
/>
<span class="relative block" :style="!selected.includes(seat.agentId) ? 'opacity:0.8' : ''">
<Avatar :type="seat.avatarType" :size="28" />
</span>
<span
class="absolute -bottom-0.5 -right-0.5 flex size-3.5 items-center justify-center rounded-full text-[8px] font-black"
style="border: 1px solid var(--pa-bg)"
:class="{
'pa-dot-running': seat.status === 'running',
'pa-dot-done': seat.status === 'done',
'pa-dot-error': seat.status === 'error',
}"
:style="seat.status === 'pending' ? 'background: var(--pa-border)' : ''"
>
<span v-if="seat.status === 'running'" class="pa-spin">◜</span>
<span v-else-if="seat.status === 'done'">✓</span>
<span v-else-if="seat.status === 'error'">✕</span>
</span>
</button>
</div>
</div>
</template>
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more