Merge branch 'dhk-1' into 'master'
Dhk 1 See merge request knowledge-base-bj/deerflow-server!92
This commit is contained in:
commit
c766e3fa3b
54 files changed
+4890
-323
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -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`(已安装)
|
||||
```
|
||||
@@ -48,7 +48,13 @@ 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 {
|
||||
checkCodeFile,
|
||||
getFileName,
|
||||
isImageFile,
|
||||
} from "@/core/utils/files";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -85,29 +91,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 };
|
||||
@@ -116,18 +129,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();
|
||||
@@ -135,12 +151,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);
|
||||
@@ -450,7 +467,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}>
|
||||
@@ -527,7 +544,7 @@ export function ArtifactFileDetail({
|
||||
onClick={handleSaveArtifact}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||
@@ -546,7 +563,7 @@ export function ArtifactFileDetail({
|
||||
label={t.common.openInNewWindow}
|
||||
tooltip={t.common.openInNewWindow}
|
||||
onClick={async () => {
|
||||
if (isWriteFile) {
|
||||
if (useWriteFileStreaming) {
|
||||
handleOpenWriteFileInNewTab();
|
||||
return;
|
||||
}
|
||||
@@ -584,7 +601,7 @@ export function ArtifactFileDetail({
|
||||
tooltip={t.clipboard.copyToClipboard}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && language === "markdown" && (
|
||||
{!useWriteFileStreaming && language === "markdown" && (
|
||||
<ArtifactAction
|
||||
icon={isExportingWord ? LoaderIcon : FileType2Icon}
|
||||
label="另存为 Word"
|
||||
@@ -598,7 +615,7 @@ export function ArtifactFileDetail({
|
||||
label={t.common.download}
|
||||
tooltip={t.common.download}
|
||||
onClick={async () => {
|
||||
if (isWriteFile) {
|
||||
if (useWriteFileStreaming) {
|
||||
handleDownloadWriteFile();
|
||||
return;
|
||||
}
|
||||
@@ -623,7 +640,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>
|
||||
@@ -656,6 +673,8 @@ export function ArtifactFileDetail({
|
||||
<ArtifactFilePreview
|
||||
content={desensitize(displayContent ?? "")}
|
||||
language={language ?? "text"}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
@@ -696,14 +715,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;
|
||||
}) {
|
||||
@@ -739,7 +801,16 @@ export function ArtifactFilePreview({
|
||||
<Streamdown
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{ a: ArtifactLink }}
|
||||
components={{
|
||||
a: ArtifactLink,
|
||||
img: (props) => (
|
||||
<SandboxPreviewImage
|
||||
{...props}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -863,6 +864,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
|
||||
@@ -168,6 +171,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)
|
||||
+48
@@ -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",
|
||||
]
|
||||
+299
@@ -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)
|
||||
+142
@@ -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)
|
||||
+64
@@ -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)
|
||||
+191
@@ -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}」,基于任务背景与此前各席位的交付,"
|
||||
"从你的专业职责出发给出你的分析与交付。"
|
||||
)
|
||||
+68
@@ -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")
|
||||
+2
@@ -27,6 +27,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,
|
||||
@@ -53,6 +54,7 @@ __all__ = [
|
||||
"RecommendedQuestionRow",
|
||||
"RoundtableChainRow",
|
||||
"RoundtableDraftRow",
|
||||
"RoundtableJobRow",
|
||||
"RoundtableRecommendHistoryRow",
|
||||
"RunEventRow",
|
||||
"RunRow",
|
||||
|
||||
+47
@@ -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",
|
||||
]
|
||||
+67
@@ -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),
|
||||
)
|
||||
+276
@@ -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())
|
||||
Reference in new issue
Block a user