Merge origin/master into ji

This commit is contained in:
吉鑫升 committed 2026-06-05 10:04:11 +08:00
commit e96cc97462
54 files changed
+4900 -334

No files matched your search

+9 -5
View File
@@ -24,10 +24,16 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的笔记前端
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
@@ -35,5 +41,3 @@ VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# 我的笔记是否显示,侧边栏
VITE_STUDIO_ENABLED=false
@@ -15,10 +15,15 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
+3
View File
@@ -21,6 +21,9 @@
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
+32 -21
View File
@@ -1,32 +1,43 @@
# 内网 / 局域网生产打包(npm run build:network 或 pnpm build:network)
# 打包前请将 192.168.1.100 改为实际服务器 IP;变量会写入 dist,运行时不可再改
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# Gateway / DeerFlow API(与 offline-backend DEPLOY 文档一致)
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://192.168.1.100:8000
# CMZS 后端接口
VITE_API_BASE_URL=http://192.168.1.100/zncm
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=http://192.168.1.100/gpt
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=http://192.168.1.100/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=http://192.168.1.100/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=http://192.168.1.100/knowlibrary
VITE_AIPI_B1COH_URL=http://192.168.1.100/web
VITE_AIPI_B2ACTOR_URL=http://192.168.1.100
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=http://192.168.1.100/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=http://192.168.1.100/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://192.168.1.100
VITE_AIPI_CONSUMER_URL=http://192.168.1.100/consumer
# MaxKey 鉴权(getTokenByOnlineTicket,内网部署请改为实际地址)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=http://192.168.1.100
VITE_AIPI_XKLINKTO_URL=http://192.168.1.100
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# 可选:Canvas 独立 LangGraph 地址
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
+4
View File
@@ -19,13 +19,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -3,3 +3,6 @@
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
# VITE_STUDIO_ENABLED=true
+5
View File
@@ -19,12 +19,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -0,0 +1,514 @@
# 多智能体规划 · 后台挂起执行 + 进度可视化 —— 分步实现方案
> 目标:在「多智能体规划」第二步增加**后台挂起**能力——把当前任务挂到后台,自动一路跑到
> 最后一步(Step3 结果绘制完成)为止;历史记录下拉框里进行中的任务显示加载动画;点击进行中
> 的任务弹出一个**大弹窗**,里面是**横向的智能体派活链路流程图**(节点 = 各席位智能体,图标 +
> 名称,不同颜色,正在发言的节点和它的连接线带加载动画);弹窗里有「查看」按钮,点击进入正在
> 进行中的任务实时观察。
---
## 0. 现状与架构事实(动手前必读)
研究自现有代码,决定了整套方案的形态。**关键结论:当前编排循环跑在前端,离开页面即中断。**
### 0.1 前端现状
| 事实 | 位置 |
|------|------|
| Step2 编排循环(leader → 顺序派 special → leader … 直到 `dispatched===[]` 共识 / `MAX_CYCLES=8`)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts:992-1204`(recommend)、`1228-1439`(chain) |
| 循环由 `currentStep===2` 的 `useEffect` 启停;离开 Step2 的 cleanup 会 `orchestrationCancelRef.current=true` + `abortAllInFlight()` 中断 | `useStep2Orchestration.ts:1468-1541`(尤其 cleanup `1534-1537`、`1471-1477`) |
| 共识判定:`leaderRes.dispatched.length===0` → `setHasConsensus(true)` | `useStep2Orchestration.ts:1091-1099` |
| 进入 Step3:手动点按钮,前置 `hasConsensus===true` | `Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595` |
| Step3「结果绘制」:前端流式调 `roundtable-report` 智能体,写出 `outputs/方案总览.html`,`phase→"done"` 即完成 | `src/roundtable-planning/hooks/useStep3Report.ts:36,51,165,560-596,636-652` |
| 草稿持久化:`/api/roundtable-drafts`,含 step1/step2/step3 三段快照 + `furthestStep(1|2|3)`,**无显式 status 字段** | `src/roundtable-planning/api/drafts.ts:25-110,112,184-225` |
| 历史下拉 UI(触发按钮 + 列表 + 重命名/删除) | `RoundtablePlanningPage.tsx:771-892` |
| 自动保存触发点(Step1 回复完 / Step2 每轮气泡收尾 / Step3 报告保存 / 步骤前进) | `useDraftPersistence.ts:248-316` |
| 加载草稿恢复:`handleLoadDraft` → `getDraft` → `cancelAllStreams` → hydrate → `setCurrentStep(furthestStep)` | `useDraftPersistence.ts:308-368` |
### 0.2 后端现状
| 事实 | 位置 |
|------|------|
| `/api/multi-agent/init`:为各席位并行建 thread + 单例总控 `roundtable-coordinator`,SSE 返回 `seat_ready`/`coordinator_ready`/`init_done(thread_ids)` | `app/gateway/routers/multi_agent.py:647-823` |
| `/api/multi-agent/run/stream`:驱动**单轮** leader 或 special 的 LLM 执行,末尾 JSON 帧 `status`(派活列表 / `[]` 共识 / `clarification` / `error`) | `multi_agent.py:1076-1142` |
| `/api/multi-agent/report/init`:为 Step3 报告建 thread | `multi_agent.py`(report/init) |
| **后端不存在编排循环**——leader 派活由前端解释后再逐个发 `run/stream`;后端只做单轮执行 + 把派活/完成「广播」追加进各 thread 历史消息 | `multi_agent.py:24-29,487-502,1108-1142` |
| 派活格式:`agent_orchestration` 工具调用 `{agent_name, task}`(旧式 bash 解析兼容) | `multi_agent.py:525-552` |
### 0.3 可复用模板:AI 写作的「后台多阶段流程」
AI 写作已经把「后台跑多阶段流程 + 前端 SSE 订阅 + 可 resume + 定时清理」跑通了,**圆桌后台作业直接照搬这套**:
| 模板要素 | 位置 |
|------|------|
| LangGraph 图驱动多阶段(researching/writing_outline/writing_draft/reviewing/…),条件路由推进,`interrupt()` 暂停点等用户输入 | `packages/harness/deerflow/agents/ai_writing/graph.py`、`nodes/*`(`pause_material_confirm` 等 `45-290`) |
| Session 持久化表 `ai_writing_sessions`:`id/user_id/title/status/draft_*/transcript(LONGTEXT)/...`,`status∈{in_progress,done,error}` | `packages/harness/deerflow/persistence/ai_writing_sessions/model.py:14-39` |
| `transcript` 用 `PortableLongText`(MySQL LONGTEXT,避开 64KB),时间用 `BeijingDateTime` | 同上 `27-38` |
| 进度走 LangGraph Server 标准流 `stream_mode=["messages-tuple","values"]`,**graph 在 langgraph runtime 里跑,任意 worker 可 join stream**(这是后台健壮性的关键) | `ai_writing.py:1-17`、`langgraph.json:16-18` |
| Checkpointer 共享 `AsyncSqliteSaver`,落 `.deer-flow/data/checkpoints.db`(需挂数据卷) | `runtime/checkpointer/async_provider.py` |
| Cleanup cron(leader 文件锁、活跃保护、先删 CP 再删 DB、admin 手动触发) | `app/gateway/ai_writing_cleanup.py:26,34-59,111-135,193-215,307-367` |
| **多 worker 坑**:模块级内存 dict(`_event_queues`)每 worker 一份会丢会话;SSE 进程内直调会整段 buffer。解法:状态进 DB+checkpointer、SSE 走真 TCP loopback `127.0.0.1`(`trust_env=False` 禁代理) | `multi_agent.py:271-293`、`AIWritingPage.tsx` 文件头注释 §3/§5 |
### 0.4 决策(已与产品确认)
1. **后端真后台执行**:把编排循环搬到后端 run,刷新 / 关页 / 换设备都不中断。
2. **流程图节点 = 智能体派活链路**:横向排列的席位节点,正在发言的节点 + 连接线加载动画。
> ⚠️ **本方案最大的工作量在 Phase 2**:把目前跑在前端 TS 里的编排循环(`runOrchestration`/`runChainOrchestration`)
> 移植成一个后端 **LangGraph「圆桌总控编排图」**。这是与 AI 写作一致、可在 LangGraph runtime 里
> 后台跑且天然 checkpoint 的健壮路径。下文每个阶段都给出验收标准,可独立提测。
---
## 1. 总体架构
```
┌─────────────────────────────── 前端 (frontend-web) ───────────────────────────────┐
│ Step2Panel ──「后台挂起」按钮──▶ POST /api/roundtable-jobs (start) │
│ HistoryDropdown ── 进行中任务显示 spinner ──▶ 点击 ──▶ DispatchChainModal(大弹窗) │
│ DispatchChainModal ── 订阅 GET /api/roundtable-jobs/{id}/stream(SSE) │
│ 横向派活链路流程图(节点=席位, 活动节点+连线动画) ──「查看」──▶ 进入任务实时观察 │
└───────────────────────────────────────────────────────────────────────────────────┘
│ SSE 进度 / 快照
┌─────────────────────────────── 后端 (backend) ─────────────────────────────────────┐
│ /api/roundtable-jobs 路由层(start/get/stream/resume/cancel) ── 仿 ai_writing.py │
│ │ 启动后台 run │
│ LangGraph「圆桌总控编排图」 roundtable_orchestrator ── 仿 ai_writing graph │
│ init → leader_call → parse_dispatch → dispatch_seats(顺序) → loop ↺ │
│ → consensus → report_draw(写 HTML) → done (clarification 点 interrupt()) │
│ │ 每次状态转移持久化进度 │
│ roundtable_jobs 表(status/phase/dispatch_chain/active_speaker/...) + 共享 checkpointer│
│ cleanup cron (仿 ai_writing_cleanup.py) │
└───────────────────────────────────────────────────────────────────────────────────┘
```
**核心思路**:后台作业 = 一个 LangGraph 图的一次 run。图节点内部复用现有的单轮执行能力
(`multi_agent.py` 里 leader/special 的 run 逻辑),把「解释派活 → 顺序派活 → 回到 leader」的
循环从前端搬进图节点。前端从「驱动者」退化为「观察者」:通过 SSE 订阅进度渲染。
---
## 2. 数据契约(前后端共享,先定义后实现)
### 2.1 作业状态机 `JobStatus`
```
queued # 已创建,等待 worker 拉起
running # 编排进行中(Step2 派活循环 / Step3 绘制)
awaiting_input # 命中 clarification,等用户回答(interrupt 挂起)
done # 已跑到 Step3 绘制完成
error # 出错终止
cancelled # 用户取消
```
### 2.2 作业阶段 `JobPhase`(细化给流程图/进度条用)
```
initializing # 建 thread / 总控就绪
leader_thinking # 总控在决策派活
dispatching # 正在顺序派活给席位(配合 active_speaker)
consensus # 共识达成(dispatched=[])
report_drawing # Step3 报告绘制中
report_done # 绘制完成
```
### 2.3 派活链路节点 `DispatchChainNode`(流程图核心数据)
```ts
interface DispatchChainNode {
agentId: string; // 席位 agent_id(小写)
name: string; // 显示名
avatarType: string; // 复用 getAvatarTypeFor(agentId, index) → 决定颜色
order: number; // 横向排列次序(chain 模式=链条顺序;recommend 模式=席位/发言顺序)
state: 'pending' | 'active' | 'done'; // 节点状态(驱动颜色/动画)
lastSpokeCycle?: number; // 最近发言轮次(可选,做 tooltip)
}
```
### 2.4 进度事件 `JobProgressEvent`(SSE 推送的归一化事件)
```ts
type JobProgressEvent =
| { type: 'status'; status: JobStatus }
| { type: 'phase'; phase: JobPhase; cycle: number }
| { type: 'chain'; nodes: DispatchChainNode[] } // 派活链路全量/增量
| { type: 'active'; agentId: string | null } // 当前发言席位(null=总控/无)
| { type: 'consensus'; percentage: number }
| { type: 'dialogue'; bubble: Step2DialogueLike } // 新增/收尾的对话气泡(供「查看」实时渲染)
| { type: 'clarification'; question: string } // 命中澄清
| { type: 'done'; step3: DraftStep3Snapshot } // 终态:最终报告快照
| { type: 'error'; detail: string };
```
> SSE 通道可直接复用 LangGraph Server 的 `values` 流(图状态快照),由前端 hook 把 `values`
> 映射成上述归一化事件;也可在路由层做映射后再下发。**推荐前端映射**(少一层后端耦合)。
### 2.5 作业记录 `RoundtableJob`(持久化 + 列表展示)
```ts
interface RoundtableJob {
id: string;
draftId: string; // 关联的草稿
userId: string;
status: JobStatus;
phase: JobPhase;
cycle: number; // 当前轮次(0..MAX_CYCLES)
dispatchChain: DispatchChainNode[];
activeAgentId: string | null;
consensusPercentage: number;
pendingClarification: string | null;
threadIds: Record<string,string> | null; // 复用 ThreadIdMap
coordinatorName: string | null;
orchestrationMode: 'recommend' | 'chain';
chain?: { id: string; title: string } | null;
error: string | null;
createdAt: string;
updatedAt: string;
}
```
---
## Phase 1 —— 后端:圆桌作业持久化模型
**目标**:建一张 `roundtable_jobs` 表(仿 `ai_writing_sessions`),存作业状态/阶段/派活链路,
并在 `roundtable_drafts` 列表里能 join 出当前作业状态。
**涉及文件(新增/改)**
- 新增 `packages/harness/deerflow/persistence/roundtable_jobs/model.py`(ORM 模型,仿 `ai_writing_sessions/model.py:14-39`)
- 新增 `.../roundtable_jobs/sql.py`(Repository:`create / get / update_progress / list_by_user / list_older_than / delete`,仿 `ai_writing_sessions/sql.py`)
- 新增 alembic 迁移 `persistence/migrations/versions/<date>_create_roundtable_jobs.py`
- 改 `roundtable_drafts` 的列表查询:返回里带上 `status`/`jobId`(LEFT JOIN roundtable_jobs,或在 draft 行上冗余一个 `status` 字段,二选一,见下)
**步骤**
1. 字段设计(对齐 §2.5):`id(PK)`、`draft_id`、`user_id`、`status`、`phase`、`cycle(Int)`、
`dispatch_chain(PortableLongText/JSON)`、`active_agent_id`、`consensus_percentage(Int)`、
`pending_clarification(Text)`、`thread_ids(JSON)`、`coordinator_name`、`orchestration_mode`、
`chain(JSON)`、`error(Text)`、`created_at/updated_at(BeijingDateTime)`。
2. `dispatch_chain` / `thread_ids` / `chain` 用 `PortableLongText` 存 JSON 字符串,repo 层序列化。
3. 列表 join:给 `DraftMeta` 增加 `status?: JobStatus` 与 `jobId?: string`。
**推荐方案**:草稿与作业 1:1(一个草稿最多一个活跃作业),在 draft 列表查询 LEFT JOIN
`roundtable_jobs` 取最新作业的 `status`。
4. 索引:`(user_id, updated_at)`、`(draft_id)`、`(status)`(cleanup/列表用)。
**验收**
- 迁移可 `alembic upgrade head` 跑通,MySQL 上 `dispatch_chain` 为 `longtext`。
- 单测:create→update_progress→get 往返;`list_by_user` 按 `updated_at` 倒序;`list_older_than` 分批。
---
## Phase 2 —— 后端:圆桌总控编排 LangGraph 图(核心 / 最大工作量)
**目标**:把前端的编排循环移植成一个后端 LangGraph 图,可在 LangGraph runtime 里后台跑、天然
checkpoint,一路跑到 Step3 绘制完成。
**涉及文件(新增)**
- `packages/harness/deerflow/agents/roundtable_orchestrator/graph.py`(图装配,仿 `ai_writing/graph.py`)
- `.../roundtable_orchestrator/state.py`(图状态 schema:intent、selectedAgents、threadIds、coordinatorName、
mode/chain、dialogues、dispatchChain、activeAgentId、cycle、consensus、phase、step3 等)
- `.../roundtable_orchestrator/nodes/*.py`(各节点)
- 在 `langgraph.json` 注册新 assistant `roundtable_orchestrator`(仿 ai_writing assistant 注册,参考 `langgraph.json:16-18`)
**图节点设计(把前端循环逐段翻译过来)**
| 节点 | 职责 | 对应前端逻辑 |
|------|------|------|
| `init` | 复用 `_create_thread_direct` 批量建席位 thread + 总控就绪(或接收前端已建好的 `threadIds` 直接用) | `useStep2Orchestration.ts:1485-1531` / `multi_agent.py:647-823` |
| `leader_call` | 跑一轮总控(agent_type=leader),拿 `status`(派活列表 / `[]` / `clarification`) | 单轮逻辑 `multi_agent.py:1076-1142` |
| `route_after_leader`(条件边) | `clarification`→`interrupt`;有派活→`dispatch_seats`;`[]`→`consensus` | `useStep2Orchestration.ts:1091-1181` |
| `dispatch_seats` | **顺序**派活:对 dispatched 里每个席位依次跑 special run;每开始一个就更新 `activeAgentId` + 进度持久化 | `useStep2Orchestration.ts:1101-1181`(务必串行,勿并行,详见前端 §5.4 注释) |
| `clarification_pause` | `interrupt()` 挂起,`status→awaiting_input`,写 `pending_clarification` | 仿 ai_writing `pause_*` `45-290` |
| `consensus` | `setHasConsensus`、`consensus=100`,落库后进入 `report_draw` | `1091-1099` |
| `report_draw` | 跑 `roundtable-report`(先 report/init 建 thread),流式写 `outputs/方案总览.html`,产出 `DraftStep3Snapshot` | `useStep3Report.ts:91-149,636-652,560-596` |
| `done` | `status→done`、写 `step3` 进作业 + 回写草稿 step3 快照 | `RoundtablePlanningPage.tsx:613-617` |
**关键实现要点**
1. **复用单轮执行**:节点内部不要重写 LLM 调用,抽出 `multi_agent.py` 里 leader/special/report 的
单轮 run 为可被图节点直接 `await` 的内部函数(去掉 HTTP 层,进程内调 LangGraph)。
2. **派活广播**:保留现有把「总控给 X 派了活 / X 完成」追加进各 thread 历史的广播
(`multi_agent.py:487-502`),让席位读到全局上下文。
3. **MAX_CYCLES 兜底**:图里维护 `cycle`,≥8 强制收敛(对齐 `roundtable-constants.ts:13`)。
4. **chain 模式**:`orchestration_mode==='chain'` 时按 `chain.seats` 固定顺序派活,跳过总控自由决策的
部分(对齐 `runChainOrchestration` `1228-1439`)。
5. **进度落库**:每个节点结束 `update_progress(jobId, {phase, cycle, dispatchChain, activeAgentId,
consensus, dialogues_delta})`,前端 SSE 才有实时数据。
6. **dialogues 累积**:把每轮气泡写进图状态 `dialogues`,既供「查看」实时渲染,也供作业结束回写草稿 step2。
**验收**
- 用脚本(仿 `scripts/probe_ai_writing_assistant.py`)直接对图发起一次 run,不开前端,能从 intent +
selectedAgents 一路跑到写出 `方案总览.html`,作业 `status` 终态 `done`。
- 命中 clarification 时图 `interrupt`,`status=awaiting_input`,`pending_clarification` 有值。
- chain 模式按链条顺序派活,recommend 模式由总控自由派活,两条路径都能收敛。
---
## Phase 3 —— 后端:作业控制 + 进度 SSE 接口
**目标**:暴露 start/get/stream/resume/cancel,后台 run 走 LangGraph Server(任意 worker 可 join)。
**涉及文件(新增/改)**
- 新增 `app/gateway/routers/roundtable_jobs.py`(仿 `ai_writing.py` 的 CRUD + 控制壳)
- 在 gateway `app.py` 注册路由
- 新增 `app/gateway/roundtable_jobs_cleanup.py`(仿 `ai_writing_cleanup.py`)+ 在启动处挂 cron
**接口契约**
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/roundtable-jobs` | 入参:`{draftId, intent, selectedAgents, threadIds?, coordinatorName?, model, orchestrationMode, chain?}`。建作业记录(`queued`) + 启动图 run(LangGraph Server thread),返回 `RoundtableJob` |
| `GET` | `/api/roundtable-jobs/{id}` | 作业快照(轮询兜底 / 弹窗首帧) |
| `GET` | `/api/roundtable-jobs/{id}/stream` | SSE 进度流:透传图 `values`/`messages-tuple`,或映射成 §2.4 事件下发。**走真 TCP loopback**(`multi_agent.py:271-293`),禁代理 |
| `POST` | `/api/roundtable-jobs/{id}/resume` | 入参 `{answer}`,回填 clarification → 图 resume,`status→running` |
| `POST` | `/api/roundtable-jobs/{id}/cancel` | 取消图 run,`status→cancelled` |
| `GET` | `/api/roundtable-jobs?draftId=` | 按草稿查活跃作业(页面恢复用) |
| `POST` | `/api/roundtable-jobs/admin/cleanup` | 管理员手动清理(仿 ai_writing admin cleanup `307-367`) |
**多 worker 健壮性(务必遵守,照搬 ai_writing 教训)**
- 作业状态只存 **DB + checkpointer**,**不要**用模块级内存 dict 存 per-session 状态。
- SSE 必须经真 TCP `127.0.0.1` loopback,不能 `httpx.ASGITransport(app=...)` 进程内直调(会整段 buffer)。
- 图 run 跑在 LangGraph runtime(独立于处理 HTTP 的 worker),刷新/换 worker 都能重新 join stream。
- 数据卷:`.deer-flow/data/checkpoints.db` 必须挂出,否则容器重启丢状态。
**验收**
- `POST /start` 后立刻 `GET /{id}` 能拿到 `queued/running`;关掉 SSE 再重连能继续收进度。
- 杀掉发起 start 的 worker(或多 worker 轮询),`resume`/`stream` 仍命中同一作业。
- cleanup 干跑:活跃作业永不删,先删 CP 再删 DB。
---
## Phase 4 —— 前端:作业 API 客户端 + 数据模型扩展
**目标**:前端有一层干净的作业 API 与类型,历史列表能拿到 status。
**涉及文件(新增/改)**
- 新增 `src/roundtable-planning/api/roundtable-jobs.ts`:
```ts
export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
export async function getJob(id: string): Promise<RoundtableJob>
export function streamJob(id: string, on: (e: JobProgressEvent) => void): () => void // 返回 unsubscribe
export async function resumeJob(id: string, answer: string): Promise<void>
export async function cancelJob(id: string): Promise<void>
```
- `streamJob` 复用项目现有 SSE/`streamMultiAgent` 的封装方式(`api/multi-agent.ts:403+`),把
LangGraph `values` 映射成 §2.4 的归一化事件。
- 改 `src/roundtable-planning/api/drafts.ts`:`DraftMeta` 增 `status?: JobStatus; jobId?: string`
(`drafts.ts:76-83` + 字段映射 `122-178` 加 snake_case 转换)。
- 新增 `src/roundtable-planning/lib/job-types.ts`:把 §2.1–2.5 的类型集中导出。
**验收**:`pnpm typecheck` 通过;`getJob`/`streamJob` 能拉到 Phase 3 的数据。
---
## Phase 5 —— 前端:Step2「后台挂起」按钮
**目标**:Step2 顶栏/底栏加按钮,点击把当前任务交给后端后台跑,前端停止本地循环、转为观察者。
**涉及文件(改)**
- `src/roundtable-planning/components/Step2Panel.tsx`(按钮放在现有「进入第三步」附近 `370-381`)
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(接线 + 观察者模式)
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`(暴露「停止本地循环并交后台」的方法)
**步骤**
1. Step2Panel 加「⏼ 后台挂起」按钮,`onBackgroundSuspend` 回调。
2. 页面 `handleBackgroundSuspend`:
- `ensureDraft(2)` 拿到 `draftId`(`useDraftPersistence.ts` ensureDraft)。
- 收集当前状态:`intentReady`、`selectedAgents`、`threadIds`、`coordinatorName`、`model`、
`orchestrationMode`、`chain`(全部来自 `step2`/`step1` 现有状态)。
- `startJob(...)` → 拿到 `jobId`。
- **停止前端本地编排**:调用 `step2.stopOrchestration()` + 置一个 `backgroundedJobId` 标记,
让 `currentStep===2` 的 useEffect **不再自启本地循环**(在 `useStep2Orchestration.ts:1468-1541`
的启动条件里加 `&& !backgrounded`)。
- toast「已转入后台,可离开页面,任务会自动跑到结果绘制完成」。
- 刷新历史列表(`drafts` 重新拉,进行中项即出现 spinner)。
3. **观察者模式**:若当前停留在 Step2 且该 draft 有 running 作业,订阅 `streamJob`,把进度事件渲染成
对话气泡/活动席位(复用现有 Step2 渲染),而不是本地驱动。
**注意:避免「双跑」**——后台已接管后,前端绝不能再并行驱动同一批 thread。用 `backgroundedJobId`
互斥;加载草稿时若发现有 running 作业,直接进观察者模式。
**验收**:点「后台挂起」→ 可立即切到别的任务/路由;回来仍在跑;不会出现前后端同时派活。
---
## Phase 6 —— 前端:历史下拉框「进行中」加载动画
**目标**:历史下拉里 `status==='running'`(或 `awaiting_input`)的草稿条目显示加载动画;点击进行中
条目不直接 load,而是打开流程图弹窗。
**涉及文件(改)**
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` 的 `HistoryDropdown`(`771-892`)
**步骤**
1. 列表项左侧名称前/后,按 `d.status` 渲染状态标记:
- `running` → 旋转的 `Loader2`(`animate-spin`)+「进行中」。
- `awaiting_input` → 呼吸点 +「待澄清」(琥珀色)。
- `done`/无 → 维持现状(显示 `Step {furthestStep}`)。
- `error` → 红点 +「出错」。
2. 点击行为分流:
- `running`/`awaiting_input` → `openDispatchChainModal(d.jobId)`(Phase 7),**不**直接 `onLoad`。
- 其它 → 维持 `onLoad(id)`。
3. 列表需要 `status`,确保 Phase 4 的 `DraftMeta.status` 已就位;下拉打开时 `refresh()` 拉最新。
**验收**:后台挂起后,历史下拉对应条目转圈;点击弹出流程图弹窗。
---
## Phase 7 —— 前端:派活链路流程图弹窗(横向 / 大弹窗 / 动画)
**目标**:大弹窗,横向排列的智能体派活链路。节点 = 席位(图标 + 名称),不同颜色;正在发言的
节点和它的连接线带加载动画;含「查看」按钮。
**涉及文件(新增)**
- `src/roundtable-planning/components/DispatchChainModal.tsx`
**实现选型**:链路是「总控 → 席位序列」的线性结构 + 需要对**单个节点和单条连线**做精确动画,
**推荐手写 flex 横向布局 + CSS 动画连接线**(轻、可控)。项目已装 `@xyflow/react@12`(带 `animated`
边)作为备选,但手写对本场景的「节点 loading + 连线流动」更直接。
**结构(手写方案草图)**
```tsx
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[88vh] flex-col gap-0 sm:max-w-5xl"> {/* 大弹窗 */}
<DialogHeader>… 任务标题 + 阶段标签(leader_thinking/dispatching/report_drawing…) …</DialogHeader>
{/* 横向链路:总控 ──▶ 席位1 ──▶ 席位2 ──▶ … ──▶ [共识] ──▶ [报告绘制] */}
<div className="custom-scrollbar overflow-x-auto py-8">
<div className="flex items-center gap-0 min-w-max px-6">
<CoordinatorNode active={phase==='leader_thinking'} />
{nodes.map((n,i)=>(
<Fragment key={n.agentId}>
<Connector active={n.state==='active'} /> {/* 活动连线:流动虚线动画 */}
<ChainNode node={n} /> {/* 活动节点:彩色+脉冲光环 */}
</Fragment>
))}
<Connector active={phase==='consensus'} />
<MilestoneNode label="共识" done={consensus===100} />
<Connector active={phase==='report_drawing'} />
<MilestoneNode label="报告绘制" active={phase==='report_drawing'} done={phase==='report_done'} />
</div>
</div>
<DialogFooter className="border-t pt-3 sm:justify-between">
<span className="text-xs text-muted-foreground">轮次 {cycle}/8 · {statusLabel}</span>
<Button onClick={onView}>查看</Button> {/* Phase 8 */}
</DialogFooter>
</DialogContent>
</Dialog>
```
**节点颜色**:`avatarType = getAvatarTypeFor(agentId, order)` → `chainTagClasses(avatarType)`
(复用 `lib/business-chain.ts:56-72` + `lib/roundtable-constants.ts`),与业务链条卡片同一套色。
**节点状态样式**
- `pending`:灰色描边、低透明度。
- `active`:席位色填充 + 外圈 `animate-ping`/脉冲光环 + 图标旋转或呼吸。
- `done`:席位色实心 + 右上角 ✓。
**连接线加载动画(active 连线)**:CSS 流动虚线(marching-ants)或渐变扫光:
```css
/* roundtable-planning.css 追加 */
@keyframes chain-flow { to { background-position: 20px 0; } }
.chain-connector--active {
background-image: linear-gradient(90deg, var(--brand-500) 50%, transparent 50%);
background-size: 20px 100%;
animation: chain-flow 0.6s linear infinite;
}
```
**数据来源**:弹窗 mount 时 `getJob(jobId)` 取首帧,随后 `streamJob(jobId, onEvent)` 实时更新
`nodes`/`activeAgentId`/`phase`/`consensus`/`cycle`;`done` 事件后停止动画、全节点置 `done`。
弹窗关闭时 `unsubscribe`。
**验收**:弹窗够大、横向滚动;活动席位节点和它左侧连线在动;阶段推进时高亮顺移;`done` 后静止。
---
## Phase 8 —— 前端:「查看」进入进行中任务并实时观察
**目标**:弹窗「查看」→ 进入该任务,停在 Step2 实时看后台进度(气泡/活动席位随 SSE 刷新),
跑完自动呈现 Step3 结果。
**涉及文件(改)**
- `RoundtablePlanningPage.tsx`(接 `onView`)
- `useDraftPersistence.ts`(`handleLoadDraft` 增「带作业」分支)
- `useStep2Orchestration.ts`(观察者渲染)
**步骤**
1. `onView`:关弹窗 → `handleLoadDraft(draftId)` 恢复该草稿到 Step2(`useDraftPersistence.ts:308-368`),
但**跳过本地编排自启**(识别到有 running 作业 → 进观察者模式,对齐 Phase 5 的互斥标记)。
2. 订阅 `streamJob(jobId)`,把 `dialogue` 事件 append 进 `step2RoundtableDialogues`、`active` 事件
驱动 `activeSpeakerId` 高亮、`consensus` 更新百分比。
3. 命中 `awaiting_input`:在 Step2 弹出澄清输入(复用现有 `pendingClarification` UI),用户回答 →
`resumeJob(jobId, answer)`(而非本地 `submitClarification`)。
4. `done` 事件:用返回的 `DraftStep3Snapshot` 走 `hydrateStep3` + 允许切到 Step3 查看报告
(`useStep3Report.hydrateSaved` `708-767`);作业列表该项变「已完成」。
**验收**:从历史「查看」进入能实时看到后台在跑;澄清能在前端回答并续跑;跑完能看到最终报告。
---
## Phase 9 —— 边界、健壮性与收尾
1. **双跑互斥**:任一 draft 同一时刻只允许一个执行者(前端本地 or 后台作业)。加载草稿/进入 Step2
前先 `GET /api/roundtable-jobs?draftId=`,有 running 就进观察者模式。
2. **后台命中澄清**:`awaiting_input` 时历史下拉显示「待澄清」徽标;流程图弹窗显示「等待澄清」;
只有「查看」进去才能回答(或在弹窗里直接给一个回答框,调用 `resumeJob`)。
3. **取消**:流程图弹窗/Step2 提供「停止后台」→ `cancelJob`,`status→cancelled`,前端可恢复本地编辑。
4. **错误恢复**:`error` 事件展示 detail;允许「重试」= 重新 `startJob`(沿用已建 threadIds)。
5. **MAX_CYCLES / 预算**:后台图内兜底(对齐前端 `MAX_CYCLES=8`),到顶强制收敛并照常进 Step3。
6. **多 worker**:严格遵守 Phase 3 的「状态进 DB+checkpointer、SSE 走 loopback」;部署若多 worker
需 sticky 或全靠 DB 态(推荐后者)。
7. **清理**:`roundtable_jobs` cleanup cron(活跃保护、先删 CP 再删 DB),与 draft 生命周期对齐。
8. **历史列表性能**:draft 列表 join 作业 status 时注意 N+1;用一次 LEFT JOIN 取最新作业。
---
## 端到端验证清单(提测前逐条过)
- [ ] Step2 点「后台挂起」→ 立刻可离开页面 / 切到别的任务,后台继续跑。
- [ ] 历史下拉里该任务显示加载动画(转圈)。
- [ ] 点击进行中任务 → 大弹窗、横向派活链路、活动节点+连线在动、节点不同颜色。
- [ ] 阶段推进(leader→派活→共识→报告绘制)时弹窗高亮顺移。
- [ ] 弹窗「查看」→ 进入任务实时观察气泡/活动席位。
- [ ] 后台命中澄清 → 列表「待澄清」+ 弹窗提示 + 可回答续跑(`resumeJob`)。
- [ ] 一路自动跑到 Step3「绘制完成」,最终报告可在 Step3 查看,作业 `done`。
- [ ] 浏览器刷新 / 关页重开后,任务仍在后台跑、状态可恢复(真后台验证)。
- [ ] 取消后台 → `cancelled`,无残留双跑。
- [ ] 多 worker(或杀 worker)下 stream/resume 不丢会话。
- [ ] cleanup 干跑不误删活跃作业。
---
## 工作量与排期建议
| 阶段 | 内容 | 粗估 | 依赖 |
|------|------|------|------|
| P0 | 数据契约定型(本文 §2) | 0.5d | — |
| P1 | 后端作业表 + repo + 迁移 | 1d | P0 |
| **P2** | **后端编排图(移植循环)** | **3–5d(核心难点)** | P0,P1 |
| P3 | 后端控制 + SSE + cleanup | 1.5d | P1,P2 |
| P4 | 前端作业 API + 类型 + draft status | 0.5d | P3 |
| P5 | Step2 后台挂起按钮 + 互斥 | 1d | P4 |
| P6 | 历史下拉 loading 动画 | 0.5d | P4 |
| P7 | 派活链路流程图弹窗 | 1.5d | P4 |
| P8 | 「查看」实时观察 + 澄清续跑 | 1.5d | P5,P7 |
| P9 | 边界/健壮性/清理 | 1d | 全部 |
> 建议先打通 **P1→P2→P3** 的后端最小闭环(脚本验证图能后台跑到出报告),再做前端 P4–P8。
> P2 是整条链路成败的关键,务必先用脱离前端的探针脚本(仿 `scripts/probe_ai_writing_assistant.py`)跑通。
---
## 附:关键参考代码索引
- 前端编排循环(待移植到后端):`useStep2Orchestration.ts:992-1204,1228-1439,1468-1541`
- 共识判定 / 进 Step3:`useStep2Orchestration.ts:1091-1099`、`Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595`
- Step3 绘制完成标志:`useStep3Report.ts:51,165,560-596,636-652`
- 草稿模型 / API / 自动保存 / 加载恢复:`api/drafts.ts:25-110,184-225`、`useDraftPersistence.ts:248-316,308-368`
- 历史下拉 UI:`RoundtablePlanningPage.tsx:771-892`
- 后端单轮执行 / 派活 / 广播:`multi_agent.py:647-823,1076-1142,525-552,487-502`、loopback `271-293`
- AI 写作模板:`agents/ai_writing/graph.py`、`ai_writing_sessions/model.py:14-39`、`ai_writing.py`、`ai_writing_cleanup.py:307-367`、`AIWritingPage.tsx` 文件头注释
- 颜色体系复用:`lib/business-chain.ts:56-72`、`lib/roundtable-constants.ts`(`getAvatarTypeFor`)
- 流程图备选库:`@xyflow/react@12`(已安装)
```
@@ -44,18 +44,23 @@ import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import {
createMarkdownAnchorResolver,
injectInPageAnchorHandler,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import {
checkCodeFile,
getFileName,
isImageFile,
} from "@/core/utils/files";
import {
createMarkdownAnchorResolver,
injectInPageAnchorHandler,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { getBackendBaseURL } from "@/core/config";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -98,29 +103,36 @@ export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
threadId,
onClose,
}: {
className?: string;
filepath: string;
threadId: string;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
const isWriteFile = useMemo(() => {
const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
const filepath = useMemo(() => {
if (isWriteFile) {
if (isWriteFileVirtual) {
const url = new URL(filepathFromProps);
return decodeURIComponent(url.pathname);
}
return filepathFromProps;
}, [filepathFromProps, isWriteFile]);
}, [filepathFromProps, isWriteFileVirtual]);
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API。 */
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath);
}, [filepath, isWriteFileVirtual]);
const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill");
}, [filepath]);
const { isCodeFile, language } = useMemo(() => {
if (isWriteFile) {
if (useWriteFileStreaming) {
let nextLanguage = checkCodeFile(filepath).language;
nextLanguage ??= "text";
return { isCodeFile: true, language: nextLanguage };
@@ -129,18 +141,21 @@ export function ArtifactFileDetail({
return { isCodeFile: true, language: "markdown" };
}
return checkCodeFile(filepath);
}, [filepath, isWriteFile, isSkillFile]);
}, [filepath, useWriteFileStreaming, isSkillFile]);
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
enabled: isCodeFile && !useWriteFileStreaming,
});
const canEditArtifact =
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
language === "markdown" &&
!useWriteFileStreaming &&
!isSkillFile &&
!isMock;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
@@ -148,12 +163,13 @@ export function ArtifactFileDetail({
const isHtmlStreaming = language === "html" && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (language === "html") return "HTML";
if (language === "markdown") return "MD";
if (isSkillFile) return "SKILL";
if (language) return language.toUpperCase();
return "FILE";
}, [isSkillFile, language]);
}, [filepath, isSkillFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
@@ -463,7 +479,7 @@ export function ArtifactFileDetail({
{fileTypeLabel}
</span>
<ArtifactTitle className="min-w-0">
{isWriteFile ? (
{useWriteFileStreaming ? (
<div className="truncate px-3">{getFileName(filepath)}</div>
) : hasMultipleArtifacts ? (
<Select value={filepath} onValueChange={select}>
@@ -540,7 +556,7 @@ export function ArtifactFileDetail({
onClick={handleSaveArtifact}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
@@ -559,7 +575,7 @@ export function ArtifactFileDetail({
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
if (isWriteFile) {
if (useWriteFileStreaming) {
handleOpenWriteFileInNewTab();
return;
}
@@ -597,7 +613,7 @@ export function ArtifactFileDetail({
tooltip={t.clipboard.copyToClipboard}
/>
)}
{!isWriteFile && language === "markdown" && (
{!useWriteFileStreaming && language === "markdown" && (
<ArtifactAction
icon={isExportingWord ? LoaderIcon : FileType2Icon}
label="另存为 Word"
@@ -611,7 +627,7 @@ export function ArtifactFileDetail({
label={t.common.download}
tooltip={t.common.download}
onClick={async () => {
if (isWriteFile) {
if (useWriteFileStreaming) {
handleDownloadWriteFile();
return;
}
@@ -636,7 +652,7 @@ export function ArtifactFileDetail({
className="hover:bg-background"
icon={XIcon}
label={t.common.close}
onClick={() => setOpen(false)}
onClick={() => (onClose ? onClose() : setOpen(false))}
tooltip={t.common.close}
/>
</ArtifactActions>
@@ -669,6 +685,8 @@ export function ArtifactFileDetail({
<ArtifactFilePreview
content={desensitize(displayContent ?? "")}
language={language ?? "text"}
threadId={threadId}
publicShareCode={publicShareCode}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
@@ -709,14 +727,57 @@ export function ArtifactFileDetail({
);
}
function SandboxPreviewImage({
src,
alt,
threadId,
publicShareCode,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
publicShareCode?: string;
}) {
if (!src || typeof src !== "string") {
return null;
}
let url = src;
if (src.startsWith("/mnt/")) {
if (publicShareCode) {
const normalized = src.startsWith("/") ? src : `/${src}`;
url = `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
} else {
url = resolveArtifactURL(src, threadId);
}
}
return (
<img
{...props}
className={cn(
"my-3 max-w-full overflow-hidden rounded-lg",
props.className,
)}
src={url}
alt={alt}
/>
);
}
export function ArtifactFilePreview({
content,
language,
threadId,
publicShareCode,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
threadId: string;
publicShareCode?: string;
animated?: boolean;
activeCursor?: boolean;
}) {
@@ -812,11 +873,20 @@ export function ArtifactFilePreview({
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={markdownComponents}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={{
...markdownComponents,
img: (props) => (
<SandboxPreviewImage
{...props}
threadId={threadId}
publicShareCode={publicShareCode}
/>
),
}}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
@@ -0,0 +1,93 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
function filenameFromContentDisposition(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
if (star) {
const raw = star[1].replace(/["']/g, "").trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
const plain = /filename="?([^";\n]+)"?/i.exec(header);
return plain ? plain[1].trim() : null;
}
function sanitizeFallbackFilename(title: string | undefined): string {
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
}
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "导出 Word 失败" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
throw new Error("导出 Word 失败");
}
const blob = await response.blob();
const filename =
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
fallbackFilename;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
/**
* 通用 Markdown → Word 导出(Gateway `writing` 路由,与 `/api/writing/rewrite` 同级)。
*
* POST /api/writing/export/docx
* Body: { title?: string, markdown: string }
*
* 返回 `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
* 文件名在 `Content-Disposition` 中。
*/
export async function downloadMarkdownDocxFromBackend(options: {
title?: string;
markdown: string;
}): Promise<void> {
const markdown = options.markdown.trim();
if (!markdown) {
throw new Error("文档内容为空");
}
const fallbackFilename = sanitizeFallbackFilename(options.title);
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/writing/export/docx`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: options.title?.trim() || undefined,
markdown,
}),
});
if (response.status === 404) {
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
return;
}
await downloadBlobResponse(response, fallbackFilename);
} catch (err) {
if (err instanceof TypeError) {
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
return;
}
throw err;
}
}
+6 -1
View File
@@ -60,7 +60,12 @@ export async function downloadArtifactFile({
filenameFromContentDisposition(cd) ?? getFileName(filepath);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
const downloadBlob =
contentType && blob.type !== contentType
? new Blob([await blob.arrayBuffer()], { type: contentType })
: blob;
const objectUrl = URL.createObjectURL(downloadBlob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
+25 -8
View File
@@ -3,9 +3,24 @@ import { useMemo } from "react";
import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import { updateArtifactContent } from "./api";
function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
}
try {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return !isImageFile(path);
} catch {
return true;
}
}
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
if (!toolCallId) {
return false;
@@ -70,16 +85,16 @@ export function useArtifactContent({
threadId: string;
enabled?: boolean;
}) {
const isWriteFile = useMemo(() => {
return filepath.startsWith("write-file:");
const useWriteFileStreaming = useMemo(() => {
return isWriteFileStreamingUrl(filepath);
}, [filepath]);
const { thread, isMock, publicShareCode } = useThread();
const content = useMemo(() => {
if (isWriteFile) {
if (useWriteFileStreaming) {
return loadArtifactContentFromToolCall({ url: filepath, thread });
}
return null;
}, [filepath, isWriteFile, thread]);
}, [filepath, useWriteFileStreaming, thread]);
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
@@ -92,7 +107,7 @@ export function useArtifactContent({
staleTime: 5 * 60 * 1000,
});
const pendingEdit = useMemo(() => {
if (isWriteFile) {
if (useWriteFileStreaming) {
return null;
}
return applyPendingTextEdit({
@@ -100,11 +115,13 @@ export function useArtifactContent({
filepath,
thread,
});
}, [data?.content, filepath, isWriteFile, thread]);
}, [data?.content, filepath, useWriteFileStreaming, thread]);
return {
content: isWriteFile ? content : (pendingEdit?.content ?? data?.content),
url: isWriteFile ? undefined : data?.url,
content: useWriteFileStreaming
? content
: (pendingEdit?.content ?? data?.content),
url: useWriteFileStreaming ? undefined : data?.url,
isLoading,
isApplyingEdit: pendingEdit?.kind === "editing",
error,
+17
View File
@@ -151,6 +151,23 @@ export function getFileExtension(filepath: string) {
return filepath.split(".").pop()!.toLocaleLowerCase();
}
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"bmp",
"ico",
"tiff",
"heic",
]);
export function isImageFile(filepath: string): boolean {
return IMAGE_EXTENSIONS.has(getFileExtension(filepath));
}
export function checkCodeFile(
filepath: string,
):
@@ -1,69 +1,12 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownDocxFromBackend } from "@/core/api/markdown-docx-export";
function filenameFromContentDisposition(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
if (star) {
const raw = star[1].replace(/["']/g, "").trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
const plain = /filename="?([^";\n]+)"?/i.exec(header);
return plain ? plain[1].trim() : null;
}
function sanitizeFallbackFilename(title: string | undefined): string {
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
}
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "导出 Word 失败" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
throw new Error("导出 Word 失败");
}
const blob = await response.blob();
const filename =
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
fallbackFilename;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
/** 调用后端导出 AI 写作草稿为 Word,仅传正文 markdown,不传目录/大纲。 */
/** AI 写作草稿导出 Word(走通用 `/api/writing/export/docx`)。 */
export async function downloadAIWritingDocx(options: {
title?: string;
draftMarkdown: string;
}): Promise<void> {
const draftMarkdown = options.draftMarkdown.trim();
if (!draftMarkdown) {
throw new Error("文档内容为空");
}
const response = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/export/docx`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: options.title?.trim() || undefined,
draft_markdown: draftMarkdown,
}),
await downloadMarkdownDocxFromBackend({
title: options.title,
markdown: options.draftMarkdown,
});
await downloadBlobResponse(response, sanitizeFallbackFilename(options.title));
}
@@ -1,6 +1,7 @@
import { FileText, PenLine } from 'lucide-react';
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
import { useAIWritingHistoryOptional } from '../../contexts/AIWritingHistoryContext';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
import { assembleStreamingDraftMarkdown } from '../../hooks/useAIWritingStream';
@@ -32,8 +33,12 @@ function DraftWaitingPlaceholder() {
export function AIWritingDraftPanel() {
const writing = useAIWritingOptional();
const history = useAIWritingHistoryOptional();
const isHistoryMode = history?.isHistoryMode ?? false;
const { artifact } = useCanvasGraphContext();
const { isDraftEditable } = useCanvasUIContext();
// 历史回看与写作完成态一致:Tiptap + 目录;流式生成中仍走 Markdown 预览
const showCompletedDraftView = isDraftEditable || isHistoryMode;
const writingStatus = writing?.state.status;
// 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布
@@ -96,7 +101,8 @@ export function AIWritingDraftPanel() {
<div className="flex-1 overflow-hidden">
<TextArtifactRenderer
content={textContent}
isStreaming={!isDraftEditable}
isStreaming={!showCompletedDraftView}
readOnly={isHistoryMode}
/>
</div>
</div>
@@ -13,8 +13,8 @@ import {
} from '@/components/ui/dialog';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../contexts/AIWritingContext';
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
@@ -74,15 +74,13 @@ export function AIWritingPanel({
hideAdvancedFields?: boolean;
} = {}) {
const navigate = useNavigate();
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
// History view state — null means normal writing mode
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
const [reqDialogOpen, setReqDialogOpen] = useState(false);
const isIdle = state.status === 'idle';
@@ -178,27 +176,13 @@ export function AIWritingPanel({
}, []);
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
setHistorySession(session);
// Push historical draft to the right panel
if (session.draft_markdown) {
setArtifact({
currentIndex: 0,
contents: [{
index: 0,
type: 'text',
title: session.draft_title ?? session.title,
fullMarkdown: session.draft_markdown,
}],
});
} else {
setArtifact(null);
}
}, [setArtifact]);
void openHistory(session);
}, [openHistory]);
const handleNewSession = useCallback(() => {
setHistorySession(null);
closeHistory();
reset();
}, [reset]);
}, [closeHistory, reset]);
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
@@ -246,60 +230,27 @@ export function AIWritingPanel({
}
}, [refresh]);
const showNewButton = historySession !== null || state.status !== 'idle';
// History view mode
if (historySession) {
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
{historySession.draft_title ?? historySession.title}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate('/page/canvas/article-types')}
title="文章类型配置"
>
<Settings2 className="size-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
<Plus className="size-3" />
新建对话
</Button>
<AIWritingHistoryDropdown
sessions={sessions}
selectedId={historySession.id}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onDelete={remove}
onRename={rename}
onRefresh={refresh}
/>
</div>
</div>
<AIWritingHistoryView session={historySession} />
</div>
);
}
const showNewButton = isHistoryMode || state.status !== 'idle';
return (
<div className="flex flex-col h-full overflow-hidden bg-background">
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
{!isHistoryMode && (
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
)}
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span
className={cn(
'text-sm font-semibold shrink-0',
!isIdle && 'cursor-pointer hover:text-primary transition-colors',
'text-sm font-semibold shrink-0 min-w-0 truncate',
isHistoryMode && 'flex-1 mr-2',
!isIdle && !isHistoryMode && 'cursor-pointer hover:text-primary transition-colors',
)}
onClick={() => { if (!isIdle && state.request) setReqDialogOpen(true); }}
onClick={() => { if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }}
title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
>
AI 智能写作
{isHistoryMode
? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
: 'AI 智能写作'}
</span>
<div className="flex items-center gap-1.5">
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
@@ -365,7 +316,7 @@ export function AIWritingPanel({
)}
<AIWritingHistoryDropdown
sessions={sessions}
selectedId={null}
selectedId={historySession?.id ?? null}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onDelete={remove}
@@ -378,8 +329,13 @@ export function AIWritingPanel({
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
<div className="flex-1 min-h-0 relative">
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
{/* 历史回看:左侧时间线(与实时写作同一套 AIWritingTimeline) */}
{isHistoryMode && historySession && (
<AIWritingHistoryView session={historySession} />
)}
{/* 未开始时显示表单 */}
{isIdle && (
{isIdle && !isHistoryMode && (
<AIWritingForm
onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields}
@@ -402,7 +358,7 @@ export function AIWritingPanel({
)}
{/* 运行中或完成后显示时间线 */}
{!isIdle && (
{!isIdle && !isHistoryMode && (
<AIWritingTimeline
events={state.progressEvents}
completedInterventions={state.completedInterventions}
@@ -507,7 +463,7 @@ export function AIWritingPanel({
</div>{/* 相对定位容器结束 */}
{/* 状态栏 */}
{!isIdle && (
{!isIdle && !isHistoryMode && (
<div
className={cn(
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
@@ -22,6 +22,8 @@ import type { RewriteAction, RewriteTone } from "../../api/rewrite";
interface TextArtifactRendererProps {
content: CanvasArtifactText;
isStreaming?: boolean;
/** 完成态只读(如历史回看):保留目录 + 排版,禁用编辑与 AI 润色 */
readOnly?: boolean;
}
interface PanelPos {
@@ -38,7 +40,7 @@ interface LastAction {
customInstruction?: string;
}
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
export function TextArtifactRenderer({ content, isStreaming, readOnly = false }: TextArtifactRendererProps) {
const { artifact, setArtifact } = useCanvasGraphContext();
const { writingSelection } = useCanvasUIContext();
@@ -72,7 +74,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
const editor = useWritingEditor({
markdown: content.fullMarkdown,
onChange: handleEditorChange,
editable: !isStreaming,
editable: !isStreaming && !readOnly,
placeholder: "开始写作…",
});
@@ -270,10 +272,15 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
);
}
// ── Edit mode ─────────────────────────────────────────────────────────
// ── Edit mode(写作完成态 / 历史回看只读)────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden">
<WritingToolbar editor={editor} markdown={content.fullMarkdown} title={content.title} />
<WritingToolbar
editor={editor}
markdown={content.fullMarkdown}
title={content.title}
readOnly={readOnly}
/>
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
@@ -282,7 +289,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
{/* 文档滚动区 */}
<div ref={setDocScrollEl} className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-10 pt-10">
{editor && (
{editor && !readOnly && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
@@ -294,7 +301,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
</div>
{/* Phase: AI action menu */}
{phase === "menu" && (
{!readOnly && phase === "menu" && (
<WritingAiMenu
pos={panelPos}
status={status}
@@ -307,7 +314,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
)}
{/* Phase: result preview */}
{phase === "preview" && (
{!readOnly && phase === "preview" && (
<WritingRewritePreview
pos={panelPos}
status={status}
@@ -69,9 +69,11 @@ interface WritingToolbarProps {
markdown?: string;
/** 文档标题,用作 .docx 文件名与封面 */
title?: string;
/** 历史回看等场景:仅保留导出,隐藏格式工具 */
readOnly?: boolean;
}
export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps) {
export function WritingToolbar({ editor, markdown, title, readOnly = false }: WritingToolbarProps) {
const [exporting, setExporting] = useState(false);
if (!editor) return null;
@@ -94,6 +96,42 @@ export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps)
}
};
if (readOnly) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0">
<span className="text-xs text-muted-foreground">历史文稿(只读)</span>
<div className="ml-auto flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
void handleExport();
}}
disabled={!canExport}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"disabled:pointer-events-none disabled:opacity-40",
)}
>
{exporting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
导出 Word
</TooltipContent>
</Tooltip>
</div>
</div>
);
}
return (
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
{/* Block type */}
@@ -0,0 +1,82 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
import { getAIWritingSession } from '../api/ai-writing-sessions';
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
import { useCanvasGraphContext } from './CanvasGraphContext';
import { useCanvasUIContext } from './CanvasUIContext';
type AIWritingHistoryContextValue = {
historySession: AIWritingSessionSummary | null;
isHistoryMode: boolean;
openHistory: (session: AIWritingSessionSummary) => Promise<void>;
closeHistory: () => void;
};
const AIWritingHistoryContext = createContext<AIWritingHistoryContextValue | null>(null);
export function AIWritingHistoryProvider({ children }: { children: ReactNode }) {
const { setArtifact } = useCanvasGraphContext();
const { setIsDraftEditable } = useCanvasUIContext();
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
const openHistory = useCallback(async (session: AIWritingSessionSummary) => {
let full = session;
if (!session.draft_markdown || !session.transcript) {
try {
full = await getAIWritingSession(session.id);
} catch {
// 列表项已够用,拉详情失败则继续用摘要
}
}
setHistorySession(full);
// 完成态布局(Tiptap + 左侧目录),由 DraftPanel 根据 isHistoryMode 只读展示
setIsDraftEditable(true);
if (full.draft_markdown) {
setArtifact({
currentIndex: 0,
contents: [{
index: 0,
type: 'text',
title: full.draft_title ?? full.title,
fullMarkdown: full.draft_markdown,
}],
});
} else {
setArtifact(null);
}
}, [setArtifact, setIsDraftEditable]);
const closeHistory = useCallback(() => {
setHistorySession(null);
setArtifact(null);
setIsDraftEditable(true);
}, [setArtifact, setIsDraftEditable]);
const value = useMemo(
() => ({
historySession,
isHistoryMode: historySession !== null,
openHistory,
closeHistory,
}),
[historySession, openHistory, closeHistory],
);
return (
<AIWritingHistoryContext.Provider value={value}>
{children}
</AIWritingHistoryContext.Provider>
);
}
export function useAIWritingHistory() {
const ctx = useContext(AIWritingHistoryContext);
if (!ctx) {
throw new Error('useAIWritingHistory must be used within AIWritingHistoryProvider');
}
return ctx;
}
export function useAIWritingHistoryOptional() {
return useContext(AIWritingHistoryContext);
}
@@ -92,6 +92,7 @@ import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
import { WritingFormProvider } from '../contexts/WritingFormContext';
import {
ResizableHandle,
@@ -131,8 +132,10 @@ function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
if (state.status === 'idle') {
// 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿)
if (state.status === 'idle' && !isHistoryMode) {
return (
<ResizablePanelGroup
orientation="horizontal"
@@ -206,6 +209,7 @@ export default function AIWritingPage({
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider>
<TooltipProvider delayDuration={300}>
@@ -217,6 +221,7 @@ export default function AIWritingPage({
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</AIWritingHistoryProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
@@ -41,6 +41,7 @@ import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
import '@/roundtable-planning/styles/roundtable-planning.css';
import { cn } from '@/lib/utils';
@@ -324,6 +325,15 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
{/* Phase 7 临时预览入口:派活链路流程图弹窗(假数据自测,接通后台后可删) */}
<Route
path="roundtable/chain-preview"
element={
<WorkspaceLayout {...layoutProps}>
<DispatchChainPreviewPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
@@ -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 { JobStatus } from "@/roundtable-planning/lib/job-types";
/**
* Step 1 snapshot. Shapes for the chat / timeline lists stay loose (`unknown[]`)
@@ -80,6 +81,13 @@ export interface DraftMeta {
furthestStep: 1 | 2 | 3;
createdAt: string;
updatedAt: string;
/**
* Latest background-job status for this draft (Phase 6 history-dropdown
* loading badge). Undefined/null when the draft has never been backgrounded.
*/
status?: JobStatus | null;
/** Latest background job id (for opening the dispatch-chain modal / 查看). */
jobId?: string | null;
}
/** Full record including the step snapshots. */
@@ -129,6 +137,8 @@ interface ServerDraftMeta {
furthest_step: number;
created_at: string | null;
updated_at: string | null;
status?: string | null;
job_id?: string | null;
}
interface ServerDraftFull extends ServerDraftMeta {
@@ -145,6 +155,8 @@ function toMeta(s: ServerDraftMeta): DraftMeta {
furthestStep: step,
createdAt: s.created_at ?? "",
updatedAt: s.updated_at ?? "",
status: (s.status as JobStatus | null | undefined) ?? null,
jobId: s.job_id ?? null,
};
}
@@ -0,0 +1,195 @@
/**
* 圆桌后台作业 API 客户端(Phase 4)。
*
* 对接后端 `/api/roundtable-jobs`(Phase 3a):启动「后台挂起」作业、查快照、订阅
* SSE 进度、回答澄清续跑、取消。后端响应已是 camelCase(`RoundtableJobResponse`),
* 故无需 snake_case 转换;唯一加工是给 `dispatchChain` 节点补 `avatarType`(后端不下发,
* 由前端 `getAvatarTypeFor(agentId, order)` 派生,避免把配色表移植到后端而漂移)。
*
* SSE 走「后端轮询 job 表后下发整份快照」的模型(见后端注释):每帧 `data: {event:
* "progress", ...job}`,前端拿到的就是最新整份 `RoundtableJob`,直接渲染即可,无需做
* 增量事件归并。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import {
isTerminalJobStatus,
type DispatchChainNode,
type RoundtableJob,
type StartJobPayload,
} from "../lib/job-types";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-jobs`;
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;
}
/** 给链路节点补 avatarType(后端不下发)。 */
function withAvatar(nodes: unknown): DispatchChainNode[] {
if (!Array.isArray(nodes)) return [];
return nodes.map((n, i) => {
const node = (n ?? {}) as Partial<DispatchChainNode> & { agentId?: string; order?: number };
const agentId = String(node.agentId ?? "");
const order = typeof node.order === "number" ? node.order : i + 1;
return {
agentId,
name: String(node.name ?? agentId),
order,
state: (node.state as DispatchChainNode["state"]) ?? "pending",
avatarType: node.avatarType || getAvatarTypeFor(agentId, order - 1),
...(node.lastSpokeCycle != null ? { lastSpokeCycle: node.lastSpokeCycle } : {}),
};
});
}
/** 后端快照 → 前端 RoundtableJob(容错 + 补 avatarType)。 */
function mapJob(raw: Record<string, unknown>): RoundtableJob {
return {
id: String(raw.id ?? ""),
draftId: (raw.draftId as string | null) ?? null,
status: (raw.status as RoundtableJob["status"]) ?? "queued",
phase: (raw.phase as RoundtableJob["phase"]) ?? "initializing",
cycle: typeof raw.cycle === "number" ? raw.cycle : 0,
dispatchChain: withAvatar(raw.dispatchChain),
activeAgentId: (raw.activeAgentId as string | null) ?? null,
consensusPercentage: typeof raw.consensusPercentage === "number" ? raw.consensusPercentage : 0,
pendingClarification: (raw.pendingClarification as string | null) ?? null,
orchestrationMode: (raw.orchestrationMode as RoundtableJob["orchestrationMode"]) ?? "recommend",
chain: (raw.chain as RoundtableJob["chain"]) ?? null,
error: (raw.error as string | null) ?? null,
createdAt: (raw.createdAt as string | null) ?? null,
updatedAt: (raw.updatedAt as string | null) ?? null,
};
}
function toStartBody(p: StartJobPayload) {
return {
draftId: p.draftId ?? null,
intent: p.intent,
agents: p.agents,
model: p.model,
orchestrationMode: p.orchestrationMode ?? "recommend",
chain: p.chain ?? null,
seedMessage: p.seedMessage,
threadIds: p.threadIds ?? null,
coordinatorName: p.coordinatorName ?? "roundtable-coordinator",
};
}
// ── CRUD / 控制 ───────────────────────────────────────────────────────────────
/** 启动一个后台作业。 */
export async function startJob(payload: StartJobPayload): Promise<RoundtableJob> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toStartBody(payload)),
});
return mapJob(await readJson<Record<string, unknown>>(res, "start job"));
}
/** 查作业快照。404 → null。 */
export async function getJob(jobId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}`, { method: "GET" });
if (res.status === 404) return null;
return mapJob(await readJson<Record<string, unknown>>(res, "get job"));
}
/** 查某草稿最近一个作业。无 → null。 */
export async function getJobByDraft(draftId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}?draft_id=${encodeURIComponent(draftId)}`, { method: "GET" });
const data = await readJson<Record<string, unknown> | null>(res, "get job by draft");
return data ? mapJob(data) : null;
}
/** 回答澄清后续跑。 */
export async function resumeJob(jobId: string, answer: string): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/resume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answer }),
});
return mapJob(await readJson<Record<string, unknown>>(res, "resume job"));
}
/** 取消作业。 */
export async function cancelJob(jobId: string): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel`, { method: "POST" });
return mapJob(await readJson<Record<string, unknown>>(res, "cancel job"));
}
// ── SSE 进度订阅 ─────────────────────────────────────────────────────────────
export interface StreamJobHandlers {
/** 每收到一帧整份快照就回调(含首帧)。 */
onProgress: (job: RoundtableJob) => void;
/** 终态(done/error/cancelled)时回调一次(在对应的 onProgress 之后)。 */
onDone?: (job: RoundtableJob) => void;
/** 流错误 / 作业不存在。 */
onError?: (detail: string) => void;
}
/**
* 订阅作业 SSE 进度。返回 unsubscribe(中断连接)。
*
* 终态帧后后端会主动关闭流;本函数也在收到终态快照时调 onDone,调用方可据此 unsubscribe。
*/
export function streamJob(jobId: string, handlers: StreamJobHandlers): () => void {
const controller = new AbortController();
void (async () => {
try {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/stream`, {
method: "GET",
signal: controller.signal,
});
if (!res.ok || !res.body) {
handlers.onError?.(`stream failed (${res.status})`);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
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 json = line.slice("data:".length).trim();
if (!json) continue;
let frame: Record<string, unknown>;
try {
frame = JSON.parse(json) as Record<string, unknown>;
} catch {
continue;
}
if (frame.event === "error") {
handlers.onError?.(String(frame.detail ?? "unknown stream error"));
continue;
}
const job = mapJob(frame);
handlers.onProgress(job);
if (isTerminalJobStatus(job.status)) handlers.onDone?.(job);
}
}
} catch (err) {
if (!controller.signal.aborted) {
handlers.onError?.(err instanceof Error ? err.message : String(err));
}
}
})();
return () => controller.abort();
}
@@ -0,0 +1,59 @@
import { FileText, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { downloadMarkdownDocxFromBackend } from "@/core/api/markdown-docx-export";
import { cn } from "@/lib/utils";
export function ConsensusDocxDownloadButton({
markdown,
title = "圆桌总控最终结论",
className,
size = "sm",
}: {
markdown: string;
title?: string;
className?: string;
size?: "sm" | "md";
}) {
const [busy, setBusy] = useState(false);
const canExport = Boolean(markdown?.trim());
const handleClick = async () => {
if (!canExport) {
toast.error("暂无可导出的结论内容");
return;
}
setBusy(true);
try {
await downloadMarkdownDocxFromBackend({ title, markdown });
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "导出 Word 失败");
} finally {
setBusy(false);
}
};
return (
<button
type="button"
onClick={() => void handleClick()}
disabled={!canExport || busy}
title={canExport ? "下载为 Word" : "暂无结论内容"}
className={cn(
"inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-white font-bold text-slate-700 transition",
"hover:bg-slate-50 hover:border-primary/30 disabled:cursor-not-allowed disabled:opacity-50",
size === "sm" ? "px-2.5 py-1 text-[10px]" : "px-3 py-1.5 text-[10.5px]",
className,
)}
>
{busy ? (
<Loader2 className={cn("animate-spin", size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5")} />
) : (
<FileText className={cn(size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5", "text-primary")} />
)}
下载 Word
</button>
);
}
@@ -0,0 +1,92 @@
/**
* 派活链路流程图弹窗 · 真后台作业容器(Phase 6/8)。
*
* 给定 ``jobId``:先拉一帧快照让弹窗立刻有内容,随后订阅 SSE(``streamJob``)实时刷新,
* 把最新 ``RoundtableJob`` 喂给纯展示组件 ``DispatchChainModal``。关闭/卸载时退订。
*
* ``onView`` 透传给 footer「查看」按钮(Phase 8 接「进入正在进行的任务」)。
*/
import { useEffect, useState } from "react";
import { DispatchChainModal } from "./DispatchChainModal";
import { cancelJob, getJob, resumeJob, streamJob } from "../api/roundtable-jobs";
import type { RoundtableJob } from "../lib/job-types";
export interface DispatchChainJobModalProps {
jobId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
onView?: (job: RoundtableJob) => void;
}
export function DispatchChainJobModal({
jobId,
open,
onOpenChange,
title,
onView,
}: DispatchChainJobModalProps) {
const [job, setJob] = useState<RoundtableJob | null>(null);
useEffect(() => {
if (!open || !jobId) {
setJob(null);
return;
}
let cancelled = false;
// 首帧快照:弹窗一打开就有内容,不必等 SSE 第一帧。
void getJob(jobId)
.then((j) => {
if (!cancelled && j) setJob(j);
})
.catch(() => {});
const unsubscribe = streamJob(jobId, {
onProgress: (j) => {
if (!cancelled) setJob(j);
},
onError: () => {
/* 流错误静默:保留已拿到的最后一帧;可后续加 toast */
},
});
return () => {
cancelled = true;
unsubscribe();
};
}, [open, jobId]);
return (
<DispatchChainModal
open={open}
onOpenChange={onOpenChange}
title={title}
status={job?.status ?? "queued"}
phase={job?.phase ?? "initializing"}
cycle={job?.cycle ?? 0}
nodes={job?.dispatchChain ?? []}
consensusPercentage={job?.consensusPercentage ?? 0}
onView={job && onView ? () => onView(job) : undefined}
error={job?.error}
pendingClarification={job?.pendingClarification}
onResume={
jobId
? (answer) => {
// 乐观更新为 running,随后 SSE 会推真状态续上。
void resumeJob(jobId, answer)
.then((j) => setJob(j))
.catch(() => {});
}
: undefined
}
onCancel={
jobId
? () => {
void cancelJob(jobId)
.then((j) => setJob(j))
.catch(() => {});
}
: undefined
}
/>
);
}
@@ -0,0 +1,402 @@
/**
* 派活链路流程图弹窗(Phase 7)。
*
* 大弹窗 + 横向流程图:总控 ──▶ 席位1 ──▶ 席位2 ──▶ … ──▶[共识]──▶[报告绘制]。
* - 节点 = 智能体席位(头像 + 名称),按 `getAvatarTypeFor` 取不同颜色;
* - 正在发言的节点(state==='active')和它**左侧的连接线**带加载动画(脉冲光环 + 流动虚线);
* - 已完成节点打勾,未开始节点置灰;
* - footer 显示 轮次/状态 + 「查看」按钮 → 进入正在进行的任务(onView)。
*
* 本组件是**纯展示组件**:所有状态由 props 传入。Phase 8 会接 `streamJob` 把后台 SSE
* 进度喂进来;当前可由 `DispatchChainPreviewPage` 用假数据 + 定时器模拟驱动预览。
*
* 颜色 / 头像复用圆桌既有体系:`getAvatar`(Avatars.tsx)+ `chainTagClasses`
* (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段
* (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。
*/
import { Fragment, useState } from "react";
import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars";
import { chainTagClasses } from "../lib/business-chain";
import type {
ChainNodeState,
DispatchChainNode,
JobPhase,
JobStatus,
} from "../lib/job-types";
export interface DispatchChainModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 任务标题。 */
title?: string;
status: JobStatus;
phase: JobPhase;
/** 当前轮次(0..maxCycles)。 */
cycle: number;
/** 最大轮次(默认 8,对齐 MAX_CYCLES)。 */
maxCycles?: number;
/** 派活链路节点(横向排列)。 */
nodes: DispatchChainNode[];
/** 共识百分比(0..100)。 */
consensusPercentage: number;
/** 「查看」按钮回调 → 进入正在进行的任务 / 查看报告。 */
onView?: () => void;
/** 命中澄清时的问题文本(status==='awaiting_input')。 */
pendingClarification?: string | null;
/** 提交澄清回答 → 续跑。 */
onResume?: (answer: string) => void;
/** 停止/取消作业。 */
onCancel?: () => void;
/** 出错详情(status==='error')。 */
error?: string | null;
}
const _NON_TERMINAL: ReadonlySet<JobStatus> = new Set<JobStatus>(["queued", "running", "awaiting_input"]);
const STATUS_LABEL: Record<JobStatus, string> = {
queued: "排队中",
running: "进行中",
awaiting_input: "等待澄清",
done: "已完成",
error: "出错",
cancelled: "已取消",
};
const PHASE_LABEL: Record<JobPhase, string> = {
initializing: "初始化席位…",
leader_thinking: "总控决策派活中…",
dispatching: "席位发言中…",
consensus: "共识达成",
report_drawing: "结果绘制中…",
report_done: "绘制完成",
};
export function DispatchChainModal({
open,
onOpenChange,
title = "多智能体研讨",
status,
phase,
cycle,
maxCycles = 8,
nodes,
consensusPercentage,
onView,
pendingClarification,
onResume,
onCancel,
error,
}: DispatchChainModalProps) {
const [answer, setAnswer] = useState("");
// 总控节点:初始化 / 决策派活阶段高亮
const coordinatorState: ChainNodeState =
phase === "initializing" || phase === "leader_thinking" ? "active" : "done";
// 共识里程碑
const consensusDone =
consensusPercentage >= 100 ||
phase === "consensus" ||
phase === "report_drawing" ||
phase === "report_done" ||
status === "done";
const consensusActive = phase === "consensus";
// 报告绘制里程碑
const reportActive = phase === "report_drawing";
const reportDone = phase === "report_done" || status === "done";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[88vh] flex-col gap-0 sm:max-w-5xl">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
{status === "running" && (
<Loader2Icon className="size-5 shrink-0 animate-spin text-primary" />
)}
<span className="truncate">{title}</span>
<span
className={cn(
"ml-1 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold",
status === "running" && "bg-primary/10 text-primary",
status === "awaiting_input" &&
"bg-amber-500/10 text-amber-600 dark:text-amber-400",
status === "done" &&
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
status === "error" && "bg-destructive/10 text-destructive",
(status === "queued" || status === "cancelled") &&
"bg-muted text-muted-foreground",
)}
>
{STATUS_LABEL[status]}
</span>
</DialogTitle>
<DialogDescription>{PHASE_LABEL[phase]}</DialogDescription>
</DialogHeader>
{/* 横向流程图(横向滚动) */}
<div className="custom-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden py-10">
<div className="flex min-w-max items-start gap-0 px-6">
{/* 总控节点 */}
<FlowNode
label="总控协调"
avatarType="human"
state={coordinatorState}
/>
{/* 席位节点 */}
{nodes.map((node) => (
<Fragment key={node.agentId}>
<Connector
active={node.state === "active"}
done={node.state === "done"}
/>
<FlowNode
label={node.name}
avatarType={node.avatarType}
state={node.state}
order={node.order}
/>
</Fragment>
))}
{/* 共识里程碑 */}
<Connector active={consensusActive} done={consensusDone} />
<MilestoneNode
label="共识达成"
emoji="🤝"
state={
consensusActive ? "active" : consensusDone ? "done" : "pending"
}
/>
{/* 报告绘制里程碑 */}
<Connector active={reportActive} done={reportDone} />
<MilestoneNode
label="结果绘制"
emoji="📊"
state={reportActive ? "active" : reportDone ? "done" : "pending"}
/>
</div>
</div>
{/* 出错详情 */}
{status === "error" && error && (
<div className="shrink-0 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
{/* 澄清作答(status==='awaiting_input')→ resumeJob 续跑 */}
{status === "awaiting_input" && pendingClarification && onResume && (
<div className="shrink-0 pt-3">
<div className="rounded-xl border border-amber-300/50 bg-amber-50/60 p-3 dark:border-amber-700/40 dark:bg-amber-950/20">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-amber-600 dark:text-amber-400">
<span className="inline-block size-1.5 rounded-full bg-amber-400 animate-pulse" />
总控需要补充信息,请回答后继续
</div>
<p className="mb-2 whitespace-pre-wrap text-sm text-foreground/80">{pendingClarification}</p>
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
rows={2}
placeholder="输入你的回答…"
className="mb-2 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary/30"
/>
<div className="flex justify-end">
<Button
size="sm"
disabled={!answer.trim()}
onClick={() => {
onResume(answer.trim());
setAnswer("");
}}
className="gap-1.5"
>
<SendIcon className="size-3.5" />
提交并继续
</Button>
</div>
</div>
</div>
)}
<DialogFooter className="shrink-0 flex-row items-center justify-between gap-2 border-t pt-3 sm:justify-between">
<span className="text-xs text-muted-foreground">
轮次 {Math.min(cycle, maxCycles)}/{maxCycles}
<span className="mx-2 text-muted-foreground/40">·</span>
共识 {Math.round(consensusPercentage)}%
<span className="mx-2 text-muted-foreground/40">·</span>
{PHASE_LABEL[phase]}
</span>
<div className="flex items-center gap-2">
{onCancel && _NON_TERMINAL.has(status) && (
<Button variant="outline" size="sm" onClick={onCancel} className="text-muted-foreground">
停止
</Button>
)}
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
{status === "done" ? "查看报告" : "查看"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── 链路节点(席位 / 总控)─────────────────────────────────────────────────
function FlowNode({
label,
avatarType,
state,
order,
}: {
label: string;
avatarType: string;
state: ChainNodeState;
order?: number;
}) {
const tag = chainTagClasses(avatarType);
return (
<div className="flex w-24 shrink-0 flex-col items-center gap-2 text-center">
<div className="relative flex size-16 items-center justify-center">
{/* 活动光环:脉冲 ping */}
{state === "active" && (
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" />
)}
{/* 头像 + 状态描边 */}
<span
className={cn(
"relative flex size-16 items-center justify-center rounded-full border-2 transition-all",
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, "h-14 w-14")}
{/* 完成打勾 */}
{state === "done" && (
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
<CheckIcon className="size-3" />
</span>
)}
{/* 进行中转圈 */}
{state === "active" && (
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
<Loader2Icon className="size-3 animate-spin" />
</span>
)}
</span>
</div>
<span
className={cn(
"max-w-full truncate rounded border px-1.5 py-0.5 text-[11px] font-medium",
state === "pending" ? "border-border bg-muted text-muted-foreground" : tag,
)}
title={label}
>
{order != null ? `${order}. ` : ""}
{label}
</span>
</div>
);
}
// ── 里程碑节点(共识 / 报告绘制)─────────────────────────────────────────────
function MilestoneNode({
label,
emoji,
state,
}: {
label: string;
emoji: string;
state: ChainNodeState;
}) {
return (
<div className="flex w-24 shrink-0 flex-col items-center gap-2 text-center">
<div className="relative flex size-16 items-center justify-center">
{state === "active" && (
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" />
)}
<span
className={cn(
"relative flex size-16 items-center justify-center rounded-full border-2 text-2xl transition-all",
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-5 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
<CheckIcon className="size-3" />
</span>
)}
{state === "active" && (
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
<Loader2Icon className="size-3 animate-spin" />
</span>
)}
</span>
</div>
<span
className={cn(
"rounded border px-1.5 py-0.5 text-[11px] font-semibold",
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>
);
}
// ── 连接线(带流动动画)─────────────────────────────────────────────────────
function Connector({ active, done }: { active: boolean; done: boolean }) {
return (
<div className="flex h-16 w-16 shrink-0 items-center justify-center">
<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>
);
}
@@ -21,7 +21,8 @@ import {
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { Step3FileBrowseMode } from "@/roundtable-planning/components/Step3FileBrowseMode";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import {
Step3ReportMode,
@@ -30,20 +31,6 @@ import {
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
/**
* Text-ish files we render in an in-app preview modal. Other types (pdf,
* images, html) keep the original window.open behaviour so the browser's
* native viewer handles them.
*/
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime === "text/markdown" || mime === "text/plain" || mime === "text/csv" || mime === "application/json") {
return true;
}
if (mime.startsWith("text/")) return true;
return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml)$/i.test(file.name);
}
interface SeatDelivery {
/** Display name of the seat (e.g. "情报收集"). */
sender: string;
@@ -78,6 +65,8 @@ interface HighFidelityReportProps {
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
/** Resets state and navigates back to Step 1. */
onReset: () => void;
openArtifactPreview: (threadId: string, path: string) => void;
seatStubMessages: Record<string, unknown[]>;
}
interface SeatArtifacts {
@@ -116,9 +105,23 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
savedReport,
onReportSaved,
onReset,
openArtifactPreview,
seatStubMessages,
}) => {
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
const consensusDocTitle = useMemo(() => {
const objective = intentReady?.objective?.trim();
if (!objective) return "圆桌总控最终结论";
const short = objective.length > 40 ? `${objective.slice(0, 40)}…` : objective;
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
const [drawMode, setDrawMode] = useState(false);
const [fileSandboxMode, setFileSandboxMode] = useState(false);
const [fileSandboxTarget, setFileSandboxTarget] = useState<{
threadId: string;
file: SessionArtifactFile;
} | null>(null);
// 进入第三步即关闭并清空第二步遗留的沙箱(artifacts 面板全局共享,第二步打开后
// 不会自动关)。点「结果绘制」后 Step3ReportMode 会按需开自己的沙箱,互不串扰。
@@ -151,10 +154,6 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
const [artifactsBySeat, setArtifactsBySeat] = useState<SeatArtifacts[]>([]);
const [expandedSeats, setExpandedSeats] = useState<Set<string>>(new Set());
const [reloadKey, setReloadKey] = useState(0);
// Active preview target — non-null means the modal is open. We carry the
// owning threadId so the modal can fetch + build download URLs without
// needing a second prop drill.
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(null);
const generatedAt = useMemo(() => formatGeneratedAt(new Date()), []);
// Fetch artifact listings for every seat thread in parallel. Re-runs when
@@ -222,23 +221,16 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
});
}, []);
const handlePreview = useCallback(
(threadId: string, file: SessionArtifactFile) => {
// Text-ish artifacts (markdown / txt / json / csv / yaml / ...) open
// in our in-app modal so the user can read the content without leaving
// the planning page. Binary or active content (pdf / png / html) still
// hands off to the browser's native viewer via the original tab.
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
const url = buildArtifactDownloadUrl(threadId, file.path);
window.open(url, "_blank", "noopener,noreferrer");
}
},
[],
);
const handleViewInSandbox = useCallback((threadId: string, file: SessionArtifactFile) => {
setFileSandboxTarget({ threadId, file });
setFileSandboxMode(true);
}, []);
const handleBackFromFileSandbox = useCallback(() => {
setFileSandboxMode(false);
setFileSandboxTarget(null);
deselectArtifact();
}, [deselectArtifact]);
const handleDownload = useCallback(
(threadId: string, file: SessionArtifactFile) => {
@@ -250,6 +242,22 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
[],
);
if (fileSandboxMode && fileSandboxTarget) {
return (
<Step3FileBrowseMode
consensusText={consensusText}
hasConsensus={hasConsensus}
consensusDocTitle={consensusDocTitle}
seatDeliveries={seatDeliveries}
artifactsBySeat={artifactsBySeat}
seatStubMessages={seatStubMessages}
openArtifactPreview={openArtifactPreview}
initialFile={fileSandboxTarget}
onBack={handleBackFromFileSandbox}
/>
);
}
if (drawMode) {
return (
<Step3ReportMode
@@ -351,10 +359,17 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
<div className="p-6 space-y-6">
{/* Section 1: Coordinator consensus conclusion */}
<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">
<CheckCircle className="w-4 h-4 text-emerald-500" />
总控最终结论
</h4>
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest flex items-center gap-2 min-w-0">
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
<span className="truncate">总控最终结论</span>
</h4>
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
className="shrink-0"
/>
</div>
{consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
@@ -420,7 +435,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
)}
</section>
{/* Section 3: Generated files per seat —— 各席位产出的 md 文件,点击预览弹窗查看内容 */}
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览 */}
<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" />
@@ -475,9 +490,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
onClick={() => handleViewInSandbox(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
title="在沙箱中查看"
>
<Eye className="w-3.5 h-3.5" />
</button>
@@ -515,15 +530,6 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
</div>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</div>
);
};
@@ -23,6 +23,7 @@ import {
Loader2,
Pause,
Play,
Rocket,
Sparkles,
} from "lucide-react";
@@ -98,6 +99,8 @@ export interface Step2PanelProps {
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
/** 「后台挂起」:把当前研讨转入后端自动跑到结果绘制完成。 */
onBackgroundSuspend: () => void;
onTriggerToast: (msg: string) => void;
}
@@ -133,6 +136,7 @@ export function Step2Panel(props: Step2PanelProps) {
onSubmitClarification,
onSavePersona,
onProceedToStep3,
onBackgroundSuspend,
onTriggerToast,
} = props;
@@ -365,6 +369,16 @@ export function Step2Panel(props: Step2PanelProps) {
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 cursor-pointer dark:bg-secondary dark:text-foreground dark:border-border"
>
<Rocket className="w-3.5 h-3.5 text-primary" />
后台挂起
</button>
<button
type="button"
onClick={onProceedToStep3}
@@ -27,11 +27,14 @@ import { cn } from "@/lib/utils";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
interface Step2SandboxLayoutProps {
children: React.ReactNode;
children?: React.ReactNode;
/** 当前预览文件所属的席位 thread id。 */
activeThreadId: string | null;
/** 为 true 时右侧区域仅展示沙箱预览,占满可用宽度(无中间说明/对话区)。 */
artifactsFill?: boolean;
/** artifactsFill 模式下用户点击沙箱关闭时的回调(退出预览、收起沙箱)。 */
onArtifactsClose?: () => void;
/** 是否有流式 run 进行中 —— 开启 artifacts API 轮询。 */
isStreaming?: boolean;
/**
@@ -43,10 +46,12 @@ interface Step2SandboxLayoutProps {
}
export function Step2SandboxLayout({
children,
children = null,
activeThreadId,
isStreaming = false,
seatMessages,
artifactsFill = false,
onArtifactsClose,
}: Step2SandboxLayoutProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
@@ -54,18 +59,22 @@ export function Step2SandboxLayout({
open: artifactsOpen,
setOpen: setArtifactsOpen,
selectedArtifact,
deselect,
} = useArtifacts();
const artifacts = rawArtifacts ?? [];
const artifactPanelOpen = artifactsOpen;
useEffect(() => {
if (layoutRef.current) {
layoutRef.current.setLayout(
artifactPanelOpen ? OPEN_MODE : CLOSE_MODE,
);
}
}, [artifactPanelOpen]);
if (artifactsFill || !layoutRef.current) return;
layoutRef.current.setLayout(artifactPanelOpen ? OPEN_MODE : CLOSE_MODE);
}, [artifactPanelOpen, artifactsFill]);
const handleArtifactsClose = () => {
deselect();
setArtifactsOpen(false);
onArtifactsClose?.();
};
const threadStub = useMemo(() => {
const messages = seatMessages ?? [];
@@ -83,6 +92,70 @@ export function Step2SandboxLayout({
const previewThreadId = activeThreadId ?? "";
const artifactPanelBody = (
<div
className={cn(
"h-full bg-[#fffdfa] p-4",
!artifactsFill &&
"transition-transform duration-300 ease-in-out",
!artifactsFill && (artifactPanelOpen ? "translate-x-0" : "translate-x-full"),
)}
>
{selectedArtifact && previewThreadId ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
/>
) : (
<div className="relative flex size-full justify-center">
{!artifactsFill && (
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
)}
{artifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="未选择文件"
description="智能体写入文件后将在此预览"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">产出文件</h2>
</header>
<main className="min-h-0 grow">
{previewThreadId ? (
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={previewThreadId}
/>
) : null}
</main>
</div>
)}
</div>
)}
</div>
);
if (artifactsFill) {
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<div className="h-full min-h-0 flex-1 bg-[#fffdfa]">{artifactPanelBody}</div>
</ThreadContext.Provider>
);
}
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup
@@ -109,54 +182,7 @@ export function Step2SandboxLayout({
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{selectedArtifact && previewThreadId ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
{artifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="未选择文件"
description="智能体写入文件后将在此预览"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">产出文件</h2>
</header>
<main className="min-h-0 grow">
{previewThreadId ? (
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={previewThreadId}
/>
) : null}
</main>
</div>
)}
</div>
)}
</div>
{artifactPanelBody}
</ResizablePanel>
</ResizablePanelGroup>
</ThreadContext.Provider>
@@ -0,0 +1,351 @@
/**
* Step 3「文件沙箱预览」模式 —— 布局对齐「结果绘制」:左栏研讨内容 + 文件清单,右栏 Step 2 沙箱。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowLeft,
CheckCircle,
Download,
Eye,
FileText,
Loader2,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
type SessionArtifactFile,
} from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
interface SeatDelivery {
sender: string;
content: string;
}
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export interface Step3FileBrowseModeProps {
consensusText: string;
hasConsensus: boolean;
consensusDocTitle: string;
seatDeliveries: SeatDelivery[];
artifactsBySeat: SeatArtifacts[];
seatStubMessages: Record<string, unknown[]>;
openArtifactPreview: (threadId: string, path: string) => void;
/** 进入模式时默认打开的文件(由「查看」触发)。 */
initialFile: { threadId: string; file: SessionArtifactFile };
onBack: () => void;
}
export function Step3FileBrowseMode({
consensusText,
hasConsensus,
consensusDocTitle,
seatDeliveries,
artifactsBySeat,
seatStubMessages,
openArtifactPreview,
initialFile,
onBack,
}: Step3FileBrowseModeProps) {
const [activeFile, setActiveFile] = useState(initialFile);
const [leftCollapsed, setLeftCollapsed] = useState(false);
useEffect(() => {
openArtifactPreview(activeFile.threadId, activeFile.file.path);
}, [activeFile.file.path, activeFile.threadId, openArtifactPreview]);
const handleSelectFile = useCallback(
(threadId: string, file: SessionArtifactFile) => {
setActiveFile({ threadId, file });
openArtifactPreview(threadId, file.path);
},
[openArtifactPreview],
);
const handleDownload = useCallback((threadId: string, file: SessionArtifactFile) => {
const url = buildArtifactDownloadUrl(threadId, file.path, { download: true });
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, []);
const activeSeatMessages = useMemo(
() => seatStubMessages[activeFile.threadId] ?? [],
[seatStubMessages, activeFile.threadId],
);
const activeDisplayName = useMemo(() => {
const seat = artifactsBySeat.find((s) => s.threadId === activeFile.threadId);
return seat?.displayName ?? "席位";
}, [artifactsBySeat, activeFile.threadId]);
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-5 h-5 text-primary shrink-0" />
<h3 className="text-sm font-black text-slate-900 tracking-tight truncate">
文件预览 · 沙箱查看
</h3>
<span className="text-[10px] font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded-lg truncate max-w-[240px]">
{activeDisplayName} / {activeFile.file.name}
</span>
</div>
<button
type="button"
onClick={onBack}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition shrink-0"
>
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
返回报告
</button>
</div>
<div className="flex-1 min-h-0 p-3">
{leftCollapsed ? (
<div className="h-full min-h-0 flex gap-3">
<aside className="w-[52px] shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<button
type="button"
onClick={() => setLeftCollapsed(false)}
title="展开侧栏"
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
<CollapsedIconButton
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
onClick={() => setLeftCollapsed(false)}
/>
<CollapsedIconButton
icon={<FileText className="w-4 h-4 text-primary" />}
title="生成的报告文件"
onClick={() => setLeftCollapsed(false)}
/>
</aside>
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
<Step2SandboxLayout
artifactsFill
activeThreadId={activeFile.threadId}
seatMessages={activeSeatMessages}
onArtifactsClose={onBack}
/>
</section>
</div>
) : (
<ResizablePanelGroup
orientation="horizontal"
className="h-full min-h-0 gap-3"
autoSaveId="roundtable-step3-file-browse-layout"
>
<ResizablePanel defaultSize={42} minSize={26} className="min-w-0">
<aside className="h-full min-h-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<div className="shrink-0 flex items-center justify-between gap-2 px-1">
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
方案内容
</span>
<button
type="button"
onClick={() => setLeftCollapsed(true)}
title="收起侧栏"
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-xs font-bold text-slate-900 flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
总控最终结论
</h4>
{hasConsensus && (
<span className="text-[9px] font-bold text-emerald-700 bg-emerald-50 border border-emerald-200 px-1.5 py-0.5 rounded-full">
已共识
</span>
)}
</div>
<div className="mb-2 flex justify-end">
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
size="sm"
/>
</div>
{consensusText ? (
<div className="max-h-32 overflow-y-auto text-[11px] text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
) : (
<p className="text-[11px] text-slate-400 italic">暂无总控结论。</p>
)}
</section>
{seatDeliveries.length > 0 && (
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<h4 className="text-xs font-bold text-slate-900 mb-2">
各席位交付
<span className="ml-1.5 text-[10px] font-mono text-slate-400">
{seatDeliveries.length}
</span>
</h4>
<ul className="space-y-1 max-h-24 overflow-y-auto">
{seatDeliveries.map((s, i) => (
<li key={`${s.sender}-${i}`} className="text-[11px] text-slate-600 truncate">
{s.sender}
</li>
))}
</ul>
</section>
)}
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 min-h-0 flex flex-col">
<h4 className="text-xs font-bold text-slate-900 mb-2 flex items-center gap-1.5 shrink-0">
<Download className="w-3.5 h-3.5 text-primary" />
生成的报告文件
</h4>
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{artifactsBySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl overflow-hidden">
<div className="px-2.5 py-1.5 bg-slate-50 border-b border-slate-100 text-[10.5px] font-bold text-slate-800 truncate">
{seat.displayName}
</div>
{seat.loading ? (
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[10px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin" />
加载中…
</div>
) : seat.error ? (
<p className="px-2.5 py-2 text-[10px] text-rose-600">{seat.error}</p>
) : seat.files.length === 0 ? (
<p className="px-2.5 py-2 text-[10px] text-slate-400 italic">无文件</p>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => {
const isActive =
activeFile.threadId === seat.threadId &&
activeFile.file.path === f.path;
return (
<li key={f.path}>
<div
className={cn(
"px-2 py-1.5 flex items-center justify-between gap-1",
isActive && "bg-[var(--brand-200)]/25",
)}
>
<button
type="button"
onClick={() => handleSelectFile(seat.threadId, f)}
className="flex items-center gap-1.5 min-w-0 text-left flex-1"
title={f.name}
>
<FileText className="w-3 h-3 text-slate-400 shrink-0" />
<span className="text-[10.5px] font-medium text-slate-800 truncate">
{f.name}
</span>
</button>
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={() => handleSelectFile(seat.threadId, f)}
className={cn(
"p-1 rounded transition",
isActive
? "text-primary bg-white/80"
: "text-slate-500 hover:text-primary hover:bg-slate-100",
)}
title="在沙箱中查看"
>
<Eye className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-500 hover:text-primary p-1 rounded hover:bg-slate-100 transition"
title="下载"
>
<Download className="w-3 h-3" />
</button>
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
))}
</div>
</section>
</aside>
</ResizablePanel>
<ResizableHandle className="w-2 rounded-full bg-slate-200/70 hover:bg-slate-300 transition" />
<ResizablePanel defaultSize={58} minSize={30} className="min-w-0">
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
<Step2SandboxLayout
artifactsFill
activeThreadId={activeFile.threadId}
seatMessages={activeSeatMessages}
onArtifactsClose={onBack}
/>
</section>
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
</div>
);
}
function CollapsedIconButton({
icon,
title,
onClick,
}: {
icon: React.ReactNode;
title: string;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30"
>
{icon}
</button>
);
}
@@ -35,6 +35,9 @@ export interface Step3PanelProps {
savedReport?: DraftStep3Snapshot | null;
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
onReset: () => void;
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
openArtifactPreview: (threadId: string, path: string) => void;
seatStubMessages: Record<string, unknown[]>;
}
export function Step3Panel(props: Step3PanelProps) {
@@ -53,6 +56,8 @@ export function Step3Panel(props: Step3PanelProps) {
savedReport,
onReportSaved,
onReset,
openArtifactPreview,
seatStubMessages,
} = props;
const seatDeliveries = step2RoundtableDialogues
@@ -83,6 +88,8 @@ export function Step3Panel(props: Step3PanelProps) {
savedReport={savedReport}
onReportSaved={onReportSaved}
onReset={onReset}
openArtifactPreview={openArtifactPreview}
seatStubMessages={seatStubMessages}
/>
);
}
@@ -36,6 +36,7 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -100,6 +101,13 @@ export function Step3ReportMode({
[seatDeliveries],
);
const consensusDocTitle = useMemo(() => {
const objective = intentReady?.objective?.trim();
if (!objective) return "圆桌总控最终结论";
const short = objective.length > 40 ? `${objective.slice(0, 40)}…` : objective;
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
const {
phase,
error,
@@ -316,7 +324,16 @@ export function Step3ReportMode({
{/* 弹窗 */}
{modal === "consensus" && (
<ContentModal title="总控最终结论" onClose={() => setModal(null)}>
<ContentModal
title="总控最终结论"
onClose={() => setModal(null)}
headerAction={
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
/>
}
>
{consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
@@ -543,10 +560,12 @@ function ContentModal({
title,
children,
onClose,
headerAction,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
headerAction?: React.ReactNode;
}) {
return (
<div
@@ -557,15 +576,18 @@ function ContentModal({
className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center justify-between px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
<div className="shrink-0 flex items-center justify-between gap-2 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900 min-w-0 truncate">{title}</h3>
<div className="flex items-center gap-2 shrink-0">
{headerAction}
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
@@ -29,6 +29,7 @@ import {
updateDraft,
type DraftStep3Snapshot,
} from "@/roundtable-planning/api/drafts";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
export interface DraftListItem {
@@ -37,6 +38,10 @@ export interface DraftListItem {
furthestStep: 1 | 2 | 3;
createdAt: string;
updatedAt: string;
/** 最近一次后台作业状态(Phase 6 历史下拉「进行中」loading)。 */
status?: JobStatus | null;
/** 最近一次后台作业 id(点击进行中项打开流程图弹窗用)。 */
jobId?: string | null;
}
export interface UseDraftPersistenceOptions {
@@ -101,6 +106,8 @@ export interface UseDraftPersistenceReturn {
* 推荐弹窗打开前调用,确保推荐历史有 draft_id 可归属。
*/
ensureDraft: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/** 强制刷新草稿列表。 */
refresh: () => Promise<void>;
}
export function useDraftPersistence(
@@ -165,6 +172,8 @@ export function useDraftPersistence(
furthestStep: m.furthestStep,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
status: m.status ?? null,
jobId: m.jobId ?? null,
})),
);
} catch (err) {
@@ -457,5 +466,7 @@ export function useDraftPersistence(
handleCommitRename,
handleCancelRename,
ensureDraft,
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
refresh: refreshList,
};
}
@@ -0,0 +1,98 @@
/**
* 圆桌后台作业(roundtable job)共享类型。
*
* 对应 `docs/multi-agent-background-run-dev.md` §2「数据契约」。本文件只放纯类型,
* 不含请求逻辑(后续 Phase 4 的 `api/roundtable-jobs.ts` 会引用这些类型)。
*
* Phase 7(派活链路流程图弹窗)只用到其中的 `JobStatus` / `JobPhase` /
* `DispatchChainNode`,其余类型为后续阶段预留,先一并定义好避免反复改动。
*/
/** 作业状态机(§2.1)。 */
export type JobStatus =
| "queued" // 已创建,等待 worker 拉起
| "running" // 编排进行中(Step2 派活循环 / Step3 绘制)
| "awaiting_input" // 命中 clarification,等用户回答(interrupt 挂起)
| "done" // 已跑到 Step3 绘制完成
| "error" // 出错终止
| "cancelled"; // 用户取消
/** 作业阶段(§2.2)——给流程图 / 进度条用。 */
export type JobPhase =
| "initializing" // 建 thread / 总控就绪
| "leader_thinking" // 总控在决策派活
| "dispatching" // 正在顺序派活给席位(配合 activeAgentId)
| "consensus" // 共识达成(dispatched=[])
| "report_drawing" // Step3 报告绘制中
| "report_done"; // 绘制完成
/** 派活链路单节点状态(驱动颜色 / 动画)。 */
export type ChainNodeState = "pending" | "active" | "done";
/** 派活链路节点(§2.3)——流程图核心数据。 */
export interface DispatchChainNode {
/** 席位 agent_id(小写)。 */
agentId: string;
/** 显示名。 */
name: string;
/** 复用 getAvatarTypeFor(agentId, order) → 决定颜色 / 头像。 */
avatarType: string;
/** 横向排列次序(chain 模式=链条顺序;recommend 模式=席位/发言顺序)。 */
order: number;
/** 节点状态。 */
state: ChainNodeState;
/** 最近发言轮次(可选,做 tooltip)。 */
lastSpokeCycle?: number;
}
/** 一条后台作业的完整快照(§2.5)——对齐后端 `RoundtableJobResponse`(camelCase)。 */
export interface RoundtableJob {
id: string;
draftId: string | null;
status: JobStatus;
phase: JobPhase;
cycle: number;
dispatchChain: DispatchChainNode[];
activeAgentId: string | null;
consensusPercentage: number;
pendingClarification: string | null;
orchestrationMode: "recommend" | "chain";
chain: { id: string; title: string } | null;
error: string | null;
createdAt: string | null;
updatedAt: string | null;
}
/** 启动一个后台作业的入参(对齐后端 `StartJobRequest`)。 */
export interface StartJobPayload {
draftId?: string | null;
/** 任务意图;缺省 seedMessage 时用它作第一轮 leader prompt。 */
intent: string;
/** 有序席位(recommend=选择顺序 / chain=链条顺序)。 */
agents: Array<{ agentId: string; name: string }>;
model?: string;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
/** 显式第一轮 leader prompt(覆盖 intent)。 */
seedMessage?: string;
/** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */
threadIds?: Record<string, string> | null;
coordinatorName?: string;
}
/** 终态集合——SSE/轮询判定作业是否结束。 */
export const TERMINAL_JOB_STATUSES: readonly JobStatus[] = ["done", "error", "cancelled"];
export function isTerminalJobStatus(status: JobStatus): boolean {
return TERMINAL_JOB_STATUSES.includes(status);
}
/** 归一化进度事件(§2.4)——SSE 推送。Phase 7 暂未用到,先占位。 */
export type JobProgressEvent =
| { type: "status"; status: JobStatus }
| { type: "phase"; phase: JobPhase; cycle: number }
| { type: "chain"; nodes: DispatchChainNode[] }
| { type: "active"; agentId: string | null }
| { type: "consensus"; percentage: number }
| { type: "clarification"; question: string }
| { type: "error"; detail: string };
@@ -0,0 +1,165 @@
/**
* 派活链路流程图弹窗 —— 预览/自测页(Phase 7 临时入口)。
*
* 路由:/page/workspace/roundtable/chain-preview
*
* 用假数据 + 定时器模拟后台作业推进(初始化 → 总控派活 → 席位逐个发言 → 共识 →
* 结果绘制 → 完成),驱动 `DispatchChainModal` 演示节点 / 连线的加载动画与状态流转。
* 后续 Phase 8 接通真实 `streamJob` 后,本页可删除(连同 WorkspaceRoutes 里的路由)。
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { DispatchChainModal } from "../components/DispatchChainModal";
import {
DEFAULT_SELECTED_AGENTS,
getAvatarTypeFor,
getDisplayNameFor,
} from "../lib/roundtable-constants";
import type {
DispatchChainNode,
JobPhase,
JobStatus,
} from "../lib/job-types";
import "../styles/roundtable-planning.css";
interface SimFrame {
phase: JobPhase;
status: JobStatus;
cycle: number;
consensus: number;
activeAgentId: string | null;
doneAgentIds: string[];
}
const AGENTS = DEFAULT_SELECTED_AGENTS;
/** 预先生成一条完整的推进时间线(每帧约 1.1s)。 */
function buildFrames(): SimFrame[] {
const ids = AGENTS.map((a) => a.agent_id);
const frames: SimFrame[] = [];
frames.push({ phase: "initializing", status: "running", cycle: 0, consensus: 0, activeAgentId: null, doneAgentIds: [] });
frames.push({ phase: "leader_thinking", status: "running", cycle: 1, consensus: 12, activeAgentId: null, doneAgentIds: [] });
// 第 1 轮:逐个席位发言
ids.forEach((id, i) => {
frames.push({
phase: "dispatching",
status: "running",
cycle: 1,
consensus: 12 + Math.round(((i + 1) / ids.length) * 50),
activeAgentId: id,
doneAgentIds: ids.slice(0, i),
});
});
// 第 2 轮:总控再决策(此时第 1 轮席位都已发言)
frames.push({ phase: "leader_thinking", status: "running", cycle: 2, consensus: 78, activeAgentId: null, doneAgentIds: [...ids] });
// 共识达成
frames.push({ phase: "consensus", status: "running", cycle: 2, consensus: 100, activeAgentId: null, doneAgentIds: [...ids] });
// 结果绘制中
frames.push({ phase: "report_drawing", status: "running", cycle: 2, consensus: 100, activeAgentId: null, doneAgentIds: [...ids] });
// 完成
frames.push({ phase: "report_done", status: "done", cycle: 2, consensus: 100, activeAgentId: null, doneAgentIds: [...ids] });
return frames;
}
export default function DispatchChainPreviewPage() {
const frames = useMemo(buildFrames, []);
const [idx, setIdx] = useState(0);
const [playing, setPlaying] = useState(true);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 自动播放:逐帧推进,跑到最后一帧停 2.2s 再循环
useEffect(() => {
if (!playing) return;
const isLast = idx >= frames.length - 1;
timerRef.current = setTimeout(
() => setIdx((i) => (i >= frames.length - 1 ? 0 : i + 1)),
isLast ? 2200 : 1100,
);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [idx, playing, frames.length]);
const frame = frames[idx];
// 由当前帧推导各席位节点状态
const nodes: DispatchChainNode[] = AGENTS.map((a, i) => {
const state =
frame.doneAgentIds.includes(a.agent_id)
? "done"
: frame.activeAgentId === a.agent_id
? "active"
: "pending";
return {
agentId: a.agent_id,
name: getDisplayNameFor(a),
avatarType: getAvatarTypeFor(a.agent_id, i),
order: i + 1,
state,
};
});
return (
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-6 bg-muted/30 p-8">
<div className="text-center">
<h1 className="text-lg font-bold">派活链路流程图 · 预览</h1>
<p className="mt-1 text-sm text-muted-foreground">
模拟后台作业推进(假数据)。帧 {idx + 1}/{frames.length} · {frame.phase}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPlaying((p) => !p)}>
{playing ? "暂停" : "继续"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIdx((i) => (i > 0 ? i - 1 : i));
setPlaying(false);
}}
>
上一帧
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setIdx((i) => (i < frames.length - 1 ? i + 1 : i));
setPlaying(false);
}}
>
下一帧
</Button>
<Button
size="sm"
onClick={() => {
setIdx(0);
setPlaying(true);
}}
>
重新播放
</Button>
</div>
<DispatchChainModal
open
onOpenChange={() => {}}
title="新能源汽车出口策略研讨(预览)"
status={frame.status}
phase={frame.phase}
cycle={frame.cycle}
nodes={nodes}
consensusPercentage={frame.consensus}
onView={() => toast.info("(预览)点击「查看」→ Phase 8 会进入正在进行的任务")}
/>
</div>
);
}
@@ -56,6 +56,7 @@ import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
@@ -74,6 +75,7 @@ import {
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
export default function RoundtablePlanningPage() {
const { theme } = useTheme();
@@ -88,6 +90,8 @@ export default function RoundtablePlanningPage() {
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
// 「业务链条」按钮 → 打开链条列表弹窗(弹窗里再决定进配置页 / 切到某条链条)。
const [isChainListOpen, setIsChainListOpen] = useState<boolean>(false);
// 历史下拉点击「进行中」任务 → 打开派活链路流程图弹窗(订阅该作业 SSE)。
const [chainModalJobId, setChainModalJobId] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -321,7 +325,11 @@ export default function RoundtablePlanningPage() {
[step2, triggerToast],
);
const handleStartNewTask = useCallback(() => {
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
// 在历史记录里可见)。
const resetToStep1 = useCallback(() => {
step2.stopOrchestration();
step2.resetStep2State();
setOrchestrationMode("recommend");
setChainMeta(null);
@@ -333,8 +341,61 @@ export default function RoundtablePlanningPage() {
// 同步触发 Step 1 init,而不是依赖 useEffect —— 已经在 Step 1 时
// currentStep 不变,useEffect 不会 re-fire。
step1.runStep1Init();
}, [drafts, step1, step2]);
const handleStartNewTask = useCallback(() => {
resetToStep1();
triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求");
}, [drafts, step1, step2, triggerToast]);
}, [resetToStep1, triggerToast]);
// 「后台挂起」:把当前研讨交给后端自动跑到 Step3 结果绘制完成;模型沿用第二步选择。
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
const handleBackgroundSuspend = useCallback(async () => {
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
if (agents.length === 0) {
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
return;
}
try {
// 确保后端存在对应草稿(作业按 draftId 关联,历史下拉据此显示「进行中」)。
const draftId = await drafts.ensureDraft(2);
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
const alreadyRunning =
existing &&
(existing.status === "running" ||
existing.status === "awaiting_input" ||
existing.status === "queued");
if (!alreadyRunning) {
await startJob({
draftId: draftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
orchestrationMode,
chain: chainMeta,
threadIds: step2.threadIds ?? undefined,
coordinatorName: step2.coordinatorName ?? undefined,
});
}
} catch (err) {
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
return;
}
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
resetToStep1();
triggerToast("🚀 已转入后台,任务会自动跑到结果绘制完成;可在「历史记录」查看进度");
void drafts.refresh();
}, [
step2,
drafts,
selectedModel,
orchestrationMode,
chainMeta,
buildInitialIntent,
resetToStep1,
triggerToast,
]);
// 本地 demo handler:继续追问 / 跳过此项 —— 不发请求,只本地 push 一条气泡。
const handleSkipQuestion = useCallback(() => {
@@ -390,6 +451,26 @@ export default function RoundtablePlanningPage() {
}}
/>
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
<DispatchChainJobModal
jobId={chainModalJobId}
open={chainModalJobId !== null}
onOpenChange={(o) => {
if (!o) setChainModalJobId(null);
}}
onView={(job) => {
// 「查看」:进入该任务。done → 加载草稿(含 step3)落到 Step3 看报告;
// 进行中 / 待澄清 → 进去后置暂停态,避免前后端「双跑」同一批 thread。
setChainModalJobId(null);
if (!job.draftId) return;
if (job.status === "running" || job.status === "awaiting_input") {
step2.setIsRoundTableRunning(false);
step2.stopOrchestration();
}
drafts.handleLoadDraft(job.draftId);
}}
/>
{showToast && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
<Sparkles className="h-4 w-4 shrink-0" />
@@ -483,6 +564,11 @@ export default function RoundtablePlanningPage() {
renameDraftValue={drafts.renameDraftValue}
setRenameDraftValue={drafts.setRenameDraftValue}
onLoad={drafts.handleLoadDraft}
onOpen={() => void drafts.refresh()}
onOpenJob={(jobId) => {
drafts.setIsHistoryOpen(false);
setChainModalJobId(jobId);
}}
onDelete={drafts.handleDeleteDraft}
onStartRename={drafts.handleStartRename}
onCommitRename={drafts.handleCommitRename}
@@ -593,6 +679,7 @@ export default function RoundtablePlanningPage() {
"🎉 多角色智能体博弈共识达成,最终交付全套路线成果文档及方案图!",
);
}}
onBackgroundSuspend={handleBackgroundSuspend}
onTriggerToast={triggerToast}
/>
)}
@@ -616,6 +703,8 @@ export default function RoundtablePlanningPage() {
setStep3SavedTick((t) => t + 1);
}}
onReset={handleStartNewTask}
openArtifactPreview={step2.openArtifactPreview}
seatStubMessages={step2.seatStubMessages}
/>
)}
</section>
@@ -762,6 +851,10 @@ interface HistoryDropdownProps {
renameDraftValue: string;
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
onLoad: (id: string) => void;
/** 下拉展开时回调(刷新列表拿最新作业状态)。 */
onOpen?: () => void;
/** 点击「进行中 / 待澄清」的任务 → 打开派活链路流程图弹窗。 */
onOpenJob: (jobId: string) => void;
onDelete: (id: string) => void;
onStartRename: (d: ReturnType<typeof useDraftPersistence>["drafts"][number]) => void;
onCommitRename: (id: string) => void;
@@ -779,6 +872,8 @@ function HistoryDropdown(props: HistoryDropdownProps) {
renameDraftValue,
setRenameDraftValue,
onLoad,
onOpen,
onOpenJob,
onDelete,
onStartRename,
onCommitRename,
@@ -790,7 +885,13 @@ function HistoryDropdown(props: HistoryDropdownProps) {
<button
id="history-dropdown-btn"
type="button"
onClick={() => setIsOpen((open) => !open)}
onClick={() =>
setIsOpen((open) => {
const next = !open;
if (next) onOpen?.();
return next;
})
}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<History className="w-3.5 h-3.5 text-slate-500" />
@@ -815,6 +916,11 @@ function HistoryDropdown(props: HistoryDropdownProps) {
{drafts.map((d) => {
const isCurrent = d.id === currentDraftId;
const isRenaming = d.id === renamingDraftId;
// 「进行中 / 待澄清」且有 jobId → 点击打开流程图弹窗,而非加载草稿。
const isActiveJob =
(d.status === "running" || d.status === "awaiting_input") && !!d.jobId;
const openOrLoad = () =>
isActiveJob && d.jobId ? onOpenJob(d.jobId) : onLoad(d.id);
return (
<li key={d.id} className={`px-3 py-2.5 ${isCurrent ? "bg-emerald-50/40" : ""}`}>
<div className="flex items-start gap-2">
@@ -835,15 +941,30 @@ function HistoryDropdown(props: HistoryDropdownProps) {
) : (
<button
type="button"
onClick={() => onLoad(d.id)}
onClick={openOrLoad}
className="block w-full text-left"
title="点击加载"
title={isActiveJob ? "点击查看进度" : "点击加载"}
>
<span className="text-[11.5px] font-semibold text-slate-800 line-clamp-2 dark:text-foreground">
{d.name}
</span>
<span className="text-[10px] text-slate-400 mt-0.5 block font-mono">
Step {d.furthestStep} ·{" "}
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono">
{d.status === "running" ? (
<span className="inline-flex items-center gap-1 text-primary font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
进行中
</span>
) : d.status === "awaiting_input" ? (
<span className="inline-flex items-center gap-1 text-amber-500 font-semibold">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
待澄清
</span>
) : d.status === "error" ? (
<span className="text-rose-500 font-semibold">出错</span>
) : (
<span>Step {d.furthestStep}</span>
)}
<span className="text-slate-300">·</span>
{new Date(d.updatedAt).toLocaleString("zh-CN", { hour12: false })}
</span>
</button>
@@ -854,11 +975,15 @@ function HistoryDropdown(props: HistoryDropdownProps) {
<>
<button
type="button"
onClick={() => onLoad(d.id)}
onClick={openOrLoad}
className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
title="加载"
title={isActiveJob ? "查看进度" : "加载"}
>
<Upload className="w-3.5 h-3.5" />
{isActiveJob ? (
<Activity className="w-3.5 h-3.5 text-primary" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
<button
type="button"
@@ -360,6 +360,50 @@
color: var(--foreground) !important;
}
/* =============================================================================
* 派活链路流程图连接线(DispatchChainModal)。
*
* 弹窗经 Radix Dialog portal 到 <body>,**不在** .roundtable-planning 作用域内,
* 因此这些类必须是**全局**(无作用域前缀),并用语义 token(--primary/--border)
* 自适应明暗。连线三态:pending(灰虚线静止) / done(主色实线) / active(主色流动虚线)。
* ========================================================================== */
@keyframes rt-chain-flow {
to {
background-position: 18px 0;
}
}
.rt-chain-connector {
width: 100%;
height: 3px;
border-radius: 9999px;
/* 默认(pending):灰色等距虚线 */
background-image: linear-gradient(
90deg,
var(--border) 45%,
transparent 45%
);
background-size: 12px 100%;
background-repeat: repeat-x;
}
.rt-chain-connector--done {
background-image: none;
background-color: var(--primary);
opacity: 0.7;
}
.rt-chain-connector--active {
background-image: linear-gradient(
90deg,
var(--primary) 45%,
transparent 45%
);
background-size: 18px 100%;
background-repeat: repeat-x;
animation: rt-chain-flow 0.55s linear infinite;
}
/* Custom elegant scrollbar styling */
::-webkit-scrollbar {
width: 6px;
@@ -46,6 +46,7 @@ from app.gateway.routers import (
recommended_questions,
roundtable_chains,
roundtable_drafts,
roundtable_jobs,
runs,
scheduled_tasks,
skills,
@@ -867,6 +868,9 @@ 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)
# Roundtable-planning background jobs at /api/roundtable-jobs
app.include_router(roundtable_jobs.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
@@ -8,6 +8,7 @@ Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, TypeVar, cast
@@ -21,6 +22,8 @@ from deerflow.runtime import RunContext, RunManager, StreamBridge
from deerflow.runtime.events.store.base import RunEventStore
from deerflow.runtime.runs.store.base import RunStore
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
@@ -172,6 +175,52 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
from deerflow.persistence.roundtable_jobs import (
make_roundtable_job_store,
register_default_store as _register_roundtable_job_default_store,
)
app.state.roundtable_job_store = make_roundtable_job_store(sf)
# 让 LangGraph Server 路径下的圆桌编排图节点能跨 app/harness 边界拿到 repo
# (Phase 2 后台编排图写进度用)。
_register_roundtable_job_default_store(app.state.roundtable_job_store)
# 圆桌后台作业执行器:把编排引擎作为后台任务跑。
# 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择:
# - "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report);
# - 其它/缺省(Phase 3a):模拟网关,产生真实进度但不调真模型,供前端联调。
# 默认 simulated —— 真网关需在能起完整后端栈 + 真模型的环境验证后再开。
if app.state.roundtable_job_store is not None:
import os
from app.gateway.roundtable_job_executor import RoundtableJobExecutor
gateway_mode = (os.getenv("ROUNDTABLE_JOB_GATEWAY") or "simulated").strip().lower()
if gateway_mode == "inprocess":
from app.gateway.roundtable_inprocess_gateway import InProcessRoundtableGateway
_app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store)
gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731
_app, user_id=p.user_id, agents=p.agents, model=p.model
)
logger.info("[roundtable-jobs] gateway = inprocess (real models)")
else:
from deerflow.agents.roundtable_orchestrator import SimulatedRoundtableGateway
# delay=3s/步:让模拟后台任务有可观察的进行中时长(切页/重连仍在跑)。
gateway_factory = lambda p: SimulatedRoundtableGateway( # noqa: E731
p.agents, mode=p.mode, delay=3.0
)
logger.info("[roundtable-jobs] gateway = simulated")
app.state.roundtable_job_executor = RoundtableJobExecutor(
app.state.roundtable_job_store,
gateway_factory=gateway_factory,
draft_store=getattr(app.state, "roundtable_draft_store", None),
)
else:
app.state.roundtable_job_executor = None
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -0,0 +1,272 @@
"""圆桌真网关(Phase 3b)—— 进程内驱动真实 leader / seat / report agent run。
实现 ``RoundtableGateway`` 协议(定义在 harness 的 roundtable_orchestrator.types),
但**进程内**跑真模型:复用 ``services.start_run``(scheduled task 同款)驱动一轮 agent,
跑完从 checkpointer 读最终 messages,用 ``multi_agent`` 的纯解析函数解析派活 / 澄清。
不走 HTTP、不需要 Request(用 ``SimpleNamespace(app=app)`` 当虚拟 request,
``set_current_user`` 由执行器设好)。
本网关位于 app 层(依赖 app.state + multi_agent helpers),实现 harness 定义的协议——
不违反 harness→app 边界(harness 永不 import app;app import harness 合法)。
⚠️ 需在能起完整后端栈 + 真模型的环境验证(见 scripts/probe_roundtable_job.py)。
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from langgraph.checkpoint.base import empty_checkpoint
from deerflow.agents.roundtable_orchestrator.types import (
LeaderTurn,
ReportResult,
SeatRef,
SeatTurn,
)
from deerflow.config.paths import get_paths
from deerflow.runtime.serialization import serialize_channel_values
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
ensure_roundtable_functional_agents,
)
logger = logging.getLogger(__name__)
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
_REPORT_FILE = "方案总览.html"
# 各角色的工具裁剪(镜像 multi_agent 的 _leader_run / _special_run)。
_LEADER_EXCLUDED = ["web_search"]
_LEADER_SKILL_STOP = ["agent_orchestration"]
_SEAT_EXCLUDED = ["ask_clarification", "present_files", "view_image", "agent_orchestration"]
_REPORT_EXCLUDED = [*_SEAT_EXCLUDED, "bash", "str_replace"]
# ── 进程内底层操作 ───────────────────────────────────────────────────────────
async def _create_thread(app) -> str:
"""同进程建一个带圆桌 metadata 的 thread + 空 checkpoint(镜像 _create_thread_direct)。"""
checkpointer = app.state.checkpointer
thread_store = app.state.thread_store
thread_id = str(uuid.uuid4())
await thread_store.create(thread_id, assistant_id=None, metadata=dict(_ROUNDTABLE_THREAD_METADATA))
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
await checkpointer.aput(
config,
empty_checkpoint(),
{"step": -1, "source": "input", "writes": None, "parents": {}, "created_at": datetime.now(UTC).isoformat()},
{},
)
return thread_id
async def _read_thread_messages(app, thread_id: str) -> list[dict[str, Any]]:
"""读 thread 最新 checkpoint 的 messages(已 serialize 成 dict 列表)。"""
checkpointer = app.state.checkpointer
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ct = await checkpointer.aget_tuple(config)
if ct is None:
return []
checkpoint = getattr(ct, "checkpoint", {}) or {}
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
return values.get("messages") or []
def _latest_ai_message(messages: list[dict[str, Any]]) -> dict[str, Any] | None:
for m in reversed(messages):
if not isinstance(m, dict):
continue
if (m.get("type") or m.get("role")) in {"ai", "assistant"}:
return m
return None
def _message_text(message: dict[str, Any] | None) -> str:
if not message:
return ""
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
return "\n".join(parts)
return ""
async def _run_agent_turn(
app,
*,
thread_id: str,
agent_name: str,
message: str,
model: str | None,
excluded_tools: list[str],
skill_stop_names: list[str],
thinking_enabled: bool,
reasoning_effort: str,
) -> list[dict[str, Any]]:
"""进程内驱动 agent_name 在 thread_id 上跑一轮(发 message),阻塞到完成,返回最终 messages。
镜像 multi_agent ``_run_payload`` 的 body 形状:agent 通过 ``context.agent_name`` 选,
assistant_id 固定 lead_agent;关闭 memory recall / subagent / plan mode(临时研讨任务)。
"""
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.services import start_run
body = RunCreateRequest(
assistant_id="lead_agent",
input={"messages": [{"type": "human", "content": [{"type": "text", "text": message}], "additional_kwargs": {}}]},
config={"recursion_limit": 1000},
context={
"agent_name": agent_name,
"model_name": model or "",
"mode": "pro",
"reasoning_effort": reasoning_effort,
"thinking_enabled": thinking_enabled,
"is_plan_mode": False,
"subagent_enabled": False,
"thread_id": thread_id,
"memory_recall_disabled": True,
},
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
on_disconnect="continue",
multitask_strategy="reject",
)
request = SimpleNamespace(app=app)
record = await start_run(body, thread_id, request) # type: ignore[arg-type]
if getattr(record, "task", None) is not None:
await record.task # 阻塞到这轮 agent 跑完,checkpoint 已落盘
return await _read_thread_messages(app, thread_id)
def _read_report_html(app, thread_id: str, user_id: str) -> str | None:
"""从 report thread 的 outputs/方案总览.html 读出报告 HTML。"""
try:
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=user_id)
path = outputs_dir / _REPORT_FILE
if path.exists():
return path.read_text(encoding="utf-8")
except Exception: # noqa: BLE001
logger.warning("read report html failed for thread %s", thread_id, exc_info=True)
return None
def _build_seat_intro(agents: list[SeatRef]) -> str:
"""镜像 init 注入 coordinator 的「可调度席位清单」。"""
lines = [f"- agent_name: {a.agent_id}({a.name})" for a in agents]
return (
"【圆桌系统初始化】本次研讨可调度的席位清单如下,请严格按 agent_name 通过 "
"agent_orchestration 技能派活,不要派给不在清单内的 agent:\n" + "\n".join(lines)
)
# ── 网关实现 ─────────────────────────────────────────────────────────────────
class InProcessRoundtableGateway:
"""进程内真网关。executor 先调 ``init_threads`` 建好线程,再跑编排引擎。"""
def __init__(self, app, *, user_id: str, agents: list[SeatRef], model: str | None = None) -> None:
self._app = app
self._user_id = user_id
self._agents = list(agents)
self._model = model
self._seat_intro = ""
self._first_leader = True
async def init_threads(self) -> tuple[dict[str, str], str]:
"""建 coordinator + 各席位 thread,返回 (thread_ids, coordinator_name)。
席位清单通过「首轮 leader 消息前缀」注入(coordinator thread 跨轮累积历史,
首轮带上即长期可见),无需单独写 checkpoint。
"""
ensure_roundtable_functional_agents()
thread_ids: dict[str, str] = {}
for a in self._agents:
thread_ids[a.agent_id] = await _create_thread(self._app)
thread_ids[COORDINATOR_AGENT_ID] = await _create_thread(self._app)
self._seat_intro = _build_seat_intro(self._agents)
return thread_ids, COORDINATOR_AGENT_ID
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
msg = message
if self._first_leader and self._seat_intro:
msg = self._seat_intro + "\n\n" + message
self._first_leader = False
messages = await _run_agent_turn(
self._app,
thread_id=thread_ids[coordinator_name],
agent_name=coordinator_name,
message=msg,
model=model or self._model,
excluded_tools=_LEADER_EXCLUDED,
skill_stop_names=_LEADER_SKILL_STOP,
thinking_enabled=False,
reasoning_effort="low",
)
ai = _latest_ai_message(messages)
final_text = _message_text(ai)
tool_calls = (ai.get("tool_calls") if ai else None) or []
# 澄清优先
clar = next((tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"), None)
if clar is not None:
cargs = clar.get("args") or {}
question = str(cargs.get("question") or "").strip() or "总控需要补充信息,请补充后继续。"
return LeaderTurn(final_text=final_text, clarification=question)
# 派活解析(复用 multi_agent 纯函数)
from app.gateway.routers.multi_agent import _extract_dispatch
dispatched: list[tuple[str, str]] = []
for tc in tool_calls:
try:
name, task = _extract_dispatch(tc)
if name and task:
dispatched.append((name, task))
except Exception: # noqa: BLE001
logger.warning("dispatch parse failed for tool_call", exc_info=True)
return LeaderTurn(final_text=final_text, dispatched=dispatched)
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
messages = await _run_agent_turn(
self._app,
thread_id=thread_ids[agent_id],
agent_name=agent_id,
message=task,
model=model or self._model,
excluded_tools=_SEAT_EXCLUDED,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
)
return SeatTurn(final_text=_message_text(_latest_ai_message(messages)))
async def create_report_thread(self) -> str:
ensure_roundtable_functional_agents()
return await _create_thread(self._app)
async def run_report(self, *, report_thread_id, prompt, model) -> ReportResult:
messages = await _run_agent_turn(
self._app,
thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID,
message=prompt,
model=model or self._model,
excluded_tools=_REPORT_EXCLUDED,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
)
summary = _message_text(_latest_ai_message(messages))
html = _read_report_html(self._app, report_thread_id, self._user_id) or summary
return ReportResult(html=html, summary=summary, model=model or self._model)
@@ -0,0 +1,152 @@
"""圆桌后台作业执行器(Phase 3a)。
把 Phase 2 的编排引擎(``run_orchestration``)作为**后台 asyncio 任务**跑起来,
survive 发起请求的生命周期。每个任务:
1. ``set_current_user``:用作业的 user_id 设进程内用户上下文,让引擎里的进度落库
(``JobProgressWriter`` → ``RoundtableJobRepository.update_progress(user_id=AUTO)``)
正确归属(与 scheduled task 后台执行同一套做法);
2. 用 ``gateway_factory`` 构造一个 ``RoundtableGateway``(Phase 3a 默认模拟网关,
Phase 3b 换成进程内真网关),跑完整编排(循环 → 共识 → 报告)。
任务句柄登记在 ``_tasks`` 里,供 ``cancel`` 用;完成后自动摘除。
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Callable
from deerflow.agents.roundtable_orchestrator import (
JobProgressWriter,
SeatRef,
run_orchestration,
)
from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
class _JobUser:
"""最小 CurrentUser:满足 user_context 的 ``.id`` 结构协议。"""
def __init__(self, user_id: str) -> None:
self.id = user_id
@dataclass
class StartParams:
job_id: str
user_id: str
agents: list[SeatRef]
seed_message: str
model: str | None = None
mode: str = "recommend"
intent_text: str = ""
thread_ids: dict[str, str] | None = None
coordinator_name: str = "roundtable-coordinator"
# 关联草稿:作业完成后把 step3 报告写回该草稿,使「查看报告」可用。
draft_id: str | None = None
# 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。
GatewayFactory = Callable[[StartParams], object]
class RoundtableJobExecutor:
def __init__(self, store, *, gateway_factory: GatewayFactory, draft_store=None) -> None:
self._store = store
self._gateway_factory = gateway_factory
# 草稿仓储(可选):作业完成后把 step3 写回草稿,供前端「查看报告」。
self._draft_store = draft_store
self._tasks: dict[str, asyncio.Task] = {}
def start_job(self, params: StartParams) -> bool:
"""启动一个后台编排任务。同一 job 已在跑则忽略(幂等)。返回是否新启动。"""
if params.job_id in self._tasks and not self._tasks[params.job_id].done():
return False
task = asyncio.create_task(self._run(params))
self._tasks[params.job_id] = task
task.add_done_callback(lambda t, jid=params.job_id: self._tasks.pop(jid, None))
return True
async def _run(self, params: StartParams) -> None:
token = set_current_user(_JobUser(params.user_id))
try:
gateway = self._gateway_factory(params)
thread_ids = params.thread_ids or {}
coordinator_name = params.coordinator_name
# 真网关需要先进程内建好 leader/seat 线程;模拟网关无 init_threads。
# 仅首启建线程(resume 时 params.thread_ids 已带,复用、不重建)。
init = getattr(gateway, "init_threads", None)
if init is not None and not thread_ids:
thread_ids, coordinator_name = await init()
try:
await self._store.update_progress(
params.job_id, thread_ids=thread_ids, coordinator_name=coordinator_name
)
except Exception: # noqa: BLE001
logger.warning("persist thread_ids for job %s failed", params.job_id, exc_info=True)
progress = JobProgressWriter(self._store, job_id=params.job_id, agents=params.agents)
result = await run_orchestration(
gateway=gateway,
progress=progress,
agents=params.agents,
thread_ids=thread_ids,
coordinator_name=coordinator_name,
model=params.model,
seed_message=params.seed_message,
mode=params.mode,
intent_text=params.intent_text,
)
# 完成且有报告:写回关联草稿的 step3 + 推进 furthest_step=3,
# 这样用户从历史「查看报告」加载草稿即可看到最终方案(沿用既有 hydrateStep3 路径)。
if (
result.status == "done"
and result.step3
and params.draft_id
and self._draft_store is not None
):
try:
await self._draft_store.update_draft(
params.draft_id,
params.user_id,
step3=result.step3,
furthest_step=3,
)
except Exception: # noqa: BLE001 — 写报告失败不影响作业本身已 done
logger.warning("persist step3 to draft %s failed", params.draft_id, exc_info=True)
except asyncio.CancelledError:
# 干净停止即可,**不在被取消的任务里写 DB**:取消态下二次 await 会再抛
# CancelledError、并可能打断进行中的 sqlite 连接。``cancelled`` 终态由
# 路由的 cancel 端点(executor.cancel + store.update_progress)权威写入。
logger.info("roundtable job %s cancelled", params.job_id)
raise
except Exception: # noqa: BLE001 — 顶层兜底:run_orchestration 内部已尽量标 error
logger.exception("roundtable job %s crashed", params.job_id)
finally:
reset_current_user(token)
def cancel(self, job_id: str) -> bool:
"""请求取消一个在跑的作业。返回是否命中在跑任务。"""
task = self._tasks.get(job_id)
if task is None or task.done():
return False
task.cancel()
return True
def is_running(self, job_id: str) -> bool:
task = self._tasks.get(job_id)
return task is not None and not task.done()
async def await_job(self, job_id: str) -> None:
"""等待某作业任务结束(测试用;生产不调用)。"""
task = self._tasks.get(job_id)
if task is not None:
try:
await task
except (Exception, asyncio.CancelledError): # noqa: BLE001 — 取消是 BaseException
pass
@@ -43,6 +43,9 @@ class DraftMetaResponse(BaseModel):
furthest_step: int = 1
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
# 最近一次后台作业的状态(Phase 6 历史下拉「进行中」loading 用);无作业时为 None。
status: str | None = None
job_id: str | None = None
class DraftListResponse(BaseModel):
@@ -137,6 +140,20 @@ async def list_drafts(request: Request) -> DraftListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_drafts(user_id)
# 富化:给每个草稿带上「最近一次后台作业」的状态 + jobId(Phase 6 历史下拉 loading 用)。
# job store 缺失(DB 未配置)或查询失败都不阻断列表,静默退化为无状态。
job_store = getattr(request.app.state, "roundtable_job_store", None)
if job_store is not None:
try:
status_map = await job_store.status_map_by_user(user_id)
except Exception: # noqa: BLE001 — 富化失败不能拖垮列表
logger.warning("roundtable draft list: job status enrichment failed", exc_info=True)
status_map = {}
for r in rows:
info = status_map.get(r.get("id"))
if info:
r["status"] = info.get("status")
r["job_id"] = info.get("jobId")
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
@@ -0,0 +1,341 @@
"""圆桌后台作业控制 API(Phase 3a)。
把 Step 2「后台挂起」的任务交给后端编排引擎跑,并暴露进度订阅 / 续跑 / 取消。
Routes(prefix ``/api/roundtable-jobs``)::
POST "" 启动一个后台作业(创建 job 行 + 拉起执行器任务)
GET ""?draft_id= 查某草稿最近一个作业
GET "/{job_id}" 作业快照
GET "/{job_id}/stream" SSE 进度流(轮询 job 表,多 worker 安全)
POST "/{job_id}/resume" 回答澄清后续跑
POST "/{job_id}/cancel" 取消
进度数据源是 ``roundtable_jobs`` 表(Phase 1),SSE 走 DB 轮询而非内存 pub/sub —— 这样
刷新 / 换 worker / 重连都能继续收到进度,规避 ai_writing 踩过的「per-worker 内存 dict
丢会话」坑。响应统一 camelCase(对齐前端 §2.5)。
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from deerflow.agents.roundtable_orchestrator import SeatRef
from deerflow.runtime.user_context import get_effective_user_id
from app.gateway.roundtable_job_executor import StartParams
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-jobs", tags=["roundtable-jobs"])
_TERMINAL = {"done", "error", "cancelled"}
# SSE 轮询间隔与最长存活(对齐 ai_writing:长连接最多挂 30 分钟)。
_POLL_INTERVAL_S = 0.7
_MAX_STREAM_TICKS = int(30 * 60 / _POLL_INTERVAL_S)
# 续跑澄清时拼给 leader 的 prompt 前缀。
_RESUME_PREFIX = "用户已回复你刚才的澄清问题,回答如下,请据此继续派活或给出结论:\n"
# ── schemas ──────────────────────────────────────────────────────────────────
class JobAgent(BaseModel):
agentId: str
name: str = ""
class StartJobRequest(BaseModel):
draftId: str | None = Field(default=None, max_length=64)
intent: str = ""
agents: list[JobAgent] = Field(default_factory=list)
model: str | None = None
orchestrationMode: str = "recommend"
chain: dict[str, Any] | None = None
# 第一轮 leader prompt;缺省用 intent。
seedMessage: str | None = None
# 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。
threadIds: dict[str, str] | None = None
coordinatorName: str = "roundtable-coordinator"
class ResumeJobRequest(BaseModel):
answer: str = ""
class CleanupRequest(BaseModel):
retention_days: int = Field(default=7, ge=0, description="保留多少天内的作业;0 = 不保留")
only_finished: bool = Field(default=True, description="仅清终态(done/error/cancelled),保护在跑作业")
batch_size: int = Field(default=500, ge=1, le=5000)
class CleanupResponse(BaseModel):
deleted: int
elapsed_seconds: float
params: dict[str, Any]
class RoundtableJobResponse(BaseModel):
id: str
draftId: str | None = None
status: str = "queued"
phase: str = "initializing"
cycle: int = 0
dispatchChain: list[dict[str, Any]] = Field(default_factory=list)
activeAgentId: str | None = None
consensusPercentage: int = 0
pendingClarification: str | None = None
orchestrationMode: str = "recommend"
chain: dict[str, Any] | None = None
error: str | None = None
createdAt: datetime | str | None = None
updatedAt: datetime | str | None = None
# ── helpers ──────────────────────────────────────────────────────────────────
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_job_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable job store not available")
return store
def _get_executor(request: Request):
executor = getattr(request.app.state, "roundtable_job_executor", None)
if executor is None:
raise HTTPException(status_code=503, detail="Roundtable job executor not available")
return executor
def _pending_nodes(agents: list[JobAgent]) -> list[dict[str, Any]]:
"""启动即落一份「全 pending」的派活链路,使 GET / 首帧 SSE 立刻能渲染流程图。"""
return [
{"agentId": a.agentId, "name": a.name or a.agentId, "order": i + 1, "state": "pending"}
for i, a in enumerate(agents)
]
def _to_response(row: dict[str, Any]) -> RoundtableJobResponse:
return RoundtableJobResponse(
id=row["id"],
draftId=row.get("draft_id"),
status=row.get("status") or "queued",
phase=row.get("phase") or "initializing",
cycle=int(row.get("cycle") or 0),
dispatchChain=row.get("dispatch_chain") or [],
activeAgentId=row.get("active_agent_id"),
consensusPercentage=int(row.get("consensus_percentage") or 0),
pendingClarification=row.get("pending_clarification"),
orchestrationMode=row.get("orchestration_mode") or "recommend",
chain=row.get("chain"),
error=row.get("error"),
createdAt=row.get("created_at"),
updatedAt=row.get("updated_at"),
)
def _seat_refs_from_nodes(nodes: list[dict[str, Any]] | None) -> list[SeatRef]:
out: list[SeatRef] = []
for n in nodes or []:
aid = str(n.get("agentId") or "")
if aid:
out.append(SeatRef(aid, str(n.get("name") or aid)))
return out
# ── routes ───────────────────────────────────────────────────────────────────
@router.post("", response_model=RoundtableJobResponse, status_code=201)
async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
user_id = _current_user_id(request)
if not body.agents:
raise HTTPException(status_code=400, detail="agents is required")
agents = [SeatRef(a.agentId, a.name or a.agentId) for a in body.agents]
job_id = uuid4().hex
row = await store.create(
id=job_id,
draft_id=body.draftId,
user_id=user_id,
orchestration_mode=body.orchestrationMode,
coordinator_name=body.coordinatorName,
thread_ids=body.threadIds,
chain=body.chain,
dispatch_chain=_pending_nodes(body.agents),
status="queued",
)
executor.start_job(
StartParams(
job_id=job_id,
user_id=user_id,
agents=agents,
seed_message=(body.seedMessage or body.intent or "").strip(),
model=body.model,
mode=body.orchestrationMode,
intent_text=body.intent,
thread_ids=body.threadIds,
coordinator_name=body.coordinatorName,
draft_id=body.draftId,
)
)
return _to_response(row)
@router.get("", response_model=RoundtableJobResponse | None)
async def get_job_by_draft(request: Request, draft_id: str) -> RoundtableJobResponse | None:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_by_draft(draft_id, user_id=user_id)
return _to_response(row) if row is not None else None
@router.get("/{job_id}", response_model=RoundtableJobResponse)
async def get_job(request: Request, job_id: str) -> RoundtableJobResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
return _to_response(row)
@router.get("/{job_id}/stream")
async def stream_job(request: Request, job_id: str) -> StreamingResponse:
store = _get_store(request)
user_id = _current_user_id(request)
def _frame(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def gen():
for _ in range(_MAX_STREAM_TICKS):
if await request.is_disconnected():
return
row = await store.get(job_id, user_id=user_id)
if row is None:
yield _frame({"event": "error", "detail": "Job not found"})
return
yield _frame({"event": "progress", **_to_response(row).model_dump(mode="json")})
if (row.get("status") or "") in _TERMINAL:
return
await asyncio.sleep(_POLL_INTERVAL_S)
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
)
@router.post("/{job_id}/resume", response_model=RoundtableJobResponse)
async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
if (row.get("status") or "") != "awaiting_input":
raise HTTPException(status_code=409, detail="Job is not awaiting input")
answer = body.answer.strip()
if not answer:
raise HTTPException(status_code=400, detail="answer is required")
updated = await store.update_progress(
job_id, user_id=user_id, status="running", pending_clarification=None
)
executor.start_job(
StartParams(
job_id=job_id,
user_id=user_id,
agents=_seat_refs_from_nodes(row.get("dispatch_chain")),
seed_message=_RESUME_PREFIX + answer,
model=None,
mode=row.get("orchestration_mode") or "recommend",
intent_text=answer,
thread_ids=row.get("thread_ids"),
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
draft_id=row.get("draft_id"),
)
)
return _to_response(updated or row)
async def _require_admin(request: Request) -> None:
"""放行 admin / auth-disabled,其余 403(与 ai_writing / scheduled_tasks 同套语义)。"""
from app.gateway.deps import get_optional_user_from_request
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="圆桌作业清理管理接口仅限管理员")
@router.post("/admin/cleanup", response_model=CleanupResponse)
async def admin_cleanup(request: Request, body: CleanupRequest) -> CleanupResponse:
"""清理过期作业行(活跃作业受 only_finished 保护,永不误删)。
分批 ``list_older_than`` → ``delete_by_ids``,避免大事务。``retention_days=0``
一刷清空所有终态作业(调试用)。
"""
await _require_admin(request)
store = _get_store(request)
cutoff = datetime.now(UTC) - timedelta(days=body.retention_days)
t0 = time.perf_counter()
deleted = 0
while True:
ids = await store.list_older_than(cutoff, only_finished=body.only_finished, limit=body.batch_size)
if not ids:
break
deleted += await store.delete_by_ids(ids)
if len(ids) < body.batch_size:
break
return CleanupResponse(
deleted=deleted,
elapsed_seconds=round(time.perf_counter() - t0, 3),
params={"retention_days": body.retention_days, "only_finished": body.only_finished, "batch_size": body.batch_size},
)
@router.post("/{job_id}/cancel", response_model=RoundtableJobResponse)
async def cancel_job(request: Request, job_id: str) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
executor.cancel(job_id)
updated = await store.update_progress(job_id, user_id=user_id, status="cancelled")
return _to_response(updated or row)
@@ -0,0 +1,48 @@
"""圆桌总控编排引擎(Phase 2)。
把前端 useStep2Orchestration 的编排循环移植到后端,作为可后台跑、可单测的纯引擎。
Phase 3 会提供具体的 ``RoundtableGateway`` 实现(复用 multi_agent.py 内部函数)+
执行器 / LangGraph 注册,把引擎接到真实的 LLM / 线程上。
公开入口:
- ``run_orchestration`` —— 跑一次完整编排(循环 → 共识 → 报告绘制)。
- ``JobProgressWriter`` —— 进度落库 + 可选 SSE 外发。
- 协议 / 数据类(``RoundtableGateway`` / ``LeaderTurn`` / ``SeatTurn`` /
``ReportResult`` / ``SeatRef``)。
"""
from __future__ import annotations
from .engine import OrchestrationResult, build_report_prompt, run_orchestration
from .progress import JobProgressWriter
from .simulated_gateway import SimulatedRoundtableGateway
from .types import (
CHAIN_CLOSEOUT_PROMPT,
CONTINUE_PROMPT,
MAX_CYCLES,
LeaderTurn,
ReportResult,
RoundtableGateway,
SeatRef,
SeatTurn,
build_chain_leader_task,
pick_chain_task,
)
__all__ = [
"run_orchestration",
"OrchestrationResult",
"build_report_prompt",
"JobProgressWriter",
"SimulatedRoundtableGateway",
"RoundtableGateway",
"LeaderTurn",
"SeatTurn",
"ReportResult",
"SeatRef",
"MAX_CYCLES",
"CONTINUE_PROMPT",
"CHAIN_CLOSEOUT_PROMPT",
"build_chain_leader_task",
"pick_chain_task",
]
@@ -0,0 +1,299 @@
"""圆桌总控编排引擎 —— 后台跑的编排循环(移植自前端 useStep2Orchestration.ts)。
纯逻辑:通过 ``RoundtableGateway`` 协议驱动「单轮 leader / seat / report」,通过
``JobProgressWriter`` 落库进度。两种模式:
- recommend(``runOrchestration``):总控自由派活循环,leader → 顺序派 special →
leader …,直到 ``dispatched==[]``(共识)或达到 ``MAX_CYCLES``;
- chain(``runChainOrchestration``):总控**按席位顺序**逐位派活,走完整链后由
leader 收口产出最终结论。
共识达成(或 max cycles 强制收口)后进入 Step3 报告绘制,写出 ``方案总览.html``,
返回 ``DraftStep3Snapshot`` 形状的 step3 快照。命中澄清则返回 ``awaiting_input``
(thread 已 checkpoint,resume 时由调用方用用户答案再发起一轮即可续跑)。
**不并行 special**(与前端同一不变量):顺序 await 每个席位,避免 token 级抖动 +
保证派活链路游标严格推进。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from .progress import JobProgressWriter
from .types import (
CHAIN_CLOSEOUT_PROMPT,
CONTINUE_PROMPT,
MAX_CYCLES,
ReportResult,
RoundtableGateway,
SeatRef,
build_chain_leader_task,
pick_chain_task,
)
logger = logging.getLogger(__name__)
@dataclass
class OrchestrationResult:
"""引擎一次运行的终态。"""
status: str # "done" | "awaiting_input" | "error"
consensus: bool = False
step3: dict | None = None
pending_clarification: str | None = None
error: str | None = None
last_leader_content: str = ""
@dataclass
class _LoopOutcome:
"""内部:编排循环(未进报告阶段)的结果。"""
kind: str # "consensus" | "clarification" | "max_cycles"
question: str | None = None
last_leader_content: str = ""
# {agent_id: 最近一次交付正文}
deliveries: dict[str, str] = field(default_factory=dict)
async def run_orchestration(
*,
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
mode: str = "recommend",
intent_text: str = "",
) -> OrchestrationResult:
"""运行一次完整编排(循环 → 共识 → 报告绘制)。
Args:
gateway: 单轮执行抽象出口。
progress: 进度写入器(落库 + 可选 SSE 外发)。
agents: 有序席位(recommend=选择顺序 / chain=链条顺序)。
thread_ids: {agent_id / coordinator_name: thread_id},预先建好。
coordinator_name: 总控 agent_id(如 "roundtable-coordinator")。
model: 选定模型(None/"" 走后端默认)。
seed_message: 第一轮 leader 的 prompt(启动=初始意图;resume=用户答案续轮)。
mode: "recommend" | "chain"。
intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。
"""
try:
await progress.begin()
if mode == "chain":
outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message)
else:
outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message)
if outcome.kind == "clarification":
question = outcome.question or "总控需要补充信息,请补充后继续。"
await progress.awaiting_input(question)
return OrchestrationResult(
status="awaiting_input",
pending_clarification=question,
last_leader_content=outcome.last_leader_content,
)
# consensus 或 max_cycles:均收口 → 报告绘制
await progress.consensus(100)
step3 = await _run_report(
gateway,
progress,
agents=agents,
model=model,
intent_text=intent_text or seed_message,
consensus_text=outcome.last_leader_content,
deliveries=outcome.deliveries,
)
await progress.done()
return OrchestrationResult(
status="done",
consensus=True,
step3=step3,
last_leader_content=outcome.last_leader_content,
)
except Exception as exc: # noqa: BLE001 — 顶层兜底:标记 error 并返回
logger.exception("roundtable orchestration failed")
await progress.error(str(exc))
return OrchestrationResult(status="error", error=str(exc))
# ── recommend 模式 ───────────────────────────────────────────────────────────
async def _run_recommend(
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
) -> _LoopOutcome:
deliveries: dict[str, str] = {}
last_leader = ""
message = seed_message
for cycle in range(1, MAX_CYCLES + 1):
await progress.leader_thinking(cycle)
turn = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=message,
model=model,
)
if turn.final_text:
last_leader = turn.final_text
if turn.is_clarification:
return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries)
if turn.is_consensus:
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# **顺序**派活 —— 不要并行
await progress.dispatching(cycle)
for agent_id, task in turn.dispatched:
await progress.seat_active(agent_id)
seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=task, model=model)
deliveries[agent_id] = seat.final_text
await progress.seat_done(agent_id, cycle)
await progress.bump_consensus()
message = CONTINUE_PROMPT
# 达到最大轮次:强制收口(对齐前端 toast「已达到最大研讨轮次」)
logger.info("roundtable recommend loop hit MAX_CYCLES=%d, forcing consensus", MAX_CYCLES)
return _LoopOutcome("max_cycles", last_leader_content=last_leader, deliveries=deliveries)
# ── chain 模式 ───────────────────────────────────────────────────────────────
async def _run_chain(
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
) -> _LoopOutcome:
deliveries: dict[str, str] = {}
last_leader = ""
total = len(agents)
for i, seat in enumerate(agents):
# (a) 总控按链条顺序只给当前席位派活
await progress.leader_thinking(i + 1)
leader_task = build_chain_leader_task(
seed_message=seed_message,
position=i,
total=total,
seat_agent_id=seat.agent_id,
seat_name=seat.name,
is_first=(i == 0),
)
turn = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=leader_task,
model=model,
)
if turn.final_text:
last_leader = turn.final_text
if turn.is_clarification:
return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries)
task = pick_chain_task(turn.dispatched, turn.final_text, seat.agent_id, seat.name)
# (b) 当前席位交付(顺序,不并行)
await progress.seat_active(seat.agent_id)
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=task, model=model)
deliveries[seat.agent_id] = seat_turn.final_text
await progress.seat_done(seat.agent_id, i + 1)
if total > 0:
await progress.set_consensus_pct(round((i + 1) / total * 90))
# (c) 总控收口:综合全链交付,产出最终结论
if total > 0:
await progress.leader_thinking(total + 1)
closeout = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=CHAIN_CLOSEOUT_PROMPT,
model=model,
)
if closeout.final_text:
last_leader = closeout.final_text
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# ── 报告阶段 ─────────────────────────────────────────────────────────────────
async def _run_report(
gateway: RoundtableGateway,
progress: JobProgressWriter,
*,
agents: list[SeatRef],
model: str | None,
intent_text: str,
consensus_text: str,
deliveries: dict[str, str],
) -> dict:
await progress.report_drawing()
report_thread_id = await gateway.create_report_thread()
prompt = build_report_prompt(intent_text, agents, deliveries, consensus_text)
report: ReportResult = await gateway.run_report(report_thread_id=report_thread_id, prompt=prompt, model=model)
return {
"html": report.html,
"generatedAt": report.generated_at or datetime.now(UTC).isoformat(),
"model": report.model or (model or ""),
"summary": report.summary,
}
def build_report_prompt(
intent_text: str,
agents: list[SeatRef],
deliveries: dict[str, str],
consensus_text: str,
) -> str:
"""拼装 Step3 报告绘制 prompt:意图 + 各席位交付 + 总控共识。
席位交付按 ``agents`` 顺序列出(缺交付的席位跳过)。报告智能体据此用 write_file
写出自包含的 ``outputs/方案总览.html``(具体绘制规则在 roundtable-report 的 SOUL)。
"""
lines: list[str] = [
"【圆桌最终成果汇总 —— 请据此绘制方案总览 HTML 看板】",
"",
"## 任务意图",
(intent_text or "").strip() or "(未提供)",
"",
"## 各席位交付",
]
name_by_id = {a.agent_id: a.name for a in agents}
has_delivery = False
for a in agents:
text = (deliveries.get(a.agent_id) or "").strip()
if not text:
continue
has_delivery = True
lines.append(f"### {name_by_id.get(a.agent_id, a.agent_id)}")
lines.append(text)
lines.append("")
if not has_delivery:
lines.append("(各席位暂无可用交付,请基于任务意图与总控结论绘制。)")
lines.append("")
lines.append("## 总控最终结论")
lines.append((consensus_text or "").strip() or "(未提供)")
return "\n".join(lines)
@@ -0,0 +1,142 @@
"""编排进度写入器 —— 把引擎的状态流转持久化进 ``roundtable_jobs`` 表。
引擎只调本类的语义方法(leader_thinking / seat_active / seat_done / consensus / …),
由它维护「派活链路各节点状态」并落库(best-effort,失败不打断编排)。落库的
``dispatch_chain`` 即 Phase 7 流程图弹窗 / Phase 6 历史下拉的数据源。
进度同时可通过可选的 ``event_sink`` 回调外发(Phase 3 的 SSE 流用),引擎逻辑不感知。
"""
from __future__ import annotations
import logging
from typing import Any, Awaitable, Callable
from .types import SeatRef
logger = logging.getLogger(__name__)
# 节点状态机:pending → active → done。
_PENDING = "pending"
_ACTIVE = "active"
_DONE = "done"
EventSink = Callable[[dict[str, Any]], Awaitable[None]] | None
class JobProgressWriter:
"""跟踪派活链路节点状态并持久化进度。
Args:
store: ``RoundtableJobRepository``(或任何提供 ``update_progress`` 的对象);
传 None 时所有持久化变 no-op(引擎仍可纯跑,便于干测)。
job_id: 目标作业 id。
agents: 有序席位列表(recommend=选择顺序 / chain=链条顺序)→ 决定节点 order。
event_sink: 可选异步回调,每次进度变更时透传一份归一化事件(Phase 3 SSE)。
"""
def __init__(
self,
store: Any | None,
*,
job_id: str,
agents: list[SeatRef],
event_sink: EventSink = None,
) -> None:
self._store = store
self._job_id = job_id
self._agents = list(agents)
self._event_sink = event_sink
self._state: dict[str, str] = {a.agent_id: _PENDING for a in agents}
self._spoke_cycle: dict[str, int] = {}
self._consensus = 0
# ── 节点快照 ──────────────────────────────────────────────────────────
def _nodes(self) -> list[dict[str, Any]]:
"""构造 dispatch_chain 节点数组(§2.3)。
不含 avatarType —— 颜色由前端 ``getAvatarTypeFor(agentId, order)`` 派生,
避免把前端的 BUILTIN_AGENT_META 配色表重复移植到后端(易漂移)。
"""
out: list[dict[str, Any]] = []
for i, a in enumerate(self._agents):
node: dict[str, Any] = {
"agentId": a.agent_id,
"name": a.name,
"order": i + 1,
"state": self._state.get(a.agent_id, _PENDING),
}
if a.agent_id in self._spoke_cycle:
node["lastSpokeCycle"] = self._spoke_cycle[a.agent_id]
out.append(node)
return out
async def _persist(self, **fields: Any) -> None:
"""落库(始终带最新 dispatch_chain + consensus)+ 外发事件,均 best-effort。"""
fields.setdefault("dispatch_chain", self._nodes())
fields.setdefault("consensus_percentage", self._consensus)
if self._store is not None:
try:
await self._store.update_progress(self._job_id, **fields)
except Exception: # noqa: BLE001 — 进度落库失败不能打断编排
logger.warning("roundtable job progress persist failed", exc_info=True)
if self._event_sink is not None:
try:
await self._event_sink({"job_id": self._job_id, **fields})
except Exception: # noqa: BLE001
logger.warning("roundtable job progress event sink failed", exc_info=True)
# ── 语义方法(引擎调用)─────────────────────────────────────────────────
async def begin(self) -> None:
await self._persist(status="running", phase="initializing", cycle=0, active_agent_id=None)
async def leader_thinking(self, cycle: int) -> None:
await self._persist(phase="leader_thinking", cycle=cycle, active_agent_id=None)
async def dispatching(self, cycle: int) -> None:
await self._persist(phase="dispatching", cycle=cycle)
async def seat_active(self, agent_id: str) -> None:
if agent_id in self._state:
self._state[agent_id] = _ACTIVE
await self._persist(phase="dispatching", active_agent_id=agent_id)
async def seat_done(self, agent_id: str, cycle: int) -> None:
if agent_id in self._state:
self._state[agent_id] = _DONE
self._spoke_cycle[agent_id] = cycle
await self._persist(active_agent_id=None)
async def bump_consensus(self) -> None:
"""recommend 每轮派活后 +8(封顶 95),对齐前端。"""
self._consensus = min(95, self._consensus + 8)
await self._persist()
async def set_consensus_pct(self, pct: int) -> None:
"""chain 每席位后设进度(封顶 95),对齐前端 round((i+1)/total*90)。"""
self._consensus = min(95, int(pct))
await self._persist()
async def consensus(self, pct: int = 100) -> None:
self._consensus = int(pct)
await self._persist(phase="consensus", active_agent_id=None)
async def report_drawing(self) -> None:
await self._persist(phase="report_drawing", active_agent_id=None)
async def done(self) -> None:
self._consensus = 100
await self._persist(status="done", phase="report_done", active_agent_id=None)
async def awaiting_input(self, question: str) -> None:
await self._persist(
status="awaiting_input",
phase="leader_thinking",
active_agent_id=None,
pending_clarification=question,
)
async def error(self, message: str) -> None:
await self._persist(status="error", error=message)
@@ -0,0 +1,64 @@
"""模拟网关 —— 产生真实的编排进度但**不调真模型 / 真 LangGraph server**。
用途:
- Phase 3a:让 ``/api/roundtable-jobs`` 控制面(路由 + 执行器 + SSE)端到端跑起来,
前端 Phase 4-8 可对着真实 API + 真实 job 表 + 真实 SSE 联调,无需真模型;
- 单测:``delay=0`` 时瞬时跑完,验证执行器 / 进度落库 / 路由生命周期。
真网关 ``InProcessRoundtableGateway``(进程内驱动 leader/seat/report run,复用
multi_agent 的纯解析函数 + ``set_current_user`` 进程内调 agent runtime)是 Phase 3b,
需在 live stack 上验证。本模拟器与真网关实现同一个 ``RoundtableGateway`` 协议,可直接替换。
"""
from __future__ import annotations
import asyncio
from .types import LeaderTurn, ReportResult, SeatRef, SeatTurn
class SimulatedRoundtableGateway:
"""脚本化的假网关。
recommend:第 1 轮把所有席位派活一遍 → 第 2 轮共识;
chain:每个 per-seat leader 调用不显式派活(引擎据链条顺序强制跑席位)→ 收口。
"""
def __init__(self, agents: list[SeatRef], *, delay: float = 0.6, mode: str = "recommend") -> None:
self._agents = list(agents)
self._delay = delay
self._mode = mode
self._leader_calls = 0
async def _tick(self) -> None:
if self._delay > 0:
await asyncio.sleep(self._delay)
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
await self._tick()
self._leader_calls += 1
if self._mode == "chain":
# chain 模式由引擎按链条顺序强制跑席位,leader 只给说明、不派活。
return LeaderTurn(final_text="(模拟)总控:请当前席位按职责给出分析。", dispatched=[])
# recommend:首轮派活全部席位,次轮共识。
if self._leader_calls == 1:
return LeaderTurn(
final_text="(模拟)总控首轮派活:已为各席位分配本轮任务。",
dispatched=[(a.agent_id, f"请「{a.name}」就任务从你的专业职责给出分析与建议。") for a in self._agents],
)
return LeaderTurn(final_text="(模拟)各席位已充分研讨,结论收敛,达成共识。", dispatched=[])
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
await self._tick()
return SeatTurn(final_text=f"(模拟){agent_id} 的交付:已基于任务背景完成本轮分析。")
async def create_report_thread(self) -> str:
return "sim-report-thread"
async def run_report(self, *, report_thread_id, prompt, model) -> ReportResult:
await self._tick()
html = (
"<!doctype html><html><head><meta charset='utf-8'><title>方案总览(模拟)</title></head>"
"<body><h1>方案总览(模拟)</h1><p>这是模拟网关生成的占位报告,用于前端联调。</p></body></html>"
)
return ReportResult(html=html, summary="(模拟)方案已生成", model=model)
@@ -0,0 +1,191 @@
"""圆桌总控编排引擎 —— 共享类型、常量与纯函数。
把前端 ``useStep2Orchestration.ts`` 的编排循环(``runOrchestration`` /
``runChainOrchestration``)忠实移植到后端。本文件只放:
- ``RoundtableGateway`` 协议:引擎驱动「单轮 leader / seat / report」的抽象出口,
把对 FastAPI ``Request`` / ``multi_agent.py`` 内部函数的依赖隔离到 Phase 3 的
具体实现里(引擎本身纯逻辑、可用假网关单测);
- ``LeaderTurn`` / ``SeatTurn`` / ``ReportResult`` / ``SeatRef`` 数据类;
- 常量(MAX_CYCLES、各轮 prompt)与 chain 模式的 prompt 构造纯函数。
对应 ``frontend-web/docs/multi-agent-background-run-dev.md`` §Phase 2。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
# 编排循环最大轮次,与前端 roundtable-constants.ts MAX_CYCLES 对齐。
MAX_CYCLES = 8
# recommend 模式每轮派活后、回到 leader 的「续轮」prompt(逐字对齐前端)。
CONTINUE_PROMPT = (
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;"
"若信息已足够,请直接输出最终方案并停止派活。"
)
# chain 模式全链走完后、总控收口的 prompt(逐字对齐前端)。
CHAIN_CLOSEOUT_PROMPT = (
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。"
"不要再派活、不要再向任何席位分配任务。"
)
# ── 数据类 ──────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SeatRef:
"""一个研讨席位(智能体)。chain 模式下 ``agents`` 的顺序即链条顺序。"""
agent_id: str
name: str
@dataclass
class LeaderTurn:
"""总控一轮执行的归一化结果(由 gateway.run_leader 产出)。"""
final_text: str = ""
# [(agent_id, task), ...];空 = 共识(不再派活)。
dispatched: list[tuple[str, str]] = field(default_factory=list)
# 命中澄清时的问题文本;非 None 即 clarification 分支。
clarification: str | None = None
@property
def is_clarification(self) -> bool:
return bool(self.clarification)
@property
def is_consensus(self) -> bool:
return not self.dispatched and not self.is_clarification
@dataclass
class SeatTurn:
"""某席位一轮交付的归一化结果(由 gateway.run_seat 产出)。"""
final_text: str = ""
@dataclass
class ReportResult:
"""Step3 报告绘制结果(由 gateway.run_report 产出)。"""
html: str = ""
summary: str | None = None
model: str | None = None
generated_at: str | None = None
# ── 网关协议 ────────────────────────────────────────────────────────────────
@runtime_checkable
class RoundtableGateway(Protocol):
"""引擎驱动「单轮执行」的抽象出口。
Phase 3 提供具体实现(``LoopbackRoundtableGateway``):内部复用
``multi_agent.py`` 的 ``_leader_run`` / ``_special_run`` / ``_create_thread_direct``
等(它们依赖 ``Request``,由 Phase 3 的启动上下文注入)。引擎只面向本协议,
因此可用假网关脱离真模型 / 真 LangGraph server 单测。
线程约定:席位 + 总控 thread 由调用方预先建好并通过 ``thread_ids`` 传入
(key = agent_id / coordinator_name,value = thread_id);report thread 由引擎在
进入报告阶段时通过 ``create_report_thread`` 现建。
"""
async def run_leader(
self,
*,
thread_ids: dict[str, str],
coordinator_name: str,
message: str,
model: str | None,
) -> LeaderTurn: ...
async def run_seat(
self,
*,
thread_ids: dict[str, str],
agent_id: str,
task: str,
model: str | None,
) -> SeatTurn: ...
async def create_report_thread(self) -> str: ...
async def run_report(
self,
*,
report_thread_id: str,
prompt: str,
model: str | None,
) -> ReportResult: ...
# ── chain 模式 prompt 构造(移植自前端 buildChainLeaderTask / pickChainTask)────
def build_chain_leader_task(
*,
seed_message: str,
position: int,
total: int,
seat_agent_id: str,
seat_name: str,
is_first: bool,
) -> str:
"""构造 chain 模式下「让总控只给当前席位派活」的 leader prompt。"""
ordinal = f"{position + 1}/{total}"
if is_first:
return "\n".join(
[
"这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:",
seed_message,
"",
f"本次研讨严格按预设链条顺序进行,共 {total} 个席位,现在是第 {ordinal} 个席位。",
f"请你作为总控,通过 agent_orchestration 技能**仅**向智能体「{seat_name}」"
f"(agent_name: {seat_agent_id})派活,结合任务背景为它分配聚焦其职责的本轮任务。",
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
]
)
return "\n".join(
[
f"链条研讨继续,现在轮到第 {ordinal} 个席位。",
f"请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体"
f"「{seat_name}」(agent_name: {seat_agent_id})派活,为它分配承接前序结论、聚焦其职责的本轮任务。",
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
]
)
def pick_chain_task(
dispatched: list[tuple[str, str]],
leader_content: str,
seat_agent_id: str,
seat_name: str,
) -> str:
"""从 leader 终态里取「派给当前链条席位」的任务文本,带纠偏(移植自前端 pickChainTask)。
1) 命中 agent_id 完全一致的派活 → 用它的 task;
2) 总控派了活但写偏 id → 仍用它给出的 task 文本(强制跑正确席位);
3) 总控只写了说明没派活 → 用 leader 正文,再不行回退到模板任务。
"""
wanted = seat_agent_id.lower()
for name, task in dispatched:
if name.lower() == wanted and (task or "").strip():
return task
for _name, task in dispatched:
if (task or "").strip():
return task
if (leader_content or "").strip():
return (
f"请你作为「{seat_name}」,基于以下总控说明与此前各席位的交付,"
f"从你的专业职责出发给出你的分析与交付:\n{leader_content.strip()}"
)
return (
f"请你作为「{seat_name}」,基于任务背景与此前各席位的交付,"
"从你的专业职责出发给出你的分析与交付。"
)
@@ -0,0 +1,68 @@
"""Create roundtable_jobs table.
Backend background-orchestration jobs for the roundtable-planning Step 2「后台挂起」
feature: one row per server-side run of the leader→dispatch→consensus→report
pipeline. Big JSON columns (dispatch_chain / thread_ids / chain) are LONGTEXT on
MySQL (can exceed the 64 KB TEXT cap) and plain TEXT elsewhere — matching
``PortableLongText`` on ``RoundtableJobRow``. ``create_all`` adds the table for
brand-new DBs; this revision creates it on DBs that predate it.
Revision ID: 20260604_01
Revises: 20260603_01
Create Date: 2026-06-04
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260604_01"
down_revision = "20260603_01"
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("roundtable_jobs"):
return
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.create_table(
"roundtable_jobs",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("draft_id", sa.String(64), nullable=True),
sa.Column("user_id", sa.String(64), nullable=True),
sa.Column("status", sa.String(32), nullable=False, server_default="queued"),
sa.Column("phase", sa.String(32), nullable=False, server_default="initializing"),
sa.Column("cycle", sa.Integer, nullable=False, server_default="0"),
sa.Column("dispatch_chain", long_text, nullable=True),
sa.Column("active_agent_id", sa.String(128), nullable=True),
sa.Column("consensus_percentage", sa.Integer, nullable=False, server_default="0"),
sa.Column("pending_clarification", sa.Text, nullable=True),
sa.Column("thread_ids", long_text, nullable=True),
sa.Column("coordinator_name", sa.String(128), nullable=True),
sa.Column("orchestration_mode", sa.String(16), nullable=False, server_default="recommend"),
sa.Column("chain", long_text, nullable=True),
sa.Column("error", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_roundtable_jobs_draft_id", "roundtable_jobs", ["draft_id"])
op.create_index("ix_roundtable_jobs_user_id", "roundtable_jobs", ["user_id"])
op.create_index("ix_roundtable_jobs_status", "roundtable_jobs", ["status"])
op.create_index("ix_roundtable_jobs_updated_at", "roundtable_jobs", ["updated_at"])
def downgrade() -> None:
if _has_table("roundtable_jobs"):
op.drop_table("roundtable_jobs")
@@ -32,6 +32,7 @@ from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
)
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskDeliveryProfileRow,
@@ -61,6 +62,7 @@ __all__ = [
"RecommendedQuestionRow",
"RoundtableChainRow",
"RoundtableDraftRow",
"RoundtableJobRow",
"RoundtableRecommendHistoryRow",
"RunEventRow",
"RunRow",
@@ -0,0 +1,47 @@
"""Roundtable background-job persistence.
Mirrors ``ai_writing_sessions``: exposes a store factory plus a process-level
default-store singleton so the (harness-side) orchestration graph nodes can write
progress without reaching into FastAPI ``request.app.state`` (harness → app imports
are forbidden). app/gateway/deps.py registers the default store at startup.
"""
from __future__ import annotations
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
from deerflow.persistence.roundtable_jobs.sql import RoundtableJobRepository
def make_roundtable_job_store(session_factory) -> RoundtableJobRepository | None:
if session_factory is None:
return None
return RoundtableJobRepository(session_factory)
# ── 进程级默认 repo 单例(跨 app/harness 边界)──────────────────────────────────
#
# Phase 2 的圆桌编排图注册成 LangGraph Server assistant 后,节点跑在 harness 进程里,
# 拿不到 FastAPI 的 request.app.state;harness → app 又禁止 import。
# 写入:app/gateway/deps.py 创建 session_factory 后 register_default_store(...)。
# 读取:图节点里 get_default_store(),None 表示未配置 DB(跳过写入即可)。
_default_store: RoundtableJobRepository | None = None
def register_default_store(store: RoundtableJobRepository | None) -> None:
"""登记进程级默认圆桌作业 store(幂等,可覆盖;传 None 显式清理,主要给测试)。"""
global _default_store
_default_store = store
def get_default_store() -> RoundtableJobRepository | None:
"""返回进程级默认圆桌作业 store;未配置时为 None。"""
return _default_store
__all__ = [
"RoundtableJobRow",
"RoundtableJobRepository",
"make_roundtable_job_store",
"register_default_store",
"get_default_store",
]
@@ -0,0 +1,67 @@
"""ORM model for roundtable background-orchestration jobs.
A "job" = one **backend** run of the roundtable orchestration pipeline kicked off
from Step 2「后台挂起」: it drives the leader→dispatch→sub-agents loop to consensus
and then Step 3 report drawing, entirely server-side, surviving page refresh/close.
Bound 1:1 to a ``roundtable_drafts`` row (``draft_id``) so the history dropdown can
show "this task is running" and the dispatch-chain modal can render live progress.
Big JSON columns (``dispatch_chain`` / ``thread_ids`` / ``chain``) are stored as
serialized strings in ``PortableLongText`` (MySQL ``LONGTEXT``) — same approach as
``ai_writing_sessions`` / ``roundtable_drafts`` — to avoid MySQL's 64 KB ``TEXT`` cap.
See ``frontend-web/docs/multi-agent-background-run-dev.md`` §2.5 for the field
contract and §Phase 1 for the build steps.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class RoundtableJobRow(Base):
__tablename__ = "roundtable_jobs"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# The draft/session this job runs for. Indexed for "active job of draft" lookups.
draft_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# Job status machine (§2.1): queued / running / awaiting_input / done / error / cancelled.
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True)
# Fine-grained phase (§2.2): initializing / leader_thinking / dispatching /
# consensus / report_drawing / report_done.
phase: Mapped[str] = mapped_column(String(32), nullable=False, default="initializing")
# Current orchestration cycle (0..MAX_CYCLES).
cycle: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Dispatch chain nodes (§2.3): JSON array of
# { agentId, name, avatarType, order, state, lastSpokeCycle? }.
dispatch_chain: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# Currently-speaking seat agent_id (null = coordinator / none).
active_agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
consensus_percentage: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# When status=awaiting_input: the coordinator's clarification question text.
pending_clarification: Mapped[str | None] = mapped_column(Text, nullable=True)
# ThreadIdMap: JSON object { "<agent_id>": "<thread_id>", ... }.
thread_ids: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
coordinator_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# "recommend"(总控自由派活)| "chain"(按业务链条顺序派活).
orchestration_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="recommend")
# chain 模式来源链条快照:JSON object { id, title }(recommend 模式为 null).
chain: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# Terminal error detail when status=error.
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# 时间列必须用 BeijingDateTime(项目规范,与 ai_writing_sessions / roundtable_drafts 对齐)。
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,276 @@
"""SQLAlchemy-backed roundtable background-job storage.
JSON columns (``dispatch_chain`` / ``thread_ids`` / ``chain``) are stored as
serialized strings in ``PortableLongText`` and round-tripped here with
``json.dumps`` / ``json.loads`` — same approach as ``ai_writing_sessions`` /
``roundtable_drafts``.
User isolation mirrors ``ai_writing_sessions``: methods take ``user_id`` defaulting
to ``AUTO`` (resolved from the run context) so backend graph nodes can write without
threading a user id through, while gateway routes pass it explicitly.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
_UNSET = object()
# 终态:列表/清理判定用。
TERMINAL_STATUSES = ("done", "error", "cancelled")
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 RoundtableJobRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: RoundtableJobRow) -> dict[str, Any]:
d = row.to_dict()
for k in ("created_at", "updated_at"):
if isinstance(d.get(k), datetime):
d[k] = d[k].isoformat()
for k in ("dispatch_chain", "thread_ids", "chain"):
d[k] = _loads(d.get(k))
return d
# ── create ────────────────────────────────────────────────────────────
async def create(
self,
*,
id: str,
draft_id: str | None = None,
user_id: str | None | _AutoSentinel = AUTO,
orchestration_mode: str = "recommend",
coordinator_name: str | None = None,
thread_ids: dict[str, str] | None = None,
chain: dict[str, Any] | None = None,
dispatch_chain: list[dict[str, Any]] | None = None,
status: str = "queued",
phase: str = "initializing",
) -> dict[str, Any]:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.create")
now = datetime.now(UTC)
row = RoundtableJobRow(
id=id,
draft_id=draft_id,
user_id=resolved_user_id,
status=status,
phase=phase,
cycle=0,
dispatch_chain=_dumps(dispatch_chain),
active_agent_id=None,
consensus_percentage=0,
pending_clarification=None,
thread_ids=_dumps(thread_ids),
coordinator_name=coordinator_name,
orchestration_mode=orchestration_mode,
chain=_dumps(chain),
error=None,
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
# ── update progress ─────────────────────────────────────────────────────
async def update_progress(
self,
job_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
status=_UNSET,
phase=_UNSET,
cycle=_UNSET,
dispatch_chain=_UNSET,
active_agent_id=_UNSET,
consensus_percentage=_UNSET,
pending_clarification=_UNSET,
thread_ids=_UNSET,
coordinator_name=_UNSET,
error=_UNSET,
) -> dict[str, Any] | None:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.update_progress")
async with self._sf() as session:
row = await session.get(RoundtableJobRow, job_id)
if row is None:
return None
if resolved_user_id is not None and row.user_id != resolved_user_id:
return None
if status is not _UNSET:
row.status = status
if phase is not _UNSET:
row.phase = phase
if cycle is not _UNSET:
row.cycle = int(cycle)
if dispatch_chain is not _UNSET:
row.dispatch_chain = _dumps(dispatch_chain)
if active_agent_id is not _UNSET:
row.active_agent_id = active_agent_id
if consensus_percentage is not _UNSET:
row.consensus_percentage = int(consensus_percentage)
if pending_clarification is not _UNSET:
row.pending_clarification = pending_clarification
if thread_ids is not _UNSET:
row.thread_ids = _dumps(thread_ids)
if coordinator_name is not _UNSET:
row.coordinator_name = coordinator_name
if error is not _UNSET:
row.error = error
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
# ── read ────────────────────────────────────────────────────────────────
async def get(
self,
job_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
) -> dict[str, Any] | None:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get")
async with self._sf() as session:
row = await session.get(RoundtableJobRow, job_id)
if row is None:
return None
if resolved_user_id is not None and row.user_id != resolved_user_id:
return None
return self._row_to_dict(row)
async def get_by_draft(
self,
draft_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
) -> dict[str, Any] | None:
"""Return the most-recently-updated job for a draft (or None)."""
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get_by_draft")
stmt = select(RoundtableJobRow).where(RoundtableJobRow.draft_id == draft_id)
if resolved_user_id is not None:
stmt = stmt.where(RoundtableJobRow.user_id == resolved_user_id)
stmt = stmt.order_by(RoundtableJobRow.updated_at.desc()).limit(1)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalars().first()
return self._row_to_dict(row) if row is not None else None
async def list_by_user(
self,
*,
user_id: str | None | _AutoSentinel = AUTO,
limit: int = 50,
) -> list[dict[str, Any]]:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.list_by_user")
stmt = select(RoundtableJobRow)
if resolved_user_id is not None:
stmt = stmt.where(RoundtableJobRow.user_id == resolved_user_id)
stmt = stmt.order_by(RoundtableJobRow.updated_at.desc()).limit(limit)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
async def status_map_by_user(
self,
user_id: str,
*,
limit: int = 200,
) -> dict[str, dict[str, str]]:
"""``{draft_id: {"jobId": ..., "status": ...}}`` for the latest job per draft.
Drives the history dropdown's per-draft status badge (Phase 6). Walks jobs
newest-first and keeps the first (latest) one seen for each draft_id.
"""
stmt = (
select(RoundtableJobRow.id, RoundtableJobRow.draft_id, RoundtableJobRow.status)
.where(RoundtableJobRow.user_id == user_id)
.where(RoundtableJobRow.draft_id.is_not(None))
.order_by(RoundtableJobRow.updated_at.desc())
.limit(limit)
)
out: dict[str, dict[str, str]] = {}
async with self._sf() as session:
result = await session.execute(stmt)
for job_id, draft_id, status in result.all():
if draft_id is None or draft_id in out:
continue
out[draft_id] = {"jobId": job_id, "status": status}
return out
# ── delete / cleanup ─────────────────────────────────────────────────────
async def delete(
self,
job_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
) -> bool:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.delete")
async with self._sf() as session:
row = await session.get(RoundtableJobRow, job_id)
if row is None:
return False
if resolved_user_id is not None and row.user_id != resolved_user_id:
return False
await session.delete(row)
await session.commit()
return True
async def list_older_than(
self,
cutoff: datetime,
*,
only_finished: bool = False,
limit: int = 500,
) -> list[str]:
"""列出 updated_at 早于 cutoff 的作业 id(最多 limit 个),供清理任务用。
only_finished=True 时只列终态(done/error/cancelled),避免清掉还在跑的作业。
"""
stmt = select(RoundtableJobRow.id).where(RoundtableJobRow.updated_at < cutoff)
if only_finished:
stmt = stmt.where(RoundtableJobRow.status.in_(TERMINAL_STATUSES))
stmt = stmt.order_by(RoundtableJobRow.updated_at.asc()).limit(limit)
async with self._sf() as session:
result = await session.execute(stmt)
return [row[0] for row in result.all()]
async def delete_by_ids(self, job_ids: list[str]) -> int:
if not job_ids:
return 0
stmt = delete(RoundtableJobRow).where(RoundtableJobRow.id.in_(job_ids))
async with self._sf() as session:
result = await session.execute(stmt)
await session.commit()
return int(result.rowcount or 0)
@@ -0,0 +1,100 @@
"""圆桌后台作业真网关 —— headless 验证脚本(Phase 3b)。
用法(在能起完整后端栈 + 配好真模型的环境):
1) 用真网关启动后端:
ROUNDTABLE_JOB_GATEWAY=inprocess make gateway
(或 docker 里给 gateway 容器加该环境变量)
2) 跑本脚本,它会:POST 启动一个作业 → 轮询 GET 打印进度 → 跑到 done 打印报告长度。
PYTHONPATH=. uv run python scripts/probe_roundtable_job.py
环境变量:
GATEWAY_URL 默认 http://127.0.0.1:8001
AUTH_COOKIE 若后端开启鉴权,传 "session=xxx" 之类的 Cookie 头
PROBE_INTENT 自定义任务意图
PROBE_MODE recommend(默认)| chain
说明:鉴权关闭的 dev 模式无需 AUTH_COOKIE。真网关会进程内驱动 roundtable-coordinator /
各席位 / roundtable-report 真模型,耗时取决于模型,可能数分钟。
"""
from __future__ import annotations
import asyncio
import os
import time
import httpx
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://127.0.0.1:8001").rstrip("/")
AUTH_COOKIE = os.getenv("AUTH_COOKIE", "")
INTENT = os.getenv("PROBE_INTENT", "为一家中型制造企业设计一套数字化转型的整体方案")
MODE = os.getenv("PROBE_MODE", "recommend")
# 用 3 个内置圆桌席位(确保可见、已 seed)。
AGENTS = [
{"agentId": "roundtable-intelligence", "name": "情报收集"},
{"agentId": "roundtable-solution-design", "name": "方案设计"},
{"agentId": "roundtable-summary", "name": "总结陈述"},
]
TERMINAL = {"done", "error", "cancelled"}
def _headers() -> dict[str, str]:
h = {"Content-Type": "application/json"}
if AUTH_COOKIE:
h["Cookie"] = AUTH_COOKIE
return h
async def main() -> None:
async with httpx.AsyncClient(timeout=30.0, trust_env=False) as client:
print(f"[probe] POST {GATEWAY_URL}/api/roundtable-jobs mode={MODE}")
resp = await client.post(
f"{GATEWAY_URL}/api/roundtable-jobs",
headers=_headers(),
json={"intent": INTENT, "agents": AGENTS, "orchestrationMode": MODE},
)
if resp.status_code != 201:
print(f"[probe] start failed: HTTP {resp.status_code}: {resp.text[:500]}")
return
job = resp.json()
job_id = job["id"]
print(f"[probe] started job {job_id}")
last = ""
t0 = time.perf_counter()
while True:
await asyncio.sleep(2.0)
r = await client.get(f"{GATEWAY_URL}/api/roundtable-jobs/{job_id}", headers=_headers())
if r.status_code != 200:
print(f"[probe] get failed: HTTP {r.status_code}: {r.text[:300]}")
return
j = r.json()
line = (
f"status={j['status']} phase={j['phase']} cycle={j['cycle']} "
f"active={j.get('activeAgentId')} consensus={j['consensusPercentage']}%"
)
if line != last:
print(f"[probe] +{time.perf_counter() - t0:5.0f}s {line}")
last = line
if j["status"] in TERMINAL:
print(f"[probe] terminal: {j['status']}")
if j["status"] == "error":
print(f"[probe] error detail: {j.get('error')}")
# 读草稿 step3 验证报告已落库
if j.get("draftId"):
dr = await client.get(
f"{GATEWAY_URL}/api/roundtable-drafts/{j['draftId']}", headers=_headers()
)
if dr.status_code == 200:
step3 = dr.json().get("step3")
html_len = len((step3 or {}).get("html") or "")
print(f"[probe] draft.step3 html length = {html_len}")
return
if __name__ == "__main__":
asyncio.run(main())