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
+6908
-351
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>
|
||||
)}
|
||||
|
||||
{/* 席位模型覆盖(右侧操作区):空 = 跟随全局所选模型;选了则该席位作为子智能体
|
||||
执行时用此模型。技能重的席位配强模型、边角席位配快模型,配合技能白名单实现
|
||||
「强弱搭配」。放到右侧操作区,与名称/描述解耦,卡片更整洁。 */}
|
||||
|
||||
Reference in new issue
Block a user