圆桌草稿持久化:localStorage 迁至后端 MySQL
新增 /api/roundtable-drafts 草稿 CRUD 与会话级推荐历史;前端 useDraftPersistence 改走 api/drafts,删除本地 drafts.ts。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
24e92c437d
commit
838cae0d2d
16 files changed
+1410
-480
No files matched your search
@@ -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/` |
|
||||
|
||||
@@ -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)
|
||||
+6
@@ -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",
|
||||
|
||||
+22
@@ -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)
|
||||
+69
@@ -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))
|
||||
+191
@@ -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)
|
||||
Reference in new issue
Block a user