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:
commit
87e0ae683e
112 files changed
+24826
-1591
No files matched your search
@@ -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`。
|
||||
|
||||
Generated
+10540
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+277
-417
@@ -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>
|
||||
|
||||
+365
-540
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>
|
||||
);
|
||||
};
|
||||
+46
-36
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
+10
-5
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+18
@@ -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)
|
||||
+53
@@ -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),
|
||||
)
|
||||
+120
@@ -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
|
||||
+49
@@ -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")
|
||||
+27
@@ -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
|
||||
+58
@@ -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")
|
||||
+56
@@ -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")
|
||||
+61
@@ -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")
|
||||
+56
@@ -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")
|
||||
+4
@@ -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",
|
||||
|
||||
+18
@@ -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)
|
||||
+46
@@ -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),
|
||||
)
|
||||
+131
@@ -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
|
||||
+22
-1
@@ -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)
|
||||
)
|
||||
|
||||
+49
-8
@@ -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:
|
||||
|
||||
+82
@@ -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。
|
||||
|
||||
@@ -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*` 这些系统角色——它们不是用来当并行席位的)。
|
||||
@@ -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 与自查方法。
|
||||
@@ -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`。
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1389
File diff suppressed because it is too large.
Load diff
@@ -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, 方案设计 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>
|
||||
@@ -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
Reference in new issue
Block a user