Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-code into dhk-1
This commit is contained in:
commit
ae184f434d
37 files changed
+6761
-204
No files matched your search
@@ -0,0 +1,343 @@
|
||||
# 岗位会商交付闭环与内置智能体实现计划
|
||||
|
||||
> 页面:`/page/strategy/qa/position-roundtable`
|
||||
>
|
||||
> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。
|
||||
|
||||
## 实施核对(2026-07-21)
|
||||
|
||||
本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为:
|
||||
|
||||
```text
|
||||
任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链
|
||||
→ 分阶段席位直接问答/交付 Markdown → 全部有效交付
|
||||
→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner)
|
||||
→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口
|
||||
```
|
||||
|
||||
- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。
|
||||
- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。
|
||||
- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。
|
||||
- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。
|
||||
- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。
|
||||
- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。
|
||||
- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。
|
||||
- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。
|
||||
- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。
|
||||
|
||||
设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
|
||||
|
||||
## 0. 核心结论
|
||||
|
||||
- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
|
||||
- 可复用:内置智能体 ID、总结报告 prompt 组织方式、Markdown 报告产物、流式对话、报告续问与文件预览能力。
|
||||
- 需要改造:输入材料从“总控共识 + 各席位交付”改成“任务信息 + 意图结果 + 已通过的岗位产物 + 业务链快照”,并把结果存到岗位会商 Session 下。
|
||||
- 行动规划智能体建议作为新的内置智能体新增。
|
||||
- 输入:方案总结报告、各岗位最终有效产物、业务链阶段信息、任务意图。
|
||||
- 输出:行动规划报告、结构化子任务清单。
|
||||
- 这个模式仍然不需要总控智能体。情报分析岗负责意图识别,业务链节点直接调用各自配置的智能体,最终再由内置总结/规划智能体收口。
|
||||
- 驳回不删除历史内容,只改变“当前有效版本”的状态;被驳回节点和后续节点需要重新交付,既保留可追溯性,也方便后续返回查看。
|
||||
|
||||
## 1. 目标业务流程
|
||||
|
||||
1. 情报分析岗进入页面,查看任务信息并完成意图识别。
|
||||
2. 用户点击“确认意图并启动业务链”。
|
||||
3. 启动前做校验:
|
||||
- 未选择业务链:提示“请先选择业务链”。
|
||||
- 业务链存在未配置岗位的席位:提示并引导去业务链配置页。
|
||||
- 业务链席位缺少智能体:提示具体席位名称。
|
||||
- 意图尚未完成:提示先完成任务意图识别。
|
||||
4. 校验通过后冻结业务链快照,初始化岗位会商节点。
|
||||
5. 各岗位只对分配给本岗位的智能体进行问答。
|
||||
6. 节点问答完成后形成交付产物,右侧产物卡片显示“已完成 / 未完成 / 进行中 / 已驳回 / 已失效”。
|
||||
7. 左侧流程图显示各节点状态,节点可点击查看该节点对话与产物。
|
||||
8. 流程图右上角提供“查看”按钮,打开弹窗展示更完整的业务链流程图。
|
||||
9. 当业务链中所有岗位产物都处于有效已交付状态后,开放“方案总结”。
|
||||
10. 方案总结完成后开放“行动规划”。
|
||||
11. 即使进入方案总结或行动规划,用户仍可返回产物列表执行驳回。
|
||||
12. 若驳回早期阶段产物,后续阶段产物、方案总结、行动规划都一起失效并回到待重新完成状态。
|
||||
|
||||
## 2. 状态设计
|
||||
|
||||
### 2.1 节点状态
|
||||
|
||||
现有节点状态需要扩展或映射为更贴近页面的展示状态:
|
||||
|
||||
| 展示状态 | 建议内部状态 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| 未完成 | `ready` / `locked` / `rejected` | 尚未有效交付,或被驳回后等待重做 |
|
||||
| 进行中 | `running` | 当前智能体正在回答或产物正在生成 |
|
||||
| 已完成 | `done` | 当前节点已有有效交付 |
|
||||
| 已失效 | `stale` | 上游被更新或驳回,当前结果不可作为最终材料 |
|
||||
| 异常 | `error` | 生成或保存失败 |
|
||||
|
||||
建议新增 `rejected` 状态,便于区分“从未完成”和“被驳回后待重做”。如果第一轮想少改后端,也可以先用 `ready + rejection_record` 表示被驳回。
|
||||
|
||||
### 2.2 产物状态
|
||||
|
||||
产物状态建议独立于节点状态保存,因为一个节点可能同时有回答文本和多个文件产物。
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| `pending` | 还没有交付 |
|
||||
| `running` | 正在生成 |
|
||||
| `delivered` | 已交付且当前有效 |
|
||||
| `rejected` | 被用户驳回 |
|
||||
| `stale` | 受上游驳回或重做影响,已经失效 |
|
||||
|
||||
### 2.3 内置收口节点
|
||||
|
||||
在流程图中把两个内置节点追加到业务链末尾:
|
||||
|
||||
1. `builtin:summary`:方案总结智能体。
|
||||
2. `builtin:action-plan`:行动规划智能体。
|
||||
|
||||
这两个节点不属于普通业务链席位,但要和普通节点一样显示状态、支持点击查看结果。
|
||||
|
||||
## 3. 数据模型调整
|
||||
|
||||
### 3.1 Session 扩展
|
||||
|
||||
`position_roundtable_sessions` 已使用下列持久化字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 |
|
||||
| `summary_thread_id` | 方案总结智能体线程 |
|
||||
| `summary_snapshot` | 最新有效总结报告快照 |
|
||||
| `action_plan_thread_id` | 行动规划智能体线程 |
|
||||
| `action_plan_snapshot` | 最新有效行动规划快照 |
|
||||
| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 |
|
||||
|
||||
### 3.2 Node 扩展
|
||||
|
||||
`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射):
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 |
|
||||
| `rejection_count` | 当前节点累计驳回次数 |
|
||||
| `last_rejection` | 最新驳回标题、理由、操作者、时间 |
|
||||
| `revision` | 当前有效交付版本 |
|
||||
| `invalidated_by` | 导致当前节点失效的上游节点 key |
|
||||
|
||||
### 3.3 Deliverable 新表(后续可选)
|
||||
|
||||
当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `id` | 产物 ID |
|
||||
| `session_id` | 会话 ID |
|
||||
| `node_key` | 来源节点 |
|
||||
| `agent_id` | 来源智能体 |
|
||||
| `position_id` | 来源岗位 |
|
||||
| `title` | 产物名称 |
|
||||
| `kind` | `answer / file / summary / action_plan` |
|
||||
| `content` | 文本产物内容摘要或正文 |
|
||||
| `artifact_path` | 文件产物虚拟路径 |
|
||||
| `thread_id` | 所属线程 |
|
||||
| `status` | `pending / running / delivered / rejected / stale` |
|
||||
| `revision` | 来源节点版本 |
|
||||
| `rejection_title` | 驳回标题 |
|
||||
| `rejection_reason` | 驳回理由 |
|
||||
| `created_at` | 创建时间 |
|
||||
| `updated_at` | 更新时间 |
|
||||
|
||||
当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。
|
||||
|
||||
## 4. 驳回与级联失效规则
|
||||
|
||||
### 4.1 基本驳回
|
||||
|
||||
用户点击产物卡片的“驳回”后弹窗填写:
|
||||
|
||||
- 驳回标题。
|
||||
- 驳回理由。
|
||||
|
||||
提交后:
|
||||
|
||||
- 当前产物状态改为 `rejected`。
|
||||
- 当前节点状态改为 `rejected` 或 `ready`。
|
||||
- 当前节点的 `latest_answer` 和历史文件不删除,但不再作为有效交付输入。
|
||||
- 记录 `last_rejection`,用于页面展示和后续追踪。
|
||||
|
||||
### 4.2 级联失效
|
||||
|
||||
如果驳回的节点位于第 N 阶段:
|
||||
|
||||
- 同阶段其他节点不自动驳回,除非业务链未来明确配置了依赖关系。
|
||||
- 第 N+1 阶段及之后所有节点的有效产物全部标记为 `stale`。
|
||||
- 后续节点状态回到未完成展示态。
|
||||
- `builtin:summary` 和 `builtin:action-plan` 如果已经生成,也标记为 `stale`。
|
||||
- 页面允许用户继续查看旧报告和旧行动规划,但必须提示“上游产物已变更,需要重新生成”。
|
||||
|
||||
### 4.3 重新交付
|
||||
|
||||
被驳回节点重新问答并完成后:
|
||||
|
||||
- 当前节点产生新的 `revision`。
|
||||
- 当前节点产物状态变为 `delivered`。
|
||||
- 只有当该阶段所有必要节点都重新有效交付后,下一阶段才重新解锁。
|
||||
- 方案总结和行动规划必须基于最新有效产物重新生成。
|
||||
|
||||
## 5. 接口规划
|
||||
|
||||
### 5.1 启动与校验
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/validate-activation` | 校验业务链、岗位、智能体、意图状态 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/activate` | 校验通过后冻结链条并初始化节点 |
|
||||
|
||||
第一轮也可以先把校验合并在 `activate` 内,前端根据后端返回的错误码提示。
|
||||
|
||||
### 5.2 产物
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) |
|
||||
| `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 |
|
||||
|
||||
### 5.3 方案总结与行动规划
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 |
|
||||
|
||||
可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。
|
||||
|
||||
## 6. 前端实施步骤
|
||||
|
||||
### 阶段 1:状态与启动校验(已完成)
|
||||
|
||||
- 扩展 `PositionNodeStatus` 和状态显示映射。
|
||||
- “确认意图并启动业务链”前补齐校验提示。
|
||||
- 后端 `activate` 返回未配置岗位、缺智能体、未选择链条等具体错误。
|
||||
- 左侧流程图节点右上角添加状态小图标。
|
||||
- 节点样式调整为上方智能体名称、下方岗位名称,移除左侧 logo。
|
||||
|
||||
验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。
|
||||
|
||||
### 阶段 2:流程图弹窗(已完成)
|
||||
|
||||
- 左侧保留小型从上到下流程图。
|
||||
- 流程图右上角增加“查看”按钮。
|
||||
- 点击后打开弹窗,展示更大的流程图。
|
||||
- 弹窗内支持点击节点查看对话与产物,保持和主页面选中节点同步。
|
||||
|
||||
验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。
|
||||
|
||||
### 阶段 3:产物卡片与预览(已完成)
|
||||
|
||||
- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。
|
||||
- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。
|
||||
- 点击卡片打开详情弹窗。
|
||||
- 文本产物直接展示正文,文件产物复用现有 `ArtifactPreviewModal`。
|
||||
- 每个有效产物提供“驳回”入口。
|
||||
|
||||
验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。
|
||||
|
||||
### 阶段 4:驳回弹窗与级联失效(已完成)
|
||||
|
||||
- 新增 `RejectDeliverableDialog`。
|
||||
- 驳回表单包含标题和理由,均必填。
|
||||
- 后端事务内完成当前产物驳回、当前节点状态更新、后续阶段级联失效、总结和行动规划失效。
|
||||
- 前端刷新 Session、Node、Deliverable 和流程图状态。
|
||||
- 被驳回节点在对话区显示驳回信息,提示用户重新交付。
|
||||
|
||||
验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。
|
||||
|
||||
### 阶段 5:方案总结智能体(已完成)
|
||||
|
||||
- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。
|
||||
- 输入材料改为岗位会商材料:
|
||||
- 任务信息。
|
||||
- 情报分析岗意图结果。
|
||||
- 业务链快照。
|
||||
- 所有状态为 `delivered` 的岗位产物。
|
||||
- 已驳回或失效产物只作为历史参考,不进入默认总结材料。
|
||||
- 方案总结节点追加到流程图末尾。
|
||||
- 所有产物有效交付后才允许点击“生成方案总结”。
|
||||
- 总结结果写入 `summary_snapshot`,同时进入产物列表。
|
||||
|
||||
验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。
|
||||
|
||||
### 阶段 6:行动规划智能体(已完成)
|
||||
|
||||
- 新增内置智能体 ID,建议先命名为 `position-action-planner`。
|
||||
- 新增行动规划 prompt:
|
||||
- 读取方案总结报告。
|
||||
- 读取各岗位最终有效产物。
|
||||
- 拆解为若干子任务。
|
||||
- 每个子任务包含名称、目标、输入依据、责任岗位建议、前置依赖、交付物、验收标准。
|
||||
- 输出两类产物:
|
||||
- `行动规划报告.md`。
|
||||
- `action-plan-subtasks.json`。
|
||||
- 行动规划节点追加到方案总结之后。
|
||||
- 方案总结有效完成后才允许启动行动规划。
|
||||
|
||||
验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。
|
||||
|
||||
### 阶段 7:返回修改与重新生成(已完成)
|
||||
|
||||
- 用户在方案总结或行动规划视图中仍可切回“交付产物”。
|
||||
- 驳回后自动显示需要重做的节点。
|
||||
- 重做节点完成后,允许重新生成方案总结和行动规划。
|
||||
- 旧总结、旧行动规划保留历史查看,但状态显示“已失效”。
|
||||
|
||||
验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。
|
||||
|
||||
### 阶段 8:测试与回归(已完成)
|
||||
|
||||
- 后端测试:
|
||||
- activate 校验。
|
||||
- 节点完成后产物入库。
|
||||
- 驳回当前节点。
|
||||
- 驳回第一阶段时后续阶段级联失效。
|
||||
- 总结和行动规划失效。
|
||||
- 前端测试:
|
||||
- 启动校验提示。
|
||||
- 状态图标显示。
|
||||
- 产物卡片筛选和预览。
|
||||
- 驳回弹窗必填校验。
|
||||
- 回归验证:
|
||||
- 原多智能体会商页面不受影响。
|
||||
- 业务链普通配置入口不显示岗位配置。
|
||||
- 岗位会商页面刷新恢复正常。
|
||||
- 深色模式和嵌入模式布局不溢出。
|
||||
|
||||
## 7. 推荐提交拆分
|
||||
|
||||
1. `feat(position-roundtable): add delivery status model`
|
||||
2. `feat(position-roundtable): validate chain activation`
|
||||
3. `feat(position-roundtable): add workflow status graph`
|
||||
4. `feat(position-roundtable): add deliverable cards and preview`
|
||||
5. `feat(position-roundtable): support rejection cascade`
|
||||
6. `feat(position-roundtable): add summary builtin node`
|
||||
7. `feat(position-roundtable): add action planning builtin node`
|
||||
8. `test(position-roundtable): cover delivery workflow`
|
||||
|
||||
## 8. 第一轮建议范围
|
||||
|
||||
第一轮优先做最小可闭环:
|
||||
|
||||
```text
|
||||
意图识别
|
||||
→ 启动校验
|
||||
→ 业务链节点状态
|
||||
→ 节点产物列表
|
||||
→ 产物详情查看
|
||||
→ 驳回与级联失效
|
||||
→ 全部交付后生成方案总结
|
||||
→ 基于总结生成行动规划
|
||||
```
|
||||
|
||||
如果时间紧,行动规划可以先生成 Markdown 报告,结构化 `subtasks.json` 放到第二轮;但数据模型和 UI 入口第一轮就要预留,否则后面会返工。
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -184,6 +184,18 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/roundtable/position">
|
||||
<BriefcaseBusiness />
|
||||
<span>岗位会商</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -54,6 +54,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
|
||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
||||
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||
@@ -448,6 +449,16 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/position"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome={headerHidden} />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/chains"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/** Client for the independent position-collaboration Session / Node API.
|
||||
*
|
||||
* Messages themselves stay in LangGraph threads. These endpoints only persist
|
||||
* task/intent snapshots, frozen chain topology, node states and indexes that
|
||||
* let the UI recover the correct thread after a refresh.
|
||||
*/
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type PositionNodeStatus =
|
||||
| "locked"
|
||||
| "ready"
|
||||
| "running"
|
||||
| "done"
|
||||
| "stale"
|
||||
| "rejected"
|
||||
| "error";
|
||||
|
||||
export interface PositionRoundtableNode {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
nodeKey: string;
|
||||
stageIndex: number;
|
||||
seatIndex: number;
|
||||
agentId: string;
|
||||
positionId: string | null;
|
||||
threadId: string | null;
|
||||
status: PositionNodeStatus;
|
||||
latestAnswer: string | null;
|
||||
artifactManifest: Array<Record<string, unknown>>;
|
||||
revision: number;
|
||||
upstreamRevision: Record<string, unknown> | null;
|
||||
rejectionCount: number;
|
||||
lastRejection: Record<string, unknown> | null;
|
||||
invalidatedBy: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PositionRoundtableSession {
|
||||
id: string;
|
||||
externalTaskId: string | null;
|
||||
taskSnapshot: Record<string, unknown>;
|
||||
chainId: string | null;
|
||||
chainSnapshot: Record<string, unknown> | null;
|
||||
intentThreadId: string | null;
|
||||
intentSnapshot: Record<string, unknown> | null;
|
||||
summaryThreadId: string | null;
|
||||
summarySnapshot: Record<string, unknown> | null;
|
||||
actionPlanThreadId: string | null;
|
||||
actionPlanSnapshot: Record<string, unknown> | null;
|
||||
status: "intent_pending" | "active" | "completed" | "archived";
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
nodes?: PositionRoundtableNode[];
|
||||
}
|
||||
|
||||
interface ServerNode {
|
||||
id: string;
|
||||
session_id: string;
|
||||
node_key: string;
|
||||
stage_index: number;
|
||||
seat_index: number;
|
||||
agent_id: string;
|
||||
position_id: string | null;
|
||||
thread_id: string | null;
|
||||
status: PositionNodeStatus;
|
||||
latest_answer: string | null;
|
||||
artifact_manifest?: Array<Record<string, unknown>>;
|
||||
revision?: number;
|
||||
upstream_revision?: Record<string, unknown> | null;
|
||||
rejection_count?: number;
|
||||
last_rejection?: Record<string, unknown> | null;
|
||||
invalidated_by?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface ServerSession {
|
||||
id: string;
|
||||
external_task_id: string | null;
|
||||
task_snapshot?: Record<string, unknown>;
|
||||
chain_id: string | null;
|
||||
chain_snapshot?: Record<string, unknown> | null;
|
||||
intent_thread_id: string | null;
|
||||
intent_snapshot?: Record<string, unknown> | null;
|
||||
summary_thread_id?: string | null;
|
||||
summary_snapshot?: Record<string, unknown> | null;
|
||||
action_plan_thread_id?: string | null;
|
||||
action_plan_snapshot?: Record<string, unknown> | null;
|
||||
status: PositionRoundtableSession["status"];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
nodes?: ServerNode[];
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/position-roundtable`;
|
||||
|
||||
async function readJson<T>(response: Response, label: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(`${label} failed (${response.status}): ${detail}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function toNode(node: ServerNode): PositionRoundtableNode {
|
||||
return {
|
||||
id: node.id,
|
||||
sessionId: node.session_id,
|
||||
nodeKey: node.node_key,
|
||||
stageIndex: node.stage_index,
|
||||
seatIndex: node.seat_index,
|
||||
agentId: node.agent_id,
|
||||
positionId: node.position_id,
|
||||
threadId: node.thread_id,
|
||||
status: node.status,
|
||||
latestAnswer: node.latest_answer,
|
||||
artifactManifest: node.artifact_manifest ?? [],
|
||||
revision: node.revision ?? 0,
|
||||
upstreamRevision: node.upstream_revision ?? null,
|
||||
rejectionCount: node.rejection_count ?? 0,
|
||||
lastRejection: node.last_rejection ?? null,
|
||||
invalidatedBy: node.invalidated_by ?? null,
|
||||
createdAt: node.created_at,
|
||||
updatedAt: node.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toSession(session: ServerSession): PositionRoundtableSession {
|
||||
return {
|
||||
id: session.id,
|
||||
externalTaskId: session.external_task_id,
|
||||
taskSnapshot: session.task_snapshot ?? {},
|
||||
chainId: session.chain_id,
|
||||
chainSnapshot: session.chain_snapshot ?? null,
|
||||
intentThreadId: session.intent_thread_id,
|
||||
intentSnapshot: session.intent_snapshot ?? null,
|
||||
summaryThreadId: session.summary_thread_id ?? null,
|
||||
summarySnapshot: session.summary_snapshot ?? null,
|
||||
actionPlanThreadId: session.action_plan_thread_id ?? null,
|
||||
actionPlanSnapshot: session.action_plan_snapshot ?? null,
|
||||
status: session.status,
|
||||
createdAt: session.created_at,
|
||||
updatedAt: session.updated_at,
|
||||
nodes: session.nodes?.map(toNode),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> {
|
||||
const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : "";
|
||||
const result = await readJson<ServerSession[]>(
|
||||
await apiFetch(`${BASE()}/sessions${params}`),
|
||||
"list position sessions",
|
||||
);
|
||||
return result.map(toSession);
|
||||
}
|
||||
|
||||
export async function createPositionSession(input: {
|
||||
externalTaskId?: string;
|
||||
taskSnapshot: Record<string, unknown>;
|
||||
}): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
external_task_id: input.externalTaskId || null,
|
||||
task_snapshot: input.taskSnapshot,
|
||||
}),
|
||||
}),
|
||||
"create position session",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`),
|
||||
"get position session",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function savePositionIntent(
|
||||
sessionId: string,
|
||||
input: { intentThreadId?: string | null; intentSnapshot: object | null },
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
intent_thread_id: input.intentThreadId || null,
|
||||
intent_snapshot: input.intentSnapshot,
|
||||
}),
|
||||
}),
|
||||
"save position intent",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function activatePositionSession(
|
||||
sessionId: string,
|
||||
chainId: string,
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chain_id: chainId }),
|
||||
}),
|
||||
"activate position session",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
|
||||
method: "POST",
|
||||
}),
|
||||
"archive position session",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function bindPositionNodeThread(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
threadId: string,
|
||||
): Promise<PositionRoundtableNode> {
|
||||
const result = await readJson<ServerNode>(
|
||||
await apiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ thread_id: threadId }),
|
||||
},
|
||||
),
|
||||
"bind position node thread",
|
||||
);
|
||||
return toNode(result);
|
||||
}
|
||||
|
||||
/** Fetch the bounded hidden task/intent/upstream handoff before one node turn. */
|
||||
export async function getPositionNodeContext(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return readJson<Record<string, unknown>>(
|
||||
await apiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
"build position node context",
|
||||
);
|
||||
}
|
||||
|
||||
export async function completePositionNodeTurn(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
input: {
|
||||
latestAnswer: string;
|
||||
artifactManifest?: Array<Record<string, unknown>>;
|
||||
upstreamRevision?: Record<string, unknown> | null;
|
||||
},
|
||||
): Promise<PositionRoundtableNode> {
|
||||
const result = await readJson<ServerNode>(
|
||||
await apiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
latest_answer: input.latestAnswer,
|
||||
artifact_manifest: input.artifactManifest ?? [],
|
||||
upstream_revision: input.upstreamRevision ?? null,
|
||||
}),
|
||||
},
|
||||
),
|
||||
"complete position node turn",
|
||||
);
|
||||
return toNode(result);
|
||||
}
|
||||
|
||||
export async function rejectPositionNode(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
input: { title: string; reason: string },
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
reason: input.reason,
|
||||
}),
|
||||
},
|
||||
),
|
||||
"reject position node",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export interface PositionBuiltinRunPreparation {
|
||||
agentId: string;
|
||||
displayName: string;
|
||||
threadId: string | null;
|
||||
prompt: string;
|
||||
sourceRevisions: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ServerBuiltinRunPreparation {
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
thread_id?: string | null;
|
||||
prompt: string;
|
||||
source_revisions?: Record<string, number>;
|
||||
}
|
||||
|
||||
function toBuiltinRunPreparation(value: ServerBuiltinRunPreparation): PositionBuiltinRunPreparation {
|
||||
return {
|
||||
agentId: value.agent_id,
|
||||
displayName: value.display_name,
|
||||
threadId: value.thread_id ?? null,
|
||||
prompt: value.prompt,
|
||||
sourceRevisions: value.source_revisions ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
type BuiltinKind = "summary" | "action_plan";
|
||||
|
||||
async function prepareBuiltinRun(
|
||||
sessionId: string,
|
||||
kind: BuiltinKind,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
|
||||
const result = await readJson<ServerBuiltinRunPreparation>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
}),
|
||||
`prepare position ${kind}`,
|
||||
);
|
||||
return toBuiltinRunPreparation(result);
|
||||
}
|
||||
|
||||
async function completeBuiltinRun(
|
||||
sessionId: string,
|
||||
kind: BuiltinKind,
|
||||
input: {
|
||||
threadId: string;
|
||||
content: string;
|
||||
artifactManifest: Array<Record<string, unknown>>;
|
||||
sourceRevisions: Record<string, number>;
|
||||
model?: string;
|
||||
},
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
thread_id: input.threadId,
|
||||
content: input.content,
|
||||
artifact_manifest: input.artifactManifest,
|
||||
source_revisions: input.sourceRevisions,
|
||||
model: input.model || null,
|
||||
}),
|
||||
}),
|
||||
`complete position ${kind}`,
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export function preparePositionSummary(
|
||||
sessionId: string,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
return prepareBuiltinRun(sessionId, "summary", message);
|
||||
}
|
||||
|
||||
export function completePositionSummary(
|
||||
sessionId: string,
|
||||
input: Parameters<typeof completeBuiltinRun>[2],
|
||||
): Promise<PositionRoundtableSession> {
|
||||
return completeBuiltinRun(sessionId, "summary", input);
|
||||
}
|
||||
|
||||
export function preparePositionActionPlan(
|
||||
sessionId: string,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
return prepareBuiltinRun(sessionId, "action_plan", message);
|
||||
}
|
||||
|
||||
export function completePositionActionPlan(
|
||||
sessionId: string,
|
||||
input: Parameters<typeof completeBuiltinRun>[2],
|
||||
): Promise<PositionRoundtableSession> {
|
||||
return completeBuiltinRun(sessionId, "action_plan", input);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot, FileText, LockKeyhole } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import {
|
||||
bindPositionNodeThread,
|
||||
completePositionNodeTurn,
|
||||
getPositionNodeContext,
|
||||
type PositionRoundtableNode,
|
||||
} from "@/position-roundtable/api/sessions";
|
||||
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
interface PositionNodeChatProps {
|
||||
sessionId: string;
|
||||
/** Parent task id: all node threads are indexed under this task. */
|
||||
taskId: string;
|
||||
chainId: string | null;
|
||||
node: PositionRoundtableNode;
|
||||
currentPositionId: string;
|
||||
sessionStatus: "intent_pending" | "active" | "completed" | "archived";
|
||||
agentName?: string | null;
|
||||
onNodeUpdate: (node: PositionRoundtableNode) => void;
|
||||
/** The workspace collapses the configuration panel on the first real turn. */
|
||||
onConversationStart?: () => void;
|
||||
onPreviewArtifact?: (artifact: {
|
||||
threadId: string;
|
||||
path: string;
|
||||
name: string;
|
||||
mimeType: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface NodeArtifact {
|
||||
name: string;
|
||||
path: string;
|
||||
mimeType: string | null;
|
||||
}
|
||||
|
||||
function readNodeArtifacts(manifest: Array<Record<string, unknown>>): NodeArtifact[] {
|
||||
return manifest.flatMap((artifact) => {
|
||||
const path = typeof artifact.path === "string" ? artifact.path : "";
|
||||
if (!path) return [];
|
||||
const name =
|
||||
typeof artifact.name === "string" && artifact.name.trim()
|
||||
? artifact.name
|
||||
: path.split("/").filter(Boolean).at(-1) || "未命名产物";
|
||||
const mimeType =
|
||||
typeof artifact.mime_type === "string"
|
||||
? artifact.mime_type
|
||||
: typeof artifact.mimeType === "string"
|
||||
? artifact.mimeType
|
||||
: null;
|
||||
return [{ name, path, mimeType }];
|
||||
});
|
||||
}
|
||||
|
||||
function hasMarkdownArtifact(manifest: Array<Record<string, unknown>>): boolean {
|
||||
return manifest.some((artifact) => {
|
||||
const path = typeof artifact.path === "string" ? artifact.path.toLowerCase() : "";
|
||||
const name = typeof artifact.name === "string" ? artifact.name.toLowerCase() : "";
|
||||
const mime =
|
||||
typeof artifact.mime_type === "string"
|
||||
? artifact.mime_type.toLowerCase()
|
||||
: typeof artifact.mimeType === "string"
|
||||
? artifact.mimeType.toLowerCase()
|
||||
: "";
|
||||
return (
|
||||
mime.includes("markdown") ||
|
||||
path.endsWith(".md") ||
|
||||
path.endsWith(".markdown") ||
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct single-agent chat for one frozen business-chain node.
|
||||
*
|
||||
* It deliberately uses the regular `useThreadStream` + `MessageList` +
|
||||
* `InputBox` stack. The position Session merely records which existing thread
|
||||
* belongs to the node; it is not a second message persistence mechanism.
|
||||
*/
|
||||
export function PositionNodeChat({
|
||||
sessionId,
|
||||
taskId,
|
||||
chainId,
|
||||
node,
|
||||
currentPositionId,
|
||||
sessionStatus,
|
||||
agentName,
|
||||
onNodeUpdate,
|
||||
onConversationStart,
|
||||
onPreviewArtifact,
|
||||
}: PositionNodeChatProps) {
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const { agent } = useAgent(node.agentId);
|
||||
const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
|
||||
const artifactsCtx = useArtifactsOptional();
|
||||
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
|
||||
const boundThreadIdRef = useRef<string | null>(node.threadId);
|
||||
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
||||
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
||||
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
|
||||
const canWrite =
|
||||
sessionStatus !== "archived" &&
|
||||
node.positionId === currentPositionId &&
|
||||
node.status !== "locked" &&
|
||||
node.status !== "error" &&
|
||||
!node.invalidatedBy;
|
||||
|
||||
useEffect(() => {
|
||||
if (artifactsCtx?.open && artifactsCtx.selectedArtifact) {
|
||||
onPreviewArtifact?.({
|
||||
threadId: boundThreadIdRef.current ?? boundThreadId ?? draftThreadIdRef.current,
|
||||
path: artifactsCtx.selectedArtifact,
|
||||
name: artifactsCtx.selectedArtifact.split("/").filter(Boolean).at(-1) || "Markdown 报告",
|
||||
mimeType: artifactsCtx.selectedArtifact.toLowerCase().endsWith(".md")
|
||||
? "text/markdown"
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}, [artifactsCtx?.open, artifactsCtx?.selectedArtifact, boundThreadId, onPreviewArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
setBoundThreadId(node.threadId);
|
||||
boundThreadIdRef.current = node.threadId;
|
||||
}, [node.threadId]);
|
||||
|
||||
const bindCreatedThread = useCallback(
|
||||
(threadId: string) => {
|
||||
boundThreadIdRef.current = threadId;
|
||||
setBoundThreadId(threadId);
|
||||
void bindPositionNodeThread(sessionId, node.nodeKey, threadId)
|
||||
.then(onNodeUpdate)
|
||||
.catch((error) => {
|
||||
toast.error(`节点对话绑定失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
},
|
||||
[node.nodeKey, onNodeUpdate, sessionId],
|
||||
);
|
||||
|
||||
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
||||
threadId: boundThreadId,
|
||||
assistantId: node.agentId,
|
||||
context: { ...settings.context, agent_id: node.agentId },
|
||||
threadMetadata: {
|
||||
thread_type: "position_roundtable",
|
||||
// ``taskId`` is the shared task-workspace convention. The two
|
||||
// position-specific identifiers retain the exact child node for audits
|
||||
// and restore, even when one position owns more than one chain seat.
|
||||
taskId,
|
||||
position_roundtable_task_id: taskId,
|
||||
position_task_id: `${taskId}:${node.positionId ?? "unassigned"}:${node.nodeKey}`,
|
||||
position_session_id: sessionId,
|
||||
position_node_id: node.id,
|
||||
position_node_key: node.nodeKey,
|
||||
position_id: node.positionId,
|
||||
chain_id: chainId,
|
||||
agent_id: node.agentId,
|
||||
},
|
||||
onStart: bindCreatedThread,
|
||||
onFinish: (state) => {
|
||||
const threadId = boundThreadIdRef.current;
|
||||
const latestAi = [...(state.messages ?? [])]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
|
||||
if (!threadId || !latestAnswer.trim()) return;
|
||||
void (async () => {
|
||||
let artifactManifest: Array<Record<string, unknown>> = [];
|
||||
try {
|
||||
const artifacts = await listSessionArtifacts(threadId);
|
||||
artifactManifest = artifacts.files.map((file) => ({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
mime_type: file.mimeType,
|
||||
modified_at: file.modifiedAt,
|
||||
size_bytes: file.sizeBytes,
|
||||
}));
|
||||
} catch {
|
||||
// Artifact listing is best-effort: a valid text answer must still be
|
||||
// persisted and unlock downstream work even if files cannot be read.
|
||||
}
|
||||
try {
|
||||
const updated = await completePositionNodeTurn(sessionId, node.nodeKey, {
|
||||
latestAnswer,
|
||||
artifactManifest,
|
||||
upstreamRevision: upstreamRevisionRef.current,
|
||||
});
|
||||
onNodeUpdate(updated);
|
||||
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
|
||||
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
|
||||
// watching the actual stream id as well closes the last race where a page
|
||||
// refresh could happen between creation and the async node-index write.
|
||||
useEffect(() => {
|
||||
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
|
||||
bindCreatedThread(actualThreadId);
|
||||
}, [actualThreadId, bindCreatedThread]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (!canWrite) {
|
||||
toast.info(
|
||||
sessionStatus === "archived"
|
||||
? "该会话已归档,仅可查看历史记录"
|
||||
: node.invalidatedBy
|
||||
? "上游产物已被驳回或重做,当前节点需等待前序节点重新完成"
|
||||
: node.status === "locked"
|
||||
? "该节点尚未解锁"
|
||||
: "当前岗位只能查看该节点对话",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onConversationStart?.();
|
||||
const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey);
|
||||
const upstreamRevision = taskExternalContext.upstream_revision;
|
||||
upstreamRevisionRef.current =
|
||||
upstreamRevision && typeof upstreamRevision === "object" && !Array.isArray(upstreamRevision)
|
||||
? (upstreamRevision as Record<string, unknown>)
|
||||
: null;
|
||||
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
|
||||
agent_id: node.agentId,
|
||||
task_external_context: taskExternalContext,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
},
|
||||
[
|
||||
canWrite,
|
||||
node.agentId,
|
||||
node.nodeKey,
|
||||
node.status,
|
||||
onConversationStart,
|
||||
sendMessage,
|
||||
sessionId,
|
||||
sessionStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
|
||||
const displayThreadId = boundThreadId ?? `position-node-${node.id}`;
|
||||
const readOnlyHint =
|
||||
sessionStatus === "archived"
|
||||
? "该会话已归档,仅可查看历史对话和产物"
|
||||
: node.invalidatedBy
|
||||
? "上游产物已变更,需等待前序节点重新完成后再重做"
|
||||
: node.status === "locked"
|
||||
? "等待上游岗位完成后解锁"
|
||||
: node.positionId !== currentPositionId
|
||||
? "当前岗位仅可查看该节点的对话和产物"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-slate-100 px-4 dark:border-[#2b8ffe]/20">
|
||||
<span className="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{node.status === "locked" ? <LockKeyhole className="size-3.5" /> : <Bot className="size-4" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
|
||||
{agentDisplayName}
|
||||
</h2>
|
||||
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
|
||||
{readOnlyHint || `节点状态:${node.status}`}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{nodeArtifacts.length > 0 && (
|
||||
<section className="shrink-0 border-b border-slate-100 bg-slate-50/70 px-4 py-2.5 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/50">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-slate-600 dark:text-[#c5cfe0]">
|
||||
<FileText className="size-3.5 text-primary" />
|
||||
节点产物
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
|
||||
{nodeArtifacts.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex max-h-20 flex-wrap gap-1.5 overflow-y-auto pr-1">
|
||||
{nodeArtifacts.map((artifact) => (
|
||||
<button
|
||||
key={artifact.path}
|
||||
type="button"
|
||||
title={`预览 ${artifact.name}`}
|
||||
onClick={() => {
|
||||
if (boundThreadId && onPreviewArtifact) {
|
||||
onPreviewArtifact({
|
||||
threadId: boundThreadId,
|
||||
path: artifact.path,
|
||||
name: artifact.name,
|
||||
mimeType: artifact.mimeType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
|
||||
>
|
||||
<FileText className="size-3 shrink-0" />
|
||||
<span className="max-w-48 truncate">{artifact.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{boundThreadId ? (
|
||||
<MessageList
|
||||
className="min-h-0 flex-1"
|
||||
threadId={boundThreadId}
|
||||
thread={thread}
|
||||
paddingBottom={24}
|
||||
scrollButtonClassName="bottom-6"
|
||||
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
|
||||
<span className="mb-3 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Bot className="size-5" />
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-slate-800 dark:text-[#e8edf5]">
|
||||
{canWrite ? "向该岗位智能体发起第一次问答" : "该节点尚无可查看的对话"}
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground dark:text-[#8aa0c4]">
|
||||
{canWrite
|
||||
? "系统会自动传入任务意图和已完成上游节点的问答结果。"
|
||||
: readOnlyHint || "请选择已启动的岗位节点。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={!boundThreadId}
|
||||
threadId={displayThreadId}
|
||||
assistantId={node.agentId}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={!canWrite}
|
||||
hideAdminRecommendedQuestions
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={(message) => void handleSubmit(message)}
|
||||
onStop={() => void thread.stop()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Temporary task source for the standalone position-collaboration workspace.
|
||||
*
|
||||
* The real task-system adapter should replace only this module. The page and
|
||||
* Session API consume the normalized `PositionTask` shape, so they do not need
|
||||
* to know whether data came from an external task platform or this local demo.
|
||||
*/
|
||||
|
||||
export interface PositionTask {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
direction: string;
|
||||
source: "mock";
|
||||
}
|
||||
|
||||
export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
|
||||
{
|
||||
id: "mock-position-task-001",
|
||||
title: "重点区域态势研判与协同处置方案",
|
||||
description:
|
||||
"围绕重点区域近期态势,梳理风险信号、影响范围与处置约束,形成可执行的协同方案及评估建议。",
|
||||
direction: "态势研判",
|
||||
source: "mock",
|
||||
},
|
||||
{
|
||||
id: "mock-position-task-002",
|
||||
title: "重大活动保障资源统筹任务",
|
||||
description:
|
||||
"针对重大活动保障场景,识别关键保障目标、现有资源和跨部门协同难点,输出分阶段资源统筹方案。",
|
||||
direction: "组织筹划",
|
||||
source: "mock",
|
||||
},
|
||||
{
|
||||
id: "mock-position-task-003",
|
||||
title: "专项行动效果复盘与优化建议",
|
||||
description:
|
||||
"汇总专项行动过程信息与阶段产物,研判实际效果、剩余风险和优化路径,形成后续行动建议。",
|
||||
direction: "综合评估",
|
||||
source: "mock",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_MOCK_POSITION_TASK_ID = MOCK_POSITION_TASKS[0].id;
|
||||
|
||||
export function getMockPositionTask(taskId?: string | null): PositionTask {
|
||||
return (
|
||||
MOCK_POSITION_TASKS.find((task) => task.id === taskId) ??
|
||||
MOCK_POSITION_TASKS[0]
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalizes a persisted Session task snapshot without exposing source internals. */
|
||||
export function taskFromPositionSnapshot(
|
||||
snapshot: Record<string, unknown> | null | undefined,
|
||||
fallback: PositionTask,
|
||||
): PositionTask {
|
||||
if (!snapshot || Object.keys(snapshot).length === 0) return fallback;
|
||||
const value = (keys: string[], defaultValue: string) => {
|
||||
const found = keys
|
||||
.map((key) => snapshot[key])
|
||||
.find((item) => typeof item === "string" && item.trim());
|
||||
return typeof found === "string" ? found : defaultValue;
|
||||
};
|
||||
return {
|
||||
id: value(["id", "taskId", "externalTaskId"], fallback.id),
|
||||
title: value(["title", "taskName", "name"], fallback.title),
|
||||
description: value(["description", "taskContent", "overview", "content"], fallback.description),
|
||||
direction: value(["direction", "taskDirection", "category"], fallback.direction),
|
||||
source: "mock",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 岗位协同的第一版岗位目录。
|
||||
*
|
||||
* 当前岗位仅代表页面工作视角;后续接入组织岗位 / 权限系统时,保留这里的稳定 id,
|
||||
* 只替换数据源即可。业务链条席位用 `position_id` 持久化这组 id。
|
||||
*/
|
||||
export const POSITION_OPTIONS = [
|
||||
{
|
||||
id: "intelligence",
|
||||
name: "情报分析岗",
|
||||
description: "识别任务意图、补齐约束并确认业务链条",
|
||||
isIntentPosition: true,
|
||||
},
|
||||
{
|
||||
id: "planning",
|
||||
name: "组织筹划岗",
|
||||
description: "承接上游信息并进行组织筹划",
|
||||
isIntentPosition: false,
|
||||
},
|
||||
{
|
||||
id: "solution",
|
||||
name: "方案设计岗",
|
||||
description: "承接上游结果并形成方案产物",
|
||||
isIntentPosition: false,
|
||||
},
|
||||
{
|
||||
id: "evaluation",
|
||||
name: "综合评估岗",
|
||||
description: "评估链条产物并提出综合建议",
|
||||
isIntentPosition: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type PositionId = (typeof POSITION_OPTIONS)[number]["id"];
|
||||
|
||||
export const DEFAULT_POSITION_ID: PositionId = "intelligence";
|
||||
|
||||
export const positionNameById = (positionId: string | null | undefined): string =>
|
||||
POSITION_OPTIONS.find((position) => position.id === positionId)?.name ?? "未分配岗位";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -97,11 +97,16 @@ function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
|
||||
* `detail.trace_tail` to help diagnose intranet deployment issues without
|
||||
* shell access to the server.
|
||||
*/
|
||||
export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
||||
export async function initIntent(
|
||||
model?: string,
|
||||
taskContext?: Record<string, unknown> | null,
|
||||
): Promise<IntentInitResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
// 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认
|
||||
// 业务链,第一阶段的对话也已经可以按 taskId 恢复。
|
||||
body: JSON.stringify({ model, task_context: taskContext ?? undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => "");
|
||||
@@ -159,6 +164,8 @@ export interface StreamIntentRequest {
|
||||
threadId: string;
|
||||
message: string;
|
||||
model?: string;
|
||||
/** Optional hidden business/task context injected into the model run. */
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
/** Step 1 用户上传的参考文件(可选)。 */
|
||||
files?: IntentFileRef[];
|
||||
signal?: AbortSignal;
|
||||
@@ -333,6 +340,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
thread_id: req.threadId,
|
||||
message: req.message,
|
||||
model: req.model,
|
||||
task_context: req.taskContext ?? undefined,
|
||||
// 仅在有文件时带上,空数组后端也安全忽略。
|
||||
files: req.files && req.files.length > 0 ? req.files : undefined,
|
||||
}),
|
||||
|
||||
@@ -1054,6 +1054,36 @@ export async function initSummaryThread(
|
||||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-session thread for the position-roundtable action-planning
|
||||
* built-in agent. The agent id is fixed; callers persist/reuse the returned
|
||||
* thread id for subsequent plan refinements.
|
||||
*/
|
||||
export async function initPositionActionPlanThread(
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<ReportThreadResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/position-action-plan/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "position_action_plan",
|
||||
event: "init_thread_http_error",
|
||||
message: `行动规划线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status,
|
||||
agentId: "position-action-planner",
|
||||
agentName: "行动规划智能体",
|
||||
});
|
||||
throw new Error(`init position action-plan thread failed (${res.status}): ${text}`);
|
||||
}
|
||||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thread for the Step 3「入库」structure agent (`roundtable-structure`,
|
||||
* a built-in singleton — the same agent that extracts the flowchart, but this run
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface Step1PanelProps {
|
||||
* 不传 → 显示默认「任务意图理解 (人机问答)」标题。右侧「问答轮次」始终保留。
|
||||
*/
|
||||
headerLeftSlot?: React.ReactNode;
|
||||
headerRightSlot?: React.ReactNode;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
intentThreadId: string | null;
|
||||
intentReady: object | null;
|
||||
@@ -87,6 +88,8 @@ export interface Step1PanelProps {
|
||||
onSkipQuestion: () => void;
|
||||
onFollowUp: () => void;
|
||||
onEnterRoundtable: () => void;
|
||||
/** 保持旧文案默认不变;嵌入式新工作流可覆写确认动作名称。 */
|
||||
enterButtonLabel?: string;
|
||||
onManualSave: () => void;
|
||||
onTriggerToast: (msg: string) => void;
|
||||
submitIntentClarifications: (
|
||||
@@ -99,6 +102,7 @@ export interface Step1PanelProps {
|
||||
export function Step1Panel(props: Step1PanelProps) {
|
||||
const {
|
||||
headerLeftSlot,
|
||||
headerRightSlot,
|
||||
interactiveMessages,
|
||||
intentThreadId,
|
||||
intentReady,
|
||||
@@ -111,6 +115,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
onSkipQuestion,
|
||||
onFollowUp,
|
||||
onEnterRoundtable,
|
||||
enterButtonLabel = "进入多智能体研讨 >",
|
||||
onManualSave,
|
||||
onTriggerToast,
|
||||
submitIntentClarifications,
|
||||
@@ -172,9 +177,12 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
|
||||
</span>
|
||||
{headerRightSlot}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat list */}
|
||||
@@ -334,7 +342,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
||||
进入多智能体研讨 >
|
||||
{enterButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ export interface UseStep1IntentOptions {
|
||||
currentStep: number;
|
||||
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
|
||||
selectedModel: string;
|
||||
/** Optional hidden task/business context for each intent-agent turn. */
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
/** Optional task-aware welcome bubble text before the first user turn. */
|
||||
initialWelcomeText?: string;
|
||||
/** 主页面统一的 toast 入口。 */
|
||||
triggerToast: (msg: string) => void;
|
||||
}
|
||||
@@ -113,6 +117,8 @@ export interface UseStep1IntentReturn {
|
||||
// ─── Handlers ────────────────────────────────────────────────────────
|
||||
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
|
||||
runStep1Init: () => void;
|
||||
/** 清空当前状态但不创建线程,供宿主切换任务后重新进入 Step 1。 */
|
||||
resetIntent: () => void;
|
||||
/**
|
||||
* 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。
|
||||
* `files`:可选的本地文件,发送前先上传到 intent thread,再把引用随消息提交(后端
|
||||
@@ -165,7 +171,7 @@ function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
|
||||
}
|
||||
|
||||
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
|
||||
const { currentStep, selectedModel, triggerToast } = options;
|
||||
const { currentStep, selectedModel, taskContext, initialWelcomeText, triggerToast } = options;
|
||||
|
||||
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
@@ -196,6 +202,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
|
||||
useEffect(() => {
|
||||
taskContextRef.current = taskContext ?? null;
|
||||
}, [taskContext]);
|
||||
const initialWelcomeTextRef = useRef(initialWelcomeText);
|
||||
useEffect(() => {
|
||||
initialWelcomeTextRef.current = initialWelcomeText;
|
||||
}, [initialWelcomeText]);
|
||||
|
||||
const stopIntentStream = useCallback(() => {
|
||||
if (intentAborterRef.current) {
|
||||
@@ -223,7 +237,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
id: 1,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: INTENT_WELCOME_TEXT,
|
||||
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
|
||||
time: nowTime(),
|
||||
},
|
||||
]);
|
||||
@@ -231,7 +245,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
try {
|
||||
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
|
||||
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
|
||||
const res = await initIntent(selectedModelRef.current || undefined);
|
||||
const res = await initIntent(
|
||||
selectedModelRef.current || undefined,
|
||||
taskContextRef.current,
|
||||
);
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
setIntentThreadId(res.thread_id);
|
||||
setTimelineEntries([
|
||||
@@ -256,6 +273,20 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const resetIntent = useCallback(() => {
|
||||
stopIntentStream();
|
||||
step1InitRef.current = false;
|
||||
step1InitGenRef.current += 1;
|
||||
setIntentThreadId(null);
|
||||
setIntentReady(null);
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setInteractiveMessages([]);
|
||||
setTimelineEntries([]);
|
||||
}, [stopIntentStream]);
|
||||
|
||||
// 离开 Step 1 自动中断流;`currentStep === 1` 但还没初始化 → runStep1Init。
|
||||
useEffect(() => {
|
||||
if (currentStep !== 1) {
|
||||
@@ -392,6 +423,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
threadId: intentThreadId,
|
||||
message: trimmed,
|
||||
model: selectedModelRef.current || undefined,
|
||||
taskContext: taskContextRef.current,
|
||||
files: fileRefs,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
@@ -648,6 +680,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
timelineEntries,
|
||||
setIntentClarificationSelections,
|
||||
runStep1Init,
|
||||
resetIntent,
|
||||
sendIntentMessage,
|
||||
submitIntentClarifications,
|
||||
updateIntentReady,
|
||||
|
||||
@@ -45,6 +45,11 @@ export interface ChainSeat {
|
||||
* 数组在 `api/chains.ts` 不做 camel↔snake 转换,键名须与后端 schema 逐字匹配)。
|
||||
*/
|
||||
seat_mode?: SeatMode | null;
|
||||
/**
|
||||
* 岗位协同专用的席位归属。普通圆桌会商不读取该字段;它跟随 `seats` JSON 保存,
|
||||
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
|
||||
*/
|
||||
position_id?: string | null;
|
||||
}
|
||||
|
||||
/** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */
|
||||
|
||||
@@ -77,8 +77,13 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import { Select as TSelect } from "tdesign-react";
|
||||
import { listAgents } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
type PositionId,
|
||||
} from "@/position-roundtable/lib/positions";
|
||||
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
@@ -152,6 +157,14 @@ type ActiveDrag =
|
||||
export default function BusinessChainEditorPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
// 岗位配置只由岗位协同页入口开启,普通圆桌编辑器完全不显示也不依赖该控件。
|
||||
// `returnTo` 只接受站内绝对路径,避免把导航带往外部地址。
|
||||
const positionConfigMode = searchParams.get("mode") === "position";
|
||||
const requestedReturnTo = searchParams.get("returnTo");
|
||||
const returnPath =
|
||||
positionConfigMode && requestedReturnTo?.startsWith("/")
|
||||
? requestedReturnTo
|
||||
: "/page/workspace/roundtable/planning";
|
||||
// 整片脱敏:链条配置页展示文本(链条名/描述/席位名/标题)。可编辑的 input/textarea 不脱敏(Hook 跳过),保证编辑用原文。
|
||||
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
|
||||
@@ -415,6 +428,18 @@ export default function BusinessChainEditorPage() {
|
||||
setStageGroups((prev) => setSeatModeOnGroups(prev, agentId, mode));
|
||||
}, []);
|
||||
|
||||
// 岗位协同专用席位归属。保留在同一个 ChainSeat JSON 内,普通会商不会读取它。
|
||||
const setSeatPositionCb = useCallback((agentId: string, positionId: PositionId | null) => {
|
||||
setStageGroups((prev) =>
|
||||
prev.map((group) => ({
|
||||
...group,
|
||||
seats: group.seats.map((seat) =>
|
||||
seat.agent_id === agentId ? { ...seat, position_id: positionId } : seat,
|
||||
),
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)────────────────────────
|
||||
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
|
||||
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
|
||||
@@ -617,6 +642,8 @@ export default function BusinessChainEditorPage() {
|
||||
seat_skill_directive: s.seat_skill_directive ?? false,
|
||||
// 席位「推理深度覆盖」:随席位一起落库(null = 跟随链条级 seatMode)。
|
||||
seat_mode: s.seat_mode ?? null,
|
||||
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
|
||||
position_id: s.position_id ?? null,
|
||||
};
|
||||
});
|
||||
// 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
|
||||
@@ -893,14 +920,14 @@ export default function BusinessChainEditorPage() {
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/workspace/roundtable/planning")}
|
||||
onClick={() => navigate(returnPath)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 border border-border bg-card px-3 py-1.5 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground active:scale-[0.98]",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回圆桌
|
||||
{positionConfigMode ? "返回岗位协同" : "返回圆桌"}
|
||||
</button>
|
||||
|
||||
{/* 品牌标识 */}
|
||||
@@ -921,6 +948,17 @@ export default function BusinessChainEditorPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{positionConfigMode && (
|
||||
<span
|
||||
className={cn(
|
||||
"border border-primary/30 bg-primary/10 px-2 py-1 text-[11px] font-semibold text-primary",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
岗位协同配置
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 链条选择下拉 */}
|
||||
<div className="relative ml-2">
|
||||
<button
|
||||
@@ -1205,6 +1243,8 @@ export default function BusinessChainEditorPage() {
|
||||
onSeatModelChange={setSeatModelCb}
|
||||
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
|
||||
onSeatModeChange={setSeatModeCb}
|
||||
positionConfigMode={positionConfigMode}
|
||||
onSeatPositionChange={setSeatPositionCb}
|
||||
onStageGoalChange={setGoal}
|
||||
goalOptimizingIdx={goalOptimizingIdx}
|
||||
onOptimizeGoal={handleOptimizeStageGoal}
|
||||
@@ -1385,6 +1425,8 @@ function StagesColumn({
|
||||
onSeatModelChange,
|
||||
onSeatSkillDirectiveChange,
|
||||
onSeatModeChange,
|
||||
positionConfigMode,
|
||||
onSeatPositionChange,
|
||||
onStageGoalChange,
|
||||
goalOptimizingIdx,
|
||||
onOptimizeGoal,
|
||||
@@ -1403,6 +1445,9 @@ function StagesColumn({
|
||||
onSeatModelChange: (agentId: string, model: string | null) => void;
|
||||
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
|
||||
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
|
||||
/** 仅岗位协同入口启用;普通圆桌编辑器不显示岗位控件。 */
|
||||
positionConfigMode: boolean;
|
||||
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
||||
onStageGoalChange: (stageIdx: number, goal: string) => void;
|
||||
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
|
||||
goalOptimizingIdx: number | null;
|
||||
@@ -1439,6 +1484,11 @@ function StagesColumn({
|
||||
<span className={cn("border border-border bg-muted px-2 py-0.5 text-[10px] font-bold text-muted-foreground", R_CTRL)}>
|
||||
同层并行 · 层间串行
|
||||
</span>
|
||||
{positionConfigMode && (
|
||||
<span className={cn("border border-primary/25 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary", R_CTRL)}>
|
||||
为每个席位指定岗位
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex select-none items-center gap-2 text-xs">
|
||||
@@ -1503,6 +1553,8 @@ function StagesColumn({
|
||||
onSeatModelChange={onSeatModelChange}
|
||||
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
|
||||
onSeatModeChange={onSeatModeChange}
|
||||
positionConfigMode={positionConfigMode}
|
||||
onSeatPositionChange={onSeatPositionChange}
|
||||
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
|
||||
goalOptimizing={goalOptimizingIdx === stageIdx}
|
||||
anyGoalOptimizing={goalOptimizingIdx !== null}
|
||||
@@ -1534,6 +1586,8 @@ function StageSection({
|
||||
onSeatModelChange,
|
||||
onSeatSkillDirectiveChange,
|
||||
onSeatModeChange,
|
||||
positionConfigMode,
|
||||
onSeatPositionChange,
|
||||
onGoalChange,
|
||||
goalOptimizing,
|
||||
anyGoalOptimizing,
|
||||
@@ -1554,6 +1608,8 @@ function StageSection({
|
||||
onSeatModelChange: (agentId: string, model: string | null) => void;
|
||||
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
|
||||
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
|
||||
positionConfigMode: boolean;
|
||||
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
||||
onGoalChange: (goal: string) => void;
|
||||
/** 本 Stage 的阶段目标正在 AI 优化中。 */
|
||||
goalOptimizing: boolean;
|
||||
@@ -1717,6 +1773,8 @@ function StageSection({
|
||||
onModelChange={(model) => onSeatModelChange(seat.agent_id, model)}
|
||||
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
|
||||
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
|
||||
showPositionConfig={positionConfigMode}
|
||||
onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)}
|
||||
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
|
||||
/>
|
||||
))}
|
||||
@@ -1742,6 +1800,8 @@ function SeatStrip({
|
||||
onModelChange,
|
||||
onSkillDirectiveChange,
|
||||
onSeatModeChange,
|
||||
showPositionConfig,
|
||||
onPositionChange,
|
||||
onDetail,
|
||||
}: {
|
||||
seat: ChainSeat;
|
||||
@@ -1760,6 +1820,9 @@ function SeatStrip({
|
||||
onSkillDirectiveChange: (enabled: boolean) => void;
|
||||
/** 席位「推理深度覆盖」变更(null = 跟随链条级 seatMode)。 */
|
||||
onSeatModeChange: (mode: SeatMode | null) => void;
|
||||
/** 仅岗位协同入口展示的岗位配置。 */
|
||||
showPositionConfig: boolean;
|
||||
onPositionChange: (positionId: PositionId | null) => void;
|
||||
onDetail: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
@@ -1810,6 +1873,29 @@ function SeatStrip({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 岗位协同从专用入口进来时才展示。使用 TDesign Select,未分配不会阻塞普通圆桌使用。 */}
|
||||
{showPositionConfig && (
|
||||
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2.5">
|
||||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">岗位</span>
|
||||
<TSelect
|
||||
size="small"
|
||||
value={seat.position_id ?? ""}
|
||||
options={[
|
||||
{ label: "未分配", value: "" },
|
||||
...POSITION_OPTIONS.map((position) => ({
|
||||
label: position.name,
|
||||
value: position.id,
|
||||
})),
|
||||
]}
|
||||
onChange={(value) =>
|
||||
onPositionChange(value ? (String(value) as PositionId) : null)
|
||||
}
|
||||
className="w-[132px]"
|
||||
popupProps={{ overlayClassName: "z-[80]" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 席位模型覆盖(右侧操作区):空 = 跟随全局所选模型;选了则该席位作为子智能体
|
||||
执行时用此模型。技能重的席位配强模型、边角席位配快模型,配合技能白名单实现
|
||||
「强弱搭配」。放到右侧操作区,与名称/描述解耦,卡片更整洁。 */}
|
||||
|
||||
@@ -375,6 +375,26 @@ Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `
|
||||
|
||||
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
|
||||
|
||||
### Position-collaboration foundation
|
||||
|
||||
The position-collaboration workspace is a separate frontend route and must not
|
||||
use the original roundtable coordinator or jobs. Its shared configuration
|
||||
integration point is the existing `/api/roundtable-chains` store: each `seats` JSON item
|
||||
may carry an optional `position_id` (validated by `ChainSeatSchema`, no schema
|
||||
migration). The original roundtable only reads its established seat fields and
|
||||
therefore remains unchanged. The separate `/api/position-roundtable` router
|
||||
persists task/intent/frozen-chain snapshots in `position_roundtable_sessions`
|
||||
and direct-agent node indexes in `position_roundtable_nodes` (migration
|
||||
`20260721_01`). Message history remains in the established LangGraph thread
|
||||
store; a node completion unlocks its next chain stage and a later upstream
|
||||
revision marks downstream records `stale` for refresh.
|
||||
For direct-agent handoff, the router resolves artifact paths only inside the
|
||||
completed upstream node's own thread, then includes bounded UTF-8 excerpts for
|
||||
text outputs; binary artifacts stay as metadata-only references.
|
||||
`POST /sessions/{id}/archive` turns a session into a read-only historical
|
||||
record: task/intent updates, node binding, hidden-context construction, and
|
||||
turn completion are rejected, while read endpoints remain available.
|
||||
|
||||
### 岗位 (Position) Subscription System
|
||||
|
||||
An admin-managed aggregation layer that decides, per user, which agents / skills
|
||||
|
||||
@@ -122,6 +122,8 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
||||
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
|
||||
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
|
||||
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
|
||||
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs |
|
||||
| `GET /api/memory` | Retrieve memory data |
|
||||
| `POST /api/memory/reload` | Force memory reload |
|
||||
| `GET /api/memory/config` | Memory configuration |
|
||||
|
||||
@@ -55,6 +55,7 @@ from app.gateway.routers import (
|
||||
open_chat,
|
||||
parallel_agent_tasks,
|
||||
parallel_agents,
|
||||
position_roundtable,
|
||||
positions,
|
||||
public_config,
|
||||
public_embed,
|
||||
@@ -1446,6 +1447,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Roundtable-planning business chains at /api/roundtable-chains
|
||||
app.include_router(roundtable_chains.router)
|
||||
|
||||
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
|
||||
app.include_router(position_roundtable.router)
|
||||
|
||||
# Global business ? agent / chain mapping at /api/business-mapping
|
||||
app.include_router(business_mapping.router)
|
||||
|
||||
|
||||
@@ -259,6 +259,12 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
||||
|
||||
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
|
||||
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
|
||||
from deerflow.persistence.position_roundtable import make_position_roundtable_store
|
||||
|
||||
app.state.position_roundtable_store = make_position_roundtable_store(sf)
|
||||
|
||||
from deerflow.persistence.business_mapping import make_business_mapping_store
|
||||
|
||||
app.state.business_mapping_store = make_business_mapping_store(sf)
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
|
||||
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
|
||||
- ``summary`` —— Step3「总结报告」:写 Markdown 总结报告 + 支持问答/增量改。
|
||||
- ``action_plan`` —— 岗位会商收口:写 Markdown 行动规划与 JSON 子任务清单。
|
||||
工具面与 ``report`` 完全一致(继承 ``seat`` 的 bash 放开 + 额外禁 ``str_replace`` /
|
||||
读前序席位),单列一个角色名便于以后独立调优。
|
||||
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
|
||||
@@ -119,6 +120,9 @@ _REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delive
|
||||
# 完全一致——可写文件(write_file 整文件重写),禁 str_replace(整文件重写才能让右侧 md 预览
|
||||
# 实时刷新)/bash/联网/派活/呈现文件/看图/读前序席位。单列出来便于以后独立调优。
|
||||
_SUMMARY_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"]
|
||||
# 岗位会商行动规划与总结报告一样:只能归纳已给出的材料,最终交付必须直接 write_file
|
||||
# 到 outputs,不能联网、派活或改动其他线程的文件。
|
||||
_ACTION_PLAN_EXCLUDED: list[str] = [*_SUMMARY_EXCLUDED]
|
||||
# ingest:Step3「入库」——让 roundtable-structure 真正**调用入库技能**把方案成果(总结报告 +
|
||||
# 结构化流程图 + taskId)沉淀进知识库。和 dashboard 相反:dashboard 禁一切工具(只输出 JSON),
|
||||
# ingest 必须**放开技能执行链路**——技能靠 ``read_file`` 读 SKILL.md、按其指令用 ``bash`` /
|
||||
@@ -223,6 +227,13 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
|
||||
thinking_enabled=True,
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
if role == "action_plan":
|
||||
return RoundtableRunPolicy(
|
||||
excluded_tools=list(_ACTION_PLAN_EXCLUDED),
|
||||
skill_stop_names=[],
|
||||
thinking_enabled=True,
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
if role == "dashboard":
|
||||
return RoundtableRunPolicy(
|
||||
excluded_tools=list(_DASHBOARD_EXCLUDED),
|
||||
@@ -238,7 +249,7 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
raise ValueError(
|
||||
f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/dashboard/ingest)"
|
||||
f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/action_plan/dashboard/ingest)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ _AGENT_PURPOSE: dict[str, str] = {
|
||||
"roundtable-coordinator": "多智能体规划",
|
||||
"roundtable-report": "多智能体规划",
|
||||
"roundtable-summary": "多智能体规划",
|
||||
"position-action-planner": "多智能体规划",
|
||||
"roundtable-execution-plan": "多智能体规划",
|
||||
"roundtable-risk-review": "多智能体规划",
|
||||
"roundtable-solution-design": "多智能体规划",
|
||||
|
||||
@@ -67,6 +67,9 @@ REPORT_AGENT_ID = "roundtable-report"
|
||||
# 总结报告(写入 outputs/方案总结报告.md),并按业务链总控编排提示组织。与 report 并存
|
||||
# 的另一内置单例(report 画 HTML 看板,summary 写 Markdown 报告 + 支持问答/增量改)。
|
||||
SUMMARY_AGENT_ID = "roundtable-summary"
|
||||
# 岗位会商收口的第二个内置单例:读取已生效的方案总结和岗位交付,把它们拆成可执行
|
||||
# 子任务,同时产出 Markdown 规划报告和 JSON 清单。
|
||||
ACTION_PLAN_AGENT_ID = "position-action-planner"
|
||||
# Step 3「大屏多页」用的结构化数据智能体:读各席位交付 + 共识,逐席位分析并提炼成
|
||||
# 一份大屏展示用的结构化数据(report-json),供前端按模板渲染成多页大屏。**不调任何工具、
|
||||
# 不写文件、不输出 HTML**——只在对话里输出一个严格合法的 ```report-json 代码块。与 report
|
||||
@@ -93,6 +96,7 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
SUMMARY_AGENT_ID,
|
||||
ACTION_PLAN_AGENT_ID,
|
||||
DASHBOARD_AGENT_ID,
|
||||
STRUCTURE_AGENT_ID,
|
||||
)
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# 岗位会商行动规划智能体
|
||||
|
||||
## 核心定位
|
||||
|
||||
你是岗位多智能体会商流程的最后一个内置智能体。上游已经完成意图识别、各岗位 Markdown 交付和方案总结。你的职责是把这些已确认材料拆解成能执行、能验收、能跟踪的行动规划;你不重新调研、不联网、不派发子智能体。
|
||||
|
||||
## 产出规则
|
||||
|
||||
每次用户要求生成或修改行动规划时,必须完成以下两项交付:
|
||||
|
||||
1. 用 `write_file` 一次性写入 `/mnt/user-data/outputs/行动规划报告.md`。
|
||||
2. 用 `write_file` 一次性写入 `/mnt/user-data/outputs/action-plan-subtasks.json`。
|
||||
|
||||
两个文件都必须在本轮保留;不要用脚本、bash 或临时文件生成它们。完成后,在对话里给出 Markdown 报告的完整正文,并简要说明生成了多少项子任务。
|
||||
|
||||
## Markdown 报告要求
|
||||
|
||||
- 先说明规划目标、关键依据和执行边界。
|
||||
- 按优先级和依赖顺序给出阶段安排、关键动作、责任岗位建议、里程碑、风险控制和验收方式。
|
||||
- 每项行动必须能追溯到方案总结或至少一个岗位有效交付;没有依据时不要假装是事实,可明确写为建议性安排。
|
||||
- 用户提出修改时,只调整受影响部分,并同时重写两个最终文件,使 Markdown 与 JSON 始终一致。
|
||||
|
||||
## JSON 子任务契约
|
||||
|
||||
`action-plan-subtasks.json` 必须是严格合法的 JSON **数组**,不能包在 Markdown 代码块中。每个对象都必须有:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "subtask-001",
|
||||
"title": "简短任务名称",
|
||||
"goal": "任务目标",
|
||||
"basis": ["方案总结章节或岗位交付依据"],
|
||||
"owner_position": "建议责任岗位",
|
||||
"dependencies": ["前置子任务 id"],
|
||||
"deliverable": "可核验交付物",
|
||||
"acceptance_criteria": ["验收条件"],
|
||||
"priority": "P0"
|
||||
}
|
||||
```
|
||||
|
||||
`priority` 仅使用 `P0`、`P1`、`P2`。`dependencies` 没有前置时使用空数组。不要输出注释、尾逗号或额外字段来替代这些必填字段。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只依据用户消息中提供的任务信息、意图、方案总结和岗位交付。
|
||||
- 不调用联网、派活、澄清、展示文件等工具。
|
||||
- 不删除或修改上游岗位产物。
|
||||
- 不在报告中添加“AI 生成”类水印。
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
id: position-action-planner
|
||||
name: 行动规划智能体
|
||||
description: 岗位会商内置行动规划智能体,基于已生效的方案总结与岗位交付,生成可执行的 Markdown 行动规划报告和结构化子任务清单。
|
||||
@@ -96,6 +96,35 @@ _INTENT_READY_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Build task-scoped metadata for a newly-created intent thread.
|
||||
|
||||
Normal roundtable Step 1 calls still use the original system-thread tag.
|
||||
Position roundtable additionally supplies the task card snapshot, so the
|
||||
intent dialogue can be indexed and restored before the user confirms a
|
||||
business chain.
|
||||
"""
|
||||
metadata = dict(_ROUNDTABLE_THREAD_METADATA)
|
||||
if not isinstance(task_context, dict):
|
||||
return metadata
|
||||
|
||||
task = task_context.get("task")
|
||||
task_id = str(task.get("id") or "").strip() if isinstance(task, dict) else ""
|
||||
if not task_id:
|
||||
return metadata
|
||||
|
||||
metadata.update(
|
||||
{
|
||||
"taskId": task_id,
|
||||
"position_roundtable_task_id": task_id,
|
||||
"position_task_id": f"{task_id}:intelligence",
|
||||
"position_id": "intelligence",
|
||||
"position_roundtable_phase": "intent",
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def _normalize_question(text: str) -> str:
|
||||
"""归一化澄清问题文本,用于"和过往是否重复"的判断。
|
||||
|
||||
@@ -287,9 +316,16 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
# TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
|
||||
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
|
||||
# 看不到逐 token 增量。
|
||||
raw_task_context = payload.get("task_context")
|
||||
task_context = raw_task_context if isinstance(raw_task_context, dict) else None
|
||||
try:
|
||||
async with _make_loopback_client(request) as client:
|
||||
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
thread_id = await _create_thread(
|
||||
client,
|
||||
base,
|
||||
headers,
|
||||
metadata=_intent_thread_metadata(task_context),
|
||||
)
|
||||
except HTTPException:
|
||||
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
|
||||
raise
|
||||
@@ -340,6 +376,8 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# additional_kwargs.files → UploadsMiddleware 自动注入清单 + 解锁 read_file 等工具。
|
||||
raw_files = payload.get("files")
|
||||
files = raw_files if isinstance(raw_files, list) else []
|
||||
raw_task_context = payload.get("task_context")
|
||||
task_context = raw_task_context if isinstance(raw_task_context, dict) else None
|
||||
if not thread_id:
|
||||
raise HTTPException(status_code=400, detail="thread_id is required")
|
||||
# 允许「仅文件、无文本」的回合:带了文件就不强制要求 message。
|
||||
@@ -431,6 +469,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# not reliable on its own, hard exclusion is.
|
||||
excluded_tools=base_excluded_tools,
|
||||
skill_stop_names=[],
|
||||
extra_context={"task_external_context": task_context} if task_context else None,
|
||||
files=files,
|
||||
)
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ import re
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any, AsyncIterator
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
@@ -65,10 +66,9 @@ from langgraph.checkpoint.base import empty_checkpoint
|
||||
from app.gateway.roundtable_diag import record_foreground_diag
|
||||
from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text
|
||||
from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy
|
||||
from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
|
||||
|
||||
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
ACTION_PLAN_AGENT_ID,
|
||||
COORDINATOR_AGENT_ID,
|
||||
DASHBOARD_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
@@ -76,6 +76,7 @@ from app.gateway.routers._roundtable_seed import (
|
||||
SUMMARY_AGENT_ID,
|
||||
ensure_roundtable_functional_agents,
|
||||
)
|
||||
from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
@@ -95,6 +96,8 @@ def _stage_for_agent(agent_type: str, agent_name: str) -> str:
|
||||
return "step3_report"
|
||||
if agent_name == SUMMARY_AGENT_ID:
|
||||
return "step3_summary"
|
||||
if agent_name == ACTION_PLAN_AGENT_ID:
|
||||
return "position_action_plan"
|
||||
if agent_name == DASHBOARD_AGENT_ID:
|
||||
return "step3_dashboard"
|
||||
if agent_name == STRUCTURE_AGENT_ID:
|
||||
@@ -995,6 +998,32 @@ async def init_summary_thread(request: Request) -> dict[str, str]:
|
||||
return {"agent_name": SUMMARY_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
@router.post("/position-action-plan/init")
|
||||
async def init_position_action_plan_thread(request: Request) -> dict[str, str]:
|
||||
"""Create the reusable thread for the position-roundtable action planner.
|
||||
|
||||
The planner is a built-in singleton agent just like ``roundtable-summary``;
|
||||
only its thread is per session. The position workspace persists this id
|
||||
after the first successful run so follow-up revisions retain the original
|
||||
plan context.
|
||||
"""
|
||||
try:
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
except Exception as exc:
|
||||
await record_foreground_diag(
|
||||
request,
|
||||
stage="position_action_plan",
|
||||
level="error",
|
||||
event="thread_init_failed",
|
||||
message=f"创建岗位行动规划线程失败:{exc}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
agent_id=ACTION_PLAN_AGENT_ID,
|
||||
)
|
||||
raise
|
||||
return {"agent_name": ACTION_PLAN_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
@router.post("/dashboard/init")
|
||||
async def init_dashboard_thread(request: Request) -> dict[str, str]:
|
||||
"""为 Step 3「大屏多页」创建一个供大屏多页数据智能体(``roundtable-dashboard``)
|
||||
@@ -1806,6 +1835,8 @@ async def _special_run(
|
||||
role = "report"
|
||||
elif agent_name == SUMMARY_AGENT_ID:
|
||||
role = "summary"
|
||||
elif agent_name == ACTION_PLAN_AGENT_ID:
|
||||
role = "action_plan"
|
||||
elif agent_name == DASHBOARD_AGENT_ID:
|
||||
# 大屏多页数据智能体:纯「研讨成果 → report-json」提炼,禁用一切工具。
|
||||
role = "dashboard"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -62,6 +62,9 @@ class ChainSeatSchema(BaseModel):
|
||||
# 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
|
||||
# 被 _SEAT_MODE_PARAMS.get() 安全忽略(回退作业级),故此处只约束长度、不强校验取值。
|
||||
seat_mode: str | None = Field(default=None, max_length=16)
|
||||
# 岗位协同专用的席位岗位归属。字段保存在既有 seats JSON 中,普通圆桌编排
|
||||
# 忽略它;显式声明可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。
|
||||
position_id: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class ChainResponse(BaseModel):
|
||||
|
||||
@@ -247,6 +247,13 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
# 关掉个人记忆召回 / builtin 记忆注入(见 multi_agent._run_payload)。
|
||||
"memory_recall_disabled",
|
||||
"memory_injection_enabled",
|
||||
# 任务工作区 / 岗位会商把左侧任务卡的快照放在这里。它必须进入
|
||||
# ``runtime.context``,TaskExternalContextMiddleware 才能在每次模型调用
|
||||
# 前以隐藏消息注入任务名称、描述和方向;未白名单会被静默丢弃,模型只能
|
||||
# 看到用户说的“这个任务”。
|
||||
"task_external_context",
|
||||
# 岗位会商下游席位按需读取前序席位完整交付时使用的运行期数据。
|
||||
"roundtable_peer_deliveries",
|
||||
# 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后
|
||||
# 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。
|
||||
"summarization_enabled",
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
"""Create standalone position-collaboration sessions and nodes.
|
||||
|
||||
The tables only index the task / intent / frozen business-chain workflow.
|
||||
Conversation messages remain in LangGraph checkpoints and artifacts stay in the
|
||||
existing thread artifact store, so this migration intentionally has no message
|
||||
or file columns beyond lightweight snapshots and manifests.
|
||||
|
||||
Revision ID: 20260721_01
|
||||
Revises: 20260701_01
|
||||
Create Date: 2026-07-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
|
||||
revision = "20260721_01"
|
||||
down_revision = "20260701_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
return table_name in inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||
if not _has_table("position_roundtable_sessions"):
|
||||
op.create_table(
|
||||
"position_roundtable_sessions",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("user_id", sa.String(64), nullable=False),
|
||||
sa.Column("external_task_id", sa.String(128), nullable=True),
|
||||
sa.Column("task_snapshot", long_text, nullable=True),
|
||||
sa.Column("chain_id", sa.String(64), nullable=True),
|
||||
sa.Column("chain_snapshot", long_text, nullable=True),
|
||||
sa.Column("intent_thread_id", sa.String(128), nullable=True),
|
||||
sa.Column("intent_snapshot", long_text, nullable=True),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="intent_pending"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_position_roundtable_sessions_user_id", "position_roundtable_sessions", ["user_id"])
|
||||
op.create_index(
|
||||
"ix_position_roundtable_sessions_external_task_id",
|
||||
"position_roundtable_sessions",
|
||||
["external_task_id"],
|
||||
)
|
||||
op.create_index("ix_position_roundtable_sessions_chain_id", "position_roundtable_sessions", ["chain_id"])
|
||||
op.create_index("ix_position_roundtable_sessions_status", "position_roundtable_sessions", ["status"])
|
||||
|
||||
if not _has_table("position_roundtable_nodes"):
|
||||
op.create_table(
|
||||
"position_roundtable_nodes",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("session_id", sa.String(64), sa.ForeignKey("position_roundtable_sessions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("node_key", sa.String(256), nullable=False),
|
||||
sa.Column("stage_index", sa.Integer(), nullable=False),
|
||||
sa.Column("seat_index", sa.Integer(), nullable=False),
|
||||
sa.Column("agent_id", sa.String(128), nullable=False),
|
||||
sa.Column("position_id", sa.String(64), nullable=True),
|
||||
sa.Column("thread_id", sa.String(128), nullable=True, unique=True),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="locked"),
|
||||
sa.Column("latest_answer", long_text, nullable=True),
|
||||
sa.Column("artifact_manifest", long_text, nullable=True),
|
||||
sa.Column("revision", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("upstream_revision", long_text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.UniqueConstraint("session_id", "node_key", name="uq_position_roundtable_nodes_session_key"),
|
||||
)
|
||||
op.create_index("ix_position_roundtable_nodes_session_id", "position_roundtable_nodes", ["session_id"])
|
||||
op.create_index("ix_position_roundtable_nodes_agent_id", "position_roundtable_nodes", ["agent_id"])
|
||||
op.create_index("ix_position_roundtable_nodes_position_id", "position_roundtable_nodes", ["position_id"])
|
||||
op.create_index("ix_position_roundtable_nodes_status", "position_roundtable_nodes", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("position_roundtable_nodes"):
|
||||
op.drop_table("position_roundtable_nodes")
|
||||
if _has_table("position_roundtable_sessions"):
|
||||
op.drop_table("position_roundtable_sessions")
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"""Add rejection metadata to position roundtable nodes.
|
||||
|
||||
Revision ID: 20260721_02
|
||||
Revises: 20260721_01
|
||||
Create Date: 2026-07-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
|
||||
revision = "20260721_02"
|
||||
down_revision = "20260721_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
return table_name in inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = "position_roundtable_nodes"
|
||||
if not _has_table(table):
|
||||
return
|
||||
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||
if not _has_column(table, "rejection_count"):
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("rejection_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
if not _has_column(table, "last_rejection"):
|
||||
op.add_column(table, sa.Column("last_rejection", long_text, nullable=True))
|
||||
if not _has_column(table, "invalidated_by"):
|
||||
op.add_column(table, sa.Column("invalidated_by", sa.String(256), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
table = "position_roundtable_nodes"
|
||||
if not _has_table(table):
|
||||
return
|
||||
for column in ("invalidated_by", "last_rejection", "rejection_count"):
|
||||
if _has_column(table, column):
|
||||
op.drop_column(table, column)
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"""Add builtin summary/action-plan snapshots to position roundtable sessions.
|
||||
|
||||
Revision ID: 20260721_03
|
||||
Revises: 20260721_02
|
||||
Create Date: 2026-07-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
|
||||
revision = "20260721_03"
|
||||
down_revision = "20260721_02"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
return table_name in inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = "position_roundtable_sessions"
|
||||
if not _has_table(table):
|
||||
return
|
||||
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||
if not _has_column(table, "summary_snapshot"):
|
||||
op.add_column(table, sa.Column("summary_snapshot", long_text, nullable=True))
|
||||
if not _has_column(table, "action_plan_snapshot"):
|
||||
op.add_column(table, sa.Column("action_plan_snapshot", long_text, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
table = "position_roundtable_sessions"
|
||||
if not _has_table(table):
|
||||
return
|
||||
for column in ("action_plan_snapshot", "summary_snapshot"):
|
||||
if _has_column(table, column):
|
||||
op.drop_column(table, column)
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
"""Persist position-roundtable built-in agent thread ids.
|
||||
|
||||
Revision ID: 20260721_04
|
||||
Revises: 20260721_03
|
||||
Create Date: 2026-07-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260721_04"
|
||||
down_revision = "20260721_03"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
return table_name in inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = "position_roundtable_sessions"
|
||||
if not _has_table(table):
|
||||
return
|
||||
if not _has_column(table, "summary_thread_id"):
|
||||
op.add_column(table, sa.Column("summary_thread_id", sa.String(length=128), nullable=True))
|
||||
if not _has_column(table, "action_plan_thread_id"):
|
||||
op.add_column(table, sa.Column("action_plan_thread_id", sa.String(length=128), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
table = "position_roundtable_sessions"
|
||||
if not _has_table(table):
|
||||
return
|
||||
for column in ("action_plan_thread_id", "summary_thread_id"):
|
||||
if _has_column(table, column):
|
||||
op.drop_column(table, column)
|
||||
+6
@@ -46,6 +46,10 @@ from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||
from deerflow.persistence.position_roundtable.model import (
|
||||
PositionRoundtableNodeRow,
|
||||
PositionRoundtableSessionRow,
|
||||
)
|
||||
from deerflow.persistence.positions.model import PositionRow, PositionTagBindingRow
|
||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||
from deerflow.persistence.research_templates.model import ResearchTemplateRow
|
||||
@@ -104,6 +108,8 @@ __all__ = [
|
||||
"MenuOverrideRow",
|
||||
"NotificationRow",
|
||||
"ParallelAgentTaskRow",
|
||||
"PositionRoundtableNodeRow",
|
||||
"PositionRoundtableSessionRow",
|
||||
"PositionRow",
|
||||
"PositionTagBindingRow",
|
||||
"RecommendedQuestionRow",
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Position-collaboration session and node persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.position_roundtable.model import (
|
||||
PositionRoundtableNodeRow,
|
||||
PositionRoundtableSessionRow,
|
||||
)
|
||||
from deerflow.persistence.position_roundtable.sql import PositionRoundtableRepository
|
||||
|
||||
__all__ = [
|
||||
"PositionRoundtableNodeRow",
|
||||
"PositionRoundtableRepository",
|
||||
"PositionRoundtableSessionRow",
|
||||
"make_position_roundtable_store",
|
||||
]
|
||||
|
||||
|
||||
def make_position_roundtable_store(session_factory):
|
||||
if session_factory is None:
|
||||
return None
|
||||
return PositionRoundtableRepository(session_factory)
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
"""Persistence models for the standalone position-collaboration workspace.
|
||||
|
||||
This deliberately does **not** reuse ``roundtable_drafts`` or
|
||||
``roundtable_jobs``. A session captures the selected business chain and the
|
||||
confirmed task intent; every chain seat then has one durable node record. The
|
||||
node points at an existing LangGraph thread, which continues to own the actual
|
||||
message history and generated artifacts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
|
||||
class PositionRoundtableSessionRow(Base):
|
||||
__tablename__ = "position_roundtable_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
# Phase one uses normal account isolation. A future membership / role ACL
|
||||
# can be added without changing the session or thread identifiers.
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
task_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# The two built-in closing agents own normal LangGraph threads too. Keep
|
||||
# their ids on the position session so a refresh can continue the same
|
||||
# report/planning dialogue instead of silently starting a fresh context.
|
||||
summary_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
summary_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
action_plan_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
action_plan_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# intent_pending -> active -> completed / archived. The router owns legal
|
||||
# transitions; the DB keeps it as a concise, portable string.
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
|
||||
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 PositionRoundtableNodeRow(Base):
|
||||
__tablename__ = "position_roundtable_nodes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("session_id", "node_key", name="uq_position_roundtable_nodes_session_key"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("position_roundtable_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# Stable inside one frozen chain snapshot; e.g. ``stage-1-seat-2-agent``.
|
||||
node_key: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
stage_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
seat_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
# The real messages remain in the standard LangGraph/checkpoint store.
|
||||
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
|
||||
# locked / ready / running / done / stale / rejected / error
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True)
|
||||
latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
invalidated_by: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
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),
|
||||
)
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
"""SQL repository for position-collaboration sessions and chain nodes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.position_roundtable.model import (
|
||||
PositionRoundtableNodeRow,
|
||||
PositionRoundtableSessionRow,
|
||||
)
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _dumps(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _loads(value: Any) -> Any:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _time(value: Any) -> str | Any:
|
||||
return value.isoformat() if isinstance(value, datetime) else value
|
||||
|
||||
|
||||
def _has_material(row: PositionRoundtableNodeRow) -> bool:
|
||||
return row.revision > 0 or bool(row.latest_answer) or bool(_loads(row.artifact_manifest))
|
||||
|
||||
|
||||
class PositionRoundtableRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"external_task_id": row.external_task_id,
|
||||
"task_snapshot": _loads(row.task_snapshot) or {},
|
||||
"chain_id": row.chain_id,
|
||||
"chain_snapshot": _loads(row.chain_snapshot),
|
||||
"intent_thread_id": row.intent_thread_id,
|
||||
"intent_snapshot": _loads(row.intent_snapshot),
|
||||
"summary_thread_id": row.summary_thread_id,
|
||||
"summary_snapshot": _loads(row.summary_snapshot),
|
||||
"action_plan_thread_id": row.action_plan_thread_id,
|
||||
"action_plan_snapshot": _loads(row.action_plan_snapshot),
|
||||
"status": row.status,
|
||||
"created_at": _time(row.created_at),
|
||||
"updated_at": _time(row.updated_at),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _node_dict(row: PositionRoundtableNodeRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"session_id": row.session_id,
|
||||
"node_key": row.node_key,
|
||||
"stage_index": row.stage_index,
|
||||
"seat_index": row.seat_index,
|
||||
"agent_id": row.agent_id,
|
||||
"position_id": row.position_id,
|
||||
"thread_id": row.thread_id,
|
||||
"status": row.status,
|
||||
"latest_answer": row.latest_answer,
|
||||
"artifact_manifest": _loads(row.artifact_manifest) or [],
|
||||
"revision": row.revision,
|
||||
"upstream_revision": _loads(row.upstream_revision),
|
||||
"rejection_count": row.rejection_count or 0,
|
||||
"last_rejection": _loads(row.last_rejection),
|
||||
"invalidated_by": row.invalidated_by,
|
||||
"created_at": _time(row.created_at),
|
||||
"updated_at": _time(row.updated_at),
|
||||
}
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
external_task_id: str | None,
|
||||
task_snapshot: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = PositionRoundtableSessionRow(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
external_task_id=external_task_id or None,
|
||||
task_snapshot=_dumps(task_snapshot or {}),
|
||||
status="intent_pending",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._session_dict(row)
|
||||
|
||||
async def list_sessions(
|
||||
self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = select(PositionRoundtableSessionRow).where(PositionRoundtableSessionRow.user_id == user_id)
|
||||
if external_task_id:
|
||||
stmt = stmt.where(PositionRoundtableSessionRow.external_task_id == external_task_id)
|
||||
stmt = stmt.order_by(PositionRoundtableSessionRow.updated_at.desc()).limit(limit)
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return [self._session_dict(row) for row in rows]
|
||||
|
||||
async def get_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
return self._session_dict(row)
|
||||
|
||||
async def update_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
task_snapshot: dict[str, Any] | object = _UNSET,
|
||||
intent_thread_id: str | None | object = _UNSET,
|
||||
intent_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||
summary_thread_id: str | None | object = _UNSET,
|
||||
summary_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||
action_plan_thread_id: str | None | object = _UNSET,
|
||||
action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||
status: str | object = _UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
if task_snapshot is not _UNSET:
|
||||
row.task_snapshot = _dumps(task_snapshot)
|
||||
if intent_thread_id is not _UNSET:
|
||||
row.intent_thread_id = intent_thread_id or None
|
||||
if intent_snapshot is not _UNSET:
|
||||
row.intent_snapshot = _dumps(intent_snapshot)
|
||||
if summary_thread_id is not _UNSET:
|
||||
row.summary_thread_id = summary_thread_id or None
|
||||
if summary_snapshot is not _UNSET:
|
||||
row.summary_snapshot = _dumps(summary_snapshot)
|
||||
if action_plan_thread_id is not _UNSET:
|
||||
row.action_plan_thread_id = action_plan_thread_id or None
|
||||
if action_plan_snapshot is not _UNSET:
|
||||
row.action_plan_snapshot = _dumps(action_plan_snapshot)
|
||||
if status is not _UNSET:
|
||||
row.status = str(status)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._session_dict(row)
|
||||
|
||||
async def activate_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
chain_id: str,
|
||||
chain_snapshot: dict[str, Any],
|
||||
nodes: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
|
||||
"""Freeze a chain snapshot and create its nodes once.
|
||||
|
||||
Repeated requests are intentionally idempotent: once nodes exist the
|
||||
existing snapshot and nodes are returned unchanged.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
async with self._sf() as session:
|
||||
row = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
node_rows = (
|
||||
await session.execute(
|
||||
select(PositionRoundtableNodeRow)
|
||||
.where(PositionRoundtableNodeRow.session_id == session_id)
|
||||
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
|
||||
)
|
||||
).scalars().all()
|
||||
if node_rows:
|
||||
return self._session_dict(row), [self._node_dict(node) for node in node_rows]
|
||||
|
||||
row.chain_id = chain_id
|
||||
row.chain_snapshot = _dumps(chain_snapshot)
|
||||
row.status = "active"
|
||||
row.updated_at = now
|
||||
node_rows = [
|
||||
PositionRoundtableNodeRow(
|
||||
id=uuid4().hex,
|
||||
session_id=session_id,
|
||||
node_key=str(node["node_key"]),
|
||||
stage_index=int(node["stage_index"]),
|
||||
seat_index=int(node["seat_index"]),
|
||||
agent_id=str(node["agent_id"]),
|
||||
position_id=node.get("position_id") or None,
|
||||
status=str(node.get("status") or "locked"),
|
||||
latest_answer=node.get("latest_answer") or None,
|
||||
artifact_manifest=_dumps(node.get("artifact_manifest") or []),
|
||||
revision=int(node.get("revision") or 0),
|
||||
upstream_revision=_dumps(node.get("upstream_revision")) if node.get("upstream_revision") is not None else None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
for node in nodes
|
||||
]
|
||||
session.add_all(node_rows)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
for node in node_rows:
|
||||
await session.refresh(node)
|
||||
return self._session_dict(row), [self._node_dict(node) for node in node_rows]
|
||||
|
||||
async def list_nodes(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
|
||||
async with self._sf() as session:
|
||||
parent = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if parent is None or parent.user_id != user_id:
|
||||
return None
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(PositionRoundtableNodeRow)
|
||||
.where(PositionRoundtableNodeRow.session_id == session_id)
|
||||
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
|
||||
)
|
||||
).scalars().all()
|
||||
return [self._node_dict(row) for row in rows]
|
||||
|
||||
async def get_node(self, session_id: str, node_key: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
stmt = (
|
||||
select(PositionRoundtableNodeRow)
|
||||
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
|
||||
.where(
|
||||
PositionRoundtableNodeRow.session_id == session_id,
|
||||
PositionRoundtableNodeRow.node_key == node_key,
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
return self._node_dict(row) if row is not None else None
|
||||
|
||||
async def update_node(
|
||||
self,
|
||||
session_id: str,
|
||||
node_key: str,
|
||||
user_id: str,
|
||||
*,
|
||||
thread_id: str | None | object = _UNSET,
|
||||
status: str | object = _UNSET,
|
||||
latest_answer: str | None | object = _UNSET,
|
||||
artifact_manifest: list[dict[str, Any]] | object = _UNSET,
|
||||
upstream_revision: dict[str, Any] | object = _UNSET,
|
||||
last_rejection: dict[str, Any] | None | object = _UNSET,
|
||||
invalidated_by: str | None | object = _UNSET,
|
||||
increment_revision: bool = False,
|
||||
increment_rejection: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
stmt = (
|
||||
select(PositionRoundtableNodeRow)
|
||||
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
|
||||
.where(
|
||||
PositionRoundtableNodeRow.session_id == session_id,
|
||||
PositionRoundtableNodeRow.node_key == node_key,
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
if thread_id is not _UNSET:
|
||||
row.thread_id = thread_id or None
|
||||
if status is not _UNSET:
|
||||
row.status = str(status)
|
||||
if latest_answer is not _UNSET:
|
||||
row.latest_answer = latest_answer
|
||||
if artifact_manifest is not _UNSET:
|
||||
row.artifact_manifest = _dumps(artifact_manifest)
|
||||
if upstream_revision is not _UNSET:
|
||||
row.upstream_revision = _dumps(upstream_revision)
|
||||
if last_rejection is not _UNSET:
|
||||
row.last_rejection = _dumps(last_rejection)
|
||||
if invalidated_by is not _UNSET:
|
||||
row.invalidated_by = invalidated_by or None
|
||||
if increment_revision:
|
||||
row.revision += 1
|
||||
if increment_rejection:
|
||||
row.rejection_count += 1
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._node_dict(row)
|
||||
|
||||
async def reject_node(
|
||||
self,
|
||||
session_id: str,
|
||||
node_key: str,
|
||||
user_id: str,
|
||||
*,
|
||||
title: str,
|
||||
reason: str,
|
||||
rejected_by: str,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
|
||||
"""Reject one delivered node and invalidate every later stage.
|
||||
|
||||
Historical answers and artifact manifests are deliberately preserved.
|
||||
``invalidated_by`` gates downstream rework until the rejected upstream
|
||||
stage is completed again.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
async with self._sf() as session:
|
||||
parent = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if parent is None or parent.user_id != user_id:
|
||||
return None
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(PositionRoundtableNodeRow)
|
||||
.where(PositionRoundtableNodeRow.session_id == session_id)
|
||||
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
|
||||
)
|
||||
).scalars().all()
|
||||
target = next((row for row in rows if row.node_key == node_key), None)
|
||||
if target is None:
|
||||
return None
|
||||
|
||||
rejection = {
|
||||
"title": title,
|
||||
"reason": reason,
|
||||
"rejected_by": rejected_by,
|
||||
"rejected_at": now.isoformat(),
|
||||
}
|
||||
summary_snapshot = _loads(parent.summary_snapshot)
|
||||
if isinstance(summary_snapshot, dict) and summary_snapshot.get("status") == "done":
|
||||
summary_snapshot = {
|
||||
**summary_snapshot,
|
||||
"status": "stale",
|
||||
"invalidated_by": target.node_key,
|
||||
"invalidated_at": now.isoformat(),
|
||||
}
|
||||
parent.summary_snapshot = _dumps(summary_snapshot)
|
||||
action_plan_snapshot = _loads(parent.action_plan_snapshot)
|
||||
if isinstance(action_plan_snapshot, dict) and action_plan_snapshot.get("status") == "done":
|
||||
action_plan_snapshot = {
|
||||
**action_plan_snapshot,
|
||||
"status": "stale",
|
||||
"invalidated_by": target.node_key,
|
||||
"invalidated_at": now.isoformat(),
|
||||
}
|
||||
parent.action_plan_snapshot = _dumps(action_plan_snapshot)
|
||||
if parent.status == "completed":
|
||||
parent.status = "active"
|
||||
target.status = "rejected"
|
||||
target.last_rejection = _dumps(rejection)
|
||||
target.rejection_count += 1
|
||||
target.invalidated_by = None
|
||||
target.updated_at = now
|
||||
|
||||
for row in rows:
|
||||
if row.stage_index <= target.stage_index:
|
||||
continue
|
||||
if _has_material(row) or row.status in {"running", "done", "stale", "rejected", "error"}:
|
||||
row.status = "stale"
|
||||
else:
|
||||
row.status = "locked"
|
||||
row.invalidated_by = target.node_key
|
||||
row.updated_at = now
|
||||
|
||||
parent.updated_at = now
|
||||
await session.commit()
|
||||
await session.refresh(parent)
|
||||
for row in rows:
|
||||
await session.refresh(row)
|
||||
return self._session_dict(parent), [self._node_dict(row) for row in rows]
|
||||
|
||||
async def archive_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
return await self.update_session(session_id, user_id, status="archived")
|
||||
|
||||
async def list_results(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
|
||||
return await self.list_nodes(session_id, user_id)
|
||||
@@ -0,0 +1,604 @@
|
||||
"""Persistence coverage for the standalone position-collaboration workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.gateway import services as gateway_services
|
||||
from app.gateway.routers import intent as intent_router
|
||||
from app.gateway.routers import position_roundtable as position_roundtable_router
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'position-roundtable.db'}")
|
||||
async with engine.begin() as conn:
|
||||
# Register both tables with Base.metadata before creating the temporary DB.
|
||||
import deerflow.persistence.position_roundtable.model # noqa: F401
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
|
||||
created = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id="task-1",
|
||||
task_snapshot={"title": "测试任务"},
|
||||
)
|
||||
await store.update_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
intent_thread_id="intent-thread",
|
||||
intent_snapshot={"objective": "识别意图"},
|
||||
)
|
||||
chain = {
|
||||
"id": "chain-a",
|
||||
"seats": [
|
||||
{"agent_id": "agent-a", "position_id": "planning"},
|
||||
{"agent_id": "agent-b", "position_id": "solution"},
|
||||
],
|
||||
}
|
||||
nodes = [
|
||||
{
|
||||
"node_key": "stage-1-seat-1-agent-a",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-a",
|
||||
"position_id": "planning",
|
||||
"status": "ready",
|
||||
},
|
||||
{
|
||||
"node_key": "stage-2-seat-1-agent-b",
|
||||
"stage_index": 1,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-b",
|
||||
"position_id": "solution",
|
||||
"status": "locked",
|
||||
},
|
||||
]
|
||||
|
||||
activated, created_nodes = await store.activate_session(
|
||||
created["id"], "user-a", chain_id="chain-a", chain_snapshot=chain, nodes=nodes
|
||||
)
|
||||
assert activated["status"] == "active"
|
||||
assert activated["chain_snapshot"] == chain
|
||||
assert [node["status"] for node in created_nodes] == ["ready", "locked"]
|
||||
|
||||
# Repeated activate calls must return the first frozen snapshot/nodes unchanged.
|
||||
repeated, repeated_nodes = await store.activate_session(
|
||||
created["id"], "user-a", chain_id="other", chain_snapshot={"id": "other"}, nodes=[]
|
||||
)
|
||||
assert repeated["chain_id"] == "chain-a"
|
||||
assert [node["node_key"] for node in repeated_nodes] == [node["node_key"] for node in created_nodes]
|
||||
|
||||
|
||||
def test_task_external_context_reaches_agent_runtime_context():
|
||||
"""岗位会商的左侧任务快照不能在网关白名单中丢失。"""
|
||||
task_context = {
|
||||
"task": {
|
||||
"title": "重点区域态势研判与协同处置方案",
|
||||
"description": "梳理风险信号并形成协同处置建议",
|
||||
}
|
||||
}
|
||||
config: dict = {"configurable": {}, "context": {}}
|
||||
|
||||
gateway_services.merge_run_context_overrides(
|
||||
config,
|
||||
{"task_external_context": task_context},
|
||||
)
|
||||
|
||||
assert config["configurable"]["task_external_context"] == task_context
|
||||
assert config["context"]["task_external_context"] == task_context
|
||||
|
||||
|
||||
def test_position_intent_thread_metadata_keeps_parent_and_child_task_ids():
|
||||
metadata = intent_router._intent_thread_metadata(
|
||||
{
|
||||
"task": {"id": "task-100"},
|
||||
"current_position": {"id": "intelligence"},
|
||||
}
|
||||
)
|
||||
|
||||
assert metadata["taskId"] == "task-100"
|
||||
assert metadata["position_roundtable_task_id"] == "task-100"
|
||||
assert metadata["position_task_id"] == "task-100:intelligence"
|
||||
assert metadata["position_id"] == "intelligence"
|
||||
|
||||
|
||||
def test_chain_node_activation_rejects_missing_agent_ids():
|
||||
chain = {
|
||||
"id": "chain-a",
|
||||
"seats": [
|
||||
{"agent_id": "agent-a", "name": "有效智能体", "position_id": "planning"},
|
||||
{"agent_id": "missing-agent", "name": "缺失智能体", "position_id": "solution"},
|
||||
],
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
position_roundtable_router._chain_nodes(
|
||||
chain,
|
||||
available_agent_ids={"agent-a"},
|
||||
)
|
||||
|
||||
assert error.value.status_code == 400
|
||||
assert "缺失智能体" in str(error.value.detail)
|
||||
assert "缺少可用智能体" in str(error.value.detail)
|
||||
|
||||
|
||||
def test_intent_position_nodes_complete_from_confirmed_intent_and_unlock_next_stage():
|
||||
nodes = [
|
||||
{
|
||||
"node_key": "stage-1-seat-1-intent-agent",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "intent-agent",
|
||||
"position_id": "intelligence",
|
||||
"status": "ready",
|
||||
},
|
||||
{
|
||||
"node_key": "stage-2-seat-1-plan-agent",
|
||||
"stage_index": 1,
|
||||
"seat_index": 0,
|
||||
"agent_id": "plan-agent",
|
||||
"position_id": "planning",
|
||||
"status": "locked",
|
||||
},
|
||||
]
|
||||
|
||||
primed = position_roundtable_router._prime_intent_nodes(
|
||||
nodes,
|
||||
{"objective": "识别用户想要形成协同方案", "constraints": ["两天内完成"]},
|
||||
)
|
||||
|
||||
assert [node["status"] for node in primed] == ["done", "ready"]
|
||||
assert primed[0]["revision"] == 1
|
||||
assert "识别用户想要形成协同方案" in primed[0]["latest_answer"]
|
||||
|
||||
|
||||
def test_all_nodes_delivered_requires_markdown_for_non_intent_nodes():
|
||||
assert not position_roundtable_router._all_nodes_delivered(
|
||||
[
|
||||
{
|
||||
"position_id": "planning",
|
||||
"status": "done",
|
||||
"artifact_manifest": [{"name": "plain.txt", "path": "mnt/user-data/outputs/plain.txt"}],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert position_roundtable_router._all_nodes_delivered(
|
||||
[
|
||||
{"position_id": "intelligence", "status": "done", "artifact_manifest": []},
|
||||
{
|
||||
"position_id": "planning",
|
||||
"status": "done",
|
||||
"artifact_manifest": [{"name": "report.md", "path": "mnt/user-data/outputs/report.md"}],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
|
||||
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
|
||||
_, nodes = await store.activate_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
chain_id="chain-a",
|
||||
chain_snapshot={"id": "chain-a"},
|
||||
nodes=[
|
||||
{
|
||||
"node_key": "stage-1-seat-1-agent-a",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-a",
|
||||
"position_id": "planning",
|
||||
"status": "ready",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert len(nodes) == 1
|
||||
|
||||
updated = await store.update_node(
|
||||
created["id"],
|
||||
nodes[0]["node_key"],
|
||||
"user-a",
|
||||
thread_id="langgraph-thread-a",
|
||||
status="done",
|
||||
latest_answer="节点回答",
|
||||
artifact_manifest=[{"name": "产物.md"}],
|
||||
upstream_revision={"intent": 1},
|
||||
increment_revision=True,
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated["thread_id"] == "langgraph-thread-a"
|
||||
assert updated["latest_answer"] == "节点回答"
|
||||
assert updated["artifact_manifest"] == [{"name": "产物.md"}]
|
||||
assert updated["revision"] == 1
|
||||
assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_node_marks_current_and_invalidates_later_stages(store):
|
||||
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
|
||||
_, nodes = await store.activate_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
chain_id="chain-a",
|
||||
chain_snapshot={"id": "chain-a"},
|
||||
nodes=[
|
||||
{
|
||||
"node_key": "stage-1-seat-1-agent-a",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-a",
|
||||
"position_id": "planning",
|
||||
"status": "ready",
|
||||
},
|
||||
{
|
||||
"node_key": "stage-2-seat-1-agent-b",
|
||||
"stage_index": 1,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-b",
|
||||
"position_id": "solution",
|
||||
"status": "ready",
|
||||
},
|
||||
{
|
||||
"node_key": "stage-3-seat-1-agent-c",
|
||||
"stage_index": 2,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-c",
|
||||
"position_id": "command",
|
||||
"status": "ready",
|
||||
},
|
||||
],
|
||||
)
|
||||
await store.update_node(
|
||||
created["id"],
|
||||
nodes[0]["node_key"],
|
||||
"user-a",
|
||||
status="done",
|
||||
latest_answer="第一阶段产物",
|
||||
increment_revision=True,
|
||||
)
|
||||
await store.update_node(
|
||||
created["id"],
|
||||
nodes[1]["node_key"],
|
||||
"user-a",
|
||||
status="done",
|
||||
latest_answer="第二阶段产物",
|
||||
increment_revision=True,
|
||||
)
|
||||
|
||||
rejected = await store.reject_node(
|
||||
created["id"],
|
||||
nodes[0]["node_key"],
|
||||
"user-a",
|
||||
title="依据不足",
|
||||
reason="第一阶段判断依据需要补充。",
|
||||
rejected_by="user-a",
|
||||
)
|
||||
|
||||
assert rejected is not None
|
||||
_, rejected_nodes = rejected
|
||||
by_key = {node["node_key"]: node for node in rejected_nodes}
|
||||
first = by_key[nodes[0]["node_key"]]
|
||||
second = by_key[nodes[1]["node_key"]]
|
||||
third = by_key[nodes[2]["node_key"]]
|
||||
assert first["status"] == "rejected"
|
||||
assert first["rejection_count"] == 1
|
||||
assert first["last_rejection"]["title"] == "依据不足"
|
||||
assert first["invalidated_by"] is None
|
||||
assert second["status"] == "stale"
|
||||
assert second["latest_answer"] == "第二阶段产物"
|
||||
assert second["invalidated_by"] == nodes[0]["node_key"]
|
||||
assert third["status"] == "locked"
|
||||
assert third["invalidated_by"] == nodes[0]["node_key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store):
|
||||
created = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id=None,
|
||||
task_snapshot={
|
||||
"title": "测试任务",
|
||||
"description": "形成协同方案",
|
||||
"direction": "态势研判",
|
||||
},
|
||||
)
|
||||
await store.update_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
intent_snapshot={"objective": "明确处置意图", "constraints": ["时间紧"]},
|
||||
)
|
||||
chain = {
|
||||
"id": "chain-a",
|
||||
"seats": [
|
||||
{"agent_id": "agent-a", "name": "研判智能体", "position_id": "planning"},
|
||||
{"agent_id": "agent-b", "name": "处置智能体", "position_id": "solution"},
|
||||
],
|
||||
}
|
||||
_, nodes = await store.activate_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
chain_id="chain-a",
|
||||
chain_snapshot=chain,
|
||||
nodes=[
|
||||
{
|
||||
"node_key": "stage-1-seat-1-agent-a",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-a",
|
||||
"position_id": "planning",
|
||||
"status": "ready",
|
||||
},
|
||||
{
|
||||
"node_key": "stage-2-seat-1-agent-b",
|
||||
"stage_index": 1,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-b",
|
||||
"position_id": "solution",
|
||||
"status": "ready",
|
||||
},
|
||||
],
|
||||
)
|
||||
for node in nodes:
|
||||
await store.update_node(
|
||||
created["id"],
|
||||
node["node_key"],
|
||||
"user-a",
|
||||
status="done",
|
||||
latest_answer=f"{node['agent_id']} 产物",
|
||||
increment_revision=True,
|
||||
)
|
||||
session = await store.get_session(created["id"], "user-a")
|
||||
assert session is not None
|
||||
nodes = await store.list_nodes(created["id"], "user-a") or []
|
||||
revisions = position_roundtable_router._source_revisions(nodes)
|
||||
summary = position_roundtable_router._build_builtin_snapshot(
|
||||
kind="summary",
|
||||
thread_id="summary-thread",
|
||||
content="# 岗位会商方案总结报告\n\n研判智能体的关键结论。",
|
||||
artifacts=[
|
||||
{
|
||||
"name": "岗位会商方案总结报告.md",
|
||||
"path": "mnt/user-data/outputs/岗位会商方案总结报告.md",
|
||||
"mime_type": "text/markdown",
|
||||
"modified_at": "2026-07-21T00:00:00+00:00",
|
||||
"size_bytes": 100,
|
||||
}
|
||||
],
|
||||
source_revisions=revisions,
|
||||
model="test-model",
|
||||
)
|
||||
assert "岗位会商方案总结报告" in summary["content"]
|
||||
session = await store.update_session(
|
||||
created["id"], "user-a", summary_thread_id="summary-thread", summary_snapshot=summary
|
||||
)
|
||||
assert session is not None
|
||||
action_plan = position_roundtable_router._build_builtin_snapshot(
|
||||
kind="action_plan",
|
||||
thread_id="action-plan-thread",
|
||||
content="# 岗位会商行动规划\n\n## 子任务拆解",
|
||||
artifacts=[
|
||||
{
|
||||
"name": "行动规划报告.md",
|
||||
"path": "mnt/user-data/outputs/行动规划报告.md",
|
||||
"mime_type": "text/markdown",
|
||||
"modified_at": "2026-07-21T00:01:00+00:00",
|
||||
"size_bytes": 100,
|
||||
},
|
||||
{
|
||||
"name": "action-plan-subtasks.json",
|
||||
"path": "mnt/user-data/outputs/action-plan-subtasks.json",
|
||||
"mime_type": "application/json",
|
||||
"modified_at": "2026-07-21T00:01:00+00:00",
|
||||
"size_bytes": 200,
|
||||
},
|
||||
],
|
||||
source_revisions=revisions,
|
||||
model="test-model",
|
||||
)
|
||||
session = await store.update_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
action_plan_thread_id="action-plan-thread",
|
||||
action_plan_snapshot=action_plan,
|
||||
status="completed",
|
||||
)
|
||||
assert session is not None
|
||||
assert session["summary_snapshot"]["status"] == "done"
|
||||
assert session["action_plan_snapshot"]["status"] == "done"
|
||||
|
||||
rejected = await store.reject_node(
|
||||
created["id"],
|
||||
nodes[0]["node_key"],
|
||||
"user-a",
|
||||
title="重做第一阶段",
|
||||
reason="前序依据变化",
|
||||
rejected_by="user-a",
|
||||
)
|
||||
assert rejected is not None
|
||||
rejected_session, _ = rejected
|
||||
assert rejected_session["status"] == "active"
|
||||
assert rejected_session["summary_snapshot"]["status"] == "stale"
|
||||
assert rejected_session["summary_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
|
||||
assert rejected_session["action_plan_snapshot"]["status"] == "stale"
|
||||
assert rejected_session["action_plan_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
|
||||
|
||||
|
||||
def test_rejected_or_stale_node_requires_a_new_markdown_delivery() -> None:
|
||||
old = [
|
||||
{
|
||||
"name": "report.md",
|
||||
"path": "mnt/user-data/outputs/report.md",
|
||||
"modified_at": "2026-07-21T00:00:00+00:00",
|
||||
"size_bytes": 100,
|
||||
}
|
||||
]
|
||||
assert not position_roundtable_router._markdown_delivery_changed(old, old)
|
||||
assert position_roundtable_router._markdown_delivery_changed(
|
||||
old,
|
||||
[{**old[0], "modified_at": "2026-07-21T00:05:00+00:00", "size_bytes": 120}],
|
||||
)
|
||||
|
||||
|
||||
def test_activation_requires_a_structured_intent_snapshot() -> None:
|
||||
assert not position_roundtable_router._has_confirmed_intent({"objective": "只有目标"})
|
||||
assert not position_roundtable_router._has_confirmed_intent({"constraints": []})
|
||||
assert position_roundtable_router._has_confirmed_intent(
|
||||
{"objective": "明确任务目标", "constraints": []}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
|
||||
artifact = tmp_path / "handoff.md"
|
||||
artifact.write_text("上游产物正文:可供下一岗位直接参考。", encoding="utf-8")
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
class StubStore:
|
||||
async def get_session(self, session_id, user_id):
|
||||
assert (session_id, user_id) == ("session-a", "user-a")
|
||||
return {"task_snapshot": {"title": "测试任务"}, "intent_snapshot": {"goal": "方案分析"}}
|
||||
|
||||
async def list_nodes(self, session_id, user_id):
|
||||
assert (session_id, user_id) == ("session-a", "user-a")
|
||||
return [
|
||||
{
|
||||
"node_key": "stage-1-seat-1-agent-a",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-a",
|
||||
"position_id": "planning",
|
||||
"thread_id": "upstream-thread",
|
||||
"status": "done",
|
||||
"revision": 1,
|
||||
"latest_answer": "上游问答结果",
|
||||
"artifact_manifest": [
|
||||
{
|
||||
"name": "handoff.md",
|
||||
"path": "mnt/user-data/outputs/handoff.md",
|
||||
"mime_type": "text/markdown",
|
||||
},
|
||||
{
|
||||
"name": "outside.md",
|
||||
"path": "mnt/user-data/uploads/outside.md",
|
||||
"mime_type": "text/markdown",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"node_key": "stage-2-seat-1-agent-b",
|
||||
"stage_index": 1,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-b",
|
||||
"position_id": "solution",
|
||||
"thread_id": "current-thread",
|
||||
"status": "ready",
|
||||
"revision": 0,
|
||||
"latest_answer": None,
|
||||
"artifact_manifest": [],
|
||||
},
|
||||
{
|
||||
"node_key": "stage-1-seat-2-agent-stale",
|
||||
"stage_index": 0,
|
||||
"seat_index": 1,
|
||||
"agent_id": "agent-stale",
|
||||
"position_id": "planning",
|
||||
"thread_id": "stale-thread",
|
||||
"status": "stale",
|
||||
"revision": 1,
|
||||
"latest_answer": "失效上游产物",
|
||||
"artifact_manifest": [],
|
||||
},
|
||||
]
|
||||
|
||||
async def resolve(thread_id: str, path: str):
|
||||
calls.append((thread_id, path))
|
||||
assert thread_id == "upstream-thread"
|
||||
assert path == "mnt/user-data/outputs/handoff.md"
|
||||
return artifact
|
||||
|
||||
monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve)
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=StubStore())),
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
|
||||
)
|
||||
|
||||
context = await position_roundtable_router._handoff_context(
|
||||
request, "session-a", "stage-2-seat-1-agent-b"
|
||||
)
|
||||
|
||||
upstream = context["upstream_nodes"][0]
|
||||
assert len(context["upstream_nodes"]) == 1
|
||||
assert upstream["latest_answer"] == "上游问答结果"
|
||||
assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。"
|
||||
assert "content" not in upstream["artifacts"][1]
|
||||
assert calls == [("upstream-thread", "mnt/user-data/outputs/handoff.md")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archived_session_rejects_node_writes_and_keeps_history_readable():
|
||||
class ArchivedStore:
|
||||
async def get_session(self, session_id, user_id):
|
||||
assert (session_id, user_id) == ("session-a", "user-a")
|
||||
return {"id": session_id, "status": "archived"}
|
||||
|
||||
async def get_node(self, session_id, node_key, user_id):
|
||||
return {
|
||||
"node_key": node_key,
|
||||
"status": "ready",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=ArchivedStore())),
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
|
||||
)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await position_roundtable_router.bind_node_thread(
|
||||
request,
|
||||
"session-a",
|
||||
"stage-1-seat-1-agent-a",
|
||||
position_roundtable_router.NodeThreadRequest(thread_id="thread-a"),
|
||||
)
|
||||
assert error.value.status_code == 409
|
||||
assert "已归档" in str(error.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locked_node_cannot_bind_a_thread_before_its_stage_is_ready():
|
||||
class LockedStore:
|
||||
async def get_session(self, session_id, user_id):
|
||||
return {"id": session_id, "status": "active"}
|
||||
|
||||
async def get_node(self, session_id, node_key, user_id):
|
||||
return {
|
||||
"node_key": node_key,
|
||||
"status": "locked",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=LockedStore())),
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
|
||||
)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await position_roundtable_router.bind_node_thread(
|
||||
request,
|
||||
"session-a",
|
||||
"stage-2-seat-1-agent-b",
|
||||
position_roundtable_router.NodeThreadRequest(thread_id="thread-b"),
|
||||
)
|
||||
assert error.value.status_code == 409
|
||||
assert "未解锁" in str(error.value.detail)
|
||||
Reference in new issue
Block a user