feat: AI writing history, Word export, roundtable dispatch chain
This commit is contained in:
1 parent
c0e3c5d692
commit
10334ddfe1
21 files changed
+1888
-143
No files matched your search
@@ -0,0 +1,514 @@
|
|||||||
|
# 多智能体规划 · 后台挂起执行 + 进度可视化 —— 分步实现方案
|
||||||
|
|
||||||
|
> 目标:在「多智能体规划」第二步增加**后台挂起**能力——把当前任务挂到后台,自动一路跑到
|
||||||
|
> 最后一步(Step3 结果绘制完成)为止;历史记录下拉框里进行中的任务显示加载动画;点击进行中
|
||||||
|
> 的任务弹出一个**大弹窗**,里面是**横向的智能体派活链路流程图**(节点 = 各席位智能体,图标 +
|
||||||
|
> 名称,不同颜色,正在发言的节点和它的连接线带加载动画);弹窗里有「查看」按钮,点击进入正在
|
||||||
|
> 进行中的任务实时观察。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 现状与架构事实(动手前必读)
|
||||||
|
|
||||||
|
研究自现有代码,决定了整套方案的形态。**关键结论:当前编排循环跑在前端,离开页面即中断。**
|
||||||
|
|
||||||
|
### 0.1 前端现状
|
||||||
|
|
||||||
|
| 事实 | 位置 |
|
||||||
|
|------|------|
|
||||||
|
| Step2 编排循环(leader → 顺序派 special → leader … 直到 `dispatched===[]` 共识 / `MAX_CYCLES=8`)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts:992-1204`(recommend)、`1228-1439`(chain) |
|
||||||
|
| 循环由 `currentStep===2` 的 `useEffect` 启停;离开 Step2 的 cleanup 会 `orchestrationCancelRef.current=true` + `abortAllInFlight()` 中断 | `useStep2Orchestration.ts:1468-1541`(尤其 cleanup `1534-1537`、`1471-1477`) |
|
||||||
|
| 共识判定:`leaderRes.dispatched.length===0` → `setHasConsensus(true)` | `useStep2Orchestration.ts:1091-1099` |
|
||||||
|
| 进入 Step3:手动点按钮,前置 `hasConsensus===true` | `Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595` |
|
||||||
|
| Step3「结果绘制」:前端流式调 `roundtable-report` 智能体,写出 `outputs/方案总览.html`,`phase→"done"` 即完成 | `src/roundtable-planning/hooks/useStep3Report.ts:36,51,165,560-596,636-652` |
|
||||||
|
| 草稿持久化:`/api/roundtable-drafts`,含 step1/step2/step3 三段快照 + `furthestStep(1|2|3)`,**无显式 status 字段** | `src/roundtable-planning/api/drafts.ts:25-110,112,184-225` |
|
||||||
|
| 历史下拉 UI(触发按钮 + 列表 + 重命名/删除) | `RoundtablePlanningPage.tsx:771-892` |
|
||||||
|
| 自动保存触发点(Step1 回复完 / Step2 每轮气泡收尾 / Step3 报告保存 / 步骤前进) | `useDraftPersistence.ts:248-316` |
|
||||||
|
| 加载草稿恢复:`handleLoadDraft` → `getDraft` → `cancelAllStreams` → hydrate → `setCurrentStep(furthestStep)` | `useDraftPersistence.ts:308-368` |
|
||||||
|
|
||||||
|
### 0.2 后端现状
|
||||||
|
|
||||||
|
| 事实 | 位置 |
|
||||||
|
|------|------|
|
||||||
|
| `/api/multi-agent/init`:为各席位并行建 thread + 单例总控 `roundtable-coordinator`,SSE 返回 `seat_ready`/`coordinator_ready`/`init_done(thread_ids)` | `app/gateway/routers/multi_agent.py:647-823` |
|
||||||
|
| `/api/multi-agent/run/stream`:驱动**单轮** leader 或 special 的 LLM 执行,末尾 JSON 帧 `status`(派活列表 / `[]` 共识 / `clarification` / `error`) | `multi_agent.py:1076-1142` |
|
||||||
|
| `/api/multi-agent/report/init`:为 Step3 报告建 thread | `multi_agent.py`(report/init) |
|
||||||
|
| **后端不存在编排循环**——leader 派活由前端解释后再逐个发 `run/stream`;后端只做单轮执行 + 把派活/完成「广播」追加进各 thread 历史消息 | `multi_agent.py:24-29,487-502,1108-1142` |
|
||||||
|
| 派活格式:`agent_orchestration` 工具调用 `{agent_name, task}`(旧式 bash 解析兼容) | `multi_agent.py:525-552` |
|
||||||
|
|
||||||
|
### 0.3 可复用模板:AI 写作的「后台多阶段流程」
|
||||||
|
|
||||||
|
AI 写作已经把「后台跑多阶段流程 + 前端 SSE 订阅 + 可 resume + 定时清理」跑通了,**圆桌后台作业直接照搬这套**:
|
||||||
|
|
||||||
|
| 模板要素 | 位置 |
|
||||||
|
|------|------|
|
||||||
|
| LangGraph 图驱动多阶段(researching/writing_outline/writing_draft/reviewing/…),条件路由推进,`interrupt()` 暂停点等用户输入 | `packages/harness/deerflow/agents/ai_writing/graph.py`、`nodes/*`(`pause_material_confirm` 等 `45-290`) |
|
||||||
|
| Session 持久化表 `ai_writing_sessions`:`id/user_id/title/status/draft_*/transcript(LONGTEXT)/...`,`status∈{in_progress,done,error}` | `packages/harness/deerflow/persistence/ai_writing_sessions/model.py:14-39` |
|
||||||
|
| `transcript` 用 `PortableLongText`(MySQL LONGTEXT,避开 64KB),时间用 `BeijingDateTime` | 同上 `27-38` |
|
||||||
|
| 进度走 LangGraph Server 标准流 `stream_mode=["messages-tuple","values"]`,**graph 在 langgraph runtime 里跑,任意 worker 可 join stream**(这是后台健壮性的关键) | `ai_writing.py:1-17`、`langgraph.json:16-18` |
|
||||||
|
| Checkpointer 共享 `AsyncSqliteSaver`,落 `.deer-flow/data/checkpoints.db`(需挂数据卷) | `runtime/checkpointer/async_provider.py` |
|
||||||
|
| Cleanup cron(leader 文件锁、活跃保护、先删 CP 再删 DB、admin 手动触发) | `app/gateway/ai_writing_cleanup.py:26,34-59,111-135,193-215,307-367` |
|
||||||
|
| **多 worker 坑**:模块级内存 dict(`_event_queues`)每 worker 一份会丢会话;SSE 进程内直调会整段 buffer。解法:状态进 DB+checkpointer、SSE 走真 TCP loopback `127.0.0.1`(`trust_env=False` 禁代理) | `multi_agent.py:271-293`、`AIWritingPage.tsx` 文件头注释 §3/§5 |
|
||||||
|
|
||||||
|
### 0.4 决策(已与产品确认)
|
||||||
|
|
||||||
|
1. **后端真后台执行**:把编排循环搬到后端 run,刷新 / 关页 / 换设备都不中断。
|
||||||
|
2. **流程图节点 = 智能体派活链路**:横向排列的席位节点,正在发言的节点 + 连接线加载动画。
|
||||||
|
|
||||||
|
> ⚠️ **本方案最大的工作量在 Phase 2**:把目前跑在前端 TS 里的编排循环(`runOrchestration`/`runChainOrchestration`)
|
||||||
|
> 移植成一个后端 **LangGraph「圆桌总控编排图」**。这是与 AI 写作一致、可在 LangGraph runtime 里
|
||||||
|
> 后台跑且天然 checkpoint 的健壮路径。下文每个阶段都给出验收标准,可独立提测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────── 前端 (frontend-web) ───────────────────────────────┐
|
||||||
|
│ Step2Panel ──「后台挂起」按钮──▶ POST /api/roundtable-jobs (start) │
|
||||||
|
│ HistoryDropdown ── 进行中任务显示 spinner ──▶ 点击 ──▶ DispatchChainModal(大弹窗) │
|
||||||
|
│ DispatchChainModal ── 订阅 GET /api/roundtable-jobs/{id}/stream(SSE) │
|
||||||
|
│ 横向派活链路流程图(节点=席位, 活动节点+连线动画) ──「查看」──▶ 进入任务实时观察 │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ SSE 进度 / 快照
|
||||||
|
┌─────────────────────────────── 后端 (backend) ─────────────────────────────────────┐
|
||||||
|
│ /api/roundtable-jobs 路由层(start/get/stream/resume/cancel) ── 仿 ai_writing.py │
|
||||||
|
│ │ 启动后台 run │
|
||||||
|
│ LangGraph「圆桌总控编排图」 roundtable_orchestrator ── 仿 ai_writing graph │
|
||||||
|
│ init → leader_call → parse_dispatch → dispatch_seats(顺序) → loop ↺ │
|
||||||
|
│ → consensus → report_draw(写 HTML) → done (clarification 点 interrupt()) │
|
||||||
|
│ │ 每次状态转移持久化进度 │
|
||||||
|
│ roundtable_jobs 表(status/phase/dispatch_chain/active_speaker/...) + 共享 checkpointer│
|
||||||
|
│ cleanup cron (仿 ai_writing_cleanup.py) │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心思路**:后台作业 = 一个 LangGraph 图的一次 run。图节点内部复用现有的单轮执行能力
|
||||||
|
(`multi_agent.py` 里 leader/special 的 run 逻辑),把「解释派活 → 顺序派活 → 回到 leader」的
|
||||||
|
循环从前端搬进图节点。前端从「驱动者」退化为「观察者」:通过 SSE 订阅进度渲染。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据契约(前后端共享,先定义后实现)
|
||||||
|
|
||||||
|
### 2.1 作业状态机 `JobStatus`
|
||||||
|
|
||||||
|
```
|
||||||
|
queued # 已创建,等待 worker 拉起
|
||||||
|
running # 编排进行中(Step2 派活循环 / Step3 绘制)
|
||||||
|
awaiting_input # 命中 clarification,等用户回答(interrupt 挂起)
|
||||||
|
done # 已跑到 Step3 绘制完成
|
||||||
|
error # 出错终止
|
||||||
|
cancelled # 用户取消
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 作业阶段 `JobPhase`(细化给流程图/进度条用)
|
||||||
|
|
||||||
|
```
|
||||||
|
initializing # 建 thread / 总控就绪
|
||||||
|
leader_thinking # 总控在决策派活
|
||||||
|
dispatching # 正在顺序派活给席位(配合 active_speaker)
|
||||||
|
consensus # 共识达成(dispatched=[])
|
||||||
|
report_drawing # Step3 报告绘制中
|
||||||
|
report_done # 绘制完成
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 派活链路节点 `DispatchChainNode`(流程图核心数据)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface DispatchChainNode {
|
||||||
|
agentId: string; // 席位 agent_id(小写)
|
||||||
|
name: string; // 显示名
|
||||||
|
avatarType: string; // 复用 getAvatarTypeFor(agentId, index) → 决定颜色
|
||||||
|
order: number; // 横向排列次序(chain 模式=链条顺序;recommend 模式=席位/发言顺序)
|
||||||
|
state: 'pending' | 'active' | 'done'; // 节点状态(驱动颜色/动画)
|
||||||
|
lastSpokeCycle?: number; // 最近发言轮次(可选,做 tooltip)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 进度事件 `JobProgressEvent`(SSE 推送的归一化事件)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type JobProgressEvent =
|
||||||
|
| { type: 'status'; status: JobStatus }
|
||||||
|
| { type: 'phase'; phase: JobPhase; cycle: number }
|
||||||
|
| { type: 'chain'; nodes: DispatchChainNode[] } // 派活链路全量/增量
|
||||||
|
| { type: 'active'; agentId: string | null } // 当前发言席位(null=总控/无)
|
||||||
|
| { type: 'consensus'; percentage: number }
|
||||||
|
| { type: 'dialogue'; bubble: Step2DialogueLike } // 新增/收尾的对话气泡(供「查看」实时渲染)
|
||||||
|
| { type: 'clarification'; question: string } // 命中澄清
|
||||||
|
| { type: 'done'; step3: DraftStep3Snapshot } // 终态:最终报告快照
|
||||||
|
| { type: 'error'; detail: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
> SSE 通道可直接复用 LangGraph Server 的 `values` 流(图状态快照),由前端 hook 把 `values`
|
||||||
|
> 映射成上述归一化事件;也可在路由层做映射后再下发。**推荐前端映射**(少一层后端耦合)。
|
||||||
|
|
||||||
|
### 2.5 作业记录 `RoundtableJob`(持久化 + 列表展示)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface RoundtableJob {
|
||||||
|
id: string;
|
||||||
|
draftId: string; // 关联的草稿
|
||||||
|
userId: string;
|
||||||
|
status: JobStatus;
|
||||||
|
phase: JobPhase;
|
||||||
|
cycle: number; // 当前轮次(0..MAX_CYCLES)
|
||||||
|
dispatchChain: DispatchChainNode[];
|
||||||
|
activeAgentId: string | null;
|
||||||
|
consensusPercentage: number;
|
||||||
|
pendingClarification: string | null;
|
||||||
|
threadIds: Record<string,string> | null; // 复用 ThreadIdMap
|
||||||
|
coordinatorName: string | null;
|
||||||
|
orchestrationMode: 'recommend' | 'chain';
|
||||||
|
chain?: { id: string; title: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 —— 后端:圆桌作业持久化模型
|
||||||
|
|
||||||
|
**目标**:建一张 `roundtable_jobs` 表(仿 `ai_writing_sessions`),存作业状态/阶段/派活链路,
|
||||||
|
并在 `roundtable_drafts` 列表里能 join 出当前作业状态。
|
||||||
|
|
||||||
|
**涉及文件(新增/改)**
|
||||||
|
- 新增 `packages/harness/deerflow/persistence/roundtable_jobs/model.py`(ORM 模型,仿 `ai_writing_sessions/model.py:14-39`)
|
||||||
|
- 新增 `.../roundtable_jobs/sql.py`(Repository:`create / get / update_progress / list_by_user / list_older_than / delete`,仿 `ai_writing_sessions/sql.py`)
|
||||||
|
- 新增 alembic 迁移 `persistence/migrations/versions/<date>_create_roundtable_jobs.py`
|
||||||
|
- 改 `roundtable_drafts` 的列表查询:返回里带上 `status`/`jobId`(LEFT JOIN roundtable_jobs,或在 draft 行上冗余一个 `status` 字段,二选一,见下)
|
||||||
|
|
||||||
|
**步骤**
|
||||||
|
1. 字段设计(对齐 §2.5):`id(PK)`、`draft_id`、`user_id`、`status`、`phase`、`cycle(Int)`、
|
||||||
|
`dispatch_chain(PortableLongText/JSON)`、`active_agent_id`、`consensus_percentage(Int)`、
|
||||||
|
`pending_clarification(Text)`、`thread_ids(JSON)`、`coordinator_name`、`orchestration_mode`、
|
||||||
|
`chain(JSON)`、`error(Text)`、`created_at/updated_at(BeijingDateTime)`。
|
||||||
|
2. `dispatch_chain` / `thread_ids` / `chain` 用 `PortableLongText` 存 JSON 字符串,repo 层序列化。
|
||||||
|
3. 列表 join:给 `DraftMeta` 增加 `status?: JobStatus` 与 `jobId?: string`。
|
||||||
|
**推荐方案**:草稿与作业 1:1(一个草稿最多一个活跃作业),在 draft 列表查询 LEFT JOIN
|
||||||
|
`roundtable_jobs` 取最新作业的 `status`。
|
||||||
|
4. 索引:`(user_id, updated_at)`、`(draft_id)`、`(status)`(cleanup/列表用)。
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
- 迁移可 `alembic upgrade head` 跑通,MySQL 上 `dispatch_chain` 为 `longtext`。
|
||||||
|
- 单测:create→update_progress→get 往返;`list_by_user` 按 `updated_at` 倒序;`list_older_than` 分批。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 —— 后端:圆桌总控编排 LangGraph 图(核心 / 最大工作量)
|
||||||
|
|
||||||
|
**目标**:把前端的编排循环移植成一个后端 LangGraph 图,可在 LangGraph runtime 里后台跑、天然
|
||||||
|
checkpoint,一路跑到 Step3 绘制完成。
|
||||||
|
|
||||||
|
**涉及文件(新增)**
|
||||||
|
- `packages/harness/deerflow/agents/roundtable_orchestrator/graph.py`(图装配,仿 `ai_writing/graph.py`)
|
||||||
|
- `.../roundtable_orchestrator/state.py`(图状态 schema:intent、selectedAgents、threadIds、coordinatorName、
|
||||||
|
mode/chain、dialogues、dispatchChain、activeAgentId、cycle、consensus、phase、step3 等)
|
||||||
|
- `.../roundtable_orchestrator/nodes/*.py`(各节点)
|
||||||
|
- 在 `langgraph.json` 注册新 assistant `roundtable_orchestrator`(仿 ai_writing assistant 注册,参考 `langgraph.json:16-18`)
|
||||||
|
|
||||||
|
**图节点设计(把前端循环逐段翻译过来)**
|
||||||
|
|
||||||
|
| 节点 | 职责 | 对应前端逻辑 |
|
||||||
|
|------|------|------|
|
||||||
|
| `init` | 复用 `_create_thread_direct` 批量建席位 thread + 总控就绪(或接收前端已建好的 `threadIds` 直接用) | `useStep2Orchestration.ts:1485-1531` / `multi_agent.py:647-823` |
|
||||||
|
| `leader_call` | 跑一轮总控(agent_type=leader),拿 `status`(派活列表 / `[]` / `clarification`) | 单轮逻辑 `multi_agent.py:1076-1142` |
|
||||||
|
| `route_after_leader`(条件边) | `clarification`→`interrupt`;有派活→`dispatch_seats`;`[]`→`consensus` | `useStep2Orchestration.ts:1091-1181` |
|
||||||
|
| `dispatch_seats` | **顺序**派活:对 dispatched 里每个席位依次跑 special run;每开始一个就更新 `activeAgentId` + 进度持久化 | `useStep2Orchestration.ts:1101-1181`(务必串行,勿并行,详见前端 §5.4 注释) |
|
||||||
|
| `clarification_pause` | `interrupt()` 挂起,`status→awaiting_input`,写 `pending_clarification` | 仿 ai_writing `pause_*` `45-290` |
|
||||||
|
| `consensus` | `setHasConsensus`、`consensus=100`,落库后进入 `report_draw` | `1091-1099` |
|
||||||
|
| `report_draw` | 跑 `roundtable-report`(先 report/init 建 thread),流式写 `outputs/方案总览.html`,产出 `DraftStep3Snapshot` | `useStep3Report.ts:91-149,636-652,560-596` |
|
||||||
|
| `done` | `status→done`、写 `step3` 进作业 + 回写草稿 step3 快照 | `RoundtablePlanningPage.tsx:613-617` |
|
||||||
|
|
||||||
|
**关键实现要点**
|
||||||
|
1. **复用单轮执行**:节点内部不要重写 LLM 调用,抽出 `multi_agent.py` 里 leader/special/report 的
|
||||||
|
单轮 run 为可被图节点直接 `await` 的内部函数(去掉 HTTP 层,进程内调 LangGraph)。
|
||||||
|
2. **派活广播**:保留现有把「总控给 X 派了活 / X 完成」追加进各 thread 历史的广播
|
||||||
|
(`multi_agent.py:487-502`),让席位读到全局上下文。
|
||||||
|
3. **MAX_CYCLES 兜底**:图里维护 `cycle`,≥8 强制收敛(对齐 `roundtable-constants.ts:13`)。
|
||||||
|
4. **chain 模式**:`orchestration_mode==='chain'` 时按 `chain.seats` 固定顺序派活,跳过总控自由决策的
|
||||||
|
部分(对齐 `runChainOrchestration` `1228-1439`)。
|
||||||
|
5. **进度落库**:每个节点结束 `update_progress(jobId, {phase, cycle, dispatchChain, activeAgentId,
|
||||||
|
consensus, dialogues_delta})`,前端 SSE 才有实时数据。
|
||||||
|
6. **dialogues 累积**:把每轮气泡写进图状态 `dialogues`,既供「查看」实时渲染,也供作业结束回写草稿 step2。
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
- 用脚本(仿 `scripts/probe_ai_writing_assistant.py`)直接对图发起一次 run,不开前端,能从 intent +
|
||||||
|
selectedAgents 一路跑到写出 `方案总览.html`,作业 `status` 终态 `done`。
|
||||||
|
- 命中 clarification 时图 `interrupt`,`status=awaiting_input`,`pending_clarification` 有值。
|
||||||
|
- chain 模式按链条顺序派活,recommend 模式由总控自由派活,两条路径都能收敛。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 —— 后端:作业控制 + 进度 SSE 接口
|
||||||
|
|
||||||
|
**目标**:暴露 start/get/stream/resume/cancel,后台 run 走 LangGraph Server(任意 worker 可 join)。
|
||||||
|
|
||||||
|
**涉及文件(新增/改)**
|
||||||
|
- 新增 `app/gateway/routers/roundtable_jobs.py`(仿 `ai_writing.py` 的 CRUD + 控制壳)
|
||||||
|
- 在 gateway `app.py` 注册路由
|
||||||
|
- 新增 `app/gateway/roundtable_jobs_cleanup.py`(仿 `ai_writing_cleanup.py`)+ 在启动处挂 cron
|
||||||
|
|
||||||
|
**接口契约**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `POST` | `/api/roundtable-jobs` | 入参:`{draftId, intent, selectedAgents, threadIds?, coordinatorName?, model, orchestrationMode, chain?}`。建作业记录(`queued`) + 启动图 run(LangGraph Server thread),返回 `RoundtableJob` |
|
||||||
|
| `GET` | `/api/roundtable-jobs/{id}` | 作业快照(轮询兜底 / 弹窗首帧) |
|
||||||
|
| `GET` | `/api/roundtable-jobs/{id}/stream` | SSE 进度流:透传图 `values`/`messages-tuple`,或映射成 §2.4 事件下发。**走真 TCP loopback**(`multi_agent.py:271-293`),禁代理 |
|
||||||
|
| `POST` | `/api/roundtable-jobs/{id}/resume` | 入参 `{answer}`,回填 clarification → 图 resume,`status→running` |
|
||||||
|
| `POST` | `/api/roundtable-jobs/{id}/cancel` | 取消图 run,`status→cancelled` |
|
||||||
|
| `GET` | `/api/roundtable-jobs?draftId=` | 按草稿查活跃作业(页面恢复用) |
|
||||||
|
| `POST` | `/api/roundtable-jobs/admin/cleanup` | 管理员手动清理(仿 ai_writing admin cleanup `307-367`) |
|
||||||
|
|
||||||
|
**多 worker 健壮性(务必遵守,照搬 ai_writing 教训)**
|
||||||
|
- 作业状态只存 **DB + checkpointer**,**不要**用模块级内存 dict 存 per-session 状态。
|
||||||
|
- SSE 必须经真 TCP `127.0.0.1` loopback,不能 `httpx.ASGITransport(app=...)` 进程内直调(会整段 buffer)。
|
||||||
|
- 图 run 跑在 LangGraph runtime(独立于处理 HTTP 的 worker),刷新/换 worker 都能重新 join stream。
|
||||||
|
- 数据卷:`.deer-flow/data/checkpoints.db` 必须挂出,否则容器重启丢状态。
|
||||||
|
|
||||||
|
**验收**
|
||||||
|
- `POST /start` 后立刻 `GET /{id}` 能拿到 `queued/running`;关掉 SSE 再重连能继续收进度。
|
||||||
|
- 杀掉发起 start 的 worker(或多 worker 轮询),`resume`/`stream` 仍命中同一作业。
|
||||||
|
- cleanup 干跑:活跃作业永不删,先删 CP 再删 DB。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 —— 前端:作业 API 客户端 + 数据模型扩展
|
||||||
|
|
||||||
|
**目标**:前端有一层干净的作业 API 与类型,历史列表能拿到 status。
|
||||||
|
|
||||||
|
**涉及文件(新增/改)**
|
||||||
|
- 新增 `src/roundtable-planning/api/roundtable-jobs.ts`:
|
||||||
|
```ts
|
||||||
|
export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
|
||||||
|
export async function getJob(id: string): Promise<RoundtableJob>
|
||||||
|
export function streamJob(id: string, on: (e: JobProgressEvent) => void): () => void // 返回 unsubscribe
|
||||||
|
export async function resumeJob(id: string, answer: string): Promise<void>
|
||||||
|
export async function cancelJob(id: string): Promise<void>
|
||||||
|
```
|
||||||
|
- `streamJob` 复用项目现有 SSE/`streamMultiAgent` 的封装方式(`api/multi-agent.ts:403+`),把
|
||||||
|
LangGraph `values` 映射成 §2.4 的归一化事件。
|
||||||
|
- 改 `src/roundtable-planning/api/drafts.ts`:`DraftMeta` 增 `status?: JobStatus; jobId?: string`
|
||||||
|
(`drafts.ts:76-83` + 字段映射 `122-178` 加 snake_case 转换)。
|
||||||
|
- 新增 `src/roundtable-planning/lib/job-types.ts`:把 §2.1–2.5 的类型集中导出。
|
||||||
|
|
||||||
|
**验收**:`pnpm typecheck` 通过;`getJob`/`streamJob` 能拉到 Phase 3 的数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 —— 前端:Step2「后台挂起」按钮
|
||||||
|
|
||||||
|
**目标**:Step2 顶栏/底栏加按钮,点击把当前任务交给后端后台跑,前端停止本地循环、转为观察者。
|
||||||
|
|
||||||
|
**涉及文件(改)**
|
||||||
|
- `src/roundtable-planning/components/Step2Panel.tsx`(按钮放在现有「进入第三步」附近 `370-381`)
|
||||||
|
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(接线 + 观察者模式)
|
||||||
|
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`(暴露「停止本地循环并交后台」的方法)
|
||||||
|
|
||||||
|
**步骤**
|
||||||
|
1. Step2Panel 加「⏼ 后台挂起」按钮,`onBackgroundSuspend` 回调。
|
||||||
|
2. 页面 `handleBackgroundSuspend`:
|
||||||
|
- `ensureDraft(2)` 拿到 `draftId`(`useDraftPersistence.ts` ensureDraft)。
|
||||||
|
- 收集当前状态:`intentReady`、`selectedAgents`、`threadIds`、`coordinatorName`、`model`、
|
||||||
|
`orchestrationMode`、`chain`(全部来自 `step2`/`step1` 现有状态)。
|
||||||
|
- `startJob(...)` → 拿到 `jobId`。
|
||||||
|
- **停止前端本地编排**:调用 `step2.stopOrchestration()` + 置一个 `backgroundedJobId` 标记,
|
||||||
|
让 `currentStep===2` 的 useEffect **不再自启本地循环**(在 `useStep2Orchestration.ts:1468-1541`
|
||||||
|
的启动条件里加 `&& !backgrounded`)。
|
||||||
|
- toast「已转入后台,可离开页面,任务会自动跑到结果绘制完成」。
|
||||||
|
- 刷新历史列表(`drafts` 重新拉,进行中项即出现 spinner)。
|
||||||
|
3. **观察者模式**:若当前停留在 Step2 且该 draft 有 running 作业,订阅 `streamJob`,把进度事件渲染成
|
||||||
|
对话气泡/活动席位(复用现有 Step2 渲染),而不是本地驱动。
|
||||||
|
|
||||||
|
**注意:避免「双跑」**——后台已接管后,前端绝不能再并行驱动同一批 thread。用 `backgroundedJobId`
|
||||||
|
互斥;加载草稿时若发现有 running 作业,直接进观察者模式。
|
||||||
|
|
||||||
|
**验收**:点「后台挂起」→ 可立即切到别的任务/路由;回来仍在跑;不会出现前后端同时派活。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 —— 前端:历史下拉框「进行中」加载动画
|
||||||
|
|
||||||
|
**目标**:历史下拉里 `status==='running'`(或 `awaiting_input`)的草稿条目显示加载动画;点击进行中
|
||||||
|
条目不直接 load,而是打开流程图弹窗。
|
||||||
|
|
||||||
|
**涉及文件(改)**
|
||||||
|
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` 的 `HistoryDropdown`(`771-892`)
|
||||||
|
|
||||||
|
**步骤**
|
||||||
|
1. 列表项左侧名称前/后,按 `d.status` 渲染状态标记:
|
||||||
|
- `running` → 旋转的 `Loader2`(`animate-spin`)+「进行中」。
|
||||||
|
- `awaiting_input` → 呼吸点 +「待澄清」(琥珀色)。
|
||||||
|
- `done`/无 → 维持现状(显示 `Step {furthestStep}`)。
|
||||||
|
- `error` → 红点 +「出错」。
|
||||||
|
2. 点击行为分流:
|
||||||
|
- `running`/`awaiting_input` → `openDispatchChainModal(d.jobId)`(Phase 7),**不**直接 `onLoad`。
|
||||||
|
- 其它 → 维持 `onLoad(id)`。
|
||||||
|
3. 列表需要 `status`,确保 Phase 4 的 `DraftMeta.status` 已就位;下拉打开时 `refresh()` 拉最新。
|
||||||
|
|
||||||
|
**验收**:后台挂起后,历史下拉对应条目转圈;点击弹出流程图弹窗。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 —— 前端:派活链路流程图弹窗(横向 / 大弹窗 / 动画)
|
||||||
|
|
||||||
|
**目标**:大弹窗,横向排列的智能体派活链路。节点 = 席位(图标 + 名称),不同颜色;正在发言的
|
||||||
|
节点和它的连接线带加载动画;含「查看」按钮。
|
||||||
|
|
||||||
|
**涉及文件(新增)**
|
||||||
|
- `src/roundtable-planning/components/DispatchChainModal.tsx`
|
||||||
|
|
||||||
|
**实现选型**:链路是「总控 → 席位序列」的线性结构 + 需要对**单个节点和单条连线**做精确动画,
|
||||||
|
**推荐手写 flex 横向布局 + CSS 动画连接线**(轻、可控)。项目已装 `@xyflow/react@12`(带 `animated`
|
||||||
|
边)作为备选,但手写对本场景的「节点 loading + 连线流动」更直接。
|
||||||
|
|
||||||
|
**结构(手写方案草图)**
|
||||||
|
```tsx
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[88vh] flex-col gap-0 sm:max-w-5xl"> {/* 大弹窗 */}
|
||||||
|
<DialogHeader>… 任务标题 + 阶段标签(leader_thinking/dispatching/report_drawing…) …</DialogHeader>
|
||||||
|
|
||||||
|
{/* 横向链路:总控 ──▶ 席位1 ──▶ 席位2 ──▶ … ──▶ [共识] ──▶ [报告绘制] */}
|
||||||
|
<div className="custom-scrollbar overflow-x-auto py-8">
|
||||||
|
<div className="flex items-center gap-0 min-w-max px-6">
|
||||||
|
<CoordinatorNode active={phase==='leader_thinking'} />
|
||||||
|
{nodes.map((n,i)=>(
|
||||||
|
<Fragment key={n.agentId}>
|
||||||
|
<Connector active={n.state==='active'} /> {/* 活动连线:流动虚线动画 */}
|
||||||
|
<ChainNode node={n} /> {/* 活动节点:彩色+脉冲光环 */}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
<Connector active={phase==='consensus'} />
|
||||||
|
<MilestoneNode label="共识" done={consensus===100} />
|
||||||
|
<Connector active={phase==='report_drawing'} />
|
||||||
|
<MilestoneNode label="报告绘制" active={phase==='report_drawing'} done={phase==='report_done'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t pt-3 sm:justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">轮次 {cycle}/8 · {statusLabel}</span>
|
||||||
|
<Button onClick={onView}>查看</Button> {/* Phase 8 */}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
**节点颜色**:`avatarType = getAvatarTypeFor(agentId, order)` → `chainTagClasses(avatarType)`
|
||||||
|
(复用 `lib/business-chain.ts:56-72` + `lib/roundtable-constants.ts`),与业务链条卡片同一套色。
|
||||||
|
|
||||||
|
**节点状态样式**
|
||||||
|
- `pending`:灰色描边、低透明度。
|
||||||
|
- `active`:席位色填充 + 外圈 `animate-ping`/脉冲光环 + 图标旋转或呼吸。
|
||||||
|
- `done`:席位色实心 + 右上角 ✓。
|
||||||
|
|
||||||
|
**连接线加载动画(active 连线)**:CSS 流动虚线(marching-ants)或渐变扫光:
|
||||||
|
```css
|
||||||
|
/* roundtable-planning.css 追加 */
|
||||||
|
@keyframes chain-flow { to { background-position: 20px 0; } }
|
||||||
|
.chain-connector--active {
|
||||||
|
background-image: linear-gradient(90deg, var(--brand-500) 50%, transparent 50%);
|
||||||
|
background-size: 20px 100%;
|
||||||
|
animation: chain-flow 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据来源**:弹窗 mount 时 `getJob(jobId)` 取首帧,随后 `streamJob(jobId, onEvent)` 实时更新
|
||||||
|
`nodes`/`activeAgentId`/`phase`/`consensus`/`cycle`;`done` 事件后停止动画、全节点置 `done`。
|
||||||
|
弹窗关闭时 `unsubscribe`。
|
||||||
|
|
||||||
|
**验收**:弹窗够大、横向滚动;活动席位节点和它左侧连线在动;阶段推进时高亮顺移;`done` 后静止。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8 —— 前端:「查看」进入进行中任务并实时观察
|
||||||
|
|
||||||
|
**目标**:弹窗「查看」→ 进入该任务,停在 Step2 实时看后台进度(气泡/活动席位随 SSE 刷新),
|
||||||
|
跑完自动呈现 Step3 结果。
|
||||||
|
|
||||||
|
**涉及文件(改)**
|
||||||
|
- `RoundtablePlanningPage.tsx`(接 `onView`)
|
||||||
|
- `useDraftPersistence.ts`(`handleLoadDraft` 增「带作业」分支)
|
||||||
|
- `useStep2Orchestration.ts`(观察者渲染)
|
||||||
|
|
||||||
|
**步骤**
|
||||||
|
1. `onView`:关弹窗 → `handleLoadDraft(draftId)` 恢复该草稿到 Step2(`useDraftPersistence.ts:308-368`),
|
||||||
|
但**跳过本地编排自启**(识别到有 running 作业 → 进观察者模式,对齐 Phase 5 的互斥标记)。
|
||||||
|
2. 订阅 `streamJob(jobId)`,把 `dialogue` 事件 append 进 `step2RoundtableDialogues`、`active` 事件
|
||||||
|
驱动 `activeSpeakerId` 高亮、`consensus` 更新百分比。
|
||||||
|
3. 命中 `awaiting_input`:在 Step2 弹出澄清输入(复用现有 `pendingClarification` UI),用户回答 →
|
||||||
|
`resumeJob(jobId, answer)`(而非本地 `submitClarification`)。
|
||||||
|
4. `done` 事件:用返回的 `DraftStep3Snapshot` 走 `hydrateStep3` + 允许切到 Step3 查看报告
|
||||||
|
(`useStep3Report.hydrateSaved` `708-767`);作业列表该项变「已完成」。
|
||||||
|
|
||||||
|
**验收**:从历史「查看」进入能实时看到后台在跑;澄清能在前端回答并续跑;跑完能看到最终报告。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9 —— 边界、健壮性与收尾
|
||||||
|
|
||||||
|
1. **双跑互斥**:任一 draft 同一时刻只允许一个执行者(前端本地 or 后台作业)。加载草稿/进入 Step2
|
||||||
|
前先 `GET /api/roundtable-jobs?draftId=`,有 running 就进观察者模式。
|
||||||
|
2. **后台命中澄清**:`awaiting_input` 时历史下拉显示「待澄清」徽标;流程图弹窗显示「等待澄清」;
|
||||||
|
只有「查看」进去才能回答(或在弹窗里直接给一个回答框,调用 `resumeJob`)。
|
||||||
|
3. **取消**:流程图弹窗/Step2 提供「停止后台」→ `cancelJob`,`status→cancelled`,前端可恢复本地编辑。
|
||||||
|
4. **错误恢复**:`error` 事件展示 detail;允许「重试」= 重新 `startJob`(沿用已建 threadIds)。
|
||||||
|
5. **MAX_CYCLES / 预算**:后台图内兜底(对齐前端 `MAX_CYCLES=8`),到顶强制收敛并照常进 Step3。
|
||||||
|
6. **多 worker**:严格遵守 Phase 3 的「状态进 DB+checkpointer、SSE 走 loopback」;部署若多 worker
|
||||||
|
需 sticky 或全靠 DB 态(推荐后者)。
|
||||||
|
7. **清理**:`roundtable_jobs` cleanup cron(活跃保护、先删 CP 再删 DB),与 draft 生命周期对齐。
|
||||||
|
8. **历史列表性能**:draft 列表 join 作业 status 时注意 N+1;用一次 LEFT JOIN 取最新作业。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 端到端验证清单(提测前逐条过)
|
||||||
|
|
||||||
|
- [ ] Step2 点「后台挂起」→ 立刻可离开页面 / 切到别的任务,后台继续跑。
|
||||||
|
- [ ] 历史下拉里该任务显示加载动画(转圈)。
|
||||||
|
- [ ] 点击进行中任务 → 大弹窗、横向派活链路、活动节点+连线在动、节点不同颜色。
|
||||||
|
- [ ] 阶段推进(leader→派活→共识→报告绘制)时弹窗高亮顺移。
|
||||||
|
- [ ] 弹窗「查看」→ 进入任务实时观察气泡/活动席位。
|
||||||
|
- [ ] 后台命中澄清 → 列表「待澄清」+ 弹窗提示 + 可回答续跑(`resumeJob`)。
|
||||||
|
- [ ] 一路自动跑到 Step3「绘制完成」,最终报告可在 Step3 查看,作业 `done`。
|
||||||
|
- [ ] 浏览器刷新 / 关页重开后,任务仍在后台跑、状态可恢复(真后台验证)。
|
||||||
|
- [ ] 取消后台 → `cancelled`,无残留双跑。
|
||||||
|
- [ ] 多 worker(或杀 worker)下 stream/resume 不丢会话。
|
||||||
|
- [ ] cleanup 干跑不误删活跃作业。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作量与排期建议
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 粗估 | 依赖 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| P0 | 数据契约定型(本文 §2) | 0.5d | — |
|
||||||
|
| P1 | 后端作业表 + repo + 迁移 | 1d | P0 |
|
||||||
|
| **P2** | **后端编排图(移植循环)** | **3–5d(核心难点)** | P0,P1 |
|
||||||
|
| P3 | 后端控制 + SSE + cleanup | 1.5d | P1,P2 |
|
||||||
|
| P4 | 前端作业 API + 类型 + draft status | 0.5d | P3 |
|
||||||
|
| P5 | Step2 后台挂起按钮 + 互斥 | 1d | P4 |
|
||||||
|
| P6 | 历史下拉 loading 动画 | 0.5d | P4 |
|
||||||
|
| P7 | 派活链路流程图弹窗 | 1.5d | P4 |
|
||||||
|
| P8 | 「查看」实时观察 + 澄清续跑 | 1.5d | P5,P7 |
|
||||||
|
| P9 | 边界/健壮性/清理 | 1d | 全部 |
|
||||||
|
|
||||||
|
> 建议先打通 **P1→P2→P3** 的后端最小闭环(脚本验证图能后台跑到出报告),再做前端 P4–P8。
|
||||||
|
> P2 是整条链路成败的关键,务必先用脱离前端的探针脚本(仿 `scripts/probe_ai_writing_assistant.py`)跑通。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:关键参考代码索引
|
||||||
|
|
||||||
|
- 前端编排循环(待移植到后端):`useStep2Orchestration.ts:992-1204,1228-1439,1468-1541`
|
||||||
|
- 共识判定 / 进 Step3:`useStep2Orchestration.ts:1091-1099`、`Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595`
|
||||||
|
- Step3 绘制完成标志:`useStep3Report.ts:51,165,560-596,636-652`
|
||||||
|
- 草稿模型 / API / 自动保存 / 加载恢复:`api/drafts.ts:25-110,184-225`、`useDraftPersistence.ts:248-316,308-368`
|
||||||
|
- 历史下拉 UI:`RoundtablePlanningPage.tsx:771-892`
|
||||||
|
- 后端单轮执行 / 派活 / 广播:`multi_agent.py:647-823,1076-1142,525-552,487-502`、loopback `271-293`
|
||||||
|
- AI 写作模板:`agents/ai_writing/graph.py`、`ai_writing_sessions/model.py:14-39`、`ai_writing.py`、`ai_writing_cleanup.py:307-367`、`AIWritingPage.tsx` 文件头注释
|
||||||
|
- 颜色体系复用:`lib/business-chain.ts:56-72`、`lib/roundtable-constants.ts`(`getAvatarTypeFor`)
|
||||||
|
- 流程图备选库:`@xyflow/react@12`(已安装)
|
||||||
|
```
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
|
|
||||||
|
function filenameFromContentDisposition(header: string | null): string | null {
|
||||||
|
if (!header) return null;
|
||||||
|
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
||||||
|
if (star) {
|
||||||
|
const raw = star[1].replace(/["']/g, "").trim();
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const plain = /filename="?([^";\n]+)"?/i.exec(header);
|
||||||
|
return plain ? plain[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFallbackFilename(title: string | undefined): string {
|
||||||
|
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
|
||||||
|
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ detail: "导出 Word 失败" }));
|
||||||
|
const detail = error.detail;
|
||||||
|
if (typeof detail === "string") throw new Error(detail);
|
||||||
|
throw new Error("导出 Word 失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const filename =
|
||||||
|
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
|
||||||
|
fallbackFilename;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.rel = "noopener";
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用 Markdown → Word 导出(Gateway `writing` 路由,与 `/api/writing/rewrite` 同级)。
|
||||||
|
*
|
||||||
|
* POST /api/writing/export/docx
|
||||||
|
* Body: { title?: string, markdown: string }
|
||||||
|
*
|
||||||
|
* 返回 `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
|
||||||
|
* 文件名在 `Content-Disposition` 中。
|
||||||
|
*/
|
||||||
|
export async function downloadMarkdownDocxFromBackend(options: {
|
||||||
|
title?: string;
|
||||||
|
markdown: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const markdown = options.markdown.trim();
|
||||||
|
if (!markdown) {
|
||||||
|
throw new Error("文档内容为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackFilename = sanitizeFallbackFilename(options.title);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/writing/export/docx`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: options.title?.trim() || undefined,
|
||||||
|
markdown,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await downloadBlobResponse(response, fallbackFilename);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TypeError) {
|
||||||
|
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,69 +1,12 @@
|
|||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { downloadMarkdownDocxFromBackend } from "@/core/api/markdown-docx-export";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
|
||||||
|
|
||||||
function filenameFromContentDisposition(header: string | null): string | null {
|
/** AI 写作草稿导出 Word(走通用 `/api/writing/export/docx`)。 */
|
||||||
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,不传目录/大纲。 */
|
|
||||||
export async function downloadAIWritingDocx(options: {
|
export async function downloadAIWritingDocx(options: {
|
||||||
title?: string;
|
title?: string;
|
||||||
draftMarkdown: string;
|
draftMarkdown: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const draftMarkdown = options.draftMarkdown.trim();
|
await downloadMarkdownDocxFromBackend({
|
||||||
if (!draftMarkdown) {
|
title: options.title,
|
||||||
throw new Error("文档内容为空");
|
markdown: options.draftMarkdown,
|
||||||
}
|
|
||||||
|
|
||||||
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 downloadBlobResponse(response, sanitizeFallbackFilename(options.title));
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FileText, PenLine } from 'lucide-react';
|
import { FileText, PenLine } from 'lucide-react';
|
||||||
|
|
||||||
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
|
import { useAIWritingOptional } from '../../contexts/AIWritingContext';
|
||||||
|
import { useAIWritingHistoryOptional } from '../../contexts/AIWritingHistoryContext';
|
||||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||||
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
|
import { useCanvasUIContext } from '../../contexts/CanvasUIContext';
|
||||||
import { assembleStreamingDraftMarkdown } from '../../hooks/useAIWritingStream';
|
import { assembleStreamingDraftMarkdown } from '../../hooks/useAIWritingStream';
|
||||||
@@ -32,8 +33,12 @@ function DraftWaitingPlaceholder() {
|
|||||||
|
|
||||||
export function AIWritingDraftPanel() {
|
export function AIWritingDraftPanel() {
|
||||||
const writing = useAIWritingOptional();
|
const writing = useAIWritingOptional();
|
||||||
|
const history = useAIWritingHistoryOptional();
|
||||||
|
const isHistoryMode = history?.isHistoryMode ?? false;
|
||||||
const { artifact } = useCanvasGraphContext();
|
const { artifact } = useCanvasGraphContext();
|
||||||
const { isDraftEditable } = useCanvasUIContext();
|
const { isDraftEditable } = useCanvasUIContext();
|
||||||
|
// 历史回看与写作完成态一致:Tiptap + 目录;流式生成中仍走 Markdown 预览
|
||||||
|
const showCompletedDraftView = isDraftEditable || isHistoryMode;
|
||||||
|
|
||||||
const writingStatus = writing?.state.status;
|
const writingStatus = writing?.state.status;
|
||||||
// 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布
|
// 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布
|
||||||
@@ -96,7 +101,8 @@ export function AIWritingDraftPanel() {
|
|||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<TextArtifactRenderer
|
<TextArtifactRenderer
|
||||||
content={textContent}
|
content={textContent}
|
||||||
isStreaming={!isDraftEditable}
|
isStreaming={!showCompletedDraftView}
|
||||||
|
readOnly={isHistoryMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||||
|
|
||||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
|
||||||
import { useAIWriting } from '../../contexts/AIWritingContext';
|
import { useAIWriting } from '../../contexts/AIWritingContext';
|
||||||
|
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
|
||||||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||||||
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
|
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
|
||||||
|
|
||||||
@@ -74,15 +74,13 @@ export function AIWritingPanel({
|
|||||||
hideAdvancedFields?: boolean;
|
hideAdvancedFields?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setArtifact } = useCanvasGraphContext();
|
|
||||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||||
|
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
|
||||||
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||||
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
|
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
|
||||||
|
|
||||||
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
|
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
|
||||||
|
|
||||||
// History view state — null means normal writing mode
|
|
||||||
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
|
|
||||||
const [reqDialogOpen, setReqDialogOpen] = useState(false);
|
const [reqDialogOpen, setReqDialogOpen] = useState(false);
|
||||||
|
|
||||||
const isIdle = state.status === 'idle';
|
const isIdle = state.status === 'idle';
|
||||||
@@ -178,27 +176,13 @@ export function AIWritingPanel({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
|
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
|
||||||
setHistorySession(session);
|
void openHistory(session);
|
||||||
// Push historical draft to the right panel
|
}, [openHistory]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleNewSession = useCallback(() => {
|
const handleNewSession = useCallback(() => {
|
||||||
setHistorySession(null);
|
closeHistory();
|
||||||
reset();
|
reset();
|
||||||
}, [reset]);
|
}, [closeHistory, reset]);
|
||||||
|
|
||||||
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
|
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
|
||||||
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
|
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
|
||||||
@@ -246,60 +230,27 @@ export function AIWritingPanel({
|
|||||||
}
|
}
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const showNewButton = historySession !== null || state.status !== 'idle';
|
const showNewButton = isHistoryMode || state.status !== 'idle';
|
||||||
|
|
||||||
// History view mode
|
|
||||||
if (historySession) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
|
||||||
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
|
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
|
||||||
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
|
|
||||||
{historySession.draft_title ?? historySession.title}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => navigate('/page/canvas/article-types')}
|
|
||||||
title="文章类型配置"
|
|
||||||
>
|
|
||||||
<Settings2 className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
|
||||||
<Plus className="size-3" />
|
|
||||||
新建对话
|
|
||||||
</Button>
|
|
||||||
<AIWritingHistoryDropdown
|
|
||||||
sessions={sessions}
|
|
||||||
selectedId={historySession.id}
|
|
||||||
loading={sessionsLoading}
|
|
||||||
onSelect={handleSelectHistory}
|
|
||||||
onDelete={remove}
|
|
||||||
onRename={rename}
|
|
||||||
onRefresh={refresh}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AIWritingHistoryView session={historySession} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||||
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
|
{!isHistoryMode && (
|
||||||
|
<DraftSync status={state.status} markdown={state.currentDraftMarkdown} title={state.currentDraftTitle} />
|
||||||
|
)}
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-sm font-semibold shrink-0',
|
'text-sm font-semibold shrink-0 min-w-0 truncate',
|
||||||
!isIdle && 'cursor-pointer hover:text-primary transition-colors',
|
isHistoryMode && 'flex-1 mr-2',
|
||||||
|
!isIdle && !isHistoryMode && 'cursor-pointer hover:text-primary transition-colors',
|
||||||
)}
|
)}
|
||||||
onClick={() => { if (!isIdle && state.request) setReqDialogOpen(true); }}
|
onClick={() => { if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }}
|
||||||
|
title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
|
||||||
>
|
>
|
||||||
AI 智能写作
|
{isHistoryMode
|
||||||
|
? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
|
||||||
|
: 'AI 智能写作'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
|
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
|
||||||
@@ -365,7 +316,7 @@ export function AIWritingPanel({
|
|||||||
)}
|
)}
|
||||||
<AIWritingHistoryDropdown
|
<AIWritingHistoryDropdown
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
selectedId={null}
|
selectedId={historySession?.id ?? null}
|
||||||
loading={sessionsLoading}
|
loading={sessionsLoading}
|
||||||
onSelect={handleSelectHistory}
|
onSelect={handleSelectHistory}
|
||||||
onDelete={remove}
|
onDelete={remove}
|
||||||
@@ -378,8 +329,13 @@ export function AIWritingPanel({
|
|||||||
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
|
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
|
||||||
<div className="flex-1 min-h-0 relative">
|
<div className="flex-1 min-h-0 relative">
|
||||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
|
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
|
||||||
|
{/* 历史回看:左侧时间线(与实时写作同一套 AIWritingTimeline) */}
|
||||||
|
{isHistoryMode && historySession && (
|
||||||
|
<AIWritingHistoryView session={historySession} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 未开始时显示表单 */}
|
{/* 未开始时显示表单 */}
|
||||||
{isIdle && (
|
{isIdle && !isHistoryMode && (
|
||||||
<AIWritingForm
|
<AIWritingForm
|
||||||
onSubmit={startWriting}
|
onSubmit={startWriting}
|
||||||
hideAdvancedFields={hideAdvancedFields}
|
hideAdvancedFields={hideAdvancedFields}
|
||||||
@@ -402,7 +358,7 @@ export function AIWritingPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 运行中或完成后显示时间线 */}
|
{/* 运行中或完成后显示时间线 */}
|
||||||
{!isIdle && (
|
{!isIdle && !isHistoryMode && (
|
||||||
<AIWritingTimeline
|
<AIWritingTimeline
|
||||||
events={state.progressEvents}
|
events={state.progressEvents}
|
||||||
completedInterventions={state.completedInterventions}
|
completedInterventions={state.completedInterventions}
|
||||||
@@ -507,7 +463,7 @@ export function AIWritingPanel({
|
|||||||
</div>{/* 相对定位容器结束 */}
|
</div>{/* 相对定位容器结束 */}
|
||||||
|
|
||||||
{/* 状态栏 */}
|
{/* 状态栏 */}
|
||||||
{!isIdle && (
|
{!isIdle && !isHistoryMode && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
|
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import type { RewriteAction, RewriteTone } from "../../api/rewrite";
|
|||||||
interface TextArtifactRendererProps {
|
interface TextArtifactRendererProps {
|
||||||
content: CanvasArtifactText;
|
content: CanvasArtifactText;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
/** 完成态只读(如历史回看):保留目录 + 排版,禁用编辑与 AI 润色 */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PanelPos {
|
interface PanelPos {
|
||||||
@@ -38,7 +40,7 @@ interface LastAction {
|
|||||||
customInstruction?: string;
|
customInstruction?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
|
export function TextArtifactRenderer({ content, isStreaming, readOnly = false }: TextArtifactRendererProps) {
|
||||||
const { artifact, setArtifact } = useCanvasGraphContext();
|
const { artifact, setArtifact } = useCanvasGraphContext();
|
||||||
const { writingSelection } = useCanvasUIContext();
|
const { writingSelection } = useCanvasUIContext();
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
|||||||
const editor = useWritingEditor({
|
const editor = useWritingEditor({
|
||||||
markdown: content.fullMarkdown,
|
markdown: content.fullMarkdown,
|
||||||
onChange: handleEditorChange,
|
onChange: handleEditorChange,
|
||||||
editable: !isStreaming,
|
editable: !isStreaming && !readOnly,
|
||||||
placeholder: "开始写作…",
|
placeholder: "开始写作…",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,10 +272,15 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edit mode ─────────────────────────────────────────────────────────
|
// ── Edit mode(写作完成态 / 历史回看只读)────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col overflow-hidden">
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
<WritingToolbar editor={editor} markdown={content.fullMarkdown} title={content.title} />
|
<WritingToolbar
|
||||||
|
editor={editor}
|
||||||
|
markdown={content.fullMarkdown}
|
||||||
|
title={content.title}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||||
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
|
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
|
||||||
@@ -282,7 +289,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
|||||||
{/* 文档滚动区 */}
|
{/* 文档滚动区 */}
|
||||||
<div ref={setDocScrollEl} className="flex-1 overflow-y-auto">
|
<div ref={setDocScrollEl} className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-2xl mx-auto px-10 pt-10">
|
<div className="max-w-2xl mx-auto px-10 pt-10">
|
||||||
{editor && (
|
{editor && !readOnly && (
|
||||||
<>
|
<>
|
||||||
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
|
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
|
||||||
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
|
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
|
||||||
@@ -294,7 +301,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phase: AI action menu */}
|
{/* Phase: AI action menu */}
|
||||||
{phase === "menu" && (
|
{!readOnly && phase === "menu" && (
|
||||||
<WritingAiMenu
|
<WritingAiMenu
|
||||||
pos={panelPos}
|
pos={panelPos}
|
||||||
status={status}
|
status={status}
|
||||||
@@ -307,7 +314,7 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Phase: result preview */}
|
{/* Phase: result preview */}
|
||||||
{phase === "preview" && (
|
{!readOnly && phase === "preview" && (
|
||||||
<WritingRewritePreview
|
<WritingRewritePreview
|
||||||
pos={panelPos}
|
pos={panelPos}
|
||||||
status={status}
|
status={status}
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ interface WritingToolbarProps {
|
|||||||
markdown?: string;
|
markdown?: string;
|
||||||
/** 文档标题,用作 .docx 文件名与封面 */
|
/** 文档标题,用作 .docx 文件名与封面 */
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** 历史回看等场景:仅保留导出,隐藏格式工具 */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps) {
|
export function WritingToolbar({ editor, markdown, title, readOnly = false }: WritingToolbarProps) {
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
@@ -94,6 +96,42 @@ export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground">历史文稿(只读)</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleExport();
|
||||||
|
}}
|
||||||
|
disabled={!canExport}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"disabled:pointer-events-none disabled:opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
导出 Word
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
|
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
|
||||||
{/* Block type */}
|
{/* Block type */}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { getAIWritingSession } from '../api/ai-writing-sessions';
|
||||||
|
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
|
||||||
|
import { useCanvasGraphContext } from './CanvasGraphContext';
|
||||||
|
import { useCanvasUIContext } from './CanvasUIContext';
|
||||||
|
|
||||||
|
type AIWritingHistoryContextValue = {
|
||||||
|
historySession: AIWritingSessionSummary | null;
|
||||||
|
isHistoryMode: boolean;
|
||||||
|
openHistory: (session: AIWritingSessionSummary) => Promise<void>;
|
||||||
|
closeHistory: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AIWritingHistoryContext = createContext<AIWritingHistoryContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AIWritingHistoryProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { setArtifact } = useCanvasGraphContext();
|
||||||
|
const { setIsDraftEditable } = useCanvasUIContext();
|
||||||
|
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
|
||||||
|
|
||||||
|
const openHistory = useCallback(async (session: AIWritingSessionSummary) => {
|
||||||
|
let full = session;
|
||||||
|
if (!session.draft_markdown || !session.transcript) {
|
||||||
|
try {
|
||||||
|
full = await getAIWritingSession(session.id);
|
||||||
|
} catch {
|
||||||
|
// 列表项已够用,拉详情失败则继续用摘要
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHistorySession(full);
|
||||||
|
// 完成态布局(Tiptap + 左侧目录),由 DraftPanel 根据 isHistoryMode 只读展示
|
||||||
|
setIsDraftEditable(true);
|
||||||
|
if (full.draft_markdown) {
|
||||||
|
setArtifact({
|
||||||
|
currentIndex: 0,
|
||||||
|
contents: [{
|
||||||
|
index: 0,
|
||||||
|
type: 'text',
|
||||||
|
title: full.draft_title ?? full.title,
|
||||||
|
fullMarkdown: full.draft_markdown,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setArtifact(null);
|
||||||
|
}
|
||||||
|
}, [setArtifact, setIsDraftEditable]);
|
||||||
|
|
||||||
|
const closeHistory = useCallback(() => {
|
||||||
|
setHistorySession(null);
|
||||||
|
setArtifact(null);
|
||||||
|
setIsDraftEditable(true);
|
||||||
|
}, [setArtifact, setIsDraftEditable]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
historySession,
|
||||||
|
isHistoryMode: historySession !== null,
|
||||||
|
openHistory,
|
||||||
|
closeHistory,
|
||||||
|
}),
|
||||||
|
[historySession, openHistory, closeHistory],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIWritingHistoryContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AIWritingHistoryContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAIWritingHistory() {
|
||||||
|
const ctx = useContext(AIWritingHistoryContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useAIWritingHistory must be used within AIWritingHistoryProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAIWritingHistoryOptional() {
|
||||||
|
return useContext(AIWritingHistoryContext);
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
|
|||||||
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
||||||
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
|
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
|
||||||
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
|
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
|
||||||
|
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
|
||||||
import { WritingFormProvider } from '../contexts/WritingFormContext';
|
import { WritingFormProvider } from '../contexts/WritingFormContext';
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
@@ -131,8 +132,10 @@ function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean
|
|||||||
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
const { state } = useAIWriting();
|
const { state } = useAIWriting();
|
||||||
|
const { isHistoryMode } = useAIWritingHistory();
|
||||||
|
|
||||||
if (state.status === 'idle') {
|
// 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿)
|
||||||
|
if (state.status === 'idle' && !isHistoryMode) {
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
@@ -206,6 +209,7 @@ export default function AIWritingPage({
|
|||||||
<CanvasThreadProvider>
|
<CanvasThreadProvider>
|
||||||
<CanvasGraphProvider>
|
<CanvasGraphProvider>
|
||||||
<CanvasUIProvider>
|
<CanvasUIProvider>
|
||||||
|
<AIWritingHistoryProvider>
|
||||||
<AIWritingProvider>
|
<AIWritingProvider>
|
||||||
<WritingFormProvider>
|
<WritingFormProvider>
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
@@ -217,6 +221,7 @@ export default function AIWritingPage({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</WritingFormProvider>
|
</WritingFormProvider>
|
||||||
</AIWritingProvider>
|
</AIWritingProvider>
|
||||||
|
</AIWritingHistoryProvider>
|
||||||
</CanvasUIProvider>
|
</CanvasUIProvider>
|
||||||
</CanvasGraphProvider>
|
</CanvasGraphProvider>
|
||||||
</CanvasThreadProvider>
|
</CanvasThreadProvider>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import StudioNotebooksPage from './StudioNotebooksPage';
|
|||||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||||
|
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||||
import '@/roundtable-planning/styles/roundtable-planning.css';
|
import '@/roundtable-planning/styles/roundtable-planning.css';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -324,6 +325,15 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Phase 7 临时预览入口:派活链路流程图弹窗(假数据自测,接通后台后可删) */}
|
||||||
|
<Route
|
||||||
|
path="roundtable/chain-preview"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<DispatchChainPreviewPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="scheduled"
|
path="scheduled"
|
||||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||||
|
|||||||
@@ -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<JobStatus, string> = {
|
||||||
|
queued: "排队中",
|
||||||
|
running: "进行中",
|
||||||
|
awaiting_input: "等待澄清",
|
||||||
|
done: "已完成",
|
||||||
|
error: "出错",
|
||||||
|
cancelled: "已取消",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHASE_LABEL: Record<JobPhase, string> = {
|
||||||
|
initializing: "初始化席位…",
|
||||||
|
leader_thinking: "总控决策派活中…",
|
||||||
|
dispatching: "席位发言中…",
|
||||||
|
consensus: "共识达成",
|
||||||
|
report_drawing: "结果绘制中…",
|
||||||
|
report_done: "绘制完成",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DispatchChainModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title = "多智能体研讨",
|
||||||
|
status,
|
||||||
|
phase,
|
||||||
|
cycle,
|
||||||
|
maxCycles = 8,
|
||||||
|
nodes,
|
||||||
|
consensusPercentage,
|
||||||
|
onView,
|
||||||
|
}: 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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[88vh] flex-col gap-0 sm:max-w-5xl">
|
||||||
|
<DialogHeader className="shrink-0">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{status === "running" && (
|
||||||
|
<Loader2Icon className="size-5 shrink-0 animate-spin text-primary" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-1 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold",
|
||||||
|
status === "running" && "bg-primary/10 text-primary",
|
||||||
|
status === "awaiting_input" &&
|
||||||
|
"bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||||
|
status === "done" &&
|
||||||
|
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||||
|
status === "error" && "bg-destructive/10 text-destructive",
|
||||||
|
(status === "queued" || status === "cancelled") &&
|
||||||
|
"bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[status]}
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{PHASE_LABEL[phase]}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 横向流程图(横向滚动) */}
|
||||||
|
<div className="custom-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden py-10">
|
||||||
|
<div className="flex min-w-max items-start gap-0 px-6">
|
||||||
|
{/* 总控节点 */}
|
||||||
|
<FlowNode
|
||||||
|
label="总控协调"
|
||||||
|
avatarType="human"
|
||||||
|
state={coordinatorState}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 席位节点 */}
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<Fragment key={node.agentId}>
|
||||||
|
<Connector
|
||||||
|
active={node.state === "active"}
|
||||||
|
done={node.state === "done"}
|
||||||
|
/>
|
||||||
|
<FlowNode
|
||||||
|
label={node.name}
|
||||||
|
avatarType={node.avatarType}
|
||||||
|
state={node.state}
|
||||||
|
order={node.order}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 共识里程碑 */}
|
||||||
|
<Connector active={consensusActive} done={consensusDone} />
|
||||||
|
<MilestoneNode
|
||||||
|
label="共识达成"
|
||||||
|
emoji="🤝"
|
||||||
|
state={
|
||||||
|
consensusActive ? "active" : consensusDone ? "done" : "pending"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 报告绘制里程碑 */}
|
||||||
|
<Connector active={reportActive} done={reportDone} />
|
||||||
|
<MilestoneNode
|
||||||
|
label="结果绘制"
|
||||||
|
emoji="📊"
|
||||||
|
state={reportActive ? "active" : reportDone ? "done" : "pending"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 flex-row items-center justify-between gap-2 border-t pt-3 sm:justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
轮次 {Math.min(cycle, maxCycles)}/{maxCycles}
|
||||||
|
<span className="mx-2 text-muted-foreground/40">·</span>
|
||||||
|
共识 {Math.round(consensusPercentage)}%
|
||||||
|
<span className="mx-2 text-muted-foreground/40">·</span>
|
||||||
|
{PHASE_LABEL[phase]}
|
||||||
|
</span>
|
||||||
|
<Button onClick={onView} disabled={!onView} className="gap-1.5">
|
||||||
|
<EyeIcon className="size-4" />
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 链路节点(席位 / 总控)─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FlowNode({
|
||||||
|
label,
|
||||||
|
avatarType,
|
||||||
|
state,
|
||||||
|
order,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
avatarType: string;
|
||||||
|
state: ChainNodeState;
|
||||||
|
order?: number;
|
||||||
|
}) {
|
||||||
|
const tag = chainTagClasses(avatarType);
|
||||||
|
return (
|
||||||
|
<div className="flex w-24 shrink-0 flex-col items-center gap-2 text-center">
|
||||||
|
<div className="relative flex size-16 items-center justify-center">
|
||||||
|
{/* 活动光环:脉冲 ping */}
|
||||||
|
{state === "active" && (
|
||||||
|
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" />
|
||||||
|
)}
|
||||||
|
{/* 头像 + 状态描边 */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-16 items-center justify-center rounded-full border-2 transition-all",
|
||||||
|
state === "active" &&
|
||||||
|
"border-primary shadow-[0_0_0_4px_rgba(22,119,255,0.12)]",
|
||||||
|
state === "done" && "border-emerald-400/70",
|
||||||
|
state === "pending" && "border-border opacity-45 grayscale",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getAvatar(avatarType, "h-14 w-14")}
|
||||||
|
{/* 完成打勾 */}
|
||||||
|
{state === "done" && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
|
||||||
|
<CheckIcon className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* 进行中转圈 */}
|
||||||
|
{state === "active" && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
|
||||||
|
<Loader2Icon className="size-3 animate-spin" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"max-w-full truncate rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||||
|
state === "pending" ? "border-border bg-muted text-muted-foreground" : tag,
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{order != null ? `${order}. ` : ""}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 里程碑节点(共识 / 报告绘制)─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MilestoneNode({
|
||||||
|
label,
|
||||||
|
emoji,
|
||||||
|
state,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
emoji: string;
|
||||||
|
state: ChainNodeState;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-24 shrink-0 flex-col items-center gap-2 text-center">
|
||||||
|
<div className="relative flex size-16 items-center justify-center">
|
||||||
|
{state === "active" && (
|
||||||
|
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-16 items-center justify-center rounded-full border-2 text-2xl transition-all",
|
||||||
|
state === "active" &&
|
||||||
|
"border-primary bg-primary/5 shadow-[0_0_0_4px_rgba(22,119,255,0.12)]",
|
||||||
|
state === "done" && "border-emerald-400/70 bg-emerald-500/5",
|
||||||
|
state === "pending" && "border-border bg-muted opacity-45 grayscale",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
{state === "done" && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
|
||||||
|
<CheckIcon className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state === "active" && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
|
||||||
|
<Loader2Icon className="size-3 animate-spin" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded border px-1.5 py-0.5 text-[11px] font-semibold",
|
||||||
|
state === "pending"
|
||||||
|
? "border-border bg-muted text-muted-foreground"
|
||||||
|
: state === "active"
|
||||||
|
? "border-primary/40 bg-primary/10 text-primary"
|
||||||
|
: "border-emerald-400/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 连接线(带流动动画)─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Connector({ active, done }: { active: boolean; done: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-16 w-16 shrink-0 items-center justify-center">
|
||||||
|
<div className="relative flex w-full items-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rt-chain-connector",
|
||||||
|
active && "rt-chain-connector--active",
|
||||||
|
done && !active && "rt-chain-connector--done",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn(
|
||||||
|
"absolute -right-1 size-4 transition-colors",
|
||||||
|
active
|
||||||
|
? "text-primary"
|
||||||
|
: done
|
||||||
|
? "text-primary/70"
|
||||||
|
: "text-muted-foreground/40",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* 派活链路流程图弹窗 —— 预览/自测页(Phase 7 临时入口)。
|
||||||
|
*
|
||||||
|
* 路由:/page/workspace/roundtable/chain-preview
|
||||||
|
*
|
||||||
|
* 用假数据 + 定时器模拟后台作业推进(初始化 → 总控派活 → 席位逐个发言 → 共识 →
|
||||||
|
* 结果绘制 → 完成),驱动 `DispatchChainModal` 演示节点 / 连线的加载动画与状态流转。
|
||||||
|
* 后续 Phase 8 接通真实 `streamJob` 后,本页可删除(连同 WorkspaceRoutes 里的路由)。
|
||||||
|
*/
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DispatchChainModal } from "../components/DispatchChainModal";
|
||||||
|
import {
|
||||||
|
DEFAULT_SELECTED_AGENTS,
|
||||||
|
getAvatarTypeFor,
|
||||||
|
getDisplayNameFor,
|
||||||
|
} from "../lib/roundtable-constants";
|
||||||
|
import type {
|
||||||
|
DispatchChainNode,
|
||||||
|
JobPhase,
|
||||||
|
JobStatus,
|
||||||
|
} from "../lib/job-types";
|
||||||
|
import "../styles/roundtable-planning.css";
|
||||||
|
|
||||||
|
interface SimFrame {
|
||||||
|
phase: JobPhase;
|
||||||
|
status: JobStatus;
|
||||||
|
cycle: number;
|
||||||
|
consensus: number;
|
||||||
|
activeAgentId: string | null;
|
||||||
|
doneAgentIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENTS = DEFAULT_SELECTED_AGENTS;
|
||||||
|
|
||||||
|
/** 预先生成一条完整的推进时间线(每帧约 1.1s)。 */
|
||||||
|
function buildFrames(): SimFrame[] {
|
||||||
|
const ids = AGENTS.map((a) => a.agent_id);
|
||||||
|
const frames: SimFrame[] = [];
|
||||||
|
|
||||||
|
frames.push({ phase: "initializing", status: "running", cycle: 0, consensus: 0, activeAgentId: null, doneAgentIds: [] });
|
||||||
|
frames.push({ phase: "leader_thinking", status: "running", cycle: 1, consensus: 12, activeAgentId: null, doneAgentIds: [] });
|
||||||
|
|
||||||
|
// 第 1 轮:逐个席位发言
|
||||||
|
ids.forEach((id, i) => {
|
||||||
|
frames.push({
|
||||||
|
phase: "dispatching",
|
||||||
|
status: "running",
|
||||||
|
cycle: 1,
|
||||||
|
consensus: 12 + Math.round(((i + 1) / ids.length) * 50),
|
||||||
|
activeAgentId: id,
|
||||||
|
doneAgentIds: ids.slice(0, i),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 第 2 轮:总控再决策(此时第 1 轮席位都已发言)
|
||||||
|
frames.push({ phase: "leader_thinking", status: "running", cycle: 2, consensus: 78, activeAgentId: null, doneAgentIds: [...ids] });
|
||||||
|
// 共识达成
|
||||||
|
frames.push({ phase: "consensus", status: "running", cycle: 2, consensus: 100, activeAgentId: null, doneAgentIds: [...ids] });
|
||||||
|
// 结果绘制中
|
||||||
|
frames.push({ phase: "report_drawing", status: "running", cycle: 2, consensus: 100, activeAgentId: null, doneAgentIds: [...ids] });
|
||||||
|
// 完成
|
||||||
|
frames.push({ phase: "report_done", status: "done", cycle: 2, consensus: 100, activeAgentId: null, doneAgentIds: [...ids] });
|
||||||
|
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DispatchChainPreviewPage() {
|
||||||
|
const frames = useMemo(buildFrames, []);
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
const [playing, setPlaying] = useState(true);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// 自动播放:逐帧推进,跑到最后一帧停 2.2s 再循环
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing) return;
|
||||||
|
const isLast = idx >= frames.length - 1;
|
||||||
|
timerRef.current = setTimeout(
|
||||||
|
() => setIdx((i) => (i >= frames.length - 1 ? 0 : i + 1)),
|
||||||
|
isLast ? 2200 : 1100,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [idx, playing, frames.length]);
|
||||||
|
|
||||||
|
const frame = frames[idx];
|
||||||
|
|
||||||
|
// 由当前帧推导各席位节点状态
|
||||||
|
const nodes: DispatchChainNode[] = AGENTS.map((a, i) => {
|
||||||
|
const state =
|
||||||
|
frame.doneAgentIds.includes(a.agent_id)
|
||||||
|
? "done"
|
||||||
|
: frame.activeAgentId === a.agent_id
|
||||||
|
? "active"
|
||||||
|
: "pending";
|
||||||
|
return {
|
||||||
|
agentId: a.agent_id,
|
||||||
|
name: getDisplayNameFor(a),
|
||||||
|
avatarType: getAvatarTypeFor(a.agent_id, i),
|
||||||
|
order: i + 1,
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-6 bg-muted/30 p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-lg font-bold">派活链路流程图 · 预览</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
模拟后台作业推进(假数据)。帧 {idx + 1}/{frames.length} · {frame.phase}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPlaying((p) => !p)}>
|
||||||
|
{playing ? "暂停" : "继续"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIdx((i) => (i > 0 ? i - 1 : i));
|
||||||
|
setPlaying(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
上一帧
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIdx((i) => (i < frames.length - 1 ? i + 1 : i));
|
||||||
|
setPlaying(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下一帧
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIdx(0);
|
||||||
|
setPlaying(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重新播放
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DispatchChainModal
|
||||||
|
open
|
||||||
|
onOpenChange={() => {}}
|
||||||
|
title="新能源汽车出口策略研讨(预览)"
|
||||||
|
status={frame.status}
|
||||||
|
phase={frame.phase}
|
||||||
|
cycle={frame.cycle}
|
||||||
|
nodes={nodes}
|
||||||
|
consensusPercentage={frame.consensus}
|
||||||
|
onView={() => toast.info("(预览)点击「查看」→ Phase 8 会进入正在进行的任务")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -360,6 +360,50 @@
|
|||||||
color: var(--foreground) !important;
|
color: var(--foreground) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
* 派活链路流程图连接线(DispatchChainModal)。
|
||||||
|
*
|
||||||
|
* 弹窗经 Radix Dialog portal 到 <body>,**不在** .roundtable-planning 作用域内,
|
||||||
|
* 因此这些类必须是**全局**(无作用域前缀),并用语义 token(--primary/--border)
|
||||||
|
* 自适应明暗。连线三态:pending(灰虚线静止) / done(主色实线) / active(主色流动虚线)。
|
||||||
|
* ========================================================================== */
|
||||||
|
@keyframes rt-chain-flow {
|
||||||
|
to {
|
||||||
|
background-position: 18px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rt-chain-connector {
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
/* 默认(pending):灰色等距虚线 */
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--border) 45%,
|
||||||
|
transparent 45%
|
||||||
|
);
|
||||||
|
background-size: 12px 100%;
|
||||||
|
background-repeat: repeat-x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rt-chain-connector--done {
|
||||||
|
background-image: none;
|
||||||
|
background-color: var(--primary);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rt-chain-connector--active {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--primary) 45%,
|
||||||
|
transparent 45%
|
||||||
|
);
|
||||||
|
background-size: 18px 100%;
|
||||||
|
background-repeat: repeat-x;
|
||||||
|
animation: rt-chain-flow 0.55s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom elegant scrollbar styling */
|
/* Custom elegant scrollbar styling */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
@@ -168,6 +168,16 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
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 event store (has its own factory with config-driven backend selection)
|
||||||
run_events_config = getattr(config, "run_events", None)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ class DraftMetaResponse(BaseModel):
|
|||||||
furthest_step: int = 1
|
furthest_step: int = 1
|
||||||
created_at: datetime | str | None = None
|
created_at: datetime | str | None = None
|
||||||
updated_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):
|
class DraftListResponse(BaseModel):
|
||||||
@@ -137,6 +140,20 @@ async def list_drafts(request: Request) -> DraftListResponse:
|
|||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
rows = await store.list_drafts(user_id)
|
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])
|
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
"""Create roundtable_jobs table.
|
||||||
|
|
||||||
|
Backend background-orchestration jobs for the roundtable-planning Step 2「后台挂起」
|
||||||
|
feature: one row per server-side run of the leader→dispatch→consensus→report
|
||||||
|
pipeline. Big JSON columns (dispatch_chain / thread_ids / chain) are LONGTEXT on
|
||||||
|
MySQL (can exceed the 64 KB TEXT cap) and plain TEXT elsewhere — matching
|
||||||
|
``PortableLongText`` on ``RoundtableJobRow``. ``create_all`` adds the table for
|
||||||
|
brand-new DBs; this revision creates it on DBs that predate it.
|
||||||
|
|
||||||
|
Revision ID: 20260604_01
|
||||||
|
Revises: 20260603_01
|
||||||
|
Create Date: 2026-06-04
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260604_01"
|
||||||
|
down_revision = "20260603_01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if _has_table("roundtable_jobs"):
|
||||||
|
return
|
||||||
|
|
||||||
|
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"roundtable_jobs",
|
||||||
|
sa.Column("id", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("draft_id", sa.String(64), nullable=True),
|
||||||
|
sa.Column("user_id", sa.String(64), nullable=True),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False, server_default="queued"),
|
||||||
|
sa.Column("phase", sa.String(32), nullable=False, server_default="initializing"),
|
||||||
|
sa.Column("cycle", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("dispatch_chain", long_text, nullable=True),
|
||||||
|
sa.Column("active_agent_id", sa.String(128), nullable=True),
|
||||||
|
sa.Column("consensus_percentage", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("pending_clarification", sa.Text, nullable=True),
|
||||||
|
sa.Column("thread_ids", long_text, nullable=True),
|
||||||
|
sa.Column("coordinator_name", sa.String(128), nullable=True),
|
||||||
|
sa.Column("orchestration_mode", sa.String(16), nullable=False, server_default="recommend"),
|
||||||
|
sa.Column("chain", long_text, nullable=True),
|
||||||
|
sa.Column("error", sa.Text, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_roundtable_jobs_draft_id", "roundtable_jobs", ["draft_id"])
|
||||||
|
op.create_index("ix_roundtable_jobs_user_id", "roundtable_jobs", ["user_id"])
|
||||||
|
op.create_index("ix_roundtable_jobs_status", "roundtable_jobs", ["status"])
|
||||||
|
op.create_index("ix_roundtable_jobs_updated_at", "roundtable_jobs", ["updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_table("roundtable_jobs"):
|
||||||
|
op.drop_table("roundtable_jobs")
|
||||||
+2
@@ -27,6 +27,7 @@ from deerflow.persistence.roundtable_drafts.model import (
|
|||||||
RoundtableDraftRow,
|
RoundtableDraftRow,
|
||||||
RoundtableRecommendHistoryRow,
|
RoundtableRecommendHistoryRow,
|
||||||
)
|
)
|
||||||
|
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
||||||
from deerflow.persistence.run.model import RunRow
|
from deerflow.persistence.run.model import RunRow
|
||||||
from deerflow.persistence.scheduled_tasks.model import (
|
from deerflow.persistence.scheduled_tasks.model import (
|
||||||
ScheduledTaskDeliveryProfileRow,
|
ScheduledTaskDeliveryProfileRow,
|
||||||
@@ -53,6 +54,7 @@ __all__ = [
|
|||||||
"RecommendedQuestionRow",
|
"RecommendedQuestionRow",
|
||||||
"RoundtableChainRow",
|
"RoundtableChainRow",
|
||||||
"RoundtableDraftRow",
|
"RoundtableDraftRow",
|
||||||
|
"RoundtableJobRow",
|
||||||
"RoundtableRecommendHistoryRow",
|
"RoundtableRecommendHistoryRow",
|
||||||
"RunEventRow",
|
"RunEventRow",
|
||||||
"RunRow",
|
"RunRow",
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
"""Roundtable background-job persistence.
|
||||||
|
|
||||||
|
Mirrors ``ai_writing_sessions``: exposes a store factory plus a process-level
|
||||||
|
default-store singleton so the (harness-side) orchestration graph nodes can write
|
||||||
|
progress without reaching into FastAPI ``request.app.state`` (harness → app imports
|
||||||
|
are forbidden). app/gateway/deps.py registers the default store at startup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
||||||
|
from deerflow.persistence.roundtable_jobs.sql import RoundtableJobRepository
|
||||||
|
|
||||||
|
|
||||||
|
def make_roundtable_job_store(session_factory) -> RoundtableJobRepository | None:
|
||||||
|
if session_factory is None:
|
||||||
|
return None
|
||||||
|
return RoundtableJobRepository(session_factory)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 进程级默认 repo 单例(跨 app/harness 边界)──────────────────────────────────
|
||||||
|
#
|
||||||
|
# Phase 2 的圆桌编排图注册成 LangGraph Server assistant 后,节点跑在 harness 进程里,
|
||||||
|
# 拿不到 FastAPI 的 request.app.state;harness → app 又禁止 import。
|
||||||
|
# 写入:app/gateway/deps.py 创建 session_factory 后 register_default_store(...)。
|
||||||
|
# 读取:图节点里 get_default_store(),None 表示未配置 DB(跳过写入即可)。
|
||||||
|
_default_store: RoundtableJobRepository | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def register_default_store(store: RoundtableJobRepository | None) -> None:
|
||||||
|
"""登记进程级默认圆桌作业 store(幂等,可覆盖;传 None 显式清理,主要给测试)。"""
|
||||||
|
global _default_store
|
||||||
|
_default_store = store
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_store() -> RoundtableJobRepository | None:
|
||||||
|
"""返回进程级默认圆桌作业 store;未配置时为 None。"""
|
||||||
|
return _default_store
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RoundtableJobRow",
|
||||||
|
"RoundtableJobRepository",
|
||||||
|
"make_roundtable_job_store",
|
||||||
|
"register_default_store",
|
||||||
|
"get_default_store",
|
||||||
|
]
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
"""ORM model for roundtable background-orchestration jobs.
|
||||||
|
|
||||||
|
A "job" = one **backend** run of the roundtable orchestration pipeline kicked off
|
||||||
|
from Step 2「后台挂起」: it drives the leader→dispatch→sub-agents loop to consensus
|
||||||
|
and then Step 3 report drawing, entirely server-side, surviving page refresh/close.
|
||||||
|
|
||||||
|
Bound 1:1 to a ``roundtable_drafts`` row (``draft_id``) so the history dropdown can
|
||||||
|
show "this task is running" and the dispatch-chain modal can render live progress.
|
||||||
|
|
||||||
|
Big JSON columns (``dispatch_chain`` / ``thread_ids`` / ``chain``) are stored as
|
||||||
|
serialized strings in ``PortableLongText`` (MySQL ``LONGTEXT``) — same approach as
|
||||||
|
``ai_writing_sessions`` / ``roundtable_drafts`` — to avoid MySQL's 64 KB ``TEXT`` cap.
|
||||||
|
|
||||||
|
See ``frontend-web/docs/multi-agent-background-run-dev.md`` §2.5 for the field
|
||||||
|
contract and §Phase 1 for the build steps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||||
|
|
||||||
|
|
||||||
|
class RoundtableJobRow(Base):
|
||||||
|
__tablename__ = "roundtable_jobs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
# The draft/session this job runs for. Indexed for "active job of draft" lookups.
|
||||||
|
draft_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
# Job status machine (§2.1): queued / running / awaiting_input / done / error / cancelled.
|
||||||
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True)
|
||||||
|
# Fine-grained phase (§2.2): initializing / leader_thinking / dispatching /
|
||||||
|
# consensus / report_drawing / report_done.
|
||||||
|
phase: Mapped[str] = mapped_column(String(32), nullable=False, default="initializing")
|
||||||
|
# Current orchestration cycle (0..MAX_CYCLES).
|
||||||
|
cycle: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# Dispatch chain nodes (§2.3): JSON array of
|
||||||
|
# { agentId, name, avatarType, order, state, lastSpokeCycle? }.
|
||||||
|
dispatch_chain: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
|
# Currently-speaking seat agent_id (null = coordinator / none).
|
||||||
|
active_agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
consensus_percentage: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# When status=awaiting_input: the coordinator's clarification question text.
|
||||||
|
pending_clarification: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# ThreadIdMap: JSON object { "<agent_id>": "<thread_id>", ... }.
|
||||||
|
thread_ids: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
|
coordinator_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
# "recommend"(总控自由派活)| "chain"(按业务链条顺序派活).
|
||||||
|
orchestration_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="recommend")
|
||||||
|
# chain 模式来源链条快照:JSON object { id, title }(recommend 模式为 null).
|
||||||
|
chain: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
|
# Terminal error detail when status=error.
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# 时间列必须用 BeijingDateTime(项目规范,与 ai_writing_sessions / roundtable_drafts 对齐)。
|
||||||
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
BeijingDateTime(),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
"""SQLAlchemy-backed roundtable background-job storage.
|
||||||
|
|
||||||
|
JSON columns (``dispatch_chain`` / ``thread_ids`` / ``chain``) are stored as
|
||||||
|
serialized strings in ``PortableLongText`` and round-tripped here with
|
||||||
|
``json.dumps`` / ``json.loads`` — same approach as ``ai_writing_sessions`` /
|
||||||
|
``roundtable_drafts``.
|
||||||
|
|
||||||
|
User isolation mirrors ``ai_writing_sessions``: methods take ``user_id`` defaulting
|
||||||
|
to ``AUTO`` (resolved from the run context) so backend graph nodes can write without
|
||||||
|
threading a user id through, while gateway routes pass it explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
||||||
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||||
|
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
# 终态:列表/清理判定用。
|
||||||
|
TERMINAL_STATUSES = ("done", "error", "cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
def _loads(value: Any) -> Any:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dumps(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
class RoundtableJobRepository:
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_dict(row: RoundtableJobRow) -> dict[str, Any]:
|
||||||
|
d = row.to_dict()
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
if isinstance(d.get(k), datetime):
|
||||||
|
d[k] = d[k].isoformat()
|
||||||
|
for k in ("dispatch_chain", "thread_ids", "chain"):
|
||||||
|
d[k] = _loads(d.get(k))
|
||||||
|
return d
|
||||||
|
|
||||||
|
# ── create ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
id: str,
|
||||||
|
draft_id: str | None = None,
|
||||||
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
|
orchestration_mode: str = "recommend",
|
||||||
|
coordinator_name: str | None = None,
|
||||||
|
thread_ids: dict[str, str] | None = None,
|
||||||
|
chain: dict[str, Any] | None = None,
|
||||||
|
dispatch_chain: list[dict[str, Any]] | None = None,
|
||||||
|
status: str = "queued",
|
||||||
|
phase: str = "initializing",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.create")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
row = RoundtableJobRow(
|
||||||
|
id=id,
|
||||||
|
draft_id=draft_id,
|
||||||
|
user_id=resolved_user_id,
|
||||||
|
status=status,
|
||||||
|
phase=phase,
|
||||||
|
cycle=0,
|
||||||
|
dispatch_chain=_dumps(dispatch_chain),
|
||||||
|
active_agent_id=None,
|
||||||
|
consensus_percentage=0,
|
||||||
|
pending_clarification=None,
|
||||||
|
thread_ids=_dumps(thread_ids),
|
||||||
|
coordinator_name=coordinator_name,
|
||||||
|
orchestration_mode=orchestration_mode,
|
||||||
|
chain=_dumps(chain),
|
||||||
|
error=None,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return self._row_to_dict(row)
|
||||||
|
|
||||||
|
# ── update progress ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def update_progress(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
|
status=_UNSET,
|
||||||
|
phase=_UNSET,
|
||||||
|
cycle=_UNSET,
|
||||||
|
dispatch_chain=_UNSET,
|
||||||
|
active_agent_id=_UNSET,
|
||||||
|
consensus_percentage=_UNSET,
|
||||||
|
pending_clarification=_UNSET,
|
||||||
|
thread_ids=_UNSET,
|
||||||
|
coordinator_name=_UNSET,
|
||||||
|
error=_UNSET,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.update_progress")
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(RoundtableJobRow, job_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||||
|
return None
|
||||||
|
if status is not _UNSET:
|
||||||
|
row.status = status
|
||||||
|
if phase is not _UNSET:
|
||||||
|
row.phase = phase
|
||||||
|
if cycle is not _UNSET:
|
||||||
|
row.cycle = int(cycle)
|
||||||
|
if dispatch_chain is not _UNSET:
|
||||||
|
row.dispatch_chain = _dumps(dispatch_chain)
|
||||||
|
if active_agent_id is not _UNSET:
|
||||||
|
row.active_agent_id = active_agent_id
|
||||||
|
if consensus_percentage is not _UNSET:
|
||||||
|
row.consensus_percentage = int(consensus_percentage)
|
||||||
|
if pending_clarification is not _UNSET:
|
||||||
|
row.pending_clarification = pending_clarification
|
||||||
|
if thread_ids is not _UNSET:
|
||||||
|
row.thread_ids = _dumps(thread_ids)
|
||||||
|
if coordinator_name is not _UNSET:
|
||||||
|
row.coordinator_name = coordinator_name
|
||||||
|
if error is not _UNSET:
|
||||||
|
row.error = error
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return self._row_to_dict(row)
|
||||||
|
|
||||||
|
# ── read ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get")
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(RoundtableJobRow, job_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||||
|
return None
|
||||||
|
return self._row_to_dict(row)
|
||||||
|
|
||||||
|
async def get_by_draft(
|
||||||
|
self,
|
||||||
|
draft_id: str,
|
||||||
|
*,
|
||||||
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Return the most-recently-updated job for a draft (or None)."""
|
||||||
|
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.get_by_draft")
|
||||||
|
stmt = select(RoundtableJobRow).where(RoundtableJobRow.draft_id == draft_id)
|
||||||
|
if resolved_user_id is not None:
|
||||||
|
stmt = stmt.where(RoundtableJobRow.user_id == resolved_user_id)
|
||||||
|
stmt = stmt.order_by(RoundtableJobRow.updated_at.desc()).limit(1)
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
row = result.scalars().first()
|
||||||
|
return self._row_to_dict(row) if row is not None else None
|
||||||
|
|
||||||
|
async def list_by_user(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.list_by_user")
|
||||||
|
stmt = select(RoundtableJobRow)
|
||||||
|
if resolved_user_id is not None:
|
||||||
|
stmt = stmt.where(RoundtableJobRow.user_id == resolved_user_id)
|
||||||
|
stmt = stmt.order_by(RoundtableJobRow.updated_at.desc()).limit(limit)
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [self._row_to_dict(r) for r in result.scalars()]
|
||||||
|
|
||||||
|
async def status_map_by_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
*,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> dict[str, dict[str, str]]:
|
||||||
|
"""``{draft_id: {"jobId": ..., "status": ...}}`` for the latest job per draft.
|
||||||
|
|
||||||
|
Drives the history dropdown's per-draft status badge (Phase 6). Walks jobs
|
||||||
|
newest-first and keeps the first (latest) one seen for each draft_id.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(RoundtableJobRow.id, RoundtableJobRow.draft_id, RoundtableJobRow.status)
|
||||||
|
.where(RoundtableJobRow.user_id == user_id)
|
||||||
|
.where(RoundtableJobRow.draft_id.is_not(None))
|
||||||
|
.order_by(RoundtableJobRow.updated_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
out: dict[str, dict[str, str]] = {}
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
for job_id, draft_id, status in result.all():
|
||||||
|
if draft_id is None or draft_id in out:
|
||||||
|
continue
|
||||||
|
out[draft_id] = {"jobId": job_id, "status": status}
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ── delete / cleanup ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
|
) -> bool:
|
||||||
|
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.delete")
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(RoundtableJobRow, job_id)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
if resolved_user_id is not None and row.user_id != resolved_user_id:
|
||||||
|
return False
|
||||||
|
await session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def list_older_than(
|
||||||
|
self,
|
||||||
|
cutoff: datetime,
|
||||||
|
*,
|
||||||
|
only_finished: bool = False,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> list[str]:
|
||||||
|
"""列出 updated_at 早于 cutoff 的作业 id(最多 limit 个),供清理任务用。
|
||||||
|
|
||||||
|
only_finished=True 时只列终态(done/error/cancelled),避免清掉还在跑的作业。
|
||||||
|
"""
|
||||||
|
stmt = select(RoundtableJobRow.id).where(RoundtableJobRow.updated_at < cutoff)
|
||||||
|
if only_finished:
|
||||||
|
stmt = stmt.where(RoundtableJobRow.status.in_(TERMINAL_STATUSES))
|
||||||
|
stmt = stmt.order_by(RoundtableJobRow.updated_at.asc()).limit(limit)
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
async def delete_by_ids(self, job_ids: list[str]) -> int:
|
||||||
|
if not job_ids:
|
||||||
|
return 0
|
||||||
|
stmt = delete(RoundtableJobRow).where(RoundtableJobRow.id.in_(job_ids))
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return int(result.rowcount or 0)
|
||||||
Reference in new issue
Block a user