From 10334ddfe1327adfbefeca2b99550ee9994af534 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Thu, 4 Jun 2026 15:40:15 +0800 Subject: [PATCH 1/3] feat: AI writing history, Word export, roundtable dispatch chain --- .../docs/multi-agent-background-run-dev.md | 514 ++++++++++++++++++ .../src/core/api/markdown-docx-export.ts | 93 ++++ .../src/open-canvas/api/writing-export.ts | 67 +-- .../ai-writing/AIWritingDraftPanel.tsx | 8 +- .../components/ai-writing/AIWritingPanel.tsx | 98 +--- .../artifact/TextArtifactRenderer.tsx | 21 +- .../components/artifact/WritingToolbar.tsx | 40 +- .../contexts/AIWritingHistoryContext.tsx | 82 +++ .../src/open-canvas/pages/AIWritingPage.tsx | 7 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 10 + .../components/DispatchChainModal.tsx | 339 ++++++++++++ .../src/roundtable-planning/lib/job-types.ts | 56 ++ .../pages/DispatchChainPreviewPage.tsx | 165 ++++++ .../styles/roundtable-planning.css | 44 ++ .../backend/app/gateway/deps.py | 10 + .../app/gateway/routers/roundtable_drafts.py | 17 + .../versions/20260604_01_roundtable_jobs.py | 68 +++ .../deerflow/persistence/models/__init__.py | 2 + .../persistence/roundtable_jobs/__init__.py | 47 ++ .../persistence/roundtable_jobs/model.py | 67 +++ .../persistence/roundtable_jobs/sql.py | 276 ++++++++++ 21 files changed, 1888 insertions(+), 143 deletions(-) create mode 100644 frontend-web/docs/multi-agent-background-run-dev.md create mode 100644 frontend-web/src/core/api/markdown-docx-export.ts create mode 100644 frontend-web/src/open-canvas/contexts/AIWritingHistoryContext.tsx create mode 100644 frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx create mode 100644 frontend-web/src/roundtable-planning/lib/job-types.ts create mode 100644 frontend-web/src/roundtable-planning/pages/DispatchChainPreviewPage.tsx create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260604_01_roundtable_jobs.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py diff --git a/frontend-web/docs/multi-agent-background-run-dev.md b/frontend-web/docs/multi-agent-background-run-dev.md new file mode 100644 index 00000000..03e62dc3 --- /dev/null +++ b/frontend-web/docs/multi-agent-background-run-dev.md @@ -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 | 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/_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 + export async function getJob(id: string): Promise + export function streamJob(id: string, on: (e: JobProgressEvent) => void): () => void // 返回 unsubscribe + export async function resumeJob(id: string, answer: string): Promise + export async function cancelJob(id: string): Promise + ``` + - `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 + + {/* 大弹窗 */} + … 任务标题 + 阶段标签(leader_thinking/dispatching/report_drawing…) … + + {/* 横向链路:总控 ──▶ 席位1 ──▶ 席位2 ──▶ … ──▶ [共识] ──▶ [报告绘制] */} +
+
+ + {nodes.map((n,i)=>( + + {/* 活动连线:流动虚线动画 */} + {/* 活动节点:彩色+脉冲光环 */} + + ))} + + + + +
+
+ + + 轮次 {cycle}/8 · {statusLabel} + {/* Phase 8 */} + +
+
+``` + +**节点颜色**:`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`(已安装) +``` diff --git a/frontend-web/src/core/api/markdown-docx-export.ts b/frontend-web/src/core/api/markdown-docx-export.ts new file mode 100644 index 00000000..2c7168b0 --- /dev/null +++ b/frontend-web/src/core/api/markdown-docx-export.ts @@ -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 { + 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; + } +} diff --git a/frontend-web/src/open-canvas/api/writing-export.ts b/frontend-web/src/open-canvas/api/writing-export.ts index 57367122..425ed9f6 100644 --- a/frontend-web/src/open-canvas/api/writing-export.ts +++ b/frontend-web/src/open-canvas/api/writing-export.ts @@ -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 { - 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)); } diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx index da07a55f..55dbbbe8 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx @@ -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() {
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 183cf4d9..003f8799 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -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(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 ( -
- -
- - {historySession.draft_title ?? historySession.title} - -
- - - -
-
- -
- ); - } + const showNewButton = isHistoryMode || state.status !== 'idle'; return (
- + {!isHistoryMode && ( + + )} {/* 头部 */}
{ 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 智能写作'}
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */} @@ -365,7 +316,7 @@ export function AIWritingPanel({ )}
+ {/* 历史回看:左侧时间线(与实时写作同一套 AIWritingTimeline) */} + {isHistoryMode && historySession && ( + + )} + {/* 未开始时显示表单 */} - {isIdle && ( + {isIdle && !isHistoryMode && ( {/* 相对定位容器结束 */} {/* 状态栏 */} - {!isIdle && ( + {!isIdle && !isHistoryMode && (
- +
{/* 左侧目录栏(仿豆包):文档标题层级导航 */} @@ -282,7 +289,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende {/* 文档滚动区 */}
- {editor && ( + {editor && !readOnly && ( <> @@ -294,7 +301,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
{/* Phase: AI action menu */} - {phase === "menu" && ( + {!readOnly && phase === "menu" && ( + 历史文稿(只读) +
+ + + + + + 导出 Word + + +
+
+ ); + } + return (
{/* Block type */} diff --git a/frontend-web/src/open-canvas/contexts/AIWritingHistoryContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingHistoryContext.tsx new file mode 100644 index 00000000..943d350c --- /dev/null +++ b/frontend-web/src/open-canvas/contexts/AIWritingHistoryContext.tsx @@ -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; + closeHistory: () => void; +}; + +const AIWritingHistoryContext = createContext(null); + +export function AIWritingHistoryProvider({ children }: { children: ReactNode }) { + const { setArtifact } = useCanvasGraphContext(); + const { setIsDraftEditable } = useCanvasUIContext(); + const [historySession, setHistorySession] = useState(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 ( + + {children} + + ); +} + +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); +} diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx index d4ab02b9..09ebe1cf 100644 --- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx +++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx @@ -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(null); const { state } = useAIWriting(); + const { isHistoryMode } = useAIWritingHistory(); - if (state.status === 'idle') { + // 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿) + if (state.status === 'idle' && !isHistoryMode) { return ( + @@ -217,6 +221,7 @@ export default function AIWritingPage({ + diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index c84f988c..f6e68ad9 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -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({ } /> + {/* Phase 7 临时预览入口:派活链路流程图弹窗(假数据自测,接通后台后可删) */} + + + + } + /> } diff --git a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx new file mode 100644 index 00000000..130ef672 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx @@ -0,0 +1,339 @@ +/** + * 派活链路流程图弹窗(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 } from "react"; +import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon } 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; +} + +const STATUS_LABEL: Record = { + queued: "排队中", + running: "进行中", + awaiting_input: "等待澄清", + done: "已完成", + error: "出错", + cancelled: "已取消", +}; + +const PHASE_LABEL: Record = { + initializing: "初始化席位…", + leader_thinking: "总控决策派活中…", + dispatching: "席位发言中…", + consensus: "共识达成", + report_drawing: "结果绘制中…", + report_done: "绘制完成", +}; + +export function DispatchChainModal({ + open, + onOpenChange, + title = "多智能体研讨", + status, + phase, + cycle, + maxCycles = 8, + nodes, + consensusPercentage, + onView, +}: DispatchChainModalProps) { + // 总控节点:初始化 / 决策派活阶段高亮 + 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 ( + + + + + {status === "running" && ( + + )} + {title} + + {STATUS_LABEL[status]} + + + {PHASE_LABEL[phase]} + + + {/* 横向流程图(横向滚动) */} +
+
+ {/* 总控节点 */} + + + {/* 席位节点 */} + {nodes.map((node) => ( + + + + + ))} + + {/* 共识里程碑 */} + + + + {/* 报告绘制里程碑 */} + + +
+
+ + + + 轮次 {Math.min(cycle, maxCycles)}/{maxCycles} + · + 共识 {Math.round(consensusPercentage)}% + · + {PHASE_LABEL[phase]} + + + +
+
+ ); +} + +// ── 链路节点(席位 / 总控)───────────────────────────────────────────────── + +function FlowNode({ + label, + avatarType, + state, + order, +}: { + label: string; + avatarType: string; + state: ChainNodeState; + order?: number; +}) { + const tag = chainTagClasses(avatarType); + return ( +
+
+ {/* 活动光环:脉冲 ping */} + {state === "active" && ( + + )} + {/* 头像 + 状态描边 */} + + {getAvatar(avatarType, "h-14 w-14")} + {/* 完成打勾 */} + {state === "done" && ( + + + + )} + {/* 进行中转圈 */} + {state === "active" && ( + + + + )} + +
+ + {order != null ? `${order}. ` : ""} + {label} + +
+ ); +} + +// ── 里程碑节点(共识 / 报告绘制)───────────────────────────────────────────── + +function MilestoneNode({ + label, + emoji, + state, +}: { + label: string; + emoji: string; + state: ChainNodeState; +}) { + return ( +
+
+ {state === "active" && ( + + )} + + {emoji} + {state === "done" && ( + + + + )} + {state === "active" && ( + + + + )} + +
+ + {label} + +
+ ); +} + +// ── 连接线(带流动动画)───────────────────────────────────────────────────── + +function Connector({ active, done }: { active: boolean; done: boolean }) { + return ( +
+
+ + +
+
+ ); +} diff --git a/frontend-web/src/roundtable-planning/lib/job-types.ts b/frontend-web/src/roundtable-planning/lib/job-types.ts new file mode 100644 index 00000000..b8b29307 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/job-types.ts @@ -0,0 +1,56 @@ +/** + * 圆桌后台作业(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.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 }; diff --git a/frontend-web/src/roundtable-planning/pages/DispatchChainPreviewPage.tsx b/frontend-web/src/roundtable-planning/pages/DispatchChainPreviewPage.tsx new file mode 100644 index 00000000..63eb1a5f --- /dev/null +++ b/frontend-web/src/roundtable-planning/pages/DispatchChainPreviewPage.tsx @@ -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 | 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 ( +
+
+

派活链路流程图 · 预览

+

+ 模拟后台作业推进(假数据)。帧 {idx + 1}/{frames.length} · {frame.phase} +

+
+ +
+ + + + +
+ + {}} + title="新能源汽车出口策略研讨(预览)" + status={frame.status} + phase={frame.phase} + cycle={frame.cycle} + nodes={nodes} + consensusPercentage={frame.consensus} + onView={() => toast.info("(预览)点击「查看」→ Phase 8 会进入正在进行的任务")} + /> +
+ ); +} diff --git a/frontend-web/src/roundtable-planning/styles/roundtable-planning.css b/frontend-web/src/roundtable-planning/styles/roundtable-planning.css index 2ebc6170..4223a4ce 100644 --- a/frontend-web/src/roundtable-planning/styles/roundtable-planning.css +++ b/frontend-web/src/roundtable-planning/styles/roundtable-planning.css @@ -360,6 +360,50 @@ color: var(--foreground) !important; } +/* ============================================================================= + * 派活链路流程图连接线(DispatchChainModal)。 + * + * 弹窗经 Radix Dialog portal 到 ,**不在** .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; diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index d7f704ae..065bdf84 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -168,6 +168,16 @@ 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) + # 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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py index 4b22cd04..e4b0b190 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py @@ -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]) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260604_01_roundtable_jobs.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260604_01_roundtable_jobs.py new file mode 100644 index 00000000..0f790925 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260604_01_roundtable_jobs.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index c1bd3e50..7f54831e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -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", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/__init__.py new file mode 100644 index 00000000..661c9bb1 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/__init__.py @@ -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", +] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py new file mode 100644 index 00000000..ae4130c0 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py @@ -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 { "": "", ... }. + 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), + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py new file mode 100644 index 00000000..ba431113 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py @@ -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) From 9d863f63e5abc2d33c11d361fcb609459f079b28 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Thu, 4 Jun 2026 18:50:31 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/.env.development | 14 +- frontend-web/.env.development.local.example | 5 + frontend-web/.env.example | 3 + frontend-web/.env.network | 53 +-- frontend-web/.env.production | 4 + frontend-web/.env.production.local.example | 3 + frontend-web/.env.sit | 5 + .../artifacts/artifact-file-detail.tsx | 5 +- .../src/roundtable-planning/api/drafts.ts | 12 + .../api/roundtable-jobs.ts | 195 ++++++++++ .../ConsensusDocxDownloadButton.tsx | 59 +++ .../components/DispatchChainJobModal.tsx | 92 +++++ .../components/DispatchChainModal.tsx | 77 +++- .../components/HighFidelityReport.tsx | 110 +++--- .../components/Step2Panel.tsx | 14 + .../components/Step2SandboxLayout.tsx | 140 ++++--- .../components/Step3FileBrowseMode.tsx | 351 ++++++++++++++++++ .../components/Step3Panel.tsx | 7 + .../components/Step3ReportMode.tsx | 42 ++- .../hooks/useDraftPersistence.ts | 11 + .../src/roundtable-planning/lib/job-types.ts | 42 +++ .../pages/RoundtablePlanningPage.tsx | 145 +++++++- .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 39 ++ .../gateway/roundtable_inprocess_gateway.py | 272 ++++++++++++++ .../app/gateway/roundtable_job_executor.py | 152 ++++++++ .../app/gateway/routers/roundtable_jobs.py | 341 +++++++++++++++++ .../roundtable_orchestrator/__init__.py | 48 +++ .../agents/roundtable_orchestrator/engine.py | 299 +++++++++++++++ .../roundtable_orchestrator/progress.py | 142 +++++++ .../simulated_gateway.py | 64 ++++ .../agents/roundtable_orchestrator/types.py | 191 ++++++++++ .../backend/scripts/probe_roundtable_job.py | 100 +++++ 33 files changed, 2878 insertions(+), 163 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/api/roundtable-jobs.ts create mode 100644 frontend-web/src/roundtable-planning/components/ConsensusDocxDownloadButton.tsx create mode 100644 frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx create mode 100644 frontend-web/src/roundtable-planning/components/Step3FileBrowseMode.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py create mode 100644 offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/progress.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/simulated_gateway.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/types.py create mode 100644 offline-backend-20260512/backend/scripts/probe_roundtable_job.py diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 0e89436f..e8c1e371 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -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 diff --git a/frontend-web/.env.development.local.example b/frontend-web/.env.development.local.example index a5092803..9a45a1fe 100644 --- a/frontend-web/.env.development.local.example +++ b/frontend-web/.env.development.local.example @@ -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 diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 0c211a17..f0dda406 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -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 diff --git a/frontend-web/.env.network b/frontend-web/.env.network index be82f968..bf2a9011 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -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= + diff --git a/frontend-web/.env.production b/frontend-web/.env.production index 6aa2d33d..ad196043 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -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= diff --git a/frontend-web/.env.production.local.example b/frontend-web/.env.production.local.example index 659844ac..f56448e0 100644 --- a/frontend-web/.env.production.local.example +++ b/frontend-web/.env.production.local.example @@ -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 diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index 913fe3a3..dbaa36d4 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -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= diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index faa635a2..ef28a656 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -85,10 +85,13 @@ 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(); @@ -623,7 +626,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} /> diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index f038503a..ed15557a 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -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, }; } diff --git a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts new file mode 100644 index 00000000..ccbc09df --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts @@ -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(res: Response, ctx: string): Promise { + 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 & { 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): 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 { + const res = await apiFetch(BASE(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(toStartBody(payload)), + }); + return mapJob(await readJson>(res, "start job")); +} + +/** 查作业快照。404 → null。 */ +export async function getJob(jobId: string): Promise { + const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}`, { method: "GET" }); + if (res.status === 404) return null; + return mapJob(await readJson>(res, "get job")); +} + +/** 查某草稿最近一个作业。无 → null。 */ +export async function getJobByDraft(draftId: string): Promise { + const res = await apiFetch(`${BASE()}?draft_id=${encodeURIComponent(draftId)}`, { method: "GET" }); + const data = await readJson | null>(res, "get job by draft"); + return data ? mapJob(data) : null; +} + +/** 回答澄清后续跑。 */ +export async function resumeJob(jobId: string, answer: string): Promise { + const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/resume`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ answer }), + }); + return mapJob(await readJson>(res, "resume job")); +} + +/** 取消作业。 */ +export async function cancelJob(jobId: string): Promise { + const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel`, { method: "POST" }); + return mapJob(await readJson>(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; + try { + frame = JSON.parse(json) as Record; + } 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(); +} diff --git a/frontend-web/src/roundtable-planning/components/ConsensusDocxDownloadButton.tsx b/frontend-web/src/roundtable-planning/components/ConsensusDocxDownloadButton.tsx new file mode 100644 index 00000000..f6d5c34a --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/ConsensusDocxDownloadButton.tsx @@ -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 ( + + ); +} diff --git a/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx b/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx new file mode 100644 index 00000000..8d0a538b --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx @@ -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(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 ( + 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 + } + /> + ); +} diff --git a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx index 130ef672..349500a5 100644 --- a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx +++ b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx @@ -14,8 +14,8 @@ * (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段 * (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。 */ -import { Fragment } from "react"; -import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon } from "lucide-react"; +import { Fragment, useState } from "react"; +import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -52,10 +52,20 @@ export interface DispatchChainModalProps { 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 = new Set(["queued", "running", "awaiting_input"]); + const STATUS_LABEL: Record = { queued: "排队中", running: "进行中", @@ -85,7 +95,12 @@ export function DispatchChainModal({ nodes, consensusPercentage, onView, + pendingClarification, + onResume, + onCancel, + error, }: DispatchChainModalProps) { + const [answer, setAnswer] = useState(""); // 总控节点:初始化 / 决策派活阶段高亮 const coordinatorState: ChainNodeState = phase === "initializing" || phase === "leader_thinking" ? "active" : "done"; @@ -177,6 +192,47 @@ export function DispatchChainModal({
+ {/* 出错详情 */} + {status === "error" && error && ( +
+ {error} +
+ )} + + {/* 澄清作答(status==='awaiting_input')→ resumeJob 续跑 */} + {status === "awaiting_input" && pendingClarification && onResume && ( +
+
+
+ + 总控需要补充信息,请回答后继续 +
+

{pendingClarification}

+