圆桌草稿持久化:localStorage 迁至后端 MySQL

新增 /api/roundtable-drafts 草稿 CRUD 与会话级推荐历史;前端 useDraftPersistence 改走 api/drafts,删除本地 drafts.ts。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-05-31 00:23:25 +08:00
1 parent 24e92c437d
commit 838cae0d2d
16 files changed
+1410 -480

No files matched your search

+65 -2
View File
@@ -20,6 +20,7 @@
- **leader 共识 = `status: []` 空数组**;澄清 = `status: "clarification"`(字符串);派活 = `status: [[name, task], ...]`。这三者**互斥不混用**。
- **后端 picks clamp 2–6,前端用户可手动加到 8**。
- **不写死任何具体模型名**:`intent / recommend / multi_agent` 三个 router 的 `model_name = str(payload.get("model") or "")`,空串会被下游 `lead_agent._resolve_model_name()` 自动回退到 `config.yaml` 的 `models[0]`(与 `/api/ai-writing` 行为一致)。内网部署只需配 `config.yaml`,前后端均无需改硬编码。
- **草稿/推荐历史持久化(2026-06)**:圆桌页的「历史草稿」从浏览器 `localStorage` 迁到**后端 MySQL**(`/api/roundtable-drafts`,按登录用户隔离);新增**会话级推荐历史**让推荐弹窗回显上次结果。两张表 `roundtable_drafts` / `roundtable_recommend_history`,启动 `create_all` 自动建表,详见 §7A。
---
@@ -73,7 +74,12 @@ offline-backend-20260512/backend/
│ ├── multi_agent.py # Step 2:init(SSE) + run/stream + 共享的 loopback 工具
│ ├── intent.py # Step 1:init + stream(import multi_agent 工具)
│ ├── recommend.py # Step 1→2:推荐 stream(import multi_agent 工具)
│ └── artifacts.py # Step 3:列出/下载/编辑 thread 产出(通用,非圆桌专用)
│ ├── artifacts.py # Step 3:列出/下载/编辑 thread 产出(通用,非圆桌专用)
│ └── roundtable_drafts.py # 持久化:草稿 CRUD + 推荐历史(§7A,2026-06 新增)
├── packages/harness/deerflow/persistence/roundtable_drafts/
│ ├── model.py # ORM:RoundtableDraftRow + RoundtableRecommendHistoryRow
│ ├── sql.py # RoundtableDraftRepository(CRUD + 历史)
│ └── __init__.py # make_roundtable_draft_store(sf)
├── packages/harness/deerflow/tools/builtins/
│ └── clarification_utils.py # resolve_allow_multiple + coerce_bool(Step1/Step2 共享)
└── .deer-flow/agents/
@@ -110,8 +116,15 @@ offline-backend-20260512/backend/
| GET | `/api/threads/{thread_id}/artifacts` | 3 | 列出该 thread 的 outputs 文件 |
| GET | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 预览/下载单个产出(含 `.skill/` 内文件提取) |
| PATCH | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 编辑文本类产物(白名单后缀) |
| GET | `/api/roundtable-drafts` | 持久化 | 列出当前用户草稿(轻量元数据) |
| POST | `/api/roundtable-drafts` | 持久化 | 新建草稿 |
| GET | `/api/roundtable-drafts/{id}` | 持久化 | 取草稿全量(含 step1/step2 快照) |
| PUT | `/api/roundtable-drafts/{id}` | 持久化 | 局部更新(title / furthest_step / step1 / step2) |
| DELETE | `/api/roundtable-drafts/{id}` | 持久化 | 删除草稿 |
| GET | `/api/roundtable-drafts/{id}/recommendations` | 持久化 | 列出该草稿的推荐历史(倒序) |
| POST | `/api/roundtable-drafts/{id}/recommendations` | 持久化 | 追加一次推荐记录 |
字段级请求/响应见各 step 的 API 文档;下文只写**后端实现语义**。
字段级请求/响应见各 step 的 API 文档;下文只写**后端实现语义**。圆桌草稿/推荐历史持久化见 §7A。
---
@@ -589,6 +602,53 @@ Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力
---
## 7A. 圆桌草稿与推荐历史持久化(`roundtable_drafts.py`,2026-06)
圆桌规划页的「历史记录」此前只存浏览器 `localStorage`(前端 `utils/drafts.ts`),换设备/清缓存即丢、大对话还会撞 5MB 配额。2026-06 迁到**后端 MySQL**,按登录用户隔离、跨设备保留;并新增**会话级推荐历史**,让推荐弹窗能回显上次推荐结果。
### 7A.1 数据模型(`deerflow.persistence.roundtable_drafts`)
仿 `user_prompts`(最简 CRUD)+ `ai_writing_sessions`(大 JSON 用 `PortableLongText` 存)两个现有模式。两张表:
- **`roundtable_drafts`**(会话主表,一条 = 一次完整圆桌会话)
- `id`(PK, str64) / `user_id`(idx) / `title`(512) / `furthest_step`(int, 1|2|3)
- `step1` / `step2`:前端 `DraftStep{1,2}Snapshot` 的 JSON,列类型 **`PortableLongText`**(MySQL `LONGTEXT`),避免 Step 2 长对话 + `seatStubMessages` 撞 `TEXT` 64KB 上限
- `created_at` / `updated_at`:**`BeijingDateTime`**(项目规范,存北京时间且 tz-aware)
- **`roundtable_recommend_history`**(推荐历史,按 `draft_id` 归属)
- `id`(PK) / `user_id`(idx) / `draft_id`(idx) / `objective`(512) / `status`(32: done|fallback|asking|error) / `model`(128)
- `rationale` / `picks`(JSON) / `candidates`(JSON 候选池快照):均 `PortableLongText`
- `created_at`:`BeijingDateTime`
JSON 列在 repo(`sql.py`)里用 `json.dumps/loads` 手动序列化(与 `ai_writing_sessions.transcript` 同法)。两个 Row 已登记进 `persistence/models/__init__.py`,**启动时 `Base.metadata.create_all` 自动建表**——新部署无需手动建表(需 mysql 账号有建库/表权限)。
### 7A.2 路由(`app/gateway/routers/roundtable_drafts.py`,前缀 `/api/roundtable-drafts`)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `` | `list_drafts` → 轻量元数据列表(不含 step 大 blob),按 `updated_at` 倒序 |
| POST | `` | `create_draft`,可带客户端 `id`(稳定 id 跨 create+update) |
| GET | `/{id}` | `get_draft` 全量;非属主 → 404 |
| PUT | `/{id}` | `update_draft`,`model_dump(exclude_unset=True)` → 只更新客户端实际发的字段 |
| DELETE | `/{id}` | 204;非属主 → 404 |
| GET | `/{id}/recommendations` | 推荐历史倒序 |
| POST | `/{id}/recommendations` | 追加一次推荐(先校验草稿属主,404 拦截越权) |
鉴权/属主:用 `_current_user_id(request)`(登录态取 `request.state.user.id`,无登录回退 `get_effective_user_id()` 的 `"default"`),与 `user_prompts` 一致;所有 repo 读写按 `user_id` 过滤,跨用户访问一律 404。store 在 `deps.py` 接为 `app.state.roundtable_draft_store`(`make_roundtable_draft_store(sf)`),`app.py` `include_router`。
### 7A.3 与前端的契约要点
- **PUT 不带 `title` 时不改标题**:前端自动保存只发 `furthest_step`/`step1`/`step2`,不发 `title`,因此**不会覆盖用户手动重命名**;标题只在 create(派生)与显式重命名(只发 title)时改。
- **推荐时机**:前端在打开推荐弹窗前先 `ensureDraft` 落地草稿拿到 `draft_id`,推荐完成后 POST 一条历史;重开弹窗 GET 历史回显最近一次,可「重新分析」再 POST。
- **字段命名**:后端 snake_case(`furthest_step`/`created_at`),前端 `api/drafts.ts` 在边界映射成 camelCase。
### 7A.4 设计取舍
- **整存 JSON 而非拆表**:step1/step2 形状宽且常变(前端 hook 里是 loose 类型),整存 JSON 避免每次改前端快照都要迁库;代价是后端不校验嵌套字段(与 `ai_writing_sessions.transcript` 同取舍)。
- **推荐历史绑定草稿而非全局**:语义是「这个会话上次推荐了什么」,故按 `draft_id` 归属;这也要求推荐前必有 `draft_id`(前端 `ensureDraft` 保证)。
- **本地 dev 走 sqlite**:`config.yaml` `database.backend` 本地默认 sqlite,走同一套 SQLAlchemy 代码,`PortableLongText/JSON` 各自降级;生产切 mysql 用同一份代码。
---
## 8. 内置席位 SOUL 设计要点
每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。
@@ -1195,6 +1255,9 @@ client = httpx.AsyncClient(transport=transport, base_url=...)
| 推荐路由 | `offline-backend-20260512/backend/app/gateway/routers/recommend.py` |
| Step 2 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| Step 3 列出/下载/编辑 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 草稿/推荐历史路由(§7A) | `offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py` |
| 草稿/推荐历史持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/` |
| store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_draft_store`) |
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
| 澄清单选/多选 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py` |
| 任务理解 Agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` |
+42 -24
View File
@@ -14,6 +14,7 @@
- **步骤折叠卡与主聊天对齐**:思考过程 / 工具调用展示走 `MessageStepsCard`,**always-show-last-tool + 折叠 "查看其他 N 个步骤"** 的视觉与主聊天 `chats/new` 的 `MessageGroup` 一致。工具图标 / 文案映射在 `lib/step-display.tsx`,新增工具时与 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段对齐。
- **`<think>` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `<think>...</think>`(含未闭合的流式中段)抽离成 `reasoning`,正文走 markdown;由 `MessageBubble.tsx::ReasoningBlock` 渲染一个折叠卡。Step 1 / Step 2 气泡和推荐弹窗的 rationale 都走这一套,与主聊天 `Reasoning` 元素的视觉对齐。
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
- **草稿/历史持久化在后端(2026-06)**:草稿不再存浏览器 `localStorage`,改走后端 MySQL(`/api/roundtable-drafts`,按登录用户隔离、跨设备保留)。`api/drafts.ts` 封装 CRUD,`useDraftPersistence` 异步对接、对外接口不变。**推荐弹窗带「会话级推荐历史」**:每个草稿一串历史,重开弹窗先显示上次推荐结果,可「重新分析」重跑(详见 §5.7 / §6.2)。后端实现见 backend doc 的「圆桌草稿持久化」一节。
- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
- **协调智能体单例(2026-05)**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"` 是固定常量,不再每会话生成新 id。后端 seeder 自动落地,「本次席位列表」改由后端通过 thread 历史 human 消息注入,前端无感。`newCoordinatorName()` 仅作为兼容性壳保留。
@@ -54,7 +55,8 @@ frontend-web/src/roundtable-planning/
│ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行)
├── api/
│ ├── intent.ts # Step 1:initIntent、streamIntent
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent;**+ 推荐历史 listRecommendHistory / saveRecommendHistory**
│ ├── drafts.ts # **草稿后端 CRUD**:listDrafts / getDraft / createDraft / updateDraft / deleteDraft / deriveDraftName(替代旧 localStorage utils/drafts.ts)
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
├── components/
│ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器
@@ -73,14 +75,13 @@ frontend-web/src/roundtable-planning/
│ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
│ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步
│ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona
│ └── useDraftPersistence.ts # 草稿:自动保存(前进切换)/手动保存/加载/重命名/删除
│ └── useDraftPersistence.ts # 草稿(后端版):异步走 api/drafts;自动保存(前进切换)/手动保存/加载/重命名/删除/ensureDraft
├── lib/
│ ├── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
│ ├── reasoning.ts # splitInlineReasoning:从模型流式文本里提取 `<think>...</think>` 思考过程
│ ├── roundtable-constants.ts # 类型(RoleEntry / SelectedAgent) + 常量 + 纯工具函数(buildRoleTemplates / splitIntentReadyBlock 等)
│ └── step-display.tsx # Step 2 ChainOfThought 折叠卡图标 + 文案 + 子节点渲染
├── utils/
│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName)
├── utils/ # (旧 drafts.ts 已删除 —— 草稿改走后端 api/drafts.ts)
└── styles/
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
```
@@ -430,19 +431,28 @@ isOrchestrating = false
- **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」
- **可改换为 Radix Select**:当前用原生 `<select>` 与 Persona 抽屉保持一致;如需统一样式可改为 `@/components/ui/select`,行为不变
### 5.7 草稿持久化(`utils/drafts.ts`)
### 5.7 草稿持久化(`api/drafts.ts` + 后端 MySQL,2026-06 改造)
- Key:`roundtable-planning:drafts:v1`(`localStorage`)
- 草稿快照含:`step1.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`step2.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit}`、`furthestStep`
- `deriveDraftName({intentReady, interactiveMessages})`:优先用 `intentReady.objective`,否则取第一个 user 消息,否则用 `未命名草稿 <时间>`,统一截断到 40 字
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发,避免覆盖 `furthestStep`
- **手动保存**:顶栏 + Step 1 底部「保存草稿」按钮均调用 `handleManualSave`
- **加载草稿**:`handleLoadDraft`
- 中断所有 in-flight(`abortAllInFlight + stopIntentStream`)
- **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`,避免 step1/step2 init effect 重新调用 init 把恢复的状态打掉
- `step1InitGenRef` 自增,淘汰可能晚归的旧 init
- `step2InitRef.current = draft.furthestStep >= 2`:曾经走到 Step 2 才标记已 init
- 角色面板基线状态:被恢复时全部置「在线」,否则 init 流程逐个置「在线」
> **重大变更**:草稿从浏览器 `localStorage` 迁到**后端 MySQL**,按登录用户隔离、跨设备/会话保留。旧 `utils/drafts.ts` 已删除;`useDraftPersistence` 仍保持对外接口不变(列表项含 `name`,由后端 `title` 映射而来),主页面无需改动。后端表/路由见 backend doc「圆桌草稿持久化」。
- **API 客户端 `api/drafts.ts`**:`listDrafts()`(轻量元数据)/ `getDraft(id)`(全量 step1+step2)/ `createDraft` / `updateDraft`(partial)/ `deleteDraft` / `deriveDraftName`。snake_case↔camelCase 映射在这一层收口(后端 `furthest_step/created_at/updated_at` ↔ 前端 `furthestStep/...`)。
- **快照形状不变**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
- `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。
- **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。
- **手动保存 / `ensureDraft`**:`handleManualSave` 走同一条保存队列;`ensureDraft(furthestStep?)` 幂等保证后端有一条对应草稿并返回 id(**推荐弹窗打开前调用**,让推荐历史有 `draft_id` 归属)。
- **加载草稿 `handleLoadDraft`**:
- 走 `getDraft(id)` **实时拉服务端最新全量**(不再读内存里可能过期的列表快照);404 → toast「草稿已不存在」并刷新列表。
- **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`(顺序不能反)。
- `setStep2InitGate(draft.furthestStep >= 2)`:曾走到 Step 2 才跳过自动 init。
- 角色面板基线:被恢复时全部「在线」,否则 init 流程逐个「在线」。
**本次随手修复的 4 个旧 localStorage 版 bug**(对接后端时一并解决):
1. **自动保存覆盖手动重命名** —— 现在所有 `updateDraft` 一律**不带 title**;标题只由 create(派生)与显式「重命名」改写。
2. **快速连续前进(1→2→3)产生重复草稿** —— 所有保存经一条**串行队列**(`saveQueueRef`)+ 同步镜像 `currentDraftIdRef`,create 完成即写 id,后续保存读到 id 走 update 而非再 create。
3. **加载读到内存旧列表快照** —— 改为 `getDraft(id)` 实时拉。
4. **localStorage 配额静默写失败 / 数据丢失** —— 迁后端后不复存在。
---
@@ -460,15 +470,22 @@ isOrchestrating = false
### 6.2 `RecommendAgentsDialog`
- 打开时管线:`listAgents() → filterRecommendCandidates() → streamRecommend()`
- **新增 `draftId` prop(2026-06)**:本次推荐归属的草稿/会话 id。由主页面在打开弹窗前 `await drafts.ensureDraft(1)` 拿到并传入(见 §6.6)。`null` 时退化为「直接推荐、不存历史」的旧行为。
- 打开时管线(`start(forceFresh=false)`):`listAgents() → filterRecommendCandidates()` → **若 `draftId` 有历史 → `listRecommendHistory()` 取最近一次直接展示(不重跑模型)**;否则 `streamRecommend()` 跑新推荐。
- 阶段 UI(`InternalState.phase`):
- `idle` → `loading_agents` → `recommending`(顶部「模型生成内容」区流式;下方推荐卡 streaming=true)→ `ready` / `fallback` / `error`
- `idle` → `loading_agents` → **`loading_history`** → **`history`**(展示上次推荐,可直接进入研讨或「重新分析」)
- 或 `loading_agents` → `recommending`(顶部「模型生成内容」流式;推荐卡 streaming=true)→ `ready` / `fallback` / `error`
- **推荐历史(会话级)**:
- 打开时若该 `draftId` 有历史,进入 `history` 态:蓝色提示条标注上次推荐时间,picks 默认勾选、rationale 回显,**不**自动重跑模型。
- 「**重新分析**」按钮(`history` 态显示该文案,其它态为「重新推荐」)→ `start(true)` 强制跑新推荐,跳过历史。
- 每次**新推荐**完成(`ready`/`fallback`)后 `saveRecommendHistory(draftId, {objective,status,model,rationale,picks,candidates})` best-effort 追加一条(失败仅告警不阻断)。
- `history`/`fallback`/`ready` 三态都允许 `onConfirm`(用户可直接采纳历史 picks)。
- **布局分两段**:
- **顶部「模型生成内容」**:`RecommenderOutput` —— 状态行(`spinner + 调度推荐 / 已连接 / 正在生成`)+ `MessageStepsCard` 步骤卡 + `ReasoningBlock`(从 `<think>` 拆出来)+ markdown rationale + 末尾 `StreamingCursor`。视觉与 Step 1/Step 2 气泡的内容区一致,参考主聊天 `chats/new`。
- **底部「推荐参会智能体」**:候选卡片(`AgentCard`),分「推荐参会 / 其他候选」两组。
- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
- 反竞态:内部 `generationRef`,每次 `runRecommendation` 自增,所有 setState 都校验 `myGen === generationRef.current`,避免「重新推荐」时旧 stream 写脏 state
- 反竞态:内部 `generationRef`,每次 `start()` 自增,所有 setState(含历史读取/流式回调)都校验 `myGen === generationRef.current`,避免「重新分析」时旧 stream / 旧历史写脏 state
- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init
### 6.3 `HighFidelityReport`(Step 3)
@@ -613,7 +630,7 @@ interface StreamMultiAgentResult {
| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
| `STORAGE_KEY` | `"roundtable-planning:drafts:v1"` | drafts.ts |
| ~~`STORAGE_KEY`~~ | 已废弃 —— 草稿改存后端 `/api/roundtable-drafts`,不再用 localStorage | (旧 utils/drafts.ts,已删除) |
| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
@@ -699,8 +716,8 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
1. **不要在 Panel 组件里写 fetch / setState 业务流程** —— Panel 只渲染,事件透传到 hook。
2. **hook 之间不要互相 import** —— 跨 hook 的数据通过主页面的 ref / callback 桥接(参考 `buildInitialIntent` 与 `loadedFromDraftRef` 的实现)。
3. **草稿 snapshot 形状**(Step 1 的 `Step1Snapshot` / Step 2 的 `Step2Snapshot`)与 `utils/drafts.ts` 的 `DraftStep{1,2}Snapshot` 是同一份语义,改字段时两边都要改。
4. **lint/typecheck**:重构后 `pnpm typecheck` 与 `pnpm build` 必须保持与重构前同样的 0 个新增错误(预存量 50 个 pre-existing 错误来自其它模块如 `next/link` shim,与本特性无关)。
3. **草稿 snapshot 形状**(Step 1 的 `Step1Snapshot` / Step 2 的 `Step2Snapshot`)与 `api/drafts.ts` 的 `DraftStep{1,2}Snapshot` 是同一份语义,改字段时两边都要改;后端以 JSON 整存,不强约束嵌套字段。
4. **lint/typecheck**:重构后 `pnpm typecheck` 与 `pnpm build` 必须保持 0 个新增错误(预存量错误来自其它无关模块如 `strategy-components` / `next/link` shim,与本特性无关)。
### 12.7 文档协同
@@ -731,7 +748,8 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
| 常量/工具函数 | `frontend-web/src/roundtable-planning/lib/roundtable-constants.ts` |
| ChainOfThought 渲染 | `frontend-web/src/roundtable-planning/lib/step-display.tsx` |
| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
| 推荐 API(含推荐历史) | `frontend-web/src/roundtable-planning/api/recommend.ts` |
| 草稿 API(后端 CRUD) | `frontend-web/src/roundtable-planning/api/drafts.ts` |
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
@@ -740,7 +758,7 @@ window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
| 草稿存档 | `frontend-web/src/roundtable-planning/utils/drafts.ts` |
| 后端草稿/历史路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py` |
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
@@ -0,0 +1,211 @@
/**
* Backend-persisted draft storage for /page/roundtable/planning.
*
* One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
* roundtable). Persisted server-side (MySQL) bound to the logged-in user, so it
* follows the account across devices/sessions — replacing the old browser-only
* `localStorage` storage (see git history of `utils/drafts.ts`).
*
* The list endpoint returns lightweight metadata only; the full step1/step2
* snapshots are fetched on demand via {@link getDraft}. This keeps the history
* dropdown cheap and guarantees a load always reads the freshest server copy
* (the old localStorage path loaded from a possibly-stale in-memory array).
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
/**
* Step 1 snapshot. Shapes for the chat / timeline lists stay loose (`unknown[]`)
* because their inline types in the hooks are wide; the hook casts on hydrate.
*/
export interface DraftStep1Snapshot {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: unknown[];
timelineEntries: unknown[];
}
export interface DraftStep2Snapshot {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
/** Per-seat stub AI messages for the sandbox `write-file:` virtual URL loader. */
seatStubMessages?: Record<string, unknown[]>;
}
/** Lightweight metadata row returned by the list endpoint. */
export interface DraftMeta {
id: string;
/** Auto-derived task summary (objective → first user message → timestamp). */
title: string;
furthestStep: 1 | 2 | 3;
createdAt: string;
updatedAt: string;
}
/** Full record including the step snapshots. */
export interface DraftRecord extends DraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
}
/** Fields accepted when creating a draft. */
export interface DraftCreatePayload {
/** Optional stable id so the client can keep one id across create + updates. */
id?: string;
title: string;
furthestStep: 1 | 2 | 3;
step1: DraftStep1Snapshot;
step2: DraftStep2Snapshot;
}
/** Partial update — send only what changed. Omitting `title` preserves a rename. */
export interface DraftUpdatePayload {
title?: string;
furthestStep?: 1 | 2 | 3;
step1?: DraftStep1Snapshot;
step2?: DraftStep2Snapshot;
}
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
// ── server ⇄ client field mapping ──────────────────────────────────────────
// Backend uses snake_case (furthest_step / created_at / updated_at); the rest
// of the frontend reads camelCase. Map at this boundary so callers stay clean.
interface ServerDraftMeta {
id: string;
title: string;
furthest_step: number;
created_at: string | null;
updated_at: string | null;
}
interface ServerDraftFull extends ServerDraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
}
function toMeta(s: ServerDraftMeta): DraftMeta {
const step = Math.min(3, Math.max(1, s.furthest_step || 1)) as 1 | 2 | 3;
return {
id: s.id,
title: s.title ?? "",
furthestStep: step,
createdAt: s.created_at ?? "",
updatedAt: s.updated_at ?? "",
};
}
function toRecord(s: ServerDraftFull): DraftRecord {
return { ...toMeta(s), step1: s.step1 ?? null, step2: s.step2 ?? null };
}
function fromCreate(p: DraftCreatePayload) {
return {
...(p.id ? { id: p.id } : {}),
title: p.title,
furthest_step: p.furthestStep,
step1: p.step1,
step2: p.step2,
};
}
function fromUpdate(p: DraftUpdatePayload) {
const out: Record<string, unknown> = {};
if (p.title !== undefined) out.title = p.title;
if (p.furthestStep !== undefined) out.furthest_step = p.furthestStep;
if (p.step1 !== undefined) out.step1 = p.step1;
if (p.step2 !== undefined) out.step2 = p.step2;
return out;
}
// ── CRUD ────────────────────────────────────────────────────────────────────
/** List the current user's drafts, newest first (lightweight metadata only). */
export async function listDrafts(): Promise<DraftMeta[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts");
return (data.drafts ?? []).map(toMeta);
}
/** Fetch the full draft (incl. step1/step2). Returns null on 404. */
export async function getDraft(id: string): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "get draft");
return toRecord(data);
}
export async function createDraft(payload: DraftCreatePayload): Promise<DraftRecord> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromCreate(payload)),
});
const data = await readJson<ServerDraftFull>(res, "create draft");
return toRecord(data);
}
export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "update draft");
return toRecord(data);
}
export async function deleteDraft(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete draft failed (${res.status}): ${text}`);
}
}
/**
* Derive a short task-summary name from whatever the user supplied so far.
* Preference: intent objective → first user message → timestamp. Trimmed to
* ~40 chars so dropdown rows stay scannable. (Ported from the old utils/drafts.)
*/
export function deriveDraftName(args: {
intentReady?: IntentSummary | null;
interactiveMessages?: unknown[];
}): string {
const MAX = 40;
const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
const objective = args.intentReady?.objective?.trim();
if (objective) return truncate(objective);
if (Array.isArray(args.interactiveMessages)) {
for (const m of args.interactiveMessages) {
if (!m || typeof m !== "object") continue;
const obj = m as Record<string, unknown>;
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
return truncate(obj.content.trim());
}
}
}
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
}
@@ -198,6 +198,104 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
return { text, id };
}
/**
* One persisted recommendation run, bound to a draft/session. Returned by
* {@link listRecommendHistory}; lets the dialog show "last recommendation for
* this session" before deciding whether to re-analyze.
*/
export interface RecommendHistoryItem {
id: string;
draftId: string;
objective: string;
/** How the run ended on the backend: "done" | "fallback" | "asking" | "error". */
status: string;
model: string | null;
rationale: string | null;
picks: RecommendPick[];
/** Candidate-pool snapshot captured at recommend time. */
candidates: RecommendCandidate[];
createdAt: string;
}
/** Payload persisted after a recommendation run completes. */
export interface SaveRecommendHistoryPayload {
objective: string;
status: string;
model?: string;
rationale?: string;
picks: RecommendPick[];
candidates: RecommendCandidate[];
}
interface ServerHistoryItem {
id: string;
draft_id: string;
objective: string;
status: string;
model: string | null;
rationale: string | null;
picks: RecommendPick[];
candidates: RecommendCandidate[];
created_at: string | null;
}
function toHistoryItem(s: ServerHistoryItem): RecommendHistoryItem {
return {
id: s.id,
draftId: s.draft_id,
objective: s.objective ?? "",
status: s.status ?? "done",
model: s.model ?? null,
rationale: s.rationale ?? null,
picks: Array.isArray(s.picks) ? s.picks : [],
candidates: Array.isArray(s.candidates) ? s.candidates : [],
createdAt: s.created_at ?? "",
};
}
/** List a draft's recommendation history, newest first. */
export async function listRecommendHistory(draftId: string): Promise<RecommendHistoryItem[]> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/roundtable-drafts/${encodeURIComponent(draftId)}/recommendations`,
{ method: "GET" },
);
if (res.status === 404) return [];
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`list recommend history failed (${res.status}): ${text}`);
}
const data = (await res.json()) as { history: ServerHistoryItem[] };
return (data.history ?? []).map(toHistoryItem);
}
/** Append one recommendation run to a draft's history. */
export async function saveRecommendHistory(
draftId: string,
payload: SaveRecommendHistoryPayload,
): Promise<RecommendHistoryItem | null> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/roundtable-drafts/${encodeURIComponent(draftId)}/recommendations`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
objective: payload.objective,
status: payload.status,
model: payload.model,
rationale: payload.rationale,
picks: payload.picks,
candidates: payload.candidates,
}),
},
);
if (res.status === 404) return null; // draft gone — skip silently
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`save recommend history failed (${res.status}): ${text}`);
}
return toHistoryItem((await res.json()) as ServerHistoryItem);
}
/**
* Open an SSE connection to `/api/recommend/stream` and resolve once the
* upstream stream finishes. Each call is one recommendation round — the
@@ -31,9 +31,12 @@ import { MessageStepsCard } from "./MessageStepsCard";
import type { IntentSummary } from "../api/intent";
import {
filterRecommendCandidates,
listRecommendHistory,
parseRecommendStreamContent,
saveRecommendHistory,
streamRecommend,
type RecommendCandidate,
type RecommendHistoryItem,
type RecommendStreamPhase,
} from "../api/recommend";
import { useAutoScroll } from "../hooks/useAutoScroll";
@@ -56,6 +59,13 @@ export interface RecommendAgentsDialogProps {
intent: IntentSummary | null;
/** Falls back to the recommender's own default if omitted. */
model?: string;
/**
* Draft/session id this recommendation belongs to. When present, the dialog
* shows the session's last recommendation on open (with a「重新分析」button)
* and persists each fresh run to history. Null → behaves statelessly (always
* runs a fresh recommendation, no history).
*/
draftId?: string | null;
onCancel: () => void;
/** Called with the user's final selection of agent ids (in display order). */
onConfirm: (agents: Array<{ agent_id: string; name: string }>) => void;
@@ -81,6 +91,8 @@ const FALLBACK_AGENT_IDS = [
type DialogPhase =
| "idle"
| "loading_agents"
| "loading_history"
| "history"
| "recommending"
| "ready"
| "fallback"
@@ -111,6 +123,8 @@ interface InternalState {
warning: string | null;
/** Latest error message; only shown when `phase === "error"`. */
errorMessage: string | null;
/** ISO time of the historical record being shown (phase === "history"). */
historyCreatedAt: string | null;
}
const INITIAL_STATE: InternalState = {
@@ -123,6 +137,7 @@ const INITIAL_STATE: InternalState = {
recommendedIds: [],
warning: null,
errorMessage: null,
historyCreatedAt: null,
};
/** 推荐流程的 phase → 中文标签;与 RoundtablePlanningPage::phaseLabel 风格保持一致。 */
@@ -164,6 +179,7 @@ export function RecommendAgentsDialog({
open,
intent,
model,
draftId,
onCancel,
onConfirm,
}: RecommendAgentsDialogProps) {
@@ -185,172 +201,240 @@ export function RecommendAgentsDialog({
}, []);
/**
* The full pipeline: fetch agents → run recommendation → seed selection.
* Wrapped so the user can re-trigger it via the "重新推荐" button.
* Render a stored history record as the current view (no streaming). Used on
* open when the session already has a prior recommendation. The user can
* confirm these picks directly or click「重新分析」to run a fresh one.
*/
const runRecommendation = useCallback(async () => {
if (!intent || !intent.objective) {
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: "缺少任务目标,无法启动推荐",
});
return;
const applyHistory = useCallback((item: RecommendHistoryItem, candidates: RecommendCandidate[]) => {
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
for (const p of item.picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
cancelInflight();
const myGen = ++generationRef.current;
const controller = new AbortController();
abortRef.current = controller;
setState({ ...INITIAL_STATE, phase: "loading_agents" });
setSelectedIds(new Set());
let agents: Agent[];
try {
agents = await listAgents();
} catch (err) {
if (myGen !== generationRef.current) return;
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
});
return;
}
if (myGen !== generationRef.current) return;
const candidates = filterRecommendCandidates(agents);
if (candidates.length === 0) {
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
});
return;
}
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(item.rationale || "");
setState((prev) => ({
...prev,
phase: "recommending",
phase: "history",
candidates,
rationale: "",
streamPhase: "connecting",
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
steps: [
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
],
rationale: rationale || item.rationale || "",
recommendedIds: seed,
reasons,
streamPhase: null,
steps: [],
warning: null,
historyCreatedAt: item.createdAt || null,
}));
}, []);
try {
const result = await streamRecommend({
intent,
/**
* Stream a fresh recommendation over the given candidate pool, seed the
* selection, and (when bound to a draft) persist the run to history.
*/
const analyze = useCallback(
async (candidates: RecommendCandidate[], myGen: number, signal: AbortSignal) => {
if (!intent) return;
setState((prev) => ({
...prev,
phase: "recommending",
candidates,
model,
signal: controller.signal,
onRationaleUpdate: (rationale) => {
if (myGen !== generationRef.current) return;
setState((prev) => ({ ...prev, rationale }));
},
onPicksUpdate: (picks) => {
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
for (const p of picks) {
rationale: "",
reasons: {},
recommendedIds: [],
warning: null,
historyCreatedAt: null,
streamPhase: "connecting",
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
steps: [
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
],
}));
setSelectedIds(new Set());
try {
const result = await streamRecommend({
intent,
candidates,
model,
signal,
onRationaleUpdate: (rationale) => {
if (myGen !== generationRef.current) return;
setState((prev) => ({ ...prev, rationale }));
},
onPicksUpdate: (picks) => {
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
for (const p of picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of seed) next.add(id);
return next;
});
setState((prev) => ({ ...prev, recommendedIds, reasons }));
},
onPhaseChange: (phase) => {
if (myGen !== generationRef.current) return;
setState((prev) => {
const key = phase;
const last = prev.steps[prev.steps.length - 1];
const steps =
last && last.key === key
? prev.steps
: [
...prev.steps,
{
id: String(prev.steps.length),
key,
label: recommendPhaseLabel(phase),
ts: nowTimeStr(),
},
];
return { ...prev, streamPhase: phase, steps };
});
},
});
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
if (result.picks) {
for (const p of result.picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of seed) next.add(id);
return next;
});
setState((prev) => ({
...prev,
recommendedIds,
reasons,
}));
},
onPhaseChange: (phase) => {
if (myGen !== generationRef.current) return;
setState((prev) => {
const key = phase;
const last = prev.steps[prev.steps.length - 1];
// 连续重复阶段不重复 push,只在阶段切换时打点。
const steps =
last && last.key === key
? prev.steps
: [
...prev.steps,
{
id: String(prev.steps.length),
key,
label: recommendPhaseLabel(phase),
ts: nowTimeStr(),
},
];
return { ...prev, streamPhase: phase, steps };
});
},
});
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
if (result.picks) {
for (const p of result.picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
}
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || "");
let finalPhase: DialogPhase;
let finalIds: string[];
let warning: string | null;
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
finalPhase = "ready";
finalIds = intersectInOrder(recommendedIds, candidates);
warning = null;
} else {
// Fallback: status === "asking" or picks too few. Use the 6 built-in
// roundtable seats, intersected against the live pool.
finalPhase = "fallback";
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
finalIds =
fallback.length >= MIN_SELECTED
? fallback
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
warning = "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。";
}
setSelectedIds(new Set(finalIds));
setState((prev) => ({
...prev,
phase: "ready",
phase: finalPhase,
rationale: rationale || prev.rationale,
recommendedIds,
recommendedIds: finalIds,
reasons,
streamPhase: null,
warning: null,
warning,
}));
// Persist this run to the session's history (best-effort).
if (draftId) {
const picks = finalIds.map((id) => ({ agent_id: id, reason: reasons[id] ?? "" }));
void saveRecommendHistory(draftId, {
objective: intent.objective,
status: finalPhase === "ready" ? "done" : "fallback",
model,
rationale: rationale || result.content || "",
picks,
candidates,
}).catch((err) => console.warn("[recommend] 保存推荐历史失败:", err));
}
} catch (err) {
if (myGen !== generationRef.current) return;
// AbortError fires when the user closes the dialog mid-stream — no
// state update needed because the dialog is going away.
if (err instanceof DOMException && err.name === "AbortError") return;
setState((prev) => ({
...prev,
phase: "error",
streamPhase: null,
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
}));
}
},
[intent, model, draftId],
);
/**
* Open pipeline: load candidates → (unless forceFresh) show the session's
* last recommendation from history → otherwise run a fresh analysis.
* `forceFresh=true` is the「重新分析」/「重新推荐」path.
*/
const start = useCallback(
async (forceFresh: boolean) => {
if (!intent || !intent.objective) {
setState({ ...INITIAL_STATE, phase: "error", errorMessage: "缺少任务目标,无法启动推荐" });
return;
}
// Fallback: status === "asking" or picks too few. Use the 6 built-in
// roundtable seats, intersected against the live pool.
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
const seed = fallback.length >= MIN_SELECTED
? fallback
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({
...prev,
phase: "fallback",
rationale: rationale || prev.rationale,
recommendedIds: seed,
reasons,
streamPhase: null,
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
}));
} catch (err) {
cancelInflight();
const myGen = ++generationRef.current;
const controller = new AbortController();
abortRef.current = controller;
setState({ ...INITIAL_STATE, phase: "loading_agents" });
setSelectedIds(new Set());
let agents: Agent[];
try {
agents = await listAgents();
} catch (err) {
if (myGen !== generationRef.current) return;
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
});
return;
}
if (myGen !== generationRef.current) return;
// AbortError fires when the user closes the dialog mid-stream — no
// state update needed because the dialog is going away.
if (err instanceof DOMException && err.name === "AbortError") return;
setState((prev) => ({
...prev,
phase: "error",
streamPhase: null,
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
}));
}
}, [intent, model, cancelInflight]);
const candidates = filterRecommendCandidates(agents);
if (candidates.length === 0) {
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
});
return;
}
// 若该会话已有推荐历史,默认展示最近一次(可重新分析),不自动重跑模型。
if (!forceFresh && draftId) {
setState((prev) => ({ ...prev, phase: "loading_history", candidates }));
try {
const history = await listRecommendHistory(draftId);
if (myGen !== generationRef.current) return;
if (history.length > 0) {
applyHistory(history[0], candidates);
return;
}
} catch (err) {
if (myGen !== generationRef.current) return;
// 历史拉取失败不阻断:退化为直接跑一次新推荐。
console.warn("[recommend] 拉取推荐历史失败,回退到新推荐:", err);
}
}
await analyze(candidates, myGen, controller.signal);
},
[intent, draftId, cancelInflight, analyze, applyHistory],
);
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
// in-flight stream so the next open starts clean.
@@ -359,11 +443,11 @@ export function RecommendAgentsDialog({
cancelInflight();
return;
}
void runRecommendation();
void start(false);
return () => {
cancelInflight();
};
}, [open, runRecommendation, cancelInflight]);
}, [open, start, cancelInflight]);
const handleToggle = useCallback((agentId: string) => {
setSelectedIds((prev) => {
@@ -391,7 +475,7 @@ export function RecommendAgentsDialog({
const selectedCount = selectedIds.size;
const canConfirm =
(state.phase === "ready" || state.phase === "fallback") &&
(state.phase === "ready" || state.phase === "fallback" || state.phase === "history") &&
selectedCount >= MIN_SELECTED &&
selectedCount <= MAX_SELECTED;
@@ -452,7 +536,9 @@ export function RecommendAgentsDialog({
============================================================ */}
<CollapsibleModelOutput
isLoading={
state.phase === "loading_agents" || state.phase === "recommending"
state.phase === "loading_agents" ||
state.phase === "loading_history" ||
state.phase === "recommending"
}
phase={state.phase}
scrollDeps={[state.rationale, state.steps, state.streamPhase]}
@@ -497,8 +583,20 @@ export function RecommendAgentsDialog({
</div>
)}
</>
) : (state.phase === "ready" || state.phase === "fallback") ? (
) : (state.phase === "ready" || state.phase === "fallback" || state.phase === "history") ? (
<>
{state.phase === "history" && (
<div className="text-xs text-sky-700 dark:text-sky-300 p-2 border border-sky-300/50 dark:border-sky-700/50 rounded-md bg-sky-50 dark:bg-sky-950/30 flex items-center justify-between gap-2">
<span>
📌 这是本会话上次的推荐结果
{state.historyCreatedAt
? `(${new Date(state.historyCreatedAt).toLocaleString("zh-CN", { hour12: false })})`
: ""}
,可直接进入研讨,或点「重新分析」让模型重新推荐。
</span>
</div>
)}
{state.warning && (
<div className="text-xs text-amber-700 dark:text-amber-300 p-2 border border-amber-300/50 dark:border-amber-700/50 rounded-md bg-amber-50 dark:bg-amber-950/30">
{state.warning}
@@ -551,15 +649,18 @@ export function RecommendAgentsDialog({
>
{selectionHint}
</span>
{(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
{(state.phase === "ready" ||
state.phase === "fallback" ||
state.phase === "history" ||
state.phase === "error") && (
<Button
variant="ghost"
variant={state.phase === "history" ? "secondary" : "ghost"}
size="sm"
onClick={() => void runRecommendation()}
onClick={() => void start(true)}
className="h-7 px-2 text-xs"
>
<RefreshCwIcon className="size-3 mr-1" />
重新推荐
{state.phase === "history" ? "重新分析" : "重新推荐"}
</Button>
)}
</div>
@@ -624,7 +725,8 @@ function CollapsibleModelOutput({
hasAutoClosedRef.current = false;
return;
}
const isCompleted = phase === "ready" || phase === "fallback" || phase === "error";
const isCompleted =
phase === "ready" || phase === "fallback" || phase === "error" || phase === "history";
if (isCompleted && !hasAutoClosedRef.current && open) {
const timer = setTimeout(() => {
setOpen(false);
@@ -639,6 +741,10 @@ function CollapsibleModelOutput({
switch (phase) {
case "loading_agents":
return { label: "加载候选", className: "bg-slate-100 text-slate-600" };
case "loading_history":
return { label: "读取历史", className: "bg-slate-100 text-slate-600" };
case "history":
return { label: "历史记录", className: "bg-sky-50 text-sky-700 border border-sky-200" };
case "recommending":
return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" };
case "ready":
@@ -713,11 +819,11 @@ function CollapsibleModelOutput({
* - 流式中正文末尾跟一个闪烁竖线
*/
function RecommenderOutput({ state }: { state: InternalState }) {
if (state.phase === "loading_agents") {
if (state.phase === "loading_agents" || state.phase === "loading_history") {
return (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在加载候选智能体...
{state.phase === "loading_history" ? "正在读取历史推荐..." : "正在加载候选智能体..."}
</div>
);
}
@@ -733,7 +839,8 @@ function RecommenderOutput({ state }: { state: InternalState }) {
const showRationaleStage =
state.phase === "recommending" ||
state.phase === "ready" ||
state.phase === "fallback";
state.phase === "fallback" ||
state.phase === "history";
if (!showRationaleStage) return null;
const isStreaming = state.phase === "recommending";
@@ -1,26 +1,42 @@
/**
* 圆桌规划草稿持久化 hook。
* 圆桌规划草稿持久化 hook(后端版)。
*
* 维护 localStorage 草稿列表与 currentDraftId、自动保存(前进切换 1→2、2→3)、
* 手动保存、加载 / 重命名 / 删除。Step 1 / Step 2 的状态由各自的 hook 提供
* 快照函数与 hydrateFromDraft;本 hook 只负责编排「什么时机存 / 加载到哪里」。
* 2026-06 改造:草稿从浏览器 localStorage 迁移到后端 MySQL(`/api/roundtable-drafts`),
* 按登录用户隔离、跨设备/会话保留。本 hook 仍只负责编排「什么时机存 / 加载到哪里」,
* Step 1 / Step 2 的状态由各自的 hook 提供快照函数与 hydrate。
*
* 对外返回的接口(`drafts` 列表项含 `name` 等字段、各 handler 签名)与旧版保持一致,
* 主页面无需改动。
*
* 同时修复了旧 localStorage 版的几个存储 bug:
* 1. **自动保存会覆盖手动重命名** —— 现在 create 时写一次派生标题,之后所有 update
* 一律不带 title;标题只由显式「重命名」改写。
* 2. **快速连续前进(1→2→3)产生重复草稿** —— 所有保存经一条串行队列,create 完成
* 即写入 `currentDraftIdRef`,后续保存读到 id 走 update 而非再 create。
* 3. **加载读到 state 里的旧列表快照** —— load 走 `getDraft(id)` 实时拉服务端最新全量。
* 4. localStorage 配额导致的静默写失败 / 数据丢失 —— 改后端后不复存在。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type {
Step1Snapshot,
} from "@/roundtable-planning/hooks/useStep1Intent";
import type {
Step2Snapshot,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { Step1Snapshot } from "@/roundtable-planning/hooks/useStep1Intent";
import type { Step2Snapshot } from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
createDraft,
deleteDraft,
deriveDraftName,
getDraft,
listDrafts,
renameDraft,
upsertDraft,
type DraftRecord,
} from "@/roundtable-planning/utils/drafts";
updateDraft,
} from "@/roundtable-planning/api/drafts";
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
export interface DraftListItem {
id: string;
name: string;
furthestStep: 1 | 2 | 3;
createdAt: string;
updatedAt: string;
}
export interface UseDraftPersistenceOptions {
currentStep: number;
@@ -44,16 +60,12 @@ export interface UseDraftPersistenceOptions {
}
export interface UseDraftPersistenceReturn {
drafts: DraftRecord[];
drafts: DraftListItem[];
currentDraftId: string | null;
isHistoryOpen: boolean;
renamingDraftId: string | null;
renameDraftValue: string;
historyDropdownRef: React.MutableRefObject<HTMLDivElement | null>;
/**
* Shared latch: 加载草稿后置 true,影响 LeftSidebar 在 Step 2 时角色面板的
* 初始 status(在线 vs 正在上线)。由主页面在加载完后 read,使用完可手动复位。
*/
loadedFromDraftRef: React.MutableRefObject<boolean>;
setIsHistoryOpen: React.Dispatch<React.SetStateAction<boolean>>;
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
@@ -61,9 +73,14 @@ export interface UseDraftPersistenceReturn {
handleManualSave: () => void;
handleLoadDraft: (id: string) => void;
handleDeleteDraft: (id: string) => void;
handleStartRename: (draft: DraftRecord) => void;
handleStartRename: (draft: DraftListItem) => void;
handleCommitRename: (id: string) => void;
handleCancelRename: () => void;
/**
* 幂等地保证后端存在一条对应当前会话的草稿,返回其 id(无可保存内容时返回 null)。
* 推荐弹窗打开前调用,确保推荐历史有 draft_id 可归属。
*/
ensureDraft: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
}
export function useDraftPersistence(
@@ -81,8 +98,8 @@ export function useDraftPersistence(
setStep2InitGate,
} = options;
const [drafts, setDrafts] = useState<DraftRecord[]>(() => listDrafts());
const [currentDraftId, setCurrentDraftId] = useState<string | null>(null);
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
const [currentDraftId, setCurrentDraftIdState] = useState<string | null>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
const [renamingDraftId, setRenamingDraftId] = useState<string | null>(null);
const [renameDraftValue, setRenameDraftValue] = useState<string>("");
@@ -91,95 +108,164 @@ export function useDraftPersistence(
const prevStepRef = useRef<number>(currentStep);
const historyDropdownRef = useRef<HTMLDivElement | null>(null);
const buildDraftPayload = useCallback(
(
furthestStep: 1 | 2 | 3,
idOverride?: string | null,
): Parameters<typeof upsertDraft>[0] => {
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
const resolvedId = idOverride ?? currentDraftId;
return {
...(resolvedId ? { id: resolvedId } : {}),
name: deriveDraftName({
intentReady: step1.intentReady,
interactiveMessages: step1.interactiveMessages,
}),
furthestStep,
step1,
step2,
};
// 同步镜像,供保存队列在 render 之前读到最新 id(修复重复建草稿竞态)。
const currentDraftIdRef = useRef<string | null>(null);
// 串行化所有写操作,避免并发 create。
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
(action) => {
setCurrentDraftIdState((prev) => {
const next = typeof action === "function" ? (action as (p: string | null) => string | null)(prev) : action;
currentDraftIdRef.current = next;
return next;
});
},
[currentDraftId, getStep1Snapshot, getStep2Snapshot],
[],
);
const saveDraft = useCallback(
(furthestStep: 1 | 2 | 3): DraftRecord | null => {
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
const hasUserMessage = step1.interactiveMessages.some(
(m) => m.sender === "user",
const refreshList = useCallback(async () => {
try {
const metas = await listDrafts();
setDrafts(
metas.map((m) => ({
id: m.id,
name: m.title,
furthestStep: m.furthestStep,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
})),
);
if (!hasUserMessage && step2.step2RoundtableDialogues.length === 0) {
return null;
}
const saved = upsertDraft(buildDraftPayload(furthestStep));
setCurrentDraftId(saved.id);
setDrafts(listDrafts());
return saved;
} catch (err) {
console.warn("[drafts] 刷新草稿列表失败:", err);
}
}, []);
// 首次挂载拉一次草稿列表。
useEffect(() => {
void refreshList();
}, [refreshList]);
/** 当前会话是否有值得保存的内容。 */
const hasSavableContent = useCallback((step1: Step1Snapshot, step2: Step2Snapshot): boolean => {
const hasUserMessage = step1.interactiveMessages.some((m) => m.sender === "user");
return hasUserMessage || step2.step2RoundtableDialogues.length > 0;
}, []);
/**
* 串行执行一次保存:有 id 走 update(不带 title,保护重命名);无 id 走 create
* (写一次派生标题)。create/update 成功后同步 currentDraftIdRef。
* 返回 draft id(无可保存内容返回 null)。
*/
const enqueueSave = useCallback(
(furthestStep: 1 | 2 | 3): Promise<string | null> => {
const run = async (): Promise<string | null> => {
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
if (!hasSavableContent(step1, step2)) return null;
const id = currentDraftIdRef.current;
if (id) {
const updated = await updateDraft(id, { furthestStep, step1, step2 });
if (updated) return updated.id;
// 服务端已不存在(被删/换账号)→ 落回新建。
}
const created = await createDraft({
title: deriveDraftName({
intentReady: step1.intentReady,
interactiveMessages: step1.interactiveMessages,
}),
furthestStep,
step1,
step2,
});
currentDraftIdRef.current = created.id;
setCurrentDraftId(created.id);
return created.id;
};
const next = saveQueueRef.current.then(run, run);
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[buildDraftPayload, getStep1Snapshot, getStep2Snapshot],
[getStep1Snapshot, getStep2Snapshot, hasSavableContent, setCurrentDraftId],
);
const ensureDraft = useCallback(
async (furthestStep: 1 | 2 | 3 = (currentStep as 1 | 2 | 3) || 1): Promise<string | null> => {
if (currentDraftIdRef.current) return currentDraftIdRef.current;
const id = await enqueueSave(furthestStep);
if (id) await refreshList();
return id;
},
[currentStep, enqueueSave, refreshList],
);
const handleManualSave = useCallback(() => {
const furthest = (currentStep as 1 | 2 | 3) ?? 1;
const saved = saveDraft(furthest);
if (saved) {
triggerToast(`💾 已保存为草稿:${saved.name}`);
} else {
triggerToast("当前会话还没有可保存的内容");
}
}, [currentStep, saveDraft, triggerToast]);
const furthest = (currentStep as 1 | 2 | 3) || 1;
void (async () => {
const id = await enqueueSave(furthest);
if (id) {
await refreshList();
triggerToast("💾 已保存为草稿");
} else {
triggerToast("当前会话还没有可保存的内容");
}
})();
}, [currentStep, enqueueSave, refreshList, triggerToast]);
// 前进切换时自动保存;后退不保存以避免覆盖 furthestStep。
useEffect(() => {
const prev = prevStepRef.current;
prevStepRef.current = currentStep;
if (currentStep > prev) {
saveDraft(currentStep as 1 | 2 | 3);
void (async () => {
const id = await enqueueSave(currentStep as 1 | 2 | 3);
if (id) await refreshList();
})();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]);
const handleLoadDraft = useCallback(
(id: string) => {
const draft = drafts.find((d) => d.id === id) ?? null;
if (!draft) {
triggerToast("⚠️ 该草稿已不存在");
setDrafts(listDrafts());
return;
}
void (async () => {
let draft;
try {
draft = await getDraft(id);
} catch (err) {
console.warn("[drafts] 加载草稿失败:", err);
triggerToast("⚠️ 加载草稿失败,请重试");
return;
}
if (!draft) {
triggerToast("⚠️ 该草稿已不存在");
await refreshList();
return;
}
cancelAllStreams();
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
loadedFromDraftRef.current = true;
cancelAllStreams();
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
loadedFromDraftRef.current = true;
hydrateStep1(draft.step1 as Step1Snapshot);
hydrateStep2(draft.step2 as Step2Snapshot);
setStep2InitGate(draft.furthestStep >= 2);
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
setStep2InitGate(draft.furthestStep >= 2);
setCurrentDraftId(draft.id);
setIsHistoryOpen(false);
prevStepRef.current = draft.furthestStep;
setCurrentStep(draft.furthestStep);
triggerToast(`📂 已加载草稿:${draft.name}`);
setCurrentDraftId(draft.id);
setIsHistoryOpen(false);
prevStepRef.current = draft.furthestStep;
setCurrentStep(draft.furthestStep);
triggerToast(`📂 已加载草稿:${draft.title}`);
})();
},
[
cancelAllStreams,
drafts,
hydrateStep1,
hydrateStep2,
refreshList,
setCurrentDraftId,
setCurrentStep,
setStep2InitGate,
triggerToast,
@@ -191,31 +277,54 @@ export function useDraftPersistence(
if (typeof window !== "undefined" && !window.confirm("确定要删除这条历史记录吗?此操作不可恢复。")) {
return;
}
deleteDraft(id);
setDrafts(listDrafts());
if (currentDraftId === id) setCurrentDraftId(null);
if (renamingDraftId === id) setRenamingDraftId(null);
triggerToast("🗑 已删除历史记录");
void (async () => {
try {
await deleteDraft(id);
} catch (err) {
console.warn("[drafts] 删除草稿失败:", err);
triggerToast("⚠️ 删除失败,请重试");
return;
}
await refreshList();
if (currentDraftIdRef.current === id) setCurrentDraftId(null);
if (renamingDraftId === id) setRenamingDraftId(null);
triggerToast("🗑 已删除历史记录");
})();
},
[currentDraftId, renamingDraftId, triggerToast],
[refreshList, renamingDraftId, setCurrentDraftId, triggerToast],
);
const handleStartRename = useCallback((draft: DraftRecord) => {
const handleStartRename = useCallback((draft: DraftListItem) => {
setRenamingDraftId(draft.id);
setRenameDraftValue(draft.name);
}, []);
const handleCommitRename = useCallback(
(id: string) => {
const updated = renameDraft(id, renameDraftValue);
if (updated) {
setDrafts(listDrafts());
triggerToast("✏️ 已重命名");
const trimmed = renameDraftValue.trim();
if (!trimmed) {
setRenamingDraftId(null);
setRenameDraftValue("");
return;
}
setRenamingDraftId(null);
setRenameDraftValue("");
void (async () => {
try {
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
const updated = await updateDraft(id, { title: trimmed });
if (updated) {
await refreshList();
triggerToast("✏️ 已重命名");
}
} catch (err) {
console.warn("[drafts] 重命名失败:", err);
triggerToast("⚠️ 重命名失败,请重试");
} finally {
setRenamingDraftId(null);
setRenameDraftValue("");
}
})();
},
[renameDraftValue, triggerToast],
[refreshList, renameDraftValue, triggerToast],
);
const handleCancelRename = useCallback(() => {
@@ -253,5 +362,6 @@ export function useDraftPersistence(
handleStartRename,
handleCommitRename,
handleCancelRename,
ensureDraft,
};
}
@@ -320,6 +320,7 @@ export default function RoundtablePlanningPage() {
open={isRecommendDialogOpen}
intent={step1.intentReady}
model={selectedModel || undefined}
draftId={drafts.currentDraftId}
onCancel={() => setIsRecommendDialogOpen(false)}
onConfirm={handleConfirmAgents}
/>
@@ -467,7 +468,9 @@ export default function RoundtablePlanningPage() {
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
setIsRecommendDialogOpen(true);
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
}}
onManualSave={drafts.handleManualSave}
onTriggerToast={triggerToast}
@@ -1,200 +0,0 @@
/**
* Local-only draft storage for /page/roundtable/planning.
*
* One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
* roundtable). Persisted in `localStorage` so refresh / browser-restart keeps
* it; cross-device sync is out of scope (would need a backend). The Step 2
* back-end LangGraph threads are reused on load, so list/preview/download of
* artifacts in Step 3 continue to work as long as the user hasn't deleted the
* threads server-side.
*/
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
const STORAGE_KEY = "roundtable-planning:drafts:v1";
/**
* Snapshot of every piece of UI state needed to resume a session. We capture
* shapes loosely (`unknown[]`) for the chat / role lists because their inline
* types in the page component are wide and would force a refactor we don't
* want as part of the persistence feature.
*/
export interface DraftStep1Snapshot {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: unknown[];
timelineEntries: unknown[];
}
export interface DraftStep2Snapshot {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
/**
* Per-seat stub AI messages used by the sandbox `write-file:` virtual URL
* loader. Older drafts won't have this — callers should default to `{}`.
*/
seatStubMessages?: Record<string, unknown[]>;
}
export interface DraftRecord {
id: string;
/** Auto-derived task summary (objective → first user message → timestamp). */
name: string;
createdAt: string;
updatedAt: string;
/** Furthest step the user had reached when this snapshot was taken. */
furthestStep: 1 | 2 | 3;
step1: DraftStep1Snapshot;
step2: DraftStep2Snapshot;
}
function isBrowser(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function readAll(): DraftRecord[] {
if (!isBrowser()) return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed as DraftRecord[];
} catch (err) {
console.warn("[drafts] failed to parse localStorage payload:", err);
return [];
}
}
function writeAll(records: DraftRecord[]): void {
if (!isBrowser()) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records));
} catch (err) {
// Quota errors are the realistic failure mode — surface but don't throw,
// since the user can keep working with their in-memory state.
console.warn("[drafts] failed to write localStorage payload:", err);
}
}
function newId(): string {
if (isBrowser() && typeof window.crypto !== "undefined" && typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
return `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** List drafts newest first. */
export function listDrafts(): DraftRecord[] {
return readAll()
.slice()
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
}
export function getDraft(id: string): DraftRecord | null {
return readAll().find((d) => d.id === id) ?? null;
}
/**
* Create-or-update a draft. If `id` is given and exists, that record is
* overwritten (updatedAt refreshed). Otherwise a new record is inserted.
* Returns the (re-)saved record so callers can read back the new id.
*/
export function upsertDraft(
payload: Omit<DraftRecord, "createdAt" | "updatedAt" | "id"> & { id?: string },
): DraftRecord {
const records = readAll();
const now = new Date().toISOString();
if (payload.id) {
const idx = records.findIndex((d) => d.id === payload.id);
if (idx >= 0) {
const existing = records[idx];
const updated: DraftRecord = {
...existing,
name: payload.name,
furthestStep: payload.furthestStep,
step1: payload.step1,
step2: payload.step2,
updatedAt: now,
};
records[idx] = updated;
writeAll(records);
return updated;
}
}
const created: DraftRecord = {
id: payload.id ?? newId(),
name: payload.name,
furthestStep: payload.furthestStep,
step1: payload.step1,
step2: payload.step2,
createdAt: now,
updatedAt: now,
};
records.push(created);
writeAll(records);
return created;
}
export function deleteDraft(id: string): void {
const records = readAll();
const next = records.filter((d) => d.id !== id);
if (next.length !== records.length) writeAll(next);
}
/**
* Rename a draft. No-op when `id` isn't found or `name` is empty. Returns the
* updated record so callers can refresh local UI state.
*/
export function renameDraft(id: string, name: string): DraftRecord | null {
const trimmed = name.trim();
if (!trimmed) return null;
const records = readAll();
const idx = records.findIndex((d) => d.id === id);
if (idx < 0) return null;
const updated: DraftRecord = {
...records[idx],
name: trimmed,
updatedAt: new Date().toISOString(),
};
records[idx] = updated;
writeAll(records);
return updated;
}
/**
* Derive a short task-summary name from whatever the user has supplied so far.
* Preference order: intent agent's structured objective → first user message
* in Step 1 → ISO timestamp fallback. The result is trimmed to ~40 chars so
* the dropdown rows stay scannable.
*/
export function deriveDraftName(args: {
intentReady?: IntentSummary | null;
interactiveMessages?: unknown[];
}): string {
const MAX = 40;
const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
const objective = args.intentReady?.objective?.trim();
if (objective) return truncate(objective);
if (Array.isArray(args.interactiveMessages)) {
for (const m of args.interactiveMessages) {
if (!m || typeof m !== "object") continue;
const obj = m as Record<string, unknown>;
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
return truncate(obj.content.trim());
}
}
}
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
}
@@ -244,6 +244,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, …) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. |
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
@@ -42,6 +42,7 @@ from app.gateway.routers import (
public_scheduled_tasks,
recommend,
recommended_questions,
roundtable_drafts,
runs,
scheduled_tasks,
skills,
@@ -833,6 +834,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(user_prompts.router)
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
app.include_router(roundtable_drafts.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
@@ -152,6 +152,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.user_prompt_store = make_user_prompt_store(sf)
from deerflow.persistence.roundtable_drafts import make_roundtable_draft_store
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -0,0 +1,223 @@
"""CRUD API for roundtable-planning drafts + recommendation history.
A draft is one full roundtable session (Step 1 intent + Step 2 multi-agent
roundtable). Bound to the requesting user (not the browser), so it follows the
account across devices/sessions — replacing the old ``localStorage`` storage.
Recommendation history is bound to a draft: the recommend dialog shows "last
recommendation for this session" on open, while still letting the user
re-analyze (which appends a new history row).
Routes (prefix ``/api/roundtable-drafts``):
GET "" list current user's drafts (lightweight)
POST "" create a draft
GET "/{draft_id}" full draft (incl. step1 / step2 blobs)
PUT "/{draft_id}" partial update (title / step / snapshots)
DELETE "/{draft_id}" delete
GET "/{draft_id}/recommendations" list recommendation history
POST "/{draft_id}/recommendations" append one recommendation run
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-drafts", tags=["roundtable-drafts"])
# ── schemas ────────────────────────────────────────────────────────────────
class DraftMetaResponse(BaseModel):
id: str
title: str = ""
furthest_step: int = 1
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class DraftListResponse(BaseModel):
drafts: list[DraftMetaResponse]
class DraftResponse(DraftMetaResponse):
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
class DraftCreateRequest(BaseModel):
# Optional client-provided id so the browser can keep one stable id across
# the create + subsequent updates without an extra round-trip.
id: str | None = Field(default=None, max_length=64)
title: str = Field(default="", max_length=512)
furthest_step: int = Field(default=1, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
class DraftUpdateRequest(BaseModel):
# All optional → callers send only what changed. Auto-save omits ``title``
# so it never clobbers a manual rename.
title: str | None = Field(default=None, max_length=512)
furthest_step: int | None = Field(default=None, ge=1, le=3)
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
class RecommendPickSchema(BaseModel):
agent_id: str
reason: str = ""
class RecommendCandidateSchema(BaseModel):
agent_id: str
name: str = ""
description: str = ""
class RecommendHistoryResponse(BaseModel):
id: str
draft_id: str
objective: str = ""
status: str = "done"
model: str | None = None
rationale: str | None = None
picks: list[RecommendPickSchema] = []
candidates: list[RecommendCandidateSchema] = []
created_at: datetime | str | None = None
class RecommendHistoryListResponse(BaseModel):
history: list[RecommendHistoryResponse]
class RecommendHistoryCreateRequest(BaseModel):
objective: str = Field(default="", max_length=512)
status: str = Field(default="done", max_length=32)
model: str | None = None
rationale: str | None = None
picks: list[RecommendPickSchema] = []
candidates: list[RecommendCandidateSchema] = []
# ── helpers ──────────────────────────────────────────────────────────────
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_draft_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable draft store not available")
return store
# ── draft routes ───────────────────────────────────────────────────────────
@router.get("", response_model=DraftListResponse)
async def list_drafts(request: Request) -> DraftListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_drafts(user_id)
return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows])
@router.post("", response_model=DraftResponse, status_code=201)
async def create_draft(request: Request, body: DraftCreateRequest) -> DraftResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_draft(
user_id,
{
"id": (body.id or uuid4().hex),
"title": body.title.strip(),
"furthest_step": body.furthest_step,
"step1": body.step1,
"step2": body.step2,
},
)
return DraftResponse(**row)
@router.get("/{draft_id}", response_model=DraftResponse)
async def get_draft(request: Request, draft_id: str) -> DraftResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_draft(draft_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Draft not found")
return DraftResponse(**row)
@router.put("/{draft_id}", response_model=DraftResponse)
async def update_draft(request: Request, draft_id: str, body: DraftUpdateRequest) -> DraftResponse:
store = _get_store(request)
user_id = _current_user_id(request)
# Build kwargs that distinguish "omitted" from "explicitly None". The store
# uses a sentinel; here we pass only the fields the client actually sent.
fields = body.model_dump(exclude_unset=True)
row = await store.update_draft(draft_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Draft not found")
return DraftResponse(**row)
@router.delete("/{draft_id}", status_code=204)
async def delete_draft(request: Request, draft_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete_draft(draft_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Draft not found")
# ── recommendation history routes ────────────────────────────────────────
@router.get("/{draft_id}/recommendations", response_model=RecommendHistoryListResponse)
async def list_recommendations(request: Request, draft_id: str) -> RecommendHistoryListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_recommendations(draft_id, user_id)
return RecommendHistoryListResponse(history=[RecommendHistoryResponse(**r) for r in rows])
@router.post("/{draft_id}/recommendations", response_model=RecommendHistoryResponse, status_code=201)
async def add_recommendation(
request: Request, draft_id: str, body: RecommendHistoryCreateRequest
) -> RecommendHistoryResponse:
store = _get_store(request)
user_id = _current_user_id(request)
# Ownership: the draft must exist and belong to the caller.
draft = await store.get_draft(draft_id, user_id)
if draft is None:
raise HTTPException(status_code=404, detail="Draft not found")
row = await store.add_recommendation(
user_id,
draft_id,
{
"id": uuid4().hex,
"objective": body.objective,
"status": body.status,
"model": body.model,
"rationale": body.rationale,
"picks": [p.model_dump() for p in body.picks],
"candidates": [c.model_dump() for c in body.candidates],
},
)
return RecommendHistoryResponse(**row)
@@ -22,6 +22,10 @@ from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
)
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskDeliveryProfileRow,
@@ -45,6 +49,8 @@ __all__ = [
"LlmCallMetricRow",
"NotificationRow",
"RecommendedQuestionRow",
"RoundtableDraftRow",
"RoundtableRecommendHistoryRow",
"RunEventRow",
"RunRow",
"ScheduledTaskDeliveryProfileRow",
@@ -0,0 +1,22 @@
"""Roundtable-planning draft + recommendation-history persistence."""
from __future__ import annotations
from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
)
from deerflow.persistence.roundtable_drafts.sql import RoundtableDraftRepository
__all__ = [
"RoundtableDraftRepository",
"RoundtableDraftRow",
"RoundtableRecommendHistoryRow",
"make_roundtable_draft_store",
]
def make_roundtable_draft_store(session_factory):
if session_factory is None:
return None
return RoundtableDraftRepository(session_factory)
@@ -0,0 +1,69 @@
"""ORM models for roundtable-planning drafts and recommendation history.
A "draft" is one full roundtable-planning session (Step 1 intent + Step 2
multi-agent roundtable), persisted per user so it follows the account across
devices/sessions — replacing the old browser-only ``localStorage`` storage.
``step1`` / ``step2`` are stored as JSON serialized into ``PortableLongText``
(MySQL ``LONGTEXT``) rather than ``PortableJSON`` (MySQL ``TEXT``, 64 KB cap),
because the Step 2 snapshot can carry a long roundtable transcript plus per-seat
stub messages that easily exceed 64 KB — mirroring ``ai_writing_sessions``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class RoundtableDraftRow(Base):
__tablename__ = "roundtable_drafts"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Auto-derived task summary (objective → first user message → timestamp).
title: Mapped[str] = mapped_column(String(512), nullable=False, default="")
# Furthest step reached when this snapshot was taken (1 | 2 | 3).
furthest_step: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# JSON blobs matching the frontend Step1Snapshot / Step2Snapshot shapes.
step1: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
step2: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object
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),
)
class RoundtableRecommendHistoryRow(Base):
"""One agent-recommendation run, bound to a draft/session.
Lets the recommend dialog show "last recommendation for this session" on
open, while still letting the user re-analyze (which appends a new row).
"""
__tablename__ = "roundtable_recommend_history"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
draft_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Denormalized intent objective at recommend time (list display / context).
objective: Mapped[str] = mapped_column(String(512), nullable=False, default="")
# How the run ended on the backend: "done" | "fallback" | "asking" | "error".
status: Mapped[str] = mapped_column(String(32), nullable=False, default="done")
model: Mapped[str | None] = mapped_column(String(128), nullable=True)
# Natural-language rationale (may be long → LONGTEXT).
rationale: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# JSON: [{"agent_id": str, "reason": str}, ...]
picks: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# JSON snapshot of the candidate pool at recommend time:
# [{"agent_id": str, "name": str, "description": str}, ...]
candidates: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
@@ -0,0 +1,191 @@
"""SQLAlchemy-backed roundtable-planning draft + recommendation-history store.
JSON columns (``step1`` / ``step2`` / ``picks`` / ``candidates``) are stored as
serialized strings in ``PortableLongText`` and round-tripped here with
``json.dumps`` / ``json.loads`` — same approach as ``ai_writing_sessions`` — so
large Step 2 transcripts never hit MySQL's 64 KB ``TEXT`` cap.
All write/read paths are scoped by ``user_id`` for ownership isolation.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
)
_UNSET = object()
def _loads(value: Any) -> Any:
if not isinstance(value, str):
return value
if not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
class RoundtableDraftRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
# ── drafts ────────────────────────────────────────────────────────────
@staticmethod
def _draft_to_meta(row: RoundtableDraftRow) -> dict[str, Any]:
"""Lightweight projection for the history dropdown (no big blobs)."""
return {
"id": row.id,
"title": row.title,
"furthest_step": row.furthest_step,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
@classmethod
def _draft_to_dict(cls, row: RoundtableDraftRow) -> dict[str, Any]:
d = cls._draft_to_meta(row)
d["step1"] = _loads(row.step1)
d["step2"] = _loads(row.step2)
return d
async def list_drafts(self, user_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
stmt = (
select(RoundtableDraftRow)
.where(RoundtableDraftRow.user_id == user_id)
.order_by(RoundtableDraftRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._draft_to_meta(row) for row in result.scalars()]
async def get_draft(self, draft_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableDraftRow, draft_id)
if row is None or row.user_id != user_id:
return None
return self._draft_to_dict(row)
async def create_draft(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = RoundtableDraftRow(
id=data["id"],
user_id=user_id,
title=(data.get("title") or "").strip(),
furthest_step=int(data.get("furthest_step") or 1),
step1=_dumps(data.get("step1")),
step2=_dumps(data.get("step2")),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._draft_to_dict(row)
async def update_draft(
self,
draft_id: str,
user_id: str,
*,
title=_UNSET,
furthest_step=_UNSET,
step1=_UNSET,
step2=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableDraftRow, draft_id)
if row is None or row.user_id != user_id:
return None
if title is not _UNSET and title is not None:
row.title = str(title).strip()
if furthest_step is not _UNSET and furthest_step is not None:
row.furthest_step = int(furthest_step)
if step1 is not _UNSET:
row.step1 = _dumps(step1)
if step2 is not _UNSET:
row.step2 = _dumps(step2)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._draft_to_dict(row)
async def delete_draft(self, draft_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(RoundtableDraftRow, draft_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
# ── recommendation history ────────────────────────────────────────────
@staticmethod
def _history_to_dict(row: RoundtableRecommendHistoryRow) -> dict[str, Any]:
return {
"id": row.id,
"draft_id": row.draft_id,
"objective": row.objective,
"status": row.status,
"model": row.model,
"rationale": row.rationale,
"picks": _loads(row.picks) or [],
"candidates": _loads(row.candidates) or [],
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
}
async def list_recommendations(
self, draft_id: str, user_id: str, *, limit: int = 20
) -> list[dict[str, Any]]:
stmt = (
select(RoundtableRecommendHistoryRow)
.where(
RoundtableRecommendHistoryRow.draft_id == draft_id,
RoundtableRecommendHistoryRow.user_id == user_id,
)
.order_by(RoundtableRecommendHistoryRow.created_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._history_to_dict(row) for row in result.scalars()]
async def add_recommendation(self, user_id: str, draft_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = RoundtableRecommendHistoryRow(
id=data["id"],
user_id=user_id,
draft_id=draft_id,
objective=(data.get("objective") or "").strip()[:512],
status=(data.get("status") or "done")[:32],
model=(data.get("model") or None),
rationale=data.get("rationale") or None,
picks=_dumps(data.get("picks") or []),
candidates=_dumps(data.get("candidates") or []),
created_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._history_to_dict(row)