From 85e119db088f69e80a6dceea3a76b9f2ede18c3e Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 17:49:25 +0800 Subject: [PATCH 1/7] feat(position-roundtable): add standalone position collaboration --- ...position-roundtable-implementation-plan.md | 508 ++++++++ .../src/position-roundtable/api/sessions.ts | 264 ++++ .../components/PositionNodeChat.tsx | 285 +++++ .../lib/mock-task-source.ts | 72 ++ .../src/position-roundtable/lib/positions.ts | 39 + .../pages/PositionRoundtablePage.tsx | 1069 ++++++++++++++--- .../components/Step1Panel.tsx | 5 +- .../hooks/useStep1Intent.ts | 17 + .../roundtable-planning/lib/business-chain.ts | 5 + .../pages/BusinessChainEditorPage.tsx | 90 +- offline-backend-20260512/backend/CLAUDE.md | 20 + offline-backend-20260512/backend/README.md | 2 + .../backend/app/gateway/app.py | 14 +- .../backend/app/gateway/deps.py | 12 +- .../gateway/routers/position_roundtable.py | 563 +++++++++ .../app/gateway/routers/roundtable_chains.py | 3 + ...0260721_01_position_roundtable_sessions.py | 87 ++ .../deerflow/persistence/models/__init__.py | 6 + .../position_roundtable/__init__.py | 22 + .../persistence/position_roundtable/model.py | 83 ++ .../persistence/position_roundtable/sql.py | 279 +++++ .../test_position_roundtable_sessions.py | 250 ++++ 22 files changed, 3494 insertions(+), 201 deletions(-) create mode 100644 frontend-web/docs/position-roundtable-implementation-plan.md create mode 100644 frontend-web/src/position-roundtable/api/sessions.ts create mode 100644 frontend-web/src/position-roundtable/components/PositionNodeChat.tsx create mode 100644 frontend-web/src/position-roundtable/lib/mock-task-source.ts create mode 100644 frontend-web/src/position-roundtable/lib/positions.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_01_position_roundtable_sessions.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py create mode 100644 offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py diff --git a/frontend-web/docs/position-roundtable-implementation-plan.md b/frontend-web/docs/position-roundtable-implementation-plan.md new file mode 100644 index 00000000..78aeb5c8 --- /dev/null +++ b/frontend-web/docs/position-roundtable-implementation-plan.md @@ -0,0 +1,508 @@ +# 岗位智能体协同页面 · 分步实现计划 + +> 页面:`/page/strategy/qa/position-roundtable` +> +> 核心模式:**不使用总控智能体**。情报分析岗先完成意图识别;其他岗位直接与业务链中分配给本岗位的智能体问答。每个节点独立保存对话和产物,并在问答时自动获得上游节点的有效结果。 + +--- + +## 0. 已确认的产品决策 + +- 新功能使用独立路由、独立业务状态和独立后端存储,不修改原多智能体会商页面的运行逻辑。 +- 岗位先使用前端固定枚举;下拉框、选择器等表单控件使用 `tdesign-react`。 +- **情报分析岗**负责任务意图识别,复用现有 `useStep1Intent`、`Step1Panel` 和意图接口。 +- 其他岗位不经过总控派活,直接使用节点配置的 `agent_id` 调用对应智能体。 +- 不调用原会商的 `multi-agent/init`、总控循环、后台圆桌作业和自动收口流程。 +- 每个业务链节点对应一条独立智能体对话线程;同一节点可以在该线程内多轮追问。 +- 当前岗位只能向分配给本岗位的节点提问;其他节点允许查看,但输入框只读。 +- 节点问答时自动获得任务、意图和已完成上游节点的问答结果与产物。 +- 左侧流程图从上到下排列,节点可点击,画布支持鼠标拖动;连接线保留但不显示依赖文字。 +- 右侧保留“智能体配置 / 分析结果”两个标签页。 + +### 0.1 默认实现口径 + +以下口径在产品未另行调整前作为实现默认值: + +- 业务链上一阶段的所有节点至少产生一次有效回答后,下一阶段节点进入 `ready` 状态。 +- 同一阶段的节点互不依赖,可以分别问答。 +- 上游节点产生新回答或新产物后,已经执行过的下游节点标记为 `stale`,但不删除历史。 +- “分析结果”同时收录智能体最终回答和文件产物;文件产物优先展示。 +- 第一版岗位下拉只是页面工作视角,不代表真实用户权限;后续接入用户岗位关系后再启用服务端岗位授权。 +- 一个外部任务允许创建多个岗位协同会话;页面默认打开最近更新的一条。 + +### 0.2 待业务最终确认 + +- 固定岗位的正式编码和名称清单。 +- 情报分析岗完成意图识别后,是否还允许承担业务链节点问答。 +- 下游解锁采用“上游首次有效回答”还是增加人工“提交完成”动作。 +- 新任务接口提供的字段、外部任务 ID 和页面进入方式。 +- 岗位是否最终与登录用户绑定,以及一名用户是否允许属于多个岗位。 + +--- + +## 1. 目标与非目标 + +### 1.1 本期目标 + +- 为业务链席位增加岗位分配能力,且只在从岗位协同页面进入配置时显示。 +- 为岗位协同任务提供可刷新、可恢复、可跨页面重新打开的持久化会话。 +- 按“任务 → 意图 → 业务链阶段 → 节点智能体”组织对话和产物。 +- 当前岗位仅展示和调用本岗位被分配的智能体。 +- 点击流程图节点可查看该节点完整问答和产物。 +- 下游智能体自动获得上游结果,不要求用户手工复制粘贴。 +- 保持现有多智能体会商、任务深链、普通智能体聊天行为不变。 + +### 1.2 本期非目标 + +- 不保留总控智能体,不做总控派活、共识判断或自动多轮研讨。 +- 不把岗位协同记录写入 `roundtable_drafts` 或 `roundtable_jobs`。 +- 不自动生成最终综合报告;后续可增加按需调用总结智能体的“生成汇总”功能。 +- 第一版不实现复杂条件分支、人工改线或动态跳转,只遵循现有业务链阶段顺序。 +- 第一版不实现完整组织权限系统;真实岗位授权作为独立阶段接入。 + +--- + +## 2. 目标业务流程 + +1. 页面根据外部任务参数创建或恢复一条岗位协同会话。 +2. 情报分析岗查看任务信息并选择业务链。 +3. 情报分析岗与意图识别智能体问答,确认结构化任务意图。 +4. 系统保存任务、意图和业务链快照,初始化所有业务链节点状态。 +5. 用户切换到其他岗位,只看到分配给当前岗位的可问答智能体。 +6. 用户点击流程图节点: + - 本岗位节点:加载或创建该节点的智能体线程,允许问答; + - 其他岗位节点:加载已有线程和产物,只读查看; + - 尚未解锁节点:展示依赖提示,不允许发送。 +7. 每次发送问题前,系统构建隐藏上下文并注入当前智能体。 +8. 智能体回答完成后保存节点最新回答、产物清单和版本号,并重新计算下游状态。 +9. 右侧“分析结果”按时间倒序聚合全部节点回答和产物。 + +--- + +## 3. 总体技术方案 + +### 3.1 复用范围 + +| 能力 | 方案 | +|------|------| +| 意图识别 | 复用 `useStep1Intent`、`Step1Panel`、`/api/intent/*` | +| 智能体问答 | 复用 `useThreadStream` 和现有 Agent Chat 消息/输入组件 | +| 消息存储 | 复用 LangGraph thread/checkpoint,不新增消息表 | +| 文件产物 | 复用 `/api/threads/{thread_id}/artifacts` | +| 隐藏上下文 | 复用 `task_external_context` 和 `TaskExternalContextMiddleware` | +| 智能体详情 | 复用 `AgentDetailPanel` | +| 业务链 | 扩展现有 `roundtable_chains.seats` JSON | +| 页面业务状态 | 新增岗位协同 Session/Node 存储 | + +### 3.2 明确不复用的圆桌能力 + +- `initMultiAgent` +- `useStep2Orchestration` +- `roundtable-coordinator` +- `roundtable_jobs` +- 圆桌共识百分比与最大循环轮次 +- 自动 Step 3 收口和报告绘制 + +### 3.3 目录建议 + +```text +frontend-web/src/position-roundtable/ +├── api/ +│ ├── sessions.ts +│ └── handoff.ts +├── components/ +│ ├── PositionTaskPanel.tsx +│ ├── PositionChainFlow.tsx +│ ├── PositionAgentChat.tsx +│ ├── PositionAgentList.tsx +│ └── PositionResults.tsx +├── hooks/ +│ ├── usePositionSession.ts +│ ├── usePositionNodeChat.ts +│ └── usePositionHandoff.ts +├── lib/ +│ ├── positions.ts +│ ├── chain-nodes.ts +│ └── types.ts +└── pages/ + └── PositionRoundtablePage.tsx +``` + +后端建议新增: + +```text +offline-backend-20260512/backend/ +├── app/gateway/routers/position_roundtable.py +└── packages/harness/deerflow/persistence/ + ├── position_roundtable_sessions/ + └── position_roundtable_nodes/ +``` + +--- + +## 4. 数据模型 + +### 4.1 业务链席位扩展 + +在现有 `ChainSeat` 中增加: + +```ts +interface ChainSeat { + agent_id: string; + name: string; + description?: string; + position_id?: string | null; +} +``` + +后端 `ChainSeatSchema` 同步增加可选 `position_id`。席位本身存储在 JSON 中,因此无需新增数据库列,但必须确保: + +- 创建、读取、更新接口都保留该字段; +- 普通编辑模式虽然不显示岗位控件,保存时也不能删除已有岗位字段; +- 老链条没有该字段时按“未配置岗位”处理; +- 原圆桌编排忽略该字段,行为保持不变。 + +### 4.2 岗位协同会话表 + +`position_roundtable_sessions` 建议字段: + +| 字段 | 含义 | +|------|------| +| `id` | 岗位协同会话 ID | +| `external_task_id` | 新任务系统提供的任务 ID,可空 | +| `task_snapshot` | 任务信息 JSON 快照 | +| `chain_id` | 来源业务链 ID | +| `chain_snapshot` | 启动时业务链完整快照 | +| `intent_thread_id` | 意图识别线程 ID | +| `intent_snapshot` | `useStep1Intent` 快照及结构化意图 | +| `status` | `intent_pending / active / completed / archived` | +| `created_by` | 创建人 | +| `created_at` | 创建时间 | +| `updated_at` | 更新时间 | + +### 4.3 岗位协同节点表 + +`position_roundtable_nodes` 建议字段: + +| 字段 | 含义 | +|------|------| +| `id` | 节点记录 ID | +| `session_id` | 所属岗位协同会话 | +| `node_key` | 会话内稳定节点键 | +| `stage_index` | 所属业务链阶段 | +| `seat_index` | 阶段内顺序 | +| `agent_id` | 对应智能体 | +| `position_id` | 分配岗位 | +| `thread_id` | 对应智能体聊天线程,可空 | +| `status` | `locked / ready / running / done / stale / error` | +| `latest_answer` | 最新有效回答快照 | +| `artifact_manifest` | 当前产物清单 JSON | +| `revision` | 本节点结果版本号 | +| `upstream_revision` | 最近一次问答使用的上游版本摘要 | +| `created_at` | 创建时间 | +| `updated_at` | 更新时间 | + +唯一约束:`(session_id, node_key)`。 + +### 4.4 Thread metadata + +节点线程创建后写入: + +```json +{ + "thread_type": "position_roundtable", + "position_session_id": "session-id", + "position_node_id": "node-id", + "position_id": "position-code", + "chain_id": "chain-id", + "agent_id": "agent-id" +} +``` + +Thread 继续保存真实消息;节点表只保存业务索引、状态和便于列表展示的快照。 + +--- + +## 5. API 规划 + +建议前缀:`/api/position-roundtable`。 + +### 5.1 会话接口 + +| 方法 | 路径 | 用途 | +|------|------|------| +| `POST` | `/sessions` | 根据任务创建岗位协同会话 | +| `GET` | `/sessions/{id}` | 加载会话、意图和全部节点状态 | +| `GET` | `/sessions?external_task_id=` | 查询任务下的会话历史 | +| `PUT` | `/sessions/{id}/task` | 更新尚未启动的任务快照 | +| `PUT` | `/sessions/{id}/intent` | 保存意图线程和意图快照 | +| `POST` | `/sessions/{id}/activate` | 冻结链条快照并初始化节点 | +| `POST` | `/sessions/{id}/archive` | 归档会话,不删除线程和产物 | + +### 5.2 节点接口 + +| 方法 | 路径 | 用途 | +|------|------|------| +| `GET` | `/sessions/{id}/nodes/{node_key}` | 获取节点、线程、状态和产物 | +| `POST` | `/sessions/{id}/nodes/{node_key}/thread` | 幂等创建节点智能体线程 | +| `POST` | `/sessions/{id}/nodes/{node_key}/context` | 构建本轮隐藏上游上下文 | +| `POST` | `/sessions/{id}/nodes/{node_key}/complete-turn` | 回答结束后同步回答、产物和版本 | +| `GET` | `/sessions/{id}/results` | 聚合全部岗位分析结果 | + +### 5.3 幂等与并发要求 + +- 节点线程接口必须幂等,同一节点并发请求只能创建一个有效 thread。 +- `complete-turn` 使用节点 `revision` 做乐观锁,避免旧请求覆盖新结果。 +- 会话激活后不允许直接替换链条;需要重新建会话或显式“基于新链条重建”。 +- 删除业务链不能删除已有会话中的链条快照。 + +--- + +## 6. 上游结果交接规则 + +### 6.1 上游节点计算 + +- 线性链:当前节点之前的所有已完成节点均为上游。 +- 分层链:只读取当前阶段之前的已完成阶段;同阶段节点不互相引用。 +- 注入顺序按 `stage_index`、`seat_index` 排列,确保每次构建稳定。 + +### 6.2 隐藏上下文结构 + +每次发送问题前,通过 `task_external_context` 注入: + +```json +{ + "kind": "position_roundtable_handoff", + "task": {}, + "intent": {}, + "current_node": {}, + "upstream_nodes": [ + { + "agent_id": "...", + "position_id": "...", + "latest_answer": "...", + "artifacts": [] + } + ], + "upstream_revision": "..." +} +``` + +### 6.3 产物传递 + +- Markdown、TXT、JSON、CSV、YAML 等文本产物:读取正文并按总字符上限注入。 +- PDF、Word、Excel、图片等非纯文本产物:复制到当前节点线程的只读上游资料目录,并在上下文中提供可读虚拟路径。 +- 单文件和总上下文均设置大小限制;发生截断时在上下文中标记 `truncated=true`。 +- 当前节点生成的文件仍写入自己的 `/mnt/user-data/outputs/`,不写回上游线程。 + +### 6.4 上游更新传播 + +1. 节点完成一次回答后 `revision + 1`。 +2. 系统重新计算所有下游节点的上游版本摘要。 +3. 已问答下游节点的摘要不一致时,将状态改为 `stale`。 +4. 用户再次向该节点提问后,使用最新上下文并把状态恢复为 `done`。 + +--- + +## 7. 分阶段实施计划 + +### 阶段 0:边界固定与基线测试 + +目标:明确新旧功能边界,给后续改造建立回归保护。 + +- [x] 固定岗位枚举及意图岗位编码(当前为 `intelligence` / 情报分析岗)。 +- [x] 固定新任务数据适配接口:当前使用可替换的模拟任务源,后续只需替换 `mock-task-source.ts`。 +- [ ] 为原圆桌路由、岗位协同路由分别增加基本渲染测试。 +- [ ] 记录原圆桌业务链创建、编辑、运行的基线行为。 +- [ ] 确认岗位协同模块不导入 `useStep2Orchestration` 或 `initMultiAgent`。 + +完成标准:原会商页面无行为变化;岗位协同页面仍可独立打开。 + +### 阶段 1:业务链岗位配置 + +目标:业务链节点能够持久化岗位,但普通业务链编辑体验不受影响。 + +- [x] `ChainSeat`、前端 API 映射增加 `position_id`。 +- [x] 后端 `ChainSeatSchema` 增加 `position_id` 并补充长度校验。 +- [x] 业务链编辑器识别 `mode=position`。 +- [x] 岗位模式下为每个节点增加 TDesign 岗位选择器。 +- [ ] 增加“未配置岗位”提示、岗位图例和保存校验。 +- [x] 普通模式隐藏岗位控件,但完整保留原岗位字段。 +- [x] 岗位协同页面增加“配置岗位”入口和返回参数。 +- [ ] 增加老链条、普通保存、岗位保存的前后端测试。 + +完成标准:从岗位页面进入时可以配置岗位;从原入口进入时看不到岗位控件;两种入口交替保存不丢字段。 + +### 阶段 2:岗位协同持久化底座 + +目标:任务、意图、链条快照和节点状态可以跨刷新恢复。 + +- [ ] 新增 Session/Node ORM、Repository 和数据库迁移。 +- [ ] 新增会话创建、查询、更新意图、激活和归档接口。 +- [ ] 激活时校验链条、岗位配置和意图结果。 +- [ ] 激活时根据链条快照一次性生成全部 Node 记录。 +- [ ] 新增前端 `sessions.ts` API 和 `usePositionSession`。 +- [ ] 用服务端 Session 替换 `POSITION_TASK_DRAFT_KEY` 主路径。 +- [ ] 页面刷新后恢复任务、意图、选中链条、节点状态和当前会话。 + +完成标准:刷新浏览器后不丢任务和意图;业务链后续编辑不改变已启动会话。 + +### 阶段 3:情报分析岗意图流程 + +目标:把情报分析岗固定为岗位协同的入口阶段。 + +- [ ] 将当前“组织筹划岗负责意图”调整为“情报分析岗负责意图”。 +- [ ] 仅意图岗位渲染 `Step1Panel` 和意图输入能力。 +- [ ] 将任务信息自动作为首轮隐藏背景或开场消息提供给意图智能体。 +- [ ] 意图变化时自动保存 `intent_snapshot`。 +- [ ] 增加“确认意图并启动业务链”动作。 +- [ ] 启动前校验业务链已选、节点岗位已配置、意图已完成。 +- [ ] 其他岗位在未启动时显示只读等待状态。 + +完成标准:情报分析岗确认意图后成功生成节点;其他岗位不能修改意图。 + +### 阶段 4:岗位直连智能体问答 + +目标:去除总控,当前岗位直接与自己节点的智能体多轮问答。 + +- [ ] 抽取或封装可嵌入的 Agent Chat 主区,复用现有消息列表和输入框。 +- [ ] 右侧智能体列表改为当前链条、当前岗位的交集。 +- [ ] 点击智能体或流程节点时切换 `activeNodeKey`。 +- [ ] 节点首次发送前幂等创建 thread,`assistantId = node.agent_id`。 +- [ ] 写入岗位协同 thread metadata。 +- [ ] 本岗位且状态为 `ready/running/done/stale` 时允许输入。 +- [ ] 其他岗位节点和 `locked` 节点使用同一消息列表只读展示。 +- [ ] 移除流程图“总控协调”节点,首部展示“情报分析岗 · 意图识别”。 + +完成标准:页面不调用任何总控接口;不同节点对话互不串线;岗位不能向未分配节点发送问题。 + +### 阶段 5:节点查看与流程状态 + +目标:流程图成为对话、产物和状态的统一导航。 + +- [ ] 节点增加 `locked/ready/running/done/stale/error` 可视状态。 +- [ ] 当前节点增加选中态,并与中间聊天和右侧智能体详情同步。 +- [ ] 点击任意已有节点加载完整消息历史。 +- [ ] 节点运行时防止重复发送并支持停止当前生成。 +- [ ] 计算上一阶段完成情况并自动解锁下一阶段。 +- [ ] 画布拖动时阻止节点误点击,双击空白区域复位。 +- [ ] 保持左侧滚动条、固定画布高度和父容器裁剪。 + +完成标准:用户能从流程图定位任意节点,并准确区分可问答、只读、待上游和结果过期状态。 + +### 阶段 6:上游问答与产物自动交接 + +目标:下游智能体真正获得上游信息,而不只是页面能够查看。 + +- [ ] 新增节点上下文构建接口。 +- [ ] 读取上游节点最新有效回答和结构化意图。 +- [ ] 读取上游文本产物并执行字符数限制与截断标记。 +- [ ] 为非文本产物实现安全复制或只读挂载。 +- [ ] 发送消息时通过 `task_external_context` 注入上下文。 +- [ ] 回答结束后同步 `latest_answer`、产物清单和 `revision`。 +- [ ] 上游更新时传播 `stale` 状态。 +- [ ] 增加上下文顺序、截断、文件隔离和版本传播测试。 + +完成标准:下游回答能够引用上游真实结论;不需要用户复制粘贴;不同 thread 的文件不串写。 + +### 阶段 7:分析结果聚合 + +目标:右侧结果页展示全部岗位、节点的真实结果。 + +- [ ] 新增 Session 结果聚合接口。 +- [ ] 回答结论生成“问答结果”记录。 +- [ ] 每个实际文件生成独立产物记录。 +- [ ] 卡片显示智能体名称、产物名称、岗位、生成时间和状态。 +- [ ] 支持按岗位、节点、产物类型筛选。 +- [ ] 点击结果跳转并高亮对应流程节点。 +- [ ] 文本产物复用现有预览组件,其他文件复用下载地址。 +- [ ] `stale` 结果显示“上游已更新”提示,不自动隐藏旧结果。 + +完成标准:结果数量、节点文件清单和实际线程产物一致;点击结果能回到对应对话。 + +### 阶段 8:嵌入、权限与恢复 + +目标:满足外部系统嵌入及多人岗位使用场景。 + +- [ ] 支持从 URL/外部任务接口恢复 `external_task_id` 和 Session。 +- [ ] 复用现有 `embed=1` 模式隐藏全局侧边栏和导航外壳。 +- [ ] 深浅主题下核对左、中、右三栏背景和 TDesign 控件。 +- [ ] 明确岗位下拉在嵌入系统中的来源和默认值。 +- [ ] 若接入真实用户岗位,新增 Session 成员/岗位授权模型。 +- [ ] 服务端校验当前用户是否能向目标节点发送问题。 +- [ ] 定义会话归档、任务关闭和只读历史规则。 + +完成标准:嵌入刷新不丢会话;不同岗位用户只能写自己的节点,同时可以按规则查看上游节点。 + +### 阶段 9:测试、回归与发布 + +目标:新功能可上线,原功能零回归。 + +- [ ] 前端类型检查、Chrome 109 CSS 兼容检查和生产构建通过。 +- [ ] 后端 Repository、Router、上下文构建和并发创建测试通过。 +- [ ] 覆盖线性链、分层链、同阶段并行节点、无岗位配置和智能体被删除场景。 +- [ ] 覆盖刷新恢复、重复点击、新旧上游版本、文件过大和产物为空场景。 +- [ ] 回归原业务链编辑、原多智能体会商和普通 Agent Chat。 +- [ ] 使用真实数据完成浅色、深色、嵌入三种视图的截图对比。 +- [ ] 编写接口说明、部署迁移说明和回滚说明。 + +完成标准:自动化测试通过;真实链条端到端走通;原页面和原接口行为不变。 + +--- + +## 8. 建议的提交拆分 + +每个提交保持可独立审查和回滚: + +1. `feat(chain): add optional position assignment to seats` +2. `feat(position-roundtable): add session persistence` +3. `feat(position-roundtable): persist intent workflow` +4. `feat(position-roundtable): add direct agent node chats` +5. `feat(position-roundtable): add interactive chain navigation` +6. `feat(position-roundtable): inject upstream handoff context` +7. `feat(position-roundtable): aggregate node artifacts` +8. `test(position-roundtable): cover workflow and regressions` + +--- + +## 9. 验收清单 + +- [ ] 岗位协同页面与原会商页面使用不同路由和不同业务存储。 +- [ ] 页面运行过程中没有创建或调用总控智能体。 +- [ ] 情报分析岗能够完成并持久化意图识别。 +- [ ] 业务链岗位配置只在岗位模式入口显示。 +- [ ] 当前岗位的智能体列表只包含被分配的链条节点。 +- [ ] 非本岗位节点可查看但不能发送消息。 +- [ ] 点击节点可以恢复对应的完整聊天记录和文件产物。 +- [ ] 下游智能体能够获得上游问答结果和产物内容。 +- [ ] 上游更新后下游结果能够提示过期。 +- [ ] 刷新、重新进入和嵌入模式下数据不丢失。 +- [ ] 原业务链配置、原多智能体会商和普通智能体问答均无回归。 + +--- + +## 10. 第一轮推荐实施范围 + +第一轮建议完成阶段 0~4,形成可实际操作的最小闭环: + +```text +岗位配置 → 创建会话 → 情报分析岗确认意图 → 生成业务链节点 +→ 切换岗位 → 点击本岗位节点 → 直接调用对应智能体问答 → 刷新恢复 +``` + +第二轮完成阶段 5~7,把流程图查看、上游自动交接和结果聚合补齐;第三轮处理真实岗位权限、外部任务接口和上线回归。 + +## 11. 当前实现状态(2026-07-21) + +已完成的首个可运行闭环: + +- 业务链席位已支持可选 `position_id`;只有从岗位协同页面进入业务链编辑器时才显示 TDesign 岗位选择器,普通会商配置会完整保留该字段且不显示该控件。未配置岗位的席位会在业务链选择区标明,并在启动前阻止进入节点执行。 +- 新任务入口已先以 `mock-task-source.ts` 提供 3 条模拟任务;页面通过 `taskId` 识别任务、自动恢复该任务最近一条未归档会话,支持“新建会话”和会话归档。正式任务接口确认后只替换这一数据源即可。 +- 新增独立的 `position_roundtable_sessions` / `position_roundtable_nodes` 持久化、迁移和 `/api/position-roundtable` 接口;它不使用原会商的草稿、总控、作业或自动收口流程。 +- 情报分析岗复用既有意图识别对话。确认意图后会冻结业务链、创建节点、将会话 ID 写入新页面 URL,刷新可恢复节点与其已绑定的标准聊天线程。 +- 业务链节点直接调用其配置的 `agent_id`,复用原消息列表和输入框。节点问答会注入任务、意图、已完成上游的回答和产物引用;同阶段全部完成后才解锁下一阶段,上游重答会把下游标记为 `stale`。 +- 节点交接接口会额外读取上游节点自身线程里的文本产物正文(Markdown、文本、JSON、CSV 等),按总量和单文件长度截断后注入下游智能体;二进制产物仍仅传递安全引用,且不会跨线程写入或共享工作目录。 +- 左侧流程图、右侧岗位智能体列表和结果卡片均以当前会话节点为准;跨岗位和未解锁节点保持只读。分析结果会把每条问答、每个实际文件分别列卡,支持按岗位、节点、产物类型筛选,并对 `stale` 结果标明“上游已更新”。 +- 选中流程节点或分析结果卡片后,中间对话区会同时展示该节点的产物清单;点击文件复用既有预览组件,读取的始终是该节点绑定线程下的产物。归档会话保持完整可读,但前后端均拒绝继续写入、绑定节点或生成上下文。 + +仍需在产品/组织接口确定后替换:新任务系统的正式字段与进入方式、真实用户岗位授权、上游非文本产物的安全挂载或复制,以及使用真实登录数据的完整端到端回归。 diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts new file mode 100644 index 00000000..793eb5b0 --- /dev/null +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -0,0 +1,264 @@ +/** 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" + | "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>; + revision: number; + upstreamRevision: Record | null; + createdAt?: string; + updatedAt?: string; +} + +export interface PositionRoundtableSession { + id: string; + externalTaskId: string | null; + taskSnapshot: Record; + chainId: string | null; + chainSnapshot: Record | null; + intentThreadId: string | null; + intentSnapshot: Record | 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>; + revision?: number; + upstream_revision?: Record | null; + created_at?: string; + updated_at?: string; +} + +interface ServerSession { + id: string; + external_task_id: string | null; + task_snapshot?: Record; + chain_id: string | null; + chain_snapshot?: Record | null; + intent_thread_id: string | null; + intent_snapshot?: Record | null; + status: PositionRoundtableSession["status"]; + created_at?: string; + updated_at?: string; + nodes?: ServerNode[]; +} + +const BASE = () => `${getBackendBaseURL()}/api/position-roundtable`; + +async function readJson(response: Response, label: string): Promise { + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`${label} failed (${response.status}): ${detail}`); + } + return response.json() as Promise; +} + +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, + 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, + status: session.status, + createdAt: session.created_at, + updatedAt: session.updated_at, + nodes: session.nodes?.map(toNode), + }; +} + +export async function listPositionSessions(externalTaskId?: string): Promise { + const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : ""; + const result = await readJson( + await apiFetch(`${BASE()}/sessions${params}`), + "list position sessions", + ); + return result.map(toSession); +} + +export async function createPositionSession(input: { + externalTaskId?: string; + taskSnapshot: Record; +}): Promise { + const result = await readJson( + 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 { + const result = await readJson( + 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 { + const result = await readJson( + 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 { + const result = await readJson( + 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 { + const result = await readJson( + 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 { + const result = await readJson( + 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> { + return readJson>( + 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>; + upstreamRevision?: Record | null; + }, +): Promise { + const result = await readJson( + 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); +} diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx new file mode 100644 index 00000000..306dd84a --- /dev/null +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Bot, FileText, LockKeyhole, Loader2 } 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 { 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 { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; + +interface PositionNodeChatProps { + sessionId: string; + chainId: string | null; + node: PositionRoundtableNode; + currentPositionId: string; + sessionStatus: "intent_pending" | "active" | "completed" | "archived"; + onNodeUpdate: (node: PositionRoundtableNode) => void; +} + +interface NodeArtifact { + name: string; + path: string; + mimeType: string | null; +} + +function readNodeArtifacts(manifest: Array>): 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 }]; + }); +} + +/** + * 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, + chainId, + node, + currentPositionId, + sessionStatus, + onNodeUpdate, +}: PositionNodeChatProps) { + const [settings, setSettings] = useLocalSettings(); + const [boundThreadId, setBoundThreadId] = useState(node.threadId); + const boundThreadIdRef = useRef(node.threadId); + const draftThreadIdRef = useRef(node.threadId ?? uuid()); + const upstreamRevisionRef = useRef | null>(null); + const [previewArtifact, setPreviewArtifact] = useState(null); + const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]); + const canWrite = + sessionStatus !== "archived" && + node.positionId === currentPositionId && + node.status !== "locked" && + node.status !== "error"; + + 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] = useThreadStream({ + threadId: boundThreadId, + assistantId: node.agentId, + context: { ...settings.context, agent_id: node.agentId }, + threadMetadata: { + thread_type: "position_roundtable", + position_session_id: sessionId, + position_node_id: node.id, + 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> = []; + 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, + })); + } 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); + } catch (error) { + toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`); + } + })(); + }, + }); + + const handleSubmit = useCallback( + async (message: PromptInputMessage) => { + if (!canWrite) { + toast.info( + sessionStatus === "archived" + ? "该会话已归档,仅可查看历史记录" + : node.status === "locked" + ? "该节点尚未解锁" + : "当前岗位只能查看该节点对话", + ); + return; + } + try { + const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey); + const upstreamRevision = taskExternalContext.upstream_revision; + upstreamRevisionRef.current = + upstreamRevision && typeof upstreamRevision === "object" && !Array.isArray(upstreamRevision) + ? (upstreamRevision as Record) + : 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, sendMessage, sessionId, sessionStatus], + ); + + const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready"; + const displayThreadId = boundThreadId ?? `position-node-${node.id}`; + const readOnlyHint = + sessionStatus === "archived" + ? "该会话已归档,仅可查看历史对话和产物" + : node.status === "locked" + ? "等待上游岗位完成后解锁" + : node.positionId !== currentPositionId + ? "当前岗位仅可查看该节点的对话和产物" + : ""; + + return ( + +
+
+ + {node.status === "locked" ? : } + +
+

+ {node.agentId} +

+

+ {readOnlyHint || `节点状态:${node.status}`} +

+
+ {thread.isLoading && } +
+ + {nodeArtifacts.length > 0 && ( +
+
+ + 节点产物 + + {nodeArtifacts.length} + +
+
+ {nodeArtifacts.map((artifact) => ( + + ))} +
+
+ )} + + {boundThreadId ? ( + void handleSubmit({ text, files: [] })} + /> + ) : ( +
+ + + +

+ {canWrite ? "向该岗位智能体发起第一次问答" : "该节点尚无可查看的对话"} +

+

+ {canWrite + ? "系统会自动传入任务意图和已完成上游节点的问答结果。" + : readOnlyHint || "请选择已启动的岗位节点。"} +

+
+ )} + +
+ setSettings("context", context)} + onSubmit={(message) => void handleSubmit(message)} + onStop={() => void thread.stop()} + /> +
+ {previewArtifact && boundThreadId && ( + setPreviewArtifact(null)} + /> + )} +
+
+ ); +} diff --git a/frontend-web/src/position-roundtable/lib/mock-task-source.ts b/frontend-web/src/position-roundtable/lib/mock-task-source.ts new file mode 100644 index 00000000..de6fb023 --- /dev/null +++ b/frontend-web/src/position-roundtable/lib/mock-task-source.ts @@ -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 | 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", + }; +} diff --git a/frontend-web/src/position-roundtable/lib/positions.ts b/frontend-web/src/position-roundtable/lib/positions.ts new file mode 100644 index 00000000..34c0582f --- /dev/null +++ b/frontend-web/src/position-roundtable/lib/positions.ts @@ -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 ?? "未分配岗位"; diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 34ba6e96..c0911b62 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { ArrowLeft, + Archive, Bot, BriefcaseBusiness, CheckCircle2, @@ -12,7 +14,9 @@ import { Layers3, Loader2, MessageSquareText, + Plus, Search, + Settings, Sparkles, Target, UserRoundCog, @@ -35,73 +39,41 @@ import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; import { listChains } from "@/roundtable-planning/api/chains"; import { Step1Panel } from "@/roundtable-planning/components/Step1Panel"; +import { PositionNodeChat } from "@/position-roundtable/components/PositionNodeChat"; import { getAvatar } from "@/roundtable-planning/components/Avatars"; import { useStep1Intent } from "@/roundtable-planning/hooks/useStep1Intent"; +import type { IntentSummary } from "@/roundtable-planning/api/intent"; import type { BusinessChain } from "@/roundtable-planning/lib/business-chain"; import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants"; - -/** - * 岗位会商与任务深链完全解耦:当前先以浏览器会话草稿承接任务信息, - * 新任务接口确认后只需要替换这组读写函数,不会影响既有会商或任务工作区。 - */ -const POSITION_TASK_DRAFT_KEY = "position-roundtable:task-draft"; - -interface PositionTaskDraft { - id: string; - title: string; - description: string; - direction: string; -} - -const DEFAULT_POSITION_TASK: PositionTaskDraft = { - id: "", - title: "未命名岗位会商任务", - description: "请补充本次岗位会商的任务背景、目标和交付要求。", - direction: "研判分析", -}; - -function readPositionTaskDraft(): PositionTaskDraft { - try { - const raw = sessionStorage.getItem(POSITION_TASK_DRAFT_KEY); - if (!raw) return DEFAULT_POSITION_TASK; - const data = JSON.parse(raw) as Partial; - return { - ...DEFAULT_POSITION_TASK, - ...data, - }; - } catch { - return DEFAULT_POSITION_TASK; - } -} +import { + DEFAULT_POSITION_ID, + POSITION_OPTIONS, + positionNameById, + type PositionId, +} from "@/position-roundtable/lib/positions"; +import { + activatePositionSession, + archivePositionSession, + createPositionSession, + getPositionSession, + listPositionSessions, + savePositionIntent, + type PositionRoundtableNode, + type PositionRoundtableSession, +} from "@/position-roundtable/api/sessions"; +import { + DEFAULT_MOCK_POSITION_TASK_ID, + getMockPositionTask, + MOCK_POSITION_TASKS, + taskFromPositionSnapshot, + type PositionTask, +} from "@/position-roundtable/lib/mock-task-source"; /** * 第一版先固定岗位,等岗位接口与权限范围确认后只替换常量数据源。 - * 组织筹划岗复用原会商 Step 1,承担意图识别。 + * 情报分析岗复用原会商 Step 1,承担意图识别。 */ -const POSITIONS = [ - { - id: "planning", - name: "组织筹划岗", - description: "识别任务意图、补齐约束并选择业务链条", - }, - { - id: "intelligence", - name: "情报分析岗", - description: "围绕已确认意图开展情报分析", - }, - { - id: "solution", - name: "方案设计岗", - description: "承接上游结果并形成方案产物", - }, - { - id: "evaluation", - name: "综合评估岗", - description: "汇总链条结果并完成综合评估", - }, -] as const; - -type PositionId = (typeof POSITIONS)[number]["id"]; +const POSITIONS = POSITION_OPTIONS; type WorkspaceTab = "agents" | "results"; function uniqueChains(chains: BusinessChain[]): BusinessChain[] { @@ -123,22 +95,56 @@ function timeLabel(): string { }).format(new Date()); } +function parseIntentSummary(snapshot: Record | null | undefined): IntentSummary | null { + if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) { + return null; + } + return { + objective: snapshot.objective, + constraints: snapshot.constraints.filter((item): item is string => typeof item === "string"), + assumptions: Array.isArray(snapshot.assumptions) + ? snapshot.assumptions.filter((item): item is string => typeof item === "string") + : undefined, + }; +} + export default function PositionRoundtablePage() { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const { theme } = useTheme(); const isDark = theme === "dark"; - const [positionId, setPositionId] = useState("planning"); - const task = useMemo(readPositionTaskDraft, []); + const [positionId, setPositionId] = useState(DEFAULT_POSITION_ID); + const taskIdFromUrl = searchParams.get("taskId") ?? DEFAULT_MOCK_POSITION_TASK_ID; + const searchParamsString = searchParams.toString(); + const fallbackTask = useMemo(() => getMockPositionTask(taskIdFromUrl), [taskIdFromUrl]); + const sessionIdFromUrl = searchParams.get("sessionId"); + const forceNewSession = searchParams.get("new") === "1"; + const [positionSession, setPositionSession] = useState(null); + const [sessionLoading, setSessionLoading] = useState(false); + const [sessionDiscoveryDone, setSessionDiscoveryDone] = useState(Boolean(sessionIdFromUrl)); + const [startingChain, setStartingChain] = useState(false); const activePosition = POSITIONS.find((item) => item.id === positionId)!; + const frozenIntent = useMemo( + () => parseIntentSummary(positionSession?.intentSnapshot), + [positionSession?.intentSnapshot], + ); + const task = useMemo( + () => taskFromPositionSnapshot(positionSession?.taskSnapshot, fallbackTask), + [fallbackTask, positionSession?.taskSnapshot], + ); const { models } = useModels(); const selectedModel = models[0]?.name ?? ""; const triggerToast = useCallback((message: string) => toast.info(message), []); const intent = useStep1Intent({ // 全岗位共用原会商的对话接口与输入面板;岗位运行编排后续再按角色接入。 - currentStep: 1, + // An existing URL-bound session already owns an intent thread/snapshot. + // Do not create an unused fresh intent thread while that Session loads. + currentStep: sessionIdFromUrl || (!forceNewSession && !sessionDiscoveryDone) ? 0 : 1, selectedModel, triggerToast, }); + const hydratedIntentSessionRef = useRef(null); const { data: chains = [], isLoading: chainsLoading } = useQuery({ queryKey: ["position-roundtable", "chains"], @@ -157,8 +163,170 @@ export default function PositionRoundtablePage() { }, [chains, selectedChainId]); const selectedChain = chains.find((chain) => chain.id === selectedChainId) ?? null; - + const activeChain = useMemo(() => { + const frozen = positionSession?.chainSnapshot; + if ( + positionSession?.status !== "intent_pending" && + frozen && + Array.isArray(frozen.seats) + ) { + return frozen as unknown as BusinessChain; + } + return selectedChain; + }, [positionSession?.chainSnapshot, positionSession?.status, selectedChain]); + const assignedAgentIds = useMemo( + () => + new Set( + (activeChain?.seats ?? []) + .filter((seat) => seat.position_id === positionId) + .map((seat) => seat.agent_id), + ), + [activeChain, positionId], + ); const [selectedAgentId, setSelectedAgentId] = useState(null); + const [activeNodeKey, setActiveNodeKey] = useState(null); + const activeNode = useMemo( + () => positionSession?.nodes?.find((node) => node.nodeKey === activeNodeKey) ?? null, + [activeNodeKey, positionSession?.nodes], + ); + useEffect(() => { + const nodes = positionSession?.nodes ?? []; + const currentNodeStillVisible = nodes.some( + (node) => node.nodeKey === activeNodeKey && node.positionId === positionId, + ); + if (currentNodeStillVisible) return; + const next = nodes.find((node) => node.positionId === positionId) ?? null; + setActiveNodeKey(next?.nodeKey ?? null); + }, [activeNodeKey, positionId, positionSession?.nodes]); + const handleNodeUpdate = useCallback((updatedNode: PositionRoundtableNode) => { + setPositionSession((current) => { + if (!current?.nodes) return current; + return { + ...current, + nodes: current.nodes.map((node) => + node.nodeKey === updatedNode.nodeKey ? updatedNode : node, + ), + }; + }); + + // Completing a node can also unlock the next stage or invalidate a later + // result. Reload the compact Session graph so every panel reflects that + // server-side transition immediately. + if (updatedNode.sessionId === sessionIdFromUrl) { + void getPositionSession(updatedNode.sessionId) + .then((session) => setPositionSession(session)) + .catch(() => undefined); + } + }, [sessionIdFromUrl]); + + useEffect(() => { + if ( + !positionSession?.id || + hydratedIntentSessionRef.current === positionSession.id || + !positionSession.intentThreadId || + !frozenIntent + ) { + return; + } + hydratedIntentSessionRef.current = positionSession.id; + intent.hydrateFromDraft({ + intentThreadId: positionSession.intentThreadId, + intentReady: frozenIntent, + interactiveMessages: [], + timelineEntries: [], + }); + }, [frozenIntent, intent, positionSession?.id, positionSession?.intentThreadId]); + const handleSelectAgent = useCallback( + (agentId: string) => { + setSelectedAgentId(agentId); + const node = positionSession?.nodes?.find( + (item) => item.agentId === agentId && item.positionId === positionId, + ); + if (node) setActiveNodeKey(node.nodeKey); + }, + [positionId, positionSession?.nodes], + ); + const openPositionConfig = useCallback( + (chainId: string) => { + const taskQuery = `taskId=${encodeURIComponent(task.id)}${forceNewSession ? "&new=1" : ""}`; + const returnTo = sessionIdFromUrl + ? `/page/strategy/qa/position-roundtable?${taskQuery}&sessionId=${encodeURIComponent(sessionIdFromUrl)}` + : `/page/strategy/qa/position-roundtable?${taskQuery}`; + navigate( + `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}&mode=position&returnTo=${encodeURIComponent(returnTo)}`, + ); + }, + [forceNewSession, navigate, sessionIdFromUrl, task.id], + ); + + // The mock task source mirrors the future external-task contract: opening a + // task without a Session restores its latest non-archived collaboration. + // No session means Step 1 may create a new intent thread only after this + // discovery finishes, avoiding an unused thread during restoration. + useEffect(() => { + if (sessionIdFromUrl) { + setSessionDiscoveryDone(true); + return; + } + if (forceNewSession) { + setSessionDiscoveryDone(true); + setSessionLoading(false); + return; + } + let cancelled = false; + setSessionDiscoveryDone(false); + setSessionLoading(true); + void listPositionSessions(task.id) + .then((sessions) => { + if (cancelled) return; + const latest = sessions.find((session) => session.status !== "archived"); + if (!latest) { + setSessionDiscoveryDone(true); + return; + } + const next = new URLSearchParams(searchParamsString); + next.set("taskId", task.id); + next.set("sessionId", latest.id); + setSearchParams(next, { replace: true }); + }) + .catch(() => { + if (!cancelled) setSessionDiscoveryDone(true); + }) + .finally(() => { + if (!cancelled) setSessionLoading(false); + }); + return () => { + cancelled = true; + }; + }, [forceNewSession, searchParamsString, sessionIdFromUrl, setSearchParams, task.id]); + + // 只恢复岗位协同自己的 Session/Node 状态;聊天消息仍由后续节点直接按 threadId + // 读取,因此不会改变原圆桌 Step1 的初始化或存储逻辑。 + useEffect(() => { + if (!sessionIdFromUrl) { + setPositionSession(null); + return; + } + let cancelled = false; + setSessionLoading(true); + void getPositionSession(sessionIdFromUrl) + .then((session) => { + if (!cancelled) setPositionSession(session); + }) + .catch((error) => { + if (!cancelled) { + setPositionSession(null); + toast.error(`岗位协同会话加载失败:${error instanceof Error ? error.message : String(error)}`); + } + }) + .finally(() => { + if (!cancelled) setSessionLoading(false); + }); + return () => { + cancelled = true; + }; + }, [sessionIdFromUrl]); + const { agent: selectedAgent } = useAgent(selectedAgentId); const [intentCompletedAt, setIntentCompletedAt] = useState(""); useEffect(() => { @@ -181,6 +349,98 @@ export default function PositionRoundtablePage() { void intent.sendIntentMessage("请继续追问当前任务中尚未明确的关键条件。", []); }, [intent]); + const handleActivateChain = useCallback(async () => { + if (startingChain) return; + if (!selectedChain) { + toast.warning("请先选择业务链条"); + return; + } + const unassignedSeats = selectedChain.seats.filter((seat) => !seat.position_id?.trim()); + if (unassignedSeats.length > 0) { + toast.warning(`该业务链还有 ${unassignedSeats.length} 个席位未配置岗位,请点击齿轮完成配置`); + return; + } + if (!intent.intentReady) { + toast.warning("请先完成任务意图识别"); + return; + } + setStartingChain(true); + try { + let session = positionSession; + if (!session) { + session = await createPositionSession({ + externalTaskId: task.id || undefined, + taskSnapshot: { ...task }, + }); + } + session = await savePositionIntent(session.id, { + intentThreadId: intent.intentThreadId, + intentSnapshot: intent.intentReady, + }); + session = await activatePositionSession(session.id, selectedChain.id); + setPositionSession(session); + const next = new URLSearchParams(searchParams); + next.set("taskId", task.id); + next.set("sessionId", session.id); + next.delete("new"); + setSearchParams(next, { replace: true }); + toast.success(`已启动业务链:${session.nodes?.length ?? 0} 个岗位节点已生成`); + } catch (error) { + toast.error(`启动业务链失败:${error instanceof Error ? error.message : String(error)}`); + } finally { + setStartingChain(false); + } + }, [intent.intentReady, intent.intentThreadId, positionSession, searchParams, selectedChain, setSearchParams, startingChain, task]); + + const handleMockTaskChange = useCallback( + (value: unknown) => { + const nextTaskId = String(value); + if (nextTaskId === task.id || !MOCK_POSITION_TASKS.some((item) => item.id === nextTaskId)) { + return; + } + intent.resetIntent(); + hydratedIntentSessionRef.current = null; + setPositionSession(null); + setSessionDiscoveryDone(false); + setSelectedAgentId(null); + setActiveNodeKey(null); + setIntentCompletedAt(""); + const next = new URLSearchParams(searchParams); + next.set("taskId", nextTaskId); + next.delete("sessionId"); + setSearchParams(next); + }, + [intent, searchParams, setSearchParams, task.id], + ); + + const handleCreateNewSession = useCallback(() => { + intent.resetIntent(); + hydratedIntentSessionRef.current = null; + setPositionSession(null); + setSessionDiscoveryDone(true); + setSelectedAgentId(null); + setActiveNodeKey(null); + setIntentCompletedAt(""); + const next = new URLSearchParams(searchParams); + next.set("taskId", task.id); + next.delete("sessionId"); + next.set("new", "1"); + setSearchParams(next); + }, [intent, searchParams, setSearchParams, task.id]); + + const handleArchiveSession = useCallback(() => { + if (!positionSession || positionSession.status === "archived") return; + if (!window.confirm("归档后该会话将只读,是否继续?")) return; + void archivePositionSession(positionSession.id) + .then((session) => { + setPositionSession((current) => (current ? { ...current, ...session } : session)); + toast.success("会话已归档,可新建会话继续研讨"); + }) + .catch((error) => { + toast.error(`归档会话失败:${error instanceof Error ? error.message : String(error)}`); + }); + }, [positionSession]); + return (
+ {positionSession && ( + + {positionSession.status === "archived" + ? "会话已归档" + : positionSession.status === "active" + ? "业务链已启动" + : "意图识别中"} + + )}
+
+ 模拟任务 + ({ + label: mockTask.title, + value: mockTask.id, + }))} + onChange={handleMockTaskChange} + /> +
+ } + onClick={handleCreateNewSession} + > + 新建会话 + + {positionSession && positionSession.status !== "archived" && ( + } + onClick={handleArchiveSession} + > + 归档 + + )} 当前岗位 @@ -221,77 +527,98 @@ export default function PositionRoundtablePage() {
-
- {intent.intentInitError && ( -
- - 对话初始化失败:{intent.intentInitError} - - - 重试 - -
- )} - - -
-

- {activePosition.name} · {positionId === "planning" ? "意图识别" : "岗位对话"} -

-

- {activePosition.description} -

+
+ {activePosition.isIntentPosition ? ( + positionSession?.status !== "intent_pending" && frozenIntent ? ( + + ) : ( + <> + {intent.intentInitError && ( +
+ 对话初始化失败:{intent.intentInitError} + + 重试 +
-
- } - interactiveMessages={intent.interactiveMessages} - intentThreadId={intent.intentThreadId} - intentReady={intent.intentReady} - isIntentSending={intent.isIntentSending} - pendingIntentClarificationMsgId={ - intent.pendingIntentClarificationMsgId - } - intentClarificationSelections={ - intent.intentClarificationSelections - } - setIntentClarificationSelections={ - intent.setIntentClarificationSelections - } - onSendMessage={(text, files) => - void intent.sendIntentMessage(text, files) - } - onStopIntent={intent.stopIntentStream} - onSkipQuestion={handleSkipQuestion} - onFollowUp={handleFollowUp} - onEnterRoundtable={() => - toast.success("意图理解已确认,等待岗位链条执行逻辑接入") - } - onManualSave={() => undefined} - onTriggerToast={triggerToast} - submitIntentClarifications={intent.submitIntentClarifications} - /> + )} + + +
+

+ {activePosition.name} · 意图识别 +

+

{activePosition.description}

+
+
+ } + interactiveMessages={intent.interactiveMessages} + intentThreadId={intent.intentThreadId} + intentReady={intent.intentReady} + isIntentSending={intent.isIntentSending} + pendingIntentClarificationMsgId={intent.pendingIntentClarificationMsgId} + intentClarificationSelections={intent.intentClarificationSelections} + setIntentClarificationSelections={intent.setIntentClarificationSelections} + onSendMessage={(text, files) => void intent.sendIntentMessage(text, files)} + onStopIntent={intent.stopIntentStream} + onSkipQuestion={handleSkipQuestion} + onFollowUp={handleFollowUp} + onEnterRoundtable={() => void handleActivateChain()} + enterButtonLabel={startingChain ? "正在启动业务链…" : "确认意图并启动业务链 >"} + onManualSave={() => undefined} + onTriggerToast={triggerToast} + submitIntentClarifications={intent.submitIntentClarifications} + /> + {(startingChain || sessionLoading) && ( +
+ + {startingChain ? "正在保存意图并生成岗位节点…" : "正在恢复岗位协同会话…"} +
+ )} + + ) + ) : positionSession && activeNode ? ( + + ) : ( + 0} + /> + )}
@@ -299,10 +626,21 @@ export default function PositionRoundtablePage() { setSelectedAgentId(null)} - intentObjective={intent.intentReady?.objective} + intentObjective={frozenIntent?.objective ?? intent.intentReady?.objective} intentCompletedAt={intentCompletedAt} + assignedAgentIds={assignedAgentIds} + hasSelectedChain={Boolean(selectedChain)} + onConfigureChain={ + selectedChain ? () => openPositionConfig(selectedChain.id) : undefined + } + nodes={positionSession?.nodes ?? []} + chain={activeChain} + onSelectNode={(node) => { + setActiveNodeKey(node.nodeKey); + setSelectedAgentId(node.agentId); + }} />
@@ -310,8 +648,75 @@ export default function PositionRoundtablePage() { ); } +function PositionNodeEmptyState({ + hasSession, + hasAssignedAgent, +}: { + hasSession: boolean; + hasAssignedAgent: boolean; +}) { + return ( +
+ + + +

+ {hasSession ? "请选择当前岗位的智能体节点" : "等待情报分析岗启动业务链"} +

+

+ {hasSession + ? hasAssignedAgent + ? "从右侧智能体配置列表或左侧流程图选择一个节点后,即可查看或继续问答。" + : "当前岗位没有获配智能体,请由情报分析岗进入业务链配置补充分配。" + : "任务意图确认并启动业务链后,各岗位会按链条阶段依次解锁。"} +

+
+ ); +} + +/** 已启动会话中的意图是冻结快照;避免在情报岗视图意外覆盖它。 */ +function IntentConfirmedState({ intent }: { intent: IntentSummary }) { + return ( +
+
+
+ + + +
+

任务意图已确认

+

+ 此会话已冻结任务意图和业务链;如需变更,请新建岗位协同会话,避免影响已完成节点。 +

+
+
+
+

任务目标

+

+ {desensitize(intent.objective)} +

+
+
+

关键约束

+
+ {intent.constraints.map((constraint, index) => ( +
+ + {desensitize(constraint)} +
+ ))} +
+
+
+
+ ); +} + /** 仅展示岗位会商任务,不在会商页编辑任务。 */ -function TaskInformationCard({ task }: { task: PositionTaskDraft }) { +function TaskInformationCard({ task }: { task: PositionTask }) { return (
@@ -321,6 +726,11 @@ function TaskInformationCard({ task }: { task: PositionTaskDraft }) {

任务信息

+ {task.source === "mock" && ( + + 模拟数据 + + )}
@@ -362,11 +772,13 @@ function BusinessChainSelector({ loading, value, onChange, + onConfigure, }: { chains: BusinessChain[]; loading: boolean; value: string; onChange: (value: string) => void; + onConfigure: (chainId: string) => void; }) { const [expanded, setExpanded] = useState(true); const [relationOpenId, setRelationOpenId] = useState(null); @@ -413,6 +825,7 @@ function BusinessChainSelector({ const selected = chain.id === value; const relationOpen = relationOpenId === chain.id; const stageGroups = chainStages(chain); + const unassignedSeatCount = chain.seats.filter((seat) => !seat.position_id?.trim()).length; let avatarOrder = 0; return (
- + {unassignedSeatCount > 0 && ( + + {unassignedSeatCount} 席待配置 + )} - > - {chain.seats.length} 席 + + {chain.seats.length} 席 +

@@ -471,24 +891,40 @@ function BusinessChainSelector({ {selected && (

- +
+ + +
+ {unassignedSeatCount > 0 && ( +

+ 还有 {unassignedSeatCount} 个智能体席位未归属岗位,请点击右侧齿轮配置后再启动。 +

+ )} {relationOpen && (
{stageGroups.flatMap((stage, stageIndex) => { @@ -561,7 +997,7 @@ function IntentUnderstandingCard({
) : (
- 组织筹划岗完成意图识别后,其他岗位会在这里看到统一的任务意图与约束。 + 情报分析岗完成意图识别后,其他岗位会在这里看到统一的任务意图与约束。
)}
@@ -587,7 +1023,17 @@ const FLOW_NODE_GAP = 10; const FLOW_NODE_CENTER = FLOW_NODE_WIDTH / 2; /** 以业务链配置页的节点卡片语义绘制窄栏版,不显示连线依赖说明。 */ -function BusinessChainFlow({ chain }: { chain: BusinessChain | null }) { +function BusinessChainFlow({ + chain, + nodes, + activeNodeKey, + onSelectNode, +}: { + chain: BusinessChain | null; + nodes: PositionRoundtableNode[]; + activeNodeKey: string | null; + onSelectNode: (node: PositionRoundtableNode) => void; +}) { const stages = chain ? chainStages(chain) : []; const [view, setView] = useState({ tx: 0, ty: 0 }); const [panning, setPanning] = useState(false); @@ -643,7 +1089,7 @@ function BusinessChainFlow({ chain }: { chain: BusinessChain | null }) { {!chain ? (
- 请先由组织筹划岗选择业务链条 + 请先由情报分析岗选择业务链条
) : (
@@ -690,6 +1136,12 @@ function BusinessChainFlow({ chain }: { chain: BusinessChain | null }) {
{stage.seats.map((seat, seatIndex) => { const order = avatarOrder++; + const node = nodes.find( + (item) => + item.stageIndex === stageIndex && + item.seatIndex === seatIndex && + item.agentId === seat.agent_id, + ); return ( onSelectNode(node) : undefined} /> ); })} @@ -751,15 +1206,30 @@ function CompactChainNode({ description, accent, avatarType, + selected = false, + nodeStatus, + onClick, }: { title: string; description: string; accent: string; avatarType: string; + selected?: boolean; + nodeStatus?: PositionRoundtableNode["status"]; + onClick?: () => void; }) { return (
{ + if (onClick) event.stopPropagation(); + }} + className={cn( + "w-[158px] rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]", + onClick && "cursor-pointer transition hover:-translate-y-0.5 hover:shadow-md", + selected && "ring-2 ring-primary/50 dark:ring-[#2b8ffe]", + nodeStatus === "locked" && "opacity-65", + )} style={{ borderLeftWidth: 5, borderLeftColor: accent }} >
@@ -784,6 +1254,12 @@ function RightWorkspace({ onBackToAgents, intentObjective, intentCompletedAt, + assignedAgentIds, + hasSelectedChain, + onConfigureChain, + nodes, + chain, + onSelectNode, }: { selectedAgent: Agent | null; selectedAgentId: string | null; @@ -791,6 +1267,12 @@ function RightWorkspace({ onBackToAgents: () => void; intentObjective?: string; intentCompletedAt: string; + assignedAgentIds: Set; + hasSelectedChain: boolean; + onConfigureChain?: () => void; + nodes: PositionRoundtableNode[]; + chain: BusinessChain | null; + onSelectNode: (node: PositionRoundtableNode) => void; }) { const [tab, setTab] = useState("agents"); @@ -856,7 +1338,12 @@ function RightWorkspace({
) : ( - + )}
@@ -865,6 +1352,9 @@ function RightWorkspace({ )} @@ -872,8 +1362,18 @@ function RightWorkspace({ ); } -/** 复用 ai 写作页的智能体分组数据和同款分组卡片视觉。 */ -function PositionAgentList({ onSelect }: { onSelect: (agentId: string) => void }) { +/** 复用 ai 写作页的智能体分组数据和同款分组卡片视觉,只展示当前岗位获配的席位。 */ +function PositionAgentList({ + onSelect, + assignedAgentIds, + hasSelectedChain, + onConfigureChain, +}: { + onSelect: (agentId: string) => void; + assignedAgentIds: Set; + hasSelectedChain: boolean; + onConfigureChain?: () => void; +}) { const { theme } = useTheme(); const isDark = theme === "dark"; const { groups, isLoading } = useAgentModuleSidebarData("position-roundtable"); @@ -882,16 +1382,16 @@ function PositionAgentList({ onSelect }: { onSelect: (agentId: string) => void } const visibleGroups = useMemo(() => { const keyword = query.trim().toLowerCase(); - if (!keyword) return groups; return groups .map((group) => ({ ...group, - agents: group.agents.filter((agent) => - `${agent.name} ${agent.description ?? ""}`.toLowerCase().includes(keyword), - ), + agents: group.agents.filter((agent) => { + if (!assignedAgentIds.has(agent.id)) return false; + return !keyword || `${agent.name} ${agent.description ?? ""}`.toLowerCase().includes(keyword); + }), })) .filter((group) => group.agents.length > 0); - }, [groups, query]); + }, [assignedAgentIds, groups, query]); const toggleGroup = (key: string) => { setCollapsed((current) => { @@ -913,7 +1413,7 @@ function PositionAgentList({ onSelect }: { onSelect: (agentId: string) => void }

- {groups.reduce((total, group) => total + group.agents.length, 0)} 个 + {visibleGroups.reduce((total, group) => total + group.agents.length, 0)} 个 void } ) : visibleGroups.length === 0 ? (
- 没有找到匹配的智能体 +

{hasSelectedChain ? "当前岗位暂未分配智能体" : "请先选择业务链条"}

+ {hasSelectedChain && onConfigureChain && ( + + 去配置岗位 + + )}
) : (
@@ -1013,17 +1523,129 @@ function PositionAgentList({ onSelect }: { onSelect: (agentId: string) => void } function AnalysisResults({ intentObjective, intentCompletedAt, + nodes, + chain, + onSelectNode, }: { intentObjective?: string; intentCompletedAt: string; + nodes: PositionRoundtableNode[]; + chain: BusinessChain | null; + onSelectNode: (node: PositionRoundtableNode) => void; }) { - if (!intentObjective) { + type ResultKind = "intent" | "answer" | "artifact"; + interface ResultItem { + id: string; + kind: ResultKind; + node: PositionRoundtableNode | null; + agentName: string; + productName: string; + summary: string; + positionId: string | null; + updatedAt: string | undefined; + } + + const [positionFilter, setPositionFilter] = useState("all"); + const [nodeFilter, setNodeFilter] = useState("all"); + const [kindFilter, setKindFilter] = useState<"all" | ResultKind>("all"); + const seatsByAgentId = useMemo( + () => new Map((chain?.seats ?? []).map((seat) => [seat.agent_id, seat])), + [chain], + ); + const resultItems = useMemo(() => { + const items: ResultItem[] = []; + if (intentObjective) { + items.push({ + id: "intent", + kind: "intent", + node: null, + agentName: "情报分析智能体", + productName: "任务意图理解", + summary: intentObjective, + positionId: DEFAULT_POSITION_ID, + updatedAt: undefined, + }); + } + for (const node of nodes) { + const seat = seatsByAgentId.get(node.agentId); + const agentName = seat?.name ?? node.agentId; + if (node.latestAnswer) { + items.push({ + id: `${node.nodeKey}:answer:${node.revision}`, + kind: "answer", + node, + agentName, + productName: "问答结果", + summary: node.latestAnswer, + positionId: node.positionId, + updatedAt: node.updatedAt, + }); + } + node.artifactManifest.forEach((artifact, index) => { + const path = typeof artifact.path === "string" ? artifact.path : ""; + 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 + : "文件产物"; + items.push({ + id: `${node.nodeKey}:artifact:${path || name}:${index}`, + kind: "artifact", + node, + agentName, + productName: name, + summary: `${mimeType}${node.status === "stale" ? " · 上游已更新,请基于最新结果复核" : ""}`, + positionId: node.positionId, + updatedAt: node.updatedAt, + }); + }); + } + return items.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "")); + }, [intentObjective, nodes, seatsByAgentId]); + const positionOptions = useMemo( + () => [ + { label: "全部岗位", value: "all" }, + ...POSITIONS.filter((position) => resultItems.some((item) => item.positionId === position.id)).map( + (position) => ({ label: position.name, value: position.id }), + ), + ], + [resultItems], + ); + const nodeOptions = useMemo( + () => [ + { label: "全部节点", value: "all" }, + ...nodes + .filter((node) => resultItems.some((item) => item.node?.nodeKey === node.nodeKey)) + .map((node) => ({ + label: desensitize(seatsByAgentId.get(node.agentId)?.name ?? node.agentId), + value: node.nodeKey, + })), + ], + [nodes, resultItems, seatsByAgentId], + ); + const visibleItems = useMemo( + () => + resultItems.filter( + (item) => + (positionFilter === "all" || item.positionId === positionFilter) && + (nodeFilter === "all" || item.node?.nodeKey === nodeFilter) && + (kindFilter === "all" || item.kind === kindFilter), + ), + [kindFilter, nodeFilter, positionFilter, resultItems], + ); + + if (resultItems.length === 0) { return (

暂无分析结果

- 组织筹划岗完成意图识别后,产物会先进入这里;后续岗位产物沿用同一张卡片追加入列。 + 情报分析岗完成意图识别后,产物会先进入这里;后续岗位的问答结果也会沿用同一张卡片追加入列。

); @@ -1037,33 +1659,100 @@ function AnalysisResults({

按生成时间倒序

- 1 个产物 + {visibleItems.length}/{resultItems.length} 项结果 -
-
- - - -
-

任务理解智能体

-

任务意图理解

-

- {desensitize(intentObjective)} -

-
+
+ setPositionFilter(String(value))} /> + setNodeFilter(String(value))} /> + setKindFilter(String(value) as "all" | ResultKind)} + /> +
+ {visibleItems.length === 0 ? ( +
+ 当前筛选条件下没有结果
-
- - - 组织筹划岗 - - - - {intentCompletedAt || "刚刚"} - -
-
+ ) : ( + visibleItems.map((item) => { + const displayedTime = item.updatedAt + ? new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(item.updatedAt)) + : intentCompletedAt || "刚刚"; + const stale = item.node?.status === "stale"; + const contents = ( + <> +
+ + {item.kind === "artifact" ? : } + +
+
+

{desensitize(item.agentName)}

+ {stale && ( + + 上游已更新 + + )} +
+

+ {desensitize(item.productName)} +

+

+ {desensitize(item.summary)} +

+
+
+
+ + + {positionNameById(item.positionId)} + + + + {displayedTime} + +
+ + ); + return item.node ? ( + + ) : ( +
+ {contents} +
+ ); + }) + )} ); } diff --git a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx index 5278bf33..b50461ac 100644 --- a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx @@ -87,6 +87,8 @@ export interface Step1PanelProps { onSkipQuestion: () => void; onFollowUp: () => void; onEnterRoundtable: () => void; + /** 保持旧文案默认不变;嵌入式新工作流可覆写确认动作名称。 */ + enterButtonLabel?: string; onManualSave: () => void; onTriggerToast: (msg: string) => void; submitIntentClarifications: ( @@ -111,6 +113,7 @@ export function Step1Panel(props: Step1PanelProps) { onSkipQuestion, onFollowUp, onEnterRoundtable, + enterButtonLabel = "进入多智能体研讨 >", onManualSave, onTriggerToast, submitIntentClarifications, @@ -334,7 +337,7 @@ export function Step1Panel(props: Step1PanelProps) { }`} > - 进入多智能体研讨 > + {enterButtonLabel} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts index ec9c3683..caf27082 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts @@ -113,6 +113,8 @@ export interface UseStep1IntentReturn { // ─── Handlers ──────────────────────────────────────────────────────── /** 清空当前 Step 1 状态并重新拉取 intent thread。 */ runStep1Init: () => void; + /** 清空当前状态但不创建线程,供宿主切换任务后重新进入 Step 1。 */ + resetIntent: () => void; /** * 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。 * `files`:可选的本地文件,发送前先上传到 intent thread,再把引用随消息提交(后端 @@ -256,6 +258,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) { @@ -648,6 +664,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe timelineEntries, setIntentClarificationSelections, runStep1Init, + resetIntent, sendIntentMessage, submitIntentClarifications, updateIntentReady, diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index bfec7ac6..f6861ddf 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -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` 记录对应)。 */ diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index c577f9f8..2d4c3bc3 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -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(); @@ -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(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() {
{/* 品牌标识 */} @@ -921,6 +948,17 @@ export default function BusinessChainEditorPage() { + {positionConfigMode && ( + + 岗位协同配置 + + )} + {/* 链条选择下拉 */}
@@ -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({ )}
+ {/* 岗位协同从专用入口进来时才展示。使用 TDesign Select,未分配不会阻塞普通圆桌使用。 */} + {showPositionConfig && ( +
+ 岗位 + ({ + label: position.name, + value: position.id, + })), + ]} + onChange={(value) => + onPositionChange(value ? (String(value) as PositionId) : null) + } + className="w-[132px]" + popupProps={{ overlayClassName: "z-[80]" }} + /> +
+ )} + {/* 席位模型覆盖(右侧操作区):空 = 跟随全局所选模型;选了则该席位作为子智能体 执行时用此模型。技能重的席位配强模型、边角席位配快模型,配合技能白名单实现 「强弱搭配」。放到右侧操作区,与名称/描述解耦,卡片更整洁。 */} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 70f6087a..87ffaaf0 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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 diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 4091ccf4..c06233a1 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -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 | diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index fe20b1ce..2a1b6fde 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -53,9 +53,10 @@ from app.gateway.routers import ( notifications, open_3qfx, open_chat, - parallel_agent_tasks, - parallel_agents, - positions, + parallel_agent_tasks, + parallel_agents, + position_roundtable, + positions, public_config, public_embed, public_leaderboard, @@ -1443,8 +1444,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract app.include_router(flow_extract.router) - # Roundtable-planning business chains at /api/roundtable-chains - app.include_router(roundtable_chains.router) + # 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) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 49db1366..69c1cb17 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -255,9 +255,15 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf) - from deerflow.persistence.roundtable_chains import make_roundtable_chain_store - - app.state.roundtable_chain_store = make_roundtable_chain_store(sf) + from deerflow.persistence.roundtable_chains import make_roundtable_chain_store + + 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 diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py new file mode 100644 index 00000000..c05afb34 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -0,0 +1,563 @@ +"""API for the standalone position-collaboration workspace. + +Unlike the existing multi-agent roundtable, this feature has no coordinator and +no background roundtable job. The API only persists the task/intent/chain +snapshot and indexes one direct-agent node per business-chain seat. Actual chat +messages and artifacts stay in the established LangGraph thread stores. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from app.gateway.path_utils import aresolve_thread_virtual_path +from deerflow.runtime.user_context import get_effective_user_id + +router = APIRouter(prefix="/api/position-roundtable", tags=["position-roundtable"]) + +NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "error"} +_HANDOFF_TEXT_BUDGET = 12_000 +_MAX_INLINE_ARTIFACTS_PER_NODE = 4 +_MAX_INLINE_ARTIFACT_CHARS = 1_800 +_TEXT_ARTIFACT_SUFFIXES = { + ".csv", + ".json", + ".log", + ".md", + ".markdown", + ".rst", + ".txt", + ".xml", + ".yaml", + ".yml", +} + + +class NodeResponse(BaseModel): + id: str + session_id: str + node_key: str + stage_index: int + seat_index: int + agent_id: str + position_id: str | None = None + thread_id: str | None = None + status: str + latest_answer: str | None = None + artifact_manifest: list[dict[str, Any]] = Field(default_factory=list) + revision: int = 0 + upstream_revision: dict[str, Any] | None = None + created_at: str | None = None + updated_at: str | None = None + + +class SessionResponse(BaseModel): + id: str + external_task_id: str | None = None + task_snapshot: dict[str, Any] = Field(default_factory=dict) + chain_id: str | None = None + chain_snapshot: dict[str, Any] | None = None + intent_thread_id: str | None = None + intent_snapshot: dict[str, Any] | None = None + status: str + created_at: str | None = None + updated_at: str | None = None + + +class SessionDetailResponse(SessionResponse): + nodes: list[NodeResponse] = Field(default_factory=list) + + +class SessionCreateRequest(BaseModel): + external_task_id: str | None = Field(default=None, max_length=128) + task_snapshot: dict[str, Any] = Field(default_factory=dict) + + +class TaskSnapshotUpdateRequest(BaseModel): + task_snapshot: dict[str, Any] = Field(default_factory=dict) + + +class IntentUpdateRequest(BaseModel): + intent_thread_id: str | None = Field(default=None, max_length=128) + intent_snapshot: dict[str, Any] | None = None + + +class ActivateRequest(BaseModel): + chain_id: str = Field(min_length=1, max_length=64) + + +class NodeThreadRequest(BaseModel): + thread_id: str = Field(min_length=1, max_length=128) + + +class CompleteTurnRequest(BaseModel): + latest_answer: str = Field(default="", max_length=500_000) + artifact_manifest: list[dict[str, Any]] = Field(default_factory=list) + upstream_revision: dict[str, Any] | None = None + + +def _current_user_id(request: Request) -> str: + user = getattr(request.state, "user", None) + return str(user.id) if user is not None else get_effective_user_id() + + +def _get_store(request: Request): + store = getattr(request.app.state, "position_roundtable_store", None) + if store is None: + raise HTTPException(status_code=503, detail="Position roundtable store not available") + return store + + +def _get_chain_store(request: Request): + store = getattr(request.app.state, "roundtable_chain_store", None) + if store is None: + raise HTTPException(status_code=503, detail="Business chain store not available") + return store + + +async def _require_writable_session( + request: Request, session_id: str +) -> tuple[Any, str, dict[str, Any]]: + """Load a Session that can still accept task, intent, or node updates.""" + store = _get_store(request) + user_id = _current_user_id(request) + session = await store.get_session(session_id, user_id) + if session is None: + raise HTTPException(status_code=404, detail="Position roundtable session not found") + if session.get("status") == "archived": + raise HTTPException(status_code=409, detail="该岗位协同会话已归档,仅可查看历史记录") + return store, user_id, session + + +def _project_session(row: dict[str, Any]) -> SessionResponse: + return SessionResponse(**row) + + +def _project_node(row: dict[str, Any]) -> NodeResponse: + return NodeResponse(**row) + + +async def _load_detail(request: Request, session_id: str) -> SessionDetailResponse: + store = _get_store(request) + user_id = _current_user_id(request) + row = await store.get_session(session_id, user_id) + if row is None: + raise HTTPException(status_code=404, detail="Position roundtable session not found") + nodes = await store.list_nodes(session_id, user_id) + return SessionDetailResponse(**row, nodes=[_project_node(node) for node in nodes or []]) + + +def _chain_nodes(chain: dict[str, Any]) -> list[dict[str, Any]]: + seats = chain.get("seats") if isinstance(chain.get("seats"), list) else [] + if not seats: + raise HTTPException(status_code=400, detail="业务链条没有可执行的智能体席位") + seat_by_agent_id = {str(seat.get("agent_id")): seat for seat in seats if seat.get("agent_id")} + if len(seat_by_agent_id) != len(seats): + raise HTTPException(status_code=400, detail="业务链条包含无效或重复的智能体席位") + + raw_stages = chain.get("stages") + stages: list[list[str]] + if isinstance(raw_stages, list) and raw_stages: + stages = [[str(agent_id) for agent_id in stage] for stage in raw_stages if isinstance(stage, list)] + else: + stages = [[str(seat["agent_id"])] for seat in seats] + if not stages or any(not stage for stage in stages): + raise HTTPException(status_code=400, detail="业务链条阶段配置无效") + + result: list[dict[str, Any]] = [] + for stage_index, stage in enumerate(stages): + for seat_index, agent_id in enumerate(stage): + seat = seat_by_agent_id.get(agent_id) + if seat is None: + raise HTTPException(status_code=400, detail=f"业务链条阶段包含不存在的席位: {agent_id}") + position_id = seat.get("position_id") + if not isinstance(position_id, str) or not position_id.strip(): + raise HTTPException( + status_code=400, + detail=f"智能体「{seat.get('name') or agent_id}」尚未配置岗位,请先从岗位协同入口配置", + ) + result.append( + { + "node_key": f"stage-{stage_index + 1}-seat-{seat_index + 1}-{agent_id}", + "stage_index": stage_index, + "seat_index": seat_index, + "agent_id": agent_id, + "position_id": position_id.strip(), + "status": "ready" if stage_index == 0 else "locked", + } + ) + return result + + +def _truncate_text(value: Any, limit: int) -> str: + if limit <= 0: + return "" + text = value if isinstance(value, str) else str(value or "") + if len(text) <= limit: + return text + return f"{text[:limit]}\n…(已按交接上下文长度截断)" + + +def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]: + if not isinstance(artifacts, list): + return [] + refs: list[dict[str, Any]] = [] + for artifact in artifacts[:20]: + if not isinstance(artifact, dict): + continue + refs.append( + { + "name": artifact.get("name") or artifact.get("filename") or "未命名产物", + "path": artifact.get("path") or artifact.get("artifact_url") or "", + "mime_type": artifact.get("mimeType") or artifact.get("mime_type") or None, + } + ) + return refs + + +def _is_inline_text_artifact(artifact: dict[str, Any]) -> bool: + """Return whether a listed output is safe and useful to pass as text.""" + mime_type = artifact.get("mime_type") + if isinstance(mime_type, str) and mime_type.lower().startswith("text/"): + return True + path = artifact.get("path") + return isinstance(path, str) and Path(path).suffix.lower() in _TEXT_ARTIFACT_SUFFIXES + + +def _read_text_excerpt(path: Path, max_chars: int) -> tuple[str, bool] | None: + """Read one bounded UTF-8 text artifact without loading the whole file.""" + if max_chars <= 0: + return None + # Four bytes per UTF-8 code point is enough to obtain the requested + # character-sized excerpt without a potentially unbounded file read. + with path.open("rb") as file: + raw = file.read(max_chars * 4 + 1) + if b"\x00" in raw: + return None + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as error: + if len(raw) == max_chars * 4 + 1 and error.start >= len(raw) - 4: + text = raw[: error.start].decode("utf-8") + else: + # Generated artifacts are expected to be UTF-8. Keep a non-UTF-8 + # output as a reference only instead of corrupting agent context. + return None + truncated = len(raw) > max_chars * 4 or len(text) > max_chars + return _truncate_text(text, max_chars), truncated + + +async def _artifact_refs_with_content( + thread_id: str | None, artifacts: Any, text_budget: int +) -> tuple[list[dict[str, Any]], int]: + """Attach bounded excerpts of text outputs, retaining binary files as refs. + + Paths are always resolved against the upstream node's own thread. This is + deliberate: an artifact manifest is client-supplied metadata, so it must + never be allowed to select another thread's user-data directory. + """ + refs = _artifact_refs(artifacts) + if not thread_id or text_budget <= 0: + return refs, 0 + + consumed = 0 + inline_count = 0 + for ref in refs: + if inline_count >= _MAX_INLINE_ARTIFACTS_PER_NODE: + break + if not _is_inline_text_artifact(ref): + continue + virtual_path = ref.get("path") + if not isinstance(virtual_path, str): + continue + normalized_path = virtual_path.replace("\\", "/").lstrip("/") + if not normalized_path.startswith("mnt/user-data/outputs/"): + continue + available = text_budget - consumed + if available <= 0: + break + excerpt_limit = min(_MAX_INLINE_ARTIFACT_CHARS, available) + try: + actual_path = await aresolve_thread_virtual_path(thread_id, normalized_path) + if not await asyncio.to_thread(actual_path.is_file): + continue + excerpt = await asyncio.to_thread(_read_text_excerpt, actual_path, excerpt_limit) + except (HTTPException, OSError): + # The file may have been removed after the manifest was stored. + # Keep its metadata so the UI still shows the original output. + continue + if excerpt is None: + continue + content, truncated = excerpt + ref["content"] = content + ref["content_truncated"] = truncated + consumed += len(content) + inline_count += 1 + return refs, consumed + + +async def _handoff_context(request: Request, session_id: str, node_key: str) -> dict[str, Any]: + """Construct bounded, deterministic hidden context for one direct-agent turn.""" + store = _get_store(request) + user_id = _current_user_id(request) + session = await store.get_session(session_id, user_id) + nodes = await store.list_nodes(session_id, user_id) + if session is None or nodes is None: + raise HTTPException(status_code=404, detail="Position roundtable session not found") + if session.get("status") == "archived": + raise HTTPException(status_code=409, detail="该岗位协同会话已归档,仅可查看历史记录") + current = next((node for node in nodes if node["node_key"] == node_key), None) + if current is None: + raise HTTPException(status_code=404, detail="Position roundtable node not found") + if current["status"] == "locked": + raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁") + + # Same-stage nodes are intentionally excluded. The business chain only + # hands off completed previous stages, in stage/seat order. + remaining = _HANDOFF_TEXT_BUDGET + upstream_candidates = [ + node + for node in nodes + if node["stage_index"] < current["stage_index"] + and node["status"] in {"done", "stale"} + ] + upstream_nodes: list[dict[str, Any]] = [] + for index, node in enumerate(upstream_candidates): + candidates_left = len(upstream_candidates) - index + per_node_limit = min(2_800, max(0, (remaining // candidates_left) * 2 // 3)) + answer = _truncate_text(node.get("latest_answer"), per_node_limit) + remaining -= len(answer) + artifact_budget = min( + _MAX_INLINE_ARTIFACT_CHARS, + max(0, remaining // candidates_left), + ) + artifacts, consumed = await _artifact_refs_with_content( + node.get("thread_id"), node.get("artifact_manifest"), artifact_budget + ) + remaining -= consumed + upstream_nodes.append( + { + "node_key": node["node_key"], + "agent_id": node["agent_id"], + "position_id": node.get("position_id"), + "revision": node.get("revision", 0), + "latest_answer": answer, + "artifacts": artifacts, + } + ) + if remaining <= 0: + break + return { + "kind": "position_roundtable_handoff", + "task": session.get("task_snapshot") or {}, + "intent": session.get("intent_snapshot") or {}, + "current_node": { + "node_key": current["node_key"], + "agent_id": current["agent_id"], + "position_id": current.get("position_id"), + "stage_index": current["stage_index"], + "seat_index": current["seat_index"], + }, + "upstream_nodes": upstream_nodes, + "upstream_revision": { + node["node_key"]: node.get("revision", 0) for node in upstream_nodes + }, + } + + +@router.get("/sessions", response_model=list[SessionResponse]) +async def list_sessions(request: Request, external_task_id: str | None = None) -> list[SessionResponse]: + rows = await _get_store(request).list_sessions( + _current_user_id(request), external_task_id=external_task_id + ) + return [_project_session(row) for row in rows] + + +@router.post("/sessions", response_model=SessionResponse, status_code=201) +async def create_session(request: Request, body: SessionCreateRequest) -> SessionResponse: + row = await _get_store(request).create_session( + _current_user_id(request), + external_task_id=body.external_task_id, + task_snapshot=body.task_snapshot, + ) + return _project_session(row) + + +@router.get("/sessions/{session_id}", response_model=SessionDetailResponse) +async def get_session(request: Request, session_id: str) -> SessionDetailResponse: + return await _load_detail(request, session_id) + + +@router.put("/sessions/{session_id}/task", response_model=SessionResponse) +async def update_task_snapshot( + request: Request, session_id: str, body: TaskSnapshotUpdateRequest +) -> SessionResponse: + store, user_id, _ = await _require_writable_session(request, session_id) + row = await store.update_session(session_id, user_id, task_snapshot=body.task_snapshot) + if row is None: + raise HTTPException(status_code=404, detail="Position roundtable session not found") + return _project_session(row) + + +@router.put("/sessions/{session_id}/intent", response_model=SessionResponse) +async def update_intent_snapshot( + request: Request, session_id: str, body: IntentUpdateRequest +) -> SessionResponse: + store, user_id, _ = await _require_writable_session(request, session_id) + row = await store.update_session( + session_id, + user_id, + intent_thread_id=body.intent_thread_id, + intent_snapshot=body.intent_snapshot, + ) + if row is None: + raise HTTPException(status_code=404, detail="Position roundtable session not found") + return _project_session(row) + + +@router.post("/sessions/{session_id}/activate", response_model=SessionDetailResponse) +async def activate_session( + request: Request, session_id: str, body: ActivateRequest +) -> SessionDetailResponse: + store, user_id, current = await _require_writable_session(request, session_id) + if not current.get("intent_snapshot"): + raise HTTPException(status_code=400, detail="请先完成并保存任务意图") + + chain = await _get_chain_store(request).get_chain( + body.chain_id, + user_id, + is_admin=getattr(getattr(request.state, "user", None), "system_role", None) == "admin", + ) + if chain is None: + raise HTTPException(status_code=404, detail="业务链条不存在或无权访问") + nodes = _chain_nodes(chain) + activated = await store.activate_session( + session_id, + user_id, + chain_id=body.chain_id, + chain_snapshot=chain, + nodes=nodes, + ) + if activated is None: + raise HTTPException(status_code=404, detail="Position roundtable session not found") + row, node_rows = activated + return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows]) + + +@router.post("/sessions/{session_id}/archive", response_model=SessionResponse) +async def archive_session(request: Request, session_id: str) -> SessionResponse: + row = await _get_store(request).archive_session(session_id, _current_user_id(request)) + if row is None: + raise HTTPException(status_code=404, detail="Position roundtable session not found") + return _project_session(row) + + +@router.get("/sessions/{session_id}/nodes/{node_key}", response_model=NodeResponse) +async def get_node(request: Request, session_id: str, node_key: str) -> NodeResponse: + row = await _get_store(request).get_node(session_id, node_key, _current_user_id(request)) + if row is None: + raise HTTPException(status_code=404, detail="Position roundtable node not found") + return _project_node(row) + + +@router.post("/sessions/{session_id}/nodes/{node_key}/thread", response_model=NodeResponse) +async def bind_node_thread( + request: Request, session_id: str, node_key: str, body: NodeThreadRequest +) -> NodeResponse: + store, user_id, _ = await _require_writable_session(request, session_id) + current = await store.get_node(session_id, node_key, user_id) + if current is None: + raise HTTPException(status_code=404, detail="Position roundtable node not found") + if current["status"] == "locked": + raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁") + if current.get("thread_id") and current["thread_id"] != body.thread_id: + raise HTTPException(status_code=409, detail="该节点已绑定另一条对话线程") + row = await store.update_node( + session_id, + node_key, + user_id, + thread_id=body.thread_id, + status="running" if current.get("status") == "ready" else current.get("status", "locked"), + ) + return _project_node(row) + + +@router.post("/sessions/{session_id}/nodes/{node_key}/context") +async def build_node_context(request: Request, session_id: str, node_key: str) -> dict[str, Any]: + """Return the bounded hidden task/intent/upstream context for a node turn.""" + return await _handoff_context(request, session_id, node_key) + + +@router.post("/sessions/{session_id}/nodes/{node_key}/complete-turn", response_model=NodeResponse) +async def complete_node_turn( + request: Request, session_id: str, node_key: str, body: CompleteTurnRequest +) -> NodeResponse: + store, user_id, _ = await _require_writable_session(request, session_id) + node = await store.get_node(session_id, node_key, user_id) + if node is None: + raise HTTPException(status_code=404, detail="Position roundtable node not found") + if node["status"] == "locked": + raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁") + row = await store.update_node( + session_id, + node_key, + user_id, + status="done", + latest_answer=body.latest_answer, + artifact_manifest=body.artifact_manifest, + upstream_revision=body.upstream_revision, + increment_revision=True, + ) + if row is None: + raise HTTPException(status_code=404, detail="Position roundtable node not found") + + # A node has no coordinator. Its completion alone drives the frozen chain: + # all parallel seats in the current stage must finish before the next stage + # is made writable. Re-answering an already completed upstream seat keeps + # downstream records for audit but marks them stale so they must be + # refreshed against the new handoff context. + nodes = await store.list_nodes(session_id, user_id) + if nodes is None: + raise HTTPException(status_code=404, detail="Position roundtable session not found") + if node.get("revision", 0) > 0: + for downstream in nodes: + if ( + downstream["stage_index"] > row["stage_index"] + and downstream["status"] in {"ready", "running", "done", "stale"} + ): + await store.update_node( + session_id, + downstream["node_key"], + user_id, + status="stale", + ) + nodes = await store.list_nodes(session_id, user_id) or [] + + current_stage = [item for item in nodes if item["stage_index"] == row["stage_index"]] + later_stages = sorted( + {item["stage_index"] for item in nodes if item["stage_index"] > row["stage_index"]} + ) + if current_stage and all(item["status"] == "done" for item in current_stage) and later_stages: + next_stage = later_stages[0] + for next_node in nodes: + if next_node["stage_index"] == next_stage and next_node["status"] == "locked": + await store.update_node( + session_id, + next_node["node_key"], + user_id, + status="ready", + ) + return _project_node(row) + + +@router.get("/sessions/{session_id}/results", response_model=list[NodeResponse]) +async def list_results(request: Request, session_id: str) -> list[NodeResponse]: + rows = await _get_store(request).list_results(session_id, _current_user_id(request)) + if rows is None: + raise HTTPException(status_code=404, detail="Position roundtable session not found") + return [_project_node(row) for row in rows] diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py index 60ce2c1d..10244f38 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py @@ -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): diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_01_position_roundtable_sessions.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_01_position_roundtable_sessions.py new file mode 100644 index 00000000..864fa6cc --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_01_position_roundtable_sessions.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 884be67d..1c18eb4e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -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", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/__init__.py new file mode 100644 index 00000000..eb01db3f --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/__init__.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py new file mode 100644 index 00000000..3ca7a2a3 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py @@ -0,0 +1,83 @@ +"""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) + # 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 / 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) + 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), + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py new file mode 100644 index 00000000..368d535f --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py @@ -0,0 +1,279 @@ +"""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 + + +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), + "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), + "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, + 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 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"), + 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, + increment_revision: 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 increment_revision: + row.revision += 1 + row.updated_at = datetime.now(UTC) + await session.commit() + await session.refresh(row) + return self._node_dict(row) + + 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) diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py new file mode 100644 index 00000000..365120cd --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py @@ -0,0 +1,250 @@ +"""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.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] + + +@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_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": [], + }, + ] + + 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 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) From 783774d3f3d01572f0949106f51cb36e48d3841b Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 19:30:55 +0800 Subject: [PATCH 2/7] fix(position-roundtable): align header controls --- .../pages/PositionRoundtablePage.tsx | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index c0911b62..beb32ddc 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -448,8 +448,8 @@ export default function PositionRoundtablePage() { isDark && "roundtable-planning--dark", )} > -
-
+
+
@@ -477,11 +477,14 @@ export default function PositionRoundtablePage() { )}
-
-
- 模拟任务 +
+
+ + 模拟任务 + ({ label: mockTask.title, @@ -493,6 +496,7 @@ export default function PositionRoundtablePage() { } onClick={handleCreateNewSession} > @@ -502,17 +506,19 @@ export default function PositionRoundtablePage() { } onClick={handleArchiveSession} > 归档 )} - + 当前岗位 ({ label: position.name, @@ -726,11 +732,6 @@ function TaskInformationCard({ task }: { task: PositionTask }) {

任务信息

- {task.source === "mock" && ( - - 模拟数据 - - )}
@@ -757,11 +758,6 @@ function TaskInformationCard({ task }: { task: PositionTask }) { {desensitize(task.direction)}
- {task.id && ( -

- #{task.id} -

- )}
); From 97ce19973dd160a95e940d7bc31045f2887f2e1c Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 19:31:08 +0800 Subject: [PATCH 3/7] docs(position-roundtable): plan delivery workflow --- ...ition-roundtable-delivery-workflow-plan.md | 318 +++++++++++ ...position-roundtable-implementation-plan.md | 508 ------------------ 2 files changed, 318 insertions(+), 508 deletions(-) create mode 100644 frontend-web/docs/position-roundtable-delivery-workflow-plan.md delete mode 100644 frontend-web/docs/position-roundtable-implementation-plan.md diff --git a/frontend-web/docs/position-roundtable-delivery-workflow-plan.md b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md new file mode 100644 index 00000000..ee3cfd84 --- /dev/null +++ b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md @@ -0,0 +1,318 @@ +# 岗位会商交付闭环与内置智能体实现计划 + +> 页面:`/page/strategy/qa/position-roundtable` +> +> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。 + +## 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` 建议增加: + +| 字段 | 含义 | +| --- | --- | +| `workflow_status` | `intent_pending / active / summarizing / planning / completed / archived` | +| `summary_thread_id` | 方案总结智能体线程 | +| `summary_snapshot` | 最新有效总结报告快照 | +| `action_plan_thread_id` | 行动规划智能体线程 | +| `action_plan_snapshot` | 最新有效行动规划快照 | +| `invalidated_at` | 最近一次级联失效时间,可选 | + +### 3.2 Node 扩展 + +`position_roundtable_nodes` 建议增加: + +| 字段 | 含义 | +| --- | --- | +| `display_status` | 面向前端的聚合状态,可后端计算也可前端映射 | +| `rejection_count` | 当前节点累计驳回次数 | +| `last_rejection` | 最新驳回标题、理由、操作者、时间 | +| `valid_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` | 更新时间 | + +这样右侧产物卡片、驳回记录、总结输入材料都能从同一张表聚合。 + +## 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}/deliverables` | 获取右侧产物列表 | +| `GET` | `/api/position-roundtable/deliverables/{deliverable_id}` | 获取产物详情 | +| `POST` | `/api/position-roundtable/deliverables/{deliverable_id}/reject` | 驳回产物并触发级联失效 | +| `POST` | `/api/position-roundtable/nodes/{node_id}/complete-turn` | 节点回答结束后同步回答与产物 | + +### 5.3 方案总结与行动规划 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `POST` | `/api/position-roundtable/sessions/{id}/summary/start` | 启动或重启方案总结 | +| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 对方案总结继续提问或修改 | +| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/start` | 基于总结和产物生成行动规划 | +| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 对行动规划继续提问或修改 | + +可以复用现有 `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 入口第一轮就要预留,否则后面会返工。 diff --git a/frontend-web/docs/position-roundtable-implementation-plan.md b/frontend-web/docs/position-roundtable-implementation-plan.md deleted file mode 100644 index 78aeb5c8..00000000 --- a/frontend-web/docs/position-roundtable-implementation-plan.md +++ /dev/null @@ -1,508 +0,0 @@ -# 岗位智能体协同页面 · 分步实现计划 - -> 页面:`/page/strategy/qa/position-roundtable` -> -> 核心模式:**不使用总控智能体**。情报分析岗先完成意图识别;其他岗位直接与业务链中分配给本岗位的智能体问答。每个节点独立保存对话和产物,并在问答时自动获得上游节点的有效结果。 - ---- - -## 0. 已确认的产品决策 - -- 新功能使用独立路由、独立业务状态和独立后端存储,不修改原多智能体会商页面的运行逻辑。 -- 岗位先使用前端固定枚举;下拉框、选择器等表单控件使用 `tdesign-react`。 -- **情报分析岗**负责任务意图识别,复用现有 `useStep1Intent`、`Step1Panel` 和意图接口。 -- 其他岗位不经过总控派活,直接使用节点配置的 `agent_id` 调用对应智能体。 -- 不调用原会商的 `multi-agent/init`、总控循环、后台圆桌作业和自动收口流程。 -- 每个业务链节点对应一条独立智能体对话线程;同一节点可以在该线程内多轮追问。 -- 当前岗位只能向分配给本岗位的节点提问;其他节点允许查看,但输入框只读。 -- 节点问答时自动获得任务、意图和已完成上游节点的问答结果与产物。 -- 左侧流程图从上到下排列,节点可点击,画布支持鼠标拖动;连接线保留但不显示依赖文字。 -- 右侧保留“智能体配置 / 分析结果”两个标签页。 - -### 0.1 默认实现口径 - -以下口径在产品未另行调整前作为实现默认值: - -- 业务链上一阶段的所有节点至少产生一次有效回答后,下一阶段节点进入 `ready` 状态。 -- 同一阶段的节点互不依赖,可以分别问答。 -- 上游节点产生新回答或新产物后,已经执行过的下游节点标记为 `stale`,但不删除历史。 -- “分析结果”同时收录智能体最终回答和文件产物;文件产物优先展示。 -- 第一版岗位下拉只是页面工作视角,不代表真实用户权限;后续接入用户岗位关系后再启用服务端岗位授权。 -- 一个外部任务允许创建多个岗位协同会话;页面默认打开最近更新的一条。 - -### 0.2 待业务最终确认 - -- 固定岗位的正式编码和名称清单。 -- 情报分析岗完成意图识别后,是否还允许承担业务链节点问答。 -- 下游解锁采用“上游首次有效回答”还是增加人工“提交完成”动作。 -- 新任务接口提供的字段、外部任务 ID 和页面进入方式。 -- 岗位是否最终与登录用户绑定,以及一名用户是否允许属于多个岗位。 - ---- - -## 1. 目标与非目标 - -### 1.1 本期目标 - -- 为业务链席位增加岗位分配能力,且只在从岗位协同页面进入配置时显示。 -- 为岗位协同任务提供可刷新、可恢复、可跨页面重新打开的持久化会话。 -- 按“任务 → 意图 → 业务链阶段 → 节点智能体”组织对话和产物。 -- 当前岗位仅展示和调用本岗位被分配的智能体。 -- 点击流程图节点可查看该节点完整问答和产物。 -- 下游智能体自动获得上游结果,不要求用户手工复制粘贴。 -- 保持现有多智能体会商、任务深链、普通智能体聊天行为不变。 - -### 1.2 本期非目标 - -- 不保留总控智能体,不做总控派活、共识判断或自动多轮研讨。 -- 不把岗位协同记录写入 `roundtable_drafts` 或 `roundtable_jobs`。 -- 不自动生成最终综合报告;后续可增加按需调用总结智能体的“生成汇总”功能。 -- 第一版不实现复杂条件分支、人工改线或动态跳转,只遵循现有业务链阶段顺序。 -- 第一版不实现完整组织权限系统;真实岗位授权作为独立阶段接入。 - ---- - -## 2. 目标业务流程 - -1. 页面根据外部任务参数创建或恢复一条岗位协同会话。 -2. 情报分析岗查看任务信息并选择业务链。 -3. 情报分析岗与意图识别智能体问答,确认结构化任务意图。 -4. 系统保存任务、意图和业务链快照,初始化所有业务链节点状态。 -5. 用户切换到其他岗位,只看到分配给当前岗位的可问答智能体。 -6. 用户点击流程图节点: - - 本岗位节点:加载或创建该节点的智能体线程,允许问答; - - 其他岗位节点:加载已有线程和产物,只读查看; - - 尚未解锁节点:展示依赖提示,不允许发送。 -7. 每次发送问题前,系统构建隐藏上下文并注入当前智能体。 -8. 智能体回答完成后保存节点最新回答、产物清单和版本号,并重新计算下游状态。 -9. 右侧“分析结果”按时间倒序聚合全部节点回答和产物。 - ---- - -## 3. 总体技术方案 - -### 3.1 复用范围 - -| 能力 | 方案 | -|------|------| -| 意图识别 | 复用 `useStep1Intent`、`Step1Panel`、`/api/intent/*` | -| 智能体问答 | 复用 `useThreadStream` 和现有 Agent Chat 消息/输入组件 | -| 消息存储 | 复用 LangGraph thread/checkpoint,不新增消息表 | -| 文件产物 | 复用 `/api/threads/{thread_id}/artifacts` | -| 隐藏上下文 | 复用 `task_external_context` 和 `TaskExternalContextMiddleware` | -| 智能体详情 | 复用 `AgentDetailPanel` | -| 业务链 | 扩展现有 `roundtable_chains.seats` JSON | -| 页面业务状态 | 新增岗位协同 Session/Node 存储 | - -### 3.2 明确不复用的圆桌能力 - -- `initMultiAgent` -- `useStep2Orchestration` -- `roundtable-coordinator` -- `roundtable_jobs` -- 圆桌共识百分比与最大循环轮次 -- 自动 Step 3 收口和报告绘制 - -### 3.3 目录建议 - -```text -frontend-web/src/position-roundtable/ -├── api/ -│ ├── sessions.ts -│ └── handoff.ts -├── components/ -│ ├── PositionTaskPanel.tsx -│ ├── PositionChainFlow.tsx -│ ├── PositionAgentChat.tsx -│ ├── PositionAgentList.tsx -│ └── PositionResults.tsx -├── hooks/ -│ ├── usePositionSession.ts -│ ├── usePositionNodeChat.ts -│ └── usePositionHandoff.ts -├── lib/ -│ ├── positions.ts -│ ├── chain-nodes.ts -│ └── types.ts -└── pages/ - └── PositionRoundtablePage.tsx -``` - -后端建议新增: - -```text -offline-backend-20260512/backend/ -├── app/gateway/routers/position_roundtable.py -└── packages/harness/deerflow/persistence/ - ├── position_roundtable_sessions/ - └── position_roundtable_nodes/ -``` - ---- - -## 4. 数据模型 - -### 4.1 业务链席位扩展 - -在现有 `ChainSeat` 中增加: - -```ts -interface ChainSeat { - agent_id: string; - name: string; - description?: string; - position_id?: string | null; -} -``` - -后端 `ChainSeatSchema` 同步增加可选 `position_id`。席位本身存储在 JSON 中,因此无需新增数据库列,但必须确保: - -- 创建、读取、更新接口都保留该字段; -- 普通编辑模式虽然不显示岗位控件,保存时也不能删除已有岗位字段; -- 老链条没有该字段时按“未配置岗位”处理; -- 原圆桌编排忽略该字段,行为保持不变。 - -### 4.2 岗位协同会话表 - -`position_roundtable_sessions` 建议字段: - -| 字段 | 含义 | -|------|------| -| `id` | 岗位协同会话 ID | -| `external_task_id` | 新任务系统提供的任务 ID,可空 | -| `task_snapshot` | 任务信息 JSON 快照 | -| `chain_id` | 来源业务链 ID | -| `chain_snapshot` | 启动时业务链完整快照 | -| `intent_thread_id` | 意图识别线程 ID | -| `intent_snapshot` | `useStep1Intent` 快照及结构化意图 | -| `status` | `intent_pending / active / completed / archived` | -| `created_by` | 创建人 | -| `created_at` | 创建时间 | -| `updated_at` | 更新时间 | - -### 4.3 岗位协同节点表 - -`position_roundtable_nodes` 建议字段: - -| 字段 | 含义 | -|------|------| -| `id` | 节点记录 ID | -| `session_id` | 所属岗位协同会话 | -| `node_key` | 会话内稳定节点键 | -| `stage_index` | 所属业务链阶段 | -| `seat_index` | 阶段内顺序 | -| `agent_id` | 对应智能体 | -| `position_id` | 分配岗位 | -| `thread_id` | 对应智能体聊天线程,可空 | -| `status` | `locked / ready / running / done / stale / error` | -| `latest_answer` | 最新有效回答快照 | -| `artifact_manifest` | 当前产物清单 JSON | -| `revision` | 本节点结果版本号 | -| `upstream_revision` | 最近一次问答使用的上游版本摘要 | -| `created_at` | 创建时间 | -| `updated_at` | 更新时间 | - -唯一约束:`(session_id, node_key)`。 - -### 4.4 Thread metadata - -节点线程创建后写入: - -```json -{ - "thread_type": "position_roundtable", - "position_session_id": "session-id", - "position_node_id": "node-id", - "position_id": "position-code", - "chain_id": "chain-id", - "agent_id": "agent-id" -} -``` - -Thread 继续保存真实消息;节点表只保存业务索引、状态和便于列表展示的快照。 - ---- - -## 5. API 规划 - -建议前缀:`/api/position-roundtable`。 - -### 5.1 会话接口 - -| 方法 | 路径 | 用途 | -|------|------|------| -| `POST` | `/sessions` | 根据任务创建岗位协同会话 | -| `GET` | `/sessions/{id}` | 加载会话、意图和全部节点状态 | -| `GET` | `/sessions?external_task_id=` | 查询任务下的会话历史 | -| `PUT` | `/sessions/{id}/task` | 更新尚未启动的任务快照 | -| `PUT` | `/sessions/{id}/intent` | 保存意图线程和意图快照 | -| `POST` | `/sessions/{id}/activate` | 冻结链条快照并初始化节点 | -| `POST` | `/sessions/{id}/archive` | 归档会话,不删除线程和产物 | - -### 5.2 节点接口 - -| 方法 | 路径 | 用途 | -|------|------|------| -| `GET` | `/sessions/{id}/nodes/{node_key}` | 获取节点、线程、状态和产物 | -| `POST` | `/sessions/{id}/nodes/{node_key}/thread` | 幂等创建节点智能体线程 | -| `POST` | `/sessions/{id}/nodes/{node_key}/context` | 构建本轮隐藏上游上下文 | -| `POST` | `/sessions/{id}/nodes/{node_key}/complete-turn` | 回答结束后同步回答、产物和版本 | -| `GET` | `/sessions/{id}/results` | 聚合全部岗位分析结果 | - -### 5.3 幂等与并发要求 - -- 节点线程接口必须幂等,同一节点并发请求只能创建一个有效 thread。 -- `complete-turn` 使用节点 `revision` 做乐观锁,避免旧请求覆盖新结果。 -- 会话激活后不允许直接替换链条;需要重新建会话或显式“基于新链条重建”。 -- 删除业务链不能删除已有会话中的链条快照。 - ---- - -## 6. 上游结果交接规则 - -### 6.1 上游节点计算 - -- 线性链:当前节点之前的所有已完成节点均为上游。 -- 分层链:只读取当前阶段之前的已完成阶段;同阶段节点不互相引用。 -- 注入顺序按 `stage_index`、`seat_index` 排列,确保每次构建稳定。 - -### 6.2 隐藏上下文结构 - -每次发送问题前,通过 `task_external_context` 注入: - -```json -{ - "kind": "position_roundtable_handoff", - "task": {}, - "intent": {}, - "current_node": {}, - "upstream_nodes": [ - { - "agent_id": "...", - "position_id": "...", - "latest_answer": "...", - "artifacts": [] - } - ], - "upstream_revision": "..." -} -``` - -### 6.3 产物传递 - -- Markdown、TXT、JSON、CSV、YAML 等文本产物:读取正文并按总字符上限注入。 -- PDF、Word、Excel、图片等非纯文本产物:复制到当前节点线程的只读上游资料目录,并在上下文中提供可读虚拟路径。 -- 单文件和总上下文均设置大小限制;发生截断时在上下文中标记 `truncated=true`。 -- 当前节点生成的文件仍写入自己的 `/mnt/user-data/outputs/`,不写回上游线程。 - -### 6.4 上游更新传播 - -1. 节点完成一次回答后 `revision + 1`。 -2. 系统重新计算所有下游节点的上游版本摘要。 -3. 已问答下游节点的摘要不一致时,将状态改为 `stale`。 -4. 用户再次向该节点提问后,使用最新上下文并把状态恢复为 `done`。 - ---- - -## 7. 分阶段实施计划 - -### 阶段 0:边界固定与基线测试 - -目标:明确新旧功能边界,给后续改造建立回归保护。 - -- [x] 固定岗位枚举及意图岗位编码(当前为 `intelligence` / 情报分析岗)。 -- [x] 固定新任务数据适配接口:当前使用可替换的模拟任务源,后续只需替换 `mock-task-source.ts`。 -- [ ] 为原圆桌路由、岗位协同路由分别增加基本渲染测试。 -- [ ] 记录原圆桌业务链创建、编辑、运行的基线行为。 -- [ ] 确认岗位协同模块不导入 `useStep2Orchestration` 或 `initMultiAgent`。 - -完成标准:原会商页面无行为变化;岗位协同页面仍可独立打开。 - -### 阶段 1:业务链岗位配置 - -目标:业务链节点能够持久化岗位,但普通业务链编辑体验不受影响。 - -- [x] `ChainSeat`、前端 API 映射增加 `position_id`。 -- [x] 后端 `ChainSeatSchema` 增加 `position_id` 并补充长度校验。 -- [x] 业务链编辑器识别 `mode=position`。 -- [x] 岗位模式下为每个节点增加 TDesign 岗位选择器。 -- [ ] 增加“未配置岗位”提示、岗位图例和保存校验。 -- [x] 普通模式隐藏岗位控件,但完整保留原岗位字段。 -- [x] 岗位协同页面增加“配置岗位”入口和返回参数。 -- [ ] 增加老链条、普通保存、岗位保存的前后端测试。 - -完成标准:从岗位页面进入时可以配置岗位;从原入口进入时看不到岗位控件;两种入口交替保存不丢字段。 - -### 阶段 2:岗位协同持久化底座 - -目标:任务、意图、链条快照和节点状态可以跨刷新恢复。 - -- [ ] 新增 Session/Node ORM、Repository 和数据库迁移。 -- [ ] 新增会话创建、查询、更新意图、激活和归档接口。 -- [ ] 激活时校验链条、岗位配置和意图结果。 -- [ ] 激活时根据链条快照一次性生成全部 Node 记录。 -- [ ] 新增前端 `sessions.ts` API 和 `usePositionSession`。 -- [ ] 用服务端 Session 替换 `POSITION_TASK_DRAFT_KEY` 主路径。 -- [ ] 页面刷新后恢复任务、意图、选中链条、节点状态和当前会话。 - -完成标准:刷新浏览器后不丢任务和意图;业务链后续编辑不改变已启动会话。 - -### 阶段 3:情报分析岗意图流程 - -目标:把情报分析岗固定为岗位协同的入口阶段。 - -- [ ] 将当前“组织筹划岗负责意图”调整为“情报分析岗负责意图”。 -- [ ] 仅意图岗位渲染 `Step1Panel` 和意图输入能力。 -- [ ] 将任务信息自动作为首轮隐藏背景或开场消息提供给意图智能体。 -- [ ] 意图变化时自动保存 `intent_snapshot`。 -- [ ] 增加“确认意图并启动业务链”动作。 -- [ ] 启动前校验业务链已选、节点岗位已配置、意图已完成。 -- [ ] 其他岗位在未启动时显示只读等待状态。 - -完成标准:情报分析岗确认意图后成功生成节点;其他岗位不能修改意图。 - -### 阶段 4:岗位直连智能体问答 - -目标:去除总控,当前岗位直接与自己节点的智能体多轮问答。 - -- [ ] 抽取或封装可嵌入的 Agent Chat 主区,复用现有消息列表和输入框。 -- [ ] 右侧智能体列表改为当前链条、当前岗位的交集。 -- [ ] 点击智能体或流程节点时切换 `activeNodeKey`。 -- [ ] 节点首次发送前幂等创建 thread,`assistantId = node.agent_id`。 -- [ ] 写入岗位协同 thread metadata。 -- [ ] 本岗位且状态为 `ready/running/done/stale` 时允许输入。 -- [ ] 其他岗位节点和 `locked` 节点使用同一消息列表只读展示。 -- [ ] 移除流程图“总控协调”节点,首部展示“情报分析岗 · 意图识别”。 - -完成标准:页面不调用任何总控接口;不同节点对话互不串线;岗位不能向未分配节点发送问题。 - -### 阶段 5:节点查看与流程状态 - -目标:流程图成为对话、产物和状态的统一导航。 - -- [ ] 节点增加 `locked/ready/running/done/stale/error` 可视状态。 -- [ ] 当前节点增加选中态,并与中间聊天和右侧智能体详情同步。 -- [ ] 点击任意已有节点加载完整消息历史。 -- [ ] 节点运行时防止重复发送并支持停止当前生成。 -- [ ] 计算上一阶段完成情况并自动解锁下一阶段。 -- [ ] 画布拖动时阻止节点误点击,双击空白区域复位。 -- [ ] 保持左侧滚动条、固定画布高度和父容器裁剪。 - -完成标准:用户能从流程图定位任意节点,并准确区分可问答、只读、待上游和结果过期状态。 - -### 阶段 6:上游问答与产物自动交接 - -目标:下游智能体真正获得上游信息,而不只是页面能够查看。 - -- [ ] 新增节点上下文构建接口。 -- [ ] 读取上游节点最新有效回答和结构化意图。 -- [ ] 读取上游文本产物并执行字符数限制与截断标记。 -- [ ] 为非文本产物实现安全复制或只读挂载。 -- [ ] 发送消息时通过 `task_external_context` 注入上下文。 -- [ ] 回答结束后同步 `latest_answer`、产物清单和 `revision`。 -- [ ] 上游更新时传播 `stale` 状态。 -- [ ] 增加上下文顺序、截断、文件隔离和版本传播测试。 - -完成标准:下游回答能够引用上游真实结论;不需要用户复制粘贴;不同 thread 的文件不串写。 - -### 阶段 7:分析结果聚合 - -目标:右侧结果页展示全部岗位、节点的真实结果。 - -- [ ] 新增 Session 结果聚合接口。 -- [ ] 回答结论生成“问答结果”记录。 -- [ ] 每个实际文件生成独立产物记录。 -- [ ] 卡片显示智能体名称、产物名称、岗位、生成时间和状态。 -- [ ] 支持按岗位、节点、产物类型筛选。 -- [ ] 点击结果跳转并高亮对应流程节点。 -- [ ] 文本产物复用现有预览组件,其他文件复用下载地址。 -- [ ] `stale` 结果显示“上游已更新”提示,不自动隐藏旧结果。 - -完成标准:结果数量、节点文件清单和实际线程产物一致;点击结果能回到对应对话。 - -### 阶段 8:嵌入、权限与恢复 - -目标:满足外部系统嵌入及多人岗位使用场景。 - -- [ ] 支持从 URL/外部任务接口恢复 `external_task_id` 和 Session。 -- [ ] 复用现有 `embed=1` 模式隐藏全局侧边栏和导航外壳。 -- [ ] 深浅主题下核对左、中、右三栏背景和 TDesign 控件。 -- [ ] 明确岗位下拉在嵌入系统中的来源和默认值。 -- [ ] 若接入真实用户岗位,新增 Session 成员/岗位授权模型。 -- [ ] 服务端校验当前用户是否能向目标节点发送问题。 -- [ ] 定义会话归档、任务关闭和只读历史规则。 - -完成标准:嵌入刷新不丢会话;不同岗位用户只能写自己的节点,同时可以按规则查看上游节点。 - -### 阶段 9:测试、回归与发布 - -目标:新功能可上线,原功能零回归。 - -- [ ] 前端类型检查、Chrome 109 CSS 兼容检查和生产构建通过。 -- [ ] 后端 Repository、Router、上下文构建和并发创建测试通过。 -- [ ] 覆盖线性链、分层链、同阶段并行节点、无岗位配置和智能体被删除场景。 -- [ ] 覆盖刷新恢复、重复点击、新旧上游版本、文件过大和产物为空场景。 -- [ ] 回归原业务链编辑、原多智能体会商和普通 Agent Chat。 -- [ ] 使用真实数据完成浅色、深色、嵌入三种视图的截图对比。 -- [ ] 编写接口说明、部署迁移说明和回滚说明。 - -完成标准:自动化测试通过;真实链条端到端走通;原页面和原接口行为不变。 - ---- - -## 8. 建议的提交拆分 - -每个提交保持可独立审查和回滚: - -1. `feat(chain): add optional position assignment to seats` -2. `feat(position-roundtable): add session persistence` -3. `feat(position-roundtable): persist intent workflow` -4. `feat(position-roundtable): add direct agent node chats` -5. `feat(position-roundtable): add interactive chain navigation` -6. `feat(position-roundtable): inject upstream handoff context` -7. `feat(position-roundtable): aggregate node artifacts` -8. `test(position-roundtable): cover workflow and regressions` - ---- - -## 9. 验收清单 - -- [ ] 岗位协同页面与原会商页面使用不同路由和不同业务存储。 -- [ ] 页面运行过程中没有创建或调用总控智能体。 -- [ ] 情报分析岗能够完成并持久化意图识别。 -- [ ] 业务链岗位配置只在岗位模式入口显示。 -- [ ] 当前岗位的智能体列表只包含被分配的链条节点。 -- [ ] 非本岗位节点可查看但不能发送消息。 -- [ ] 点击节点可以恢复对应的完整聊天记录和文件产物。 -- [ ] 下游智能体能够获得上游问答结果和产物内容。 -- [ ] 上游更新后下游结果能够提示过期。 -- [ ] 刷新、重新进入和嵌入模式下数据不丢失。 -- [ ] 原业务链配置、原多智能体会商和普通智能体问答均无回归。 - ---- - -## 10. 第一轮推荐实施范围 - -第一轮建议完成阶段 0~4,形成可实际操作的最小闭环: - -```text -岗位配置 → 创建会话 → 情报分析岗确认意图 → 生成业务链节点 -→ 切换岗位 → 点击本岗位节点 → 直接调用对应智能体问答 → 刷新恢复 -``` - -第二轮完成阶段 5~7,把流程图查看、上游自动交接和结果聚合补齐;第三轮处理真实岗位权限、外部任务接口和上线回归。 - -## 11. 当前实现状态(2026-07-21) - -已完成的首个可运行闭环: - -- 业务链席位已支持可选 `position_id`;只有从岗位协同页面进入业务链编辑器时才显示 TDesign 岗位选择器,普通会商配置会完整保留该字段且不显示该控件。未配置岗位的席位会在业务链选择区标明,并在启动前阻止进入节点执行。 -- 新任务入口已先以 `mock-task-source.ts` 提供 3 条模拟任务;页面通过 `taskId` 识别任务、自动恢复该任务最近一条未归档会话,支持“新建会话”和会话归档。正式任务接口确认后只替换这一数据源即可。 -- 新增独立的 `position_roundtable_sessions` / `position_roundtable_nodes` 持久化、迁移和 `/api/position-roundtable` 接口;它不使用原会商的草稿、总控、作业或自动收口流程。 -- 情报分析岗复用既有意图识别对话。确认意图后会冻结业务链、创建节点、将会话 ID 写入新页面 URL,刷新可恢复节点与其已绑定的标准聊天线程。 -- 业务链节点直接调用其配置的 `agent_id`,复用原消息列表和输入框。节点问答会注入任务、意图、已完成上游的回答和产物引用;同阶段全部完成后才解锁下一阶段,上游重答会把下游标记为 `stale`。 -- 节点交接接口会额外读取上游节点自身线程里的文本产物正文(Markdown、文本、JSON、CSV 等),按总量和单文件长度截断后注入下游智能体;二进制产物仍仅传递安全引用,且不会跨线程写入或共享工作目录。 -- 左侧流程图、右侧岗位智能体列表和结果卡片均以当前会话节点为准;跨岗位和未解锁节点保持只读。分析结果会把每条问答、每个实际文件分别列卡,支持按岗位、节点、产物类型筛选,并对 `stale` 结果标明“上游已更新”。 -- 选中流程节点或分析结果卡片后,中间对话区会同时展示该节点的产物清单;点击文件复用既有预览组件,读取的始终是该节点绑定线程下的产物。归档会话保持完整可读,但前后端均拒绝继续写入、绑定节点或生成上下文。 - -仍需在产品/组织接口确定后替换:新任务系统的正式字段与进入方式、真实用户岗位授权、上游非文本产物的安全挂载或复制,以及使用真实登录数据的完整端到端回归。 From 2ca8a23102520f8b8f74125a071c4f5d7b77ab95 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 19:48:39 +0800 Subject: [PATCH 4/7] feat(position-roundtable): add delivery workflow scaffolding --- .../src/position-roundtable/api/sessions.ts | 1 + .../pages/PositionRoundtablePage.tsx | 484 +++++++++++++----- .../gateway/routers/position_roundtable.py | 40 +- .../test_position_roundtable_sessions.py | 20 + 4 files changed, 416 insertions(+), 129 deletions(-) diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts index 793eb5b0..fbb10e10 100644 --- a/frontend-web/src/position-roundtable/api/sessions.ts +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -13,6 +13,7 @@ export type PositionNodeStatus = | "running" | "done" | "stale" + | "rejected" | "error"; export interface PositionRoundtableNode { diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index beb32ddc..468b24ea 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -2,13 +2,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; import { + AlertTriangle, ArrowLeft, Archive, Bot, BriefcaseBusiness, CheckCircle2, ChevronDown, + Circle, + CircleDashed, Clock3, + Eye, FileOutput, GitBranch, Layers3, @@ -20,6 +24,8 @@ import { Sparkles, Target, UserRoundCog, + XCircle, + type LucideIcon, } from "lucide-react"; import { Button as TButton, @@ -31,6 +37,12 @@ import { toast } from "sonner"; import { agentColor } from "@/components/workspace/agent-icon-color"; import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel"; import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-module-sidebar"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useTheme } from "@/contexts/ThemeContext"; import { useAgent } from "@/core/agents"; import type { Agent } from "@/core/agents"; @@ -75,6 +87,7 @@ import { */ const POSITIONS = POSITION_OPTIONS; type WorkspaceTab = "agents" | "results"; +type NodeVisualStatus = PositionRoundtableNode["status"] | undefined; function uniqueChains(chains: BusinessChain[]): BusinessChain[] { const seen = new Set(); @@ -95,6 +108,72 @@ function timeLabel(): string { }).format(new Date()); } +function nodeStatusMeta(status: NodeVisualStatus): { + label: string; + Icon: LucideIcon; + badgeClassName: string; + iconClassName: string; + spin?: boolean; +} { + switch (status) { + case "done": + return { + label: "已完成", + Icon: CheckCircle2, + badgeClassName: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + iconClassName: "bg-emerald-500 text-white", + }; + case "running": + return { + label: "进行中", + Icon: Loader2, + badgeClassName: "bg-sky-500/10 text-sky-700 dark:text-sky-300", + iconClassName: "bg-sky-500 text-white", + spin: true, + }; + case "stale": + return { + label: "需重做", + Icon: AlertTriangle, + badgeClassName: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + iconClassName: "bg-amber-500 text-white", + }; + case "rejected": + return { + label: "已驳回", + Icon: XCircle, + badgeClassName: "bg-rose-500/10 text-rose-700 dark:text-rose-300", + iconClassName: "bg-rose-500 text-white", + }; + case "error": + return { + label: "异常", + Icon: XCircle, + badgeClassName: "bg-destructive/10 text-destructive", + iconClassName: "bg-destructive text-destructive-foreground", + }; + case "locked": + return { + label: "未完成", + Icon: CircleDashed, + badgeClassName: "bg-slate-500/10 text-slate-500 dark:text-[#8aa0c4]", + iconClassName: "bg-slate-300 text-white dark:bg-[#315a89]", + }; + case "ready": + default: + return { + label: "未完成", + Icon: Circle, + badgeClassName: "bg-slate-500/10 text-slate-600 dark:text-[#9ebfe2]", + iconClassName: "bg-white text-slate-400 ring-1 ring-slate-300 dark:bg-[#0a315d] dark:text-[#8aa0c4] dark:ring-[#2b8ffe]/45", + }; + } +} + +function allExecutableNodesDone(nodes: PositionRoundtableNode[]): boolean { + return nodes.length > 0 && nodes.every((node) => node.status === "done"); +} + function parseIntentSummary(snapshot: Record | null | undefined): IntentSummary | null { if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) { return null; @@ -355,6 +434,15 @@ export default function PositionRoundtablePage() { toast.warning("请先选择业务链条"); return; } + if (selectedChain.seats.length === 0) { + toast.warning("当前业务链条没有可执行的智能体席位"); + return; + } + const missingAgentSeats = selectedChain.seats.filter((seat) => !seat.agent_id?.trim()); + if (missingAgentSeats.length > 0) { + toast.warning(`该业务链还有 ${missingAgentSeats.length} 个席位缺少智能体,请先完成配置`); + return; + } const unassignedSeats = selectedChain.seats.filter((seat) => !seat.position_id?.trim()); if (unassignedSeats.length > 0) { toast.warning(`该业务链还有 ${unassignedSeats.length} 个席位未配置岗位,请点击齿轮完成配置`); @@ -1016,7 +1104,6 @@ function chainStages(chain: BusinessChain) { const FLOW_NODE_COLORS = ["#3b82f6", "#a855f7", "#10b981", "#f59e0b", "#ec4899"]; const FLOW_NODE_WIDTH = 158; const FLOW_NODE_GAP = 10; -const FLOW_NODE_CENTER = FLOW_NODE_WIDTH / 2; /** 以业务链配置页的节点卡片语义绘制窄栏版,不显示连线依赖说明。 */ function BusinessChainFlow({ @@ -1030,7 +1117,88 @@ function BusinessChainFlow({ activeNodeKey: string | null; onSelectNode: (node: PositionRoundtableNode) => void; }) { - const stages = chain ? chainStages(chain) : []; + const [flowDialogOpen, setFlowDialogOpen] = useState(false); + + return ( +
+
+
+ + + +

+ 业务链条 +

+
+ {chain && ( + } + onClick={() => setFlowDialogOpen(true)} + > + 查看 + + )} +
+ {!chain ? ( +
+ 请先由情报分析岗选择业务链条 +
+ ) : ( + <> + + + + + + + 业务链条流程图 + + +
+ { + onSelectNode(node); + setFlowDialogOpen(false); + }} + className="h-full" + expanded + /> +
+
+
+ + )} +
+ ); +} + +function ChainFlowCanvas({ + chain, + nodes, + activeNodeKey, + onSelectNode, + className, + expanded = false, +}: { + chain: BusinessChain; + nodes: PositionRoundtableNode[]; + activeNodeKey: string | null; + onSelectNode: (node: PositionRoundtableNode) => void; + className?: string; + expanded?: boolean; +}) { + const stages = chainStages(chain); const [view, setView] = useState({ tx: 0, ty: 0 }); const [panning, setPanning] = useState(false); const dragRef = useRef<{ @@ -1039,7 +1207,9 @@ function BusinessChainFlow({ tx: number; ty: number; } | null>(null); - let avatarOrder = 0; + const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH; + const nodeGap = expanded ? 16 : FLOW_NODE_GAP; + const allDone = allExecutableNodesDone(nodes); const onPanStart = (event: React.PointerEvent) => { if (event.button !== 0) return; @@ -1074,105 +1244,114 @@ function BusinessChainFlow({ }; return ( -
-
- - - -

- 业务链条 -

-
- {!chain ? ( -
- 请先由情报分析岗选择业务链条 -
- ) : ( -
setView({ tx: 0, ty: 0 })} - className={cn( - "h-[456px] overflow-hidden rounded-xl border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75", - panning ? "cursor-grabbing" : "cursor-grab", - )} - style={{ - backgroundImage: - "radial-gradient(rgba(148,163,184,.55) 1px, transparent 1px)", - backgroundSize: "16px 16px", - }} - title="按住鼠标拖动画布;双击复位" - > -
- - {stages.map((stage, stageIndex) => { - const stageWidth = Math.max( - FLOW_NODE_WIDTH, - stage.seats.length * (FLOW_NODE_WIDTH + FLOW_NODE_GAP) - FLOW_NODE_GAP, - ); - return ( -
- -
- {stage.seats.map((seat, seatIndex) => { - const order = avatarOrder++; - const node = nodes.find( - (item) => - item.stageIndex === stageIndex && - item.seatIndex === seatIndex && - item.agentId === seat.agent_id, - ); - return ( - onSelectNode(node) : undefined} - /> - ); - })} -
-
- ); - })} -
-
+
setView({ tx: 0, ty: 0 })} + className={cn( + "overflow-hidden rounded-xl border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75", + panning ? "cursor-grabbing" : "cursor-grab", + className, )} -
+ style={{ + backgroundImage: "radial-gradient(rgba(148,163,184,.55) 1px, transparent 1px)", + backgroundSize: "16px 16px", + }} + title="按住鼠标拖动画布;双击复位" + > +
+ 0 ? "done" : "ready"} + width={nodeWidth} + /> + {stages.map((stage, stageIndex) => { + const stageWidth = Math.max( + nodeWidth, + stage.seats.length * (nodeWidth + nodeGap) - nodeGap, + ); + return ( +
+ +
+ {stage.seats.map((seat, seatIndex) => { + const node = nodes.find( + (item) => + item.stageIndex === stageIndex && + item.seatIndex === seatIndex && + item.agentId === seat.agent_id, + ); + return ( + onSelectNode(node) : undefined} + width={nodeWidth} + /> + ); + })} +
+
+ ); + })} + + + + +
+
); } function VerticalFlowConnector({ seatCount, width, + nodeWidth = FLOW_NODE_WIDTH, + nodeGap = FLOW_NODE_GAP, }: { seatCount: number; width: number; + nodeWidth?: number; + nodeGap?: number; }) { + const nodeCenter = nodeWidth / 2; if (seatCount <= 1) { return ; } @@ -1182,14 +1361,14 @@ function VerticalFlowConnector({ {Array.from({ length: seatCount }).map((_, index) => ( ))} @@ -1201,19 +1380,23 @@ function CompactChainNode({ title, description, accent, - avatarType, selected = false, nodeStatus, onClick, + width = FLOW_NODE_WIDTH, + titleAttr, }: { title: string; description: string; accent: string; - avatarType: string; selected?: boolean; nodeStatus?: PositionRoundtableNode["status"]; onClick?: () => void; + width?: number; + titleAttr?: string; }) { + const status = nodeStatusMeta(nodeStatus); + const StatusIcon = status.Icon; return (
-
- - {getAvatar(avatarType, "h-7 w-7")} - -
-

{title}

-

- {description} -

-
+ + + +
+

+ {title} +

+

+ {description} +

); @@ -1301,7 +1491,7 @@ function RightWorkspace({ )} > - 分析结果 + 交付产物
@@ -1529,7 +1719,7 @@ function AnalysisResults({ chain: BusinessChain | null; onSelectNode: (node: PositionRoundtableNode) => void; }) { - type ResultKind = "intent" | "answer" | "artifact"; + type ResultKind = "intent" | "delivery" | "artifact"; interface ResultItem { id: string; kind: ResultKind; @@ -1539,6 +1729,7 @@ function AnalysisResults({ summary: string; positionId: string | null; updatedAt: string | undefined; + status?: NodeVisualStatus; } const [positionFilter, setPositionFilter] = useState("all"); @@ -1560,23 +1751,47 @@ function AnalysisResults({ summary: intentObjective, positionId: DEFAULT_POSITION_ID, updatedAt: undefined, + status: "done", }); } for (const node of nodes) { const seat = seatsByAgentId.get(node.agentId); const agentName = seat?.name ?? node.agentId; - if (node.latestAnswer) { - items.push({ - id: `${node.nodeKey}:answer:${node.revision}`, - kind: "answer", - node, - agentName, - productName: "问答结果", - summary: node.latestAnswer, - positionId: node.positionId, - updatedAt: node.updatedAt, - }); - } + const artifactCount = node.artifactManifest.length; + const productName = + node.status === "done" + ? artifactCount > 0 + ? `已交付 ${artifactCount} 个产物` + : "问答结果" + : node.status === "running" + ? "交付生成中" + : node.status === "stale" + ? "产物需重做" + : node.status === "rejected" + ? "产物已驳回" + : "待交付产物"; + const summary = + node.latestAnswer || + (node.status === "locked" + ? "等待上游阶段完成后解锁。" + : node.status === "running" + ? "智能体正在生成回答与产物。" + : node.status === "stale" + ? "上游产物已变更,请重新完成该节点。" + : node.status === "rejected" + ? "该产物已被驳回,请重新问答并交付。" + : "点击左侧流程图或右侧智能体发起问答。"); + items.push({ + id: `${node.nodeKey}:delivery:${node.revision}`, + kind: "delivery", + node, + agentName, + productName, + summary, + positionId: node.positionId, + updatedAt: node.updatedAt, + status: node.status, + }); node.artifactManifest.forEach((artifact, index) => { const path = typeof artifact.path === "string" ? artifact.path : ""; const name = @@ -1598,6 +1813,7 @@ function AnalysisResults({ summary: `${mimeType}${node.status === "stale" ? " · 上游已更新,请基于最新结果复核" : ""}`, positionId: node.positionId, updatedAt: node.updatedAt, + status: node.status, }); }); } @@ -1639,9 +1855,9 @@ function AnalysisResults({ return (
-

暂无分析结果

+

暂无交付产物

- 情报分析岗完成意图识别后,产物会先进入这里;后续岗位的问答结果也会沿用同一张卡片追加入列。 + 情报分析岗启动业务链后,各岗位待交付产物会在这里排队展示。

); @@ -1651,8 +1867,8 @@ function AnalysisResults({
-

分析结果列表

-

按生成时间倒序

+

交付产物列表

+

按岗位节点与生成时间展示

{visibleItems.length}/{resultItems.length} 项结果 @@ -1667,7 +1883,7 @@ function AnalysisResults({ options={[ { label: "全部类型", value: "all" }, { label: "意图理解", value: "intent" }, - { label: "问答结果", value: "answer" }, + { label: "节点交付", value: "delivery" }, { label: "文件产物", value: "artifact" }, ]} onChange={(value) => setKindFilter(String(value) as "all" | ResultKind)} @@ -1689,6 +1905,8 @@ function AnalysisResults({ }).format(new Date(item.updatedAt)) : intentCompletedAt || "刚刚"; const stale = item.node?.status === "stale"; + const status = item.status ? nodeStatusMeta(item.status) : null; + const StatusIcon = status?.Icon; const contents = ( <>
@@ -1707,6 +1925,20 @@ function AnalysisResults({

{desensitize(item.agentName)}

+ {status && StatusIcon && ( + + + {status.label} + + )} {stale && ( 上游已更新 diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py index c05afb34..808e608f 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -20,7 +20,7 @@ from deerflow.runtime.user_context import get_effective_user_id router = APIRouter(prefix="/api/position-roundtable", tags=["position-roundtable"]) -NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "error"} +NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "error"} _HANDOFF_TEXT_BUDGET = 12_000 _MAX_INLINE_ARTIFACTS_PER_NODE = 4 _MAX_INLINE_ARTIFACT_CHARS = 1_800 @@ -152,7 +152,11 @@ async def _load_detail(request: Request, session_id: str) -> SessionDetailRespon return SessionDetailResponse(**row, nodes=[_project_node(node) for node in nodes or []]) -def _chain_nodes(chain: dict[str, Any]) -> list[dict[str, Any]]: +def _chain_nodes( + chain: dict[str, Any], + *, + available_agent_ids: set[str] | None = None, +) -> list[dict[str, Any]]: seats = chain.get("seats") if isinstance(chain.get("seats"), list) else [] if not seats: raise HTTPException(status_code=400, detail="业务链条没有可执行的智能体席位") @@ -175,6 +179,11 @@ def _chain_nodes(chain: dict[str, Any]) -> list[dict[str, Any]]: seat = seat_by_agent_id.get(agent_id) if seat is None: raise HTTPException(status_code=400, detail=f"业务链条阶段包含不存在的席位: {agent_id}") + if available_agent_ids is not None and agent_id not in available_agent_ids: + raise HTTPException( + status_code=400, + detail=f"业务链条席位「{seat.get('name') or agent_id}」缺少可用智能体,请先重新配置业务链条", + ) position_id = seat.get("position_id") if not isinstance(position_id, str) or not position_id.strip(): raise HTTPException( @@ -194,6 +203,23 @@ def _chain_nodes(chain: dict[str, Any]) -> list[dict[str, Any]]: return result +async def _available_agent_ids(request: Request, agent_ids: set[str]) -> set[str] | None: + """Return the configured agent ids if the agent store is available. + + Position collaboration freezes a business-chain snapshot before execution. + Validating seat agent ids here gives the user a concrete configuration + error instead of failing later when a node tries to create its chat thread. + """ + agent_store = getattr(request.app.state, "agent_store", None) + if agent_store is None: + return None + available: set[str] = set() + for agent_id in agent_ids: + if await agent_store.get_any(agent_id) is not None: + available.add(agent_id) + return available + + def _truncate_text(value: Any, limit: int) -> str: if limit <= 0: return "" @@ -435,7 +461,15 @@ async def activate_session( ) if chain is None: raise HTTPException(status_code=404, detail="业务链条不存在或无权访问") - nodes = _chain_nodes(chain) + seat_ids = { + str(seat.get("agent_id")) + for seat in (chain.get("seats") if isinstance(chain.get("seats"), list) else []) + if seat.get("agent_id") + } + nodes = _chain_nodes( + chain, + available_agent_ids=await _available_agent_ids(request, seat_ids), + ) activated = await store.activate_session( session_id, user_id, diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py index 365120cd..a0ea1335 100644 --- a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py +++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py @@ -79,6 +79,26 @@ async def test_session_activation_freezes_chain_and_creates_nodes_once(store): assert [node["node_key"] for node in repeated_nodes] == [node["node_key"] for node in created_nodes] +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) + + @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={}) From 1cd99a5a9027e604c0156a3b3062fc8b5a0836d2 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 20:16:52 +0800 Subject: [PATCH 5/7] feat(position-roundtable): add delivery rejection workflow --- .../src/position-roundtable/api/sessions.ts | 57 ++ .../components/PositionNodeChat.tsx | 7 +- .../pages/PositionRoundtablePage.tsx | 828 +++++++++++++++--- .../gateway/routers/position_roundtable.py | 280 +++++- ...60721_02_position_roundtable_rejections.py | 54 ++ ...3_position_roundtable_builtin_snapshots.py | 49 ++ .../persistence/position_roundtable/model.py | 7 +- .../persistence/position_roundtable/sql.py | 105 +++ .../test_position_roundtable_sessions.py | 183 ++++ 9 files changed, 1422 insertions(+), 148 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_02_position_roundtable_rejections.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_03_position_roundtable_builtin_snapshots.py diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts index fbb10e10..4980d46d 100644 --- a/frontend-web/src/position-roundtable/api/sessions.ts +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -30,6 +30,9 @@ export interface PositionRoundtableNode { artifactManifest: Array>; revision: number; upstreamRevision: Record | null; + rejectionCount: number; + lastRejection: Record | null; + invalidatedBy: string | null; createdAt?: string; updatedAt?: string; } @@ -42,6 +45,8 @@ export interface PositionRoundtableSession { chainSnapshot: Record | null; intentThreadId: string | null; intentSnapshot: Record | null; + summarySnapshot: Record | null; + actionPlanSnapshot: Record | null; status: "intent_pending" | "active" | "completed" | "archived"; createdAt?: string; updatedAt?: string; @@ -62,6 +67,9 @@ interface ServerNode { artifact_manifest?: Array>; revision?: number; upstream_revision?: Record | null; + rejection_count?: number; + last_rejection?: Record | null; + invalidated_by?: string | null; created_at?: string; updated_at?: string; } @@ -74,6 +82,8 @@ interface ServerSession { chain_snapshot?: Record | null; intent_thread_id: string | null; intent_snapshot?: Record | null; + summary_snapshot?: Record | null; + action_plan_snapshot?: Record | null; status: PositionRoundtableSession["status"]; created_at?: string; updated_at?: string; @@ -105,6 +115,9 @@ function toNode(node: ServerNode): PositionRoundtableNode { 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, }; @@ -119,6 +132,8 @@ function toSession(session: ServerSession): PositionRoundtableSession { chainSnapshot: session.chain_snapshot ?? null, intentThreadId: session.intent_thread_id, intentSnapshot: session.intent_snapshot ?? null, + summarySnapshot: session.summary_snapshot ?? null, + actionPlanSnapshot: session.action_plan_snapshot ?? null, status: session.status, createdAt: session.created_at, updatedAt: session.updated_at, @@ -263,3 +278,45 @@ export async function completePositionNodeTurn( ); return toNode(result); } + +export async function rejectPositionNode( + sessionId: string, + nodeKey: string, + input: { title: string; reason: string }, +): Promise { + const result = await readJson( + 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 async function generatePositionSummary(sessionId: string): Promise { + const result = await readJson( + await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/summary/start`, { + method: "POST", + }), + "generate position summary", + ); + return toSession(result); +} + +export async function generatePositionActionPlan(sessionId: string): Promise { + const result = await readJson( + await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/action-plan/start`, { + method: "POST", + }), + "generate position action plan", + ); + return toSession(result); +} diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx index 306dd84a..f329916c 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -78,7 +78,8 @@ export function PositionNodeChat({ sessionStatus !== "archived" && node.positionId === currentPositionId && node.status !== "locked" && - node.status !== "error"; + node.status !== "error" && + !node.invalidatedBy; useEffect(() => { setBoundThreadId(node.threadId); @@ -152,6 +153,8 @@ export function PositionNodeChat({ toast.info( sessionStatus === "archived" ? "该会话已归档,仅可查看历史记录" + : node.invalidatedBy + ? "上游产物已被驳回或重做,当前节点需等待前序节点重新完成" : node.status === "locked" ? "该节点尚未解锁" : "当前岗位只能查看该节点对话", @@ -181,6 +184,8 @@ export function PositionNodeChat({ const readOnlyHint = sessionStatus === "archived" ? "该会话已归档,仅可查看历史对话和产物" + : node.invalidatedBy + ? "上游产物已变更,需等待前序节点重新完成后再重做" : node.status === "locked" ? "等待上游岗位完成后解锁" : node.positionId !== currentPositionId diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 468b24ea..3b2036f3 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -12,13 +12,14 @@ import { Circle, CircleDashed, Clock3, - Eye, FileOutput, + FileText, GitBranch, Layers3, Loader2, MessageSquareText, Plus, + RotateCcw, Search, Settings, Sparkles, @@ -40,9 +41,11 @@ import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-m import { Dialog, DialogContent, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; import { useTheme } from "@/contexts/ThemeContext"; import { useAgent } from "@/core/agents"; import type { Agent } from "@/core/agents"; @@ -67,12 +70,16 @@ import { activatePositionSession, archivePositionSession, createPositionSession, + generatePositionActionPlan, + generatePositionSummary, getPositionSession, listPositionSessions, + rejectPositionNode, savePositionIntent, type PositionRoundtableNode, type PositionRoundtableSession, } from "@/position-roundtable/api/sessions"; +import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; import { DEFAULT_MOCK_POSITION_TASK_ID, getMockPositionTask, @@ -174,6 +181,59 @@ function allExecutableNodesDone(nodes: PositionRoundtableNode[]): boolean { return nodes.length > 0 && nodes.every((node) => node.status === "done"); } +function nodeHasDelivery(node: PositionRoundtableNode): boolean { + return ( + node.revision > 0 || + Boolean(node.latestAnswer?.trim()) || + node.artifactManifest.some((artifact) => typeof artifact.path === "string" && artifact.path.trim()) + ); +} + +function canRejectNodeDelivery(node: PositionRoundtableNode): boolean { + return node.status === "done" && nodeHasDelivery(node); +} + +function recordString(record: Record | null | undefined, key: string): string { + const value = record?.[key]; + return typeof value === "string" ? value : ""; +} + +function nodeRejectionTitle(node: PositionRoundtableNode): string { + return recordString(node.lastRejection, "title"); +} + +function nodeRejectionReason(node: PositionRoundtableNode): string { + return recordString(node.lastRejection, "reason"); +} + +function snapshotStatus(snapshot: Record | null | undefined): string { + const value = snapshot?.status; + return typeof value === "string" ? value : ""; +} + +function snapshotText(snapshot: Record | null | undefined, key: string): string { + const value = snapshot?.[key]; + return typeof value === "string" ? value : ""; +} + +function builtinNodeStatus( + snapshot: Record | null | undefined, + ready: boolean, +): NodeVisualStatus { + const status = snapshotStatus(snapshot); + if (status === "done") return "done"; + if (status === "stale") return "stale"; + if (status === "running") return "running"; + if (status === "error") return "error"; + return ready ? "ready" : "locked"; +} + +function resultPositionName(positionId: string | null): string { + if (positionId === "summary") return "内置总结节点"; + if (positionId === "action_plan") return "内置规划节点"; + return positionNameById(positionId); +} + function parseIntentSummary(snapshot: Record | null | undefined): IntentSummary | null { if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) { return null; @@ -558,6 +618,8 @@ export default function PositionRoundtablePage() { > {positionSession.status === "archived" ? "会话已归档" + : positionSession.status === "completed" + ? "行动规划已生成" : positionSession.status === "active" ? "业务链已启动" : "意图识别中"} @@ -635,6 +697,7 @@ export default function PositionRoundtablePage() { { setActiveNodeKey(node.nodeKey); @@ -726,6 +789,7 @@ export default function PositionRoundtablePage() { intentCompletedAt={intentCompletedAt} assignedAgentIds={assignedAgentIds} hasSelectedChain={Boolean(selectedChain)} + session={positionSession} onConfigureChain={ selectedChain ? () => openPositionConfig(selectedChain.id) : undefined } @@ -735,6 +799,7 @@ export default function PositionRoundtablePage() { setActiveNodeKey(node.nodeKey); setSelectedAgentId(node.agentId); }} + onSessionUpdate={setPositionSession} />
@@ -1109,11 +1174,13 @@ const FLOW_NODE_GAP = 10; function BusinessChainFlow({ chain, nodes, + session, activeNodeKey, onSelectNode, }: { chain: BusinessChain | null; nodes: PositionRoundtableNode[]; + session: PositionRoundtableSession | null; activeNodeKey: string | null; onSelectNode: (node: PositionRoundtableNode) => void; }) { @@ -1134,11 +1201,11 @@ function BusinessChainFlow({ } + shape="circle" + icon={} onClick={() => setFlowDialogOpen(true)} - > - 查看 - + title="查看完整流程图" + /> )}
{!chain ? ( @@ -1150,12 +1217,13 @@ function BusinessChainFlow({ - + @@ -1166,6 +1234,7 @@ function BusinessChainFlow({ { onSelectNode(node); @@ -1186,6 +1255,7 @@ function BusinessChainFlow({ function ChainFlowCanvas({ chain, nodes, + session, activeNodeKey, onSelectNode, className, @@ -1193,6 +1263,7 @@ function ChainFlowCanvas({ }: { chain: BusinessChain; nodes: PositionRoundtableNode[]; + session: PositionRoundtableSession | null; activeNodeKey: string | null; onSelectNode: (node: PositionRoundtableNode) => void; className?: string; @@ -1210,6 +1281,11 @@ function ChainFlowCanvas({ const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH; const nodeGap = expanded ? 16 : FLOW_NODE_GAP; const allDone = allExecutableNodesDone(nodes); + const summaryStatus = builtinNodeStatus(session?.summarySnapshot, allDone); + const actionPlanStatus = builtinNodeStatus( + session?.actionPlanSnapshot, + snapshotStatus(session?.summarySnapshot) === "done", + ); const onPanStart = (event: React.PointerEvent) => { if (event.button !== 0) return; @@ -1322,18 +1398,18 @@ function ChainFlowCanvas({ title="方案总结智能体" description="内置总结节点" accent="#6366f1" - nodeStatus={allDone ? "ready" : "locked"} + nodeStatus={summaryStatus} width={nodeWidth} - titleAttr="待接入:对所有有效交付产物进行方案总结" + titleAttr="对所有有效交付产物进行方案总结" />
@@ -1353,7 +1429,11 @@ function VerticalFlowConnector({ }) { const nodeCenter = nodeWidth / 2; if (seatCount <= 1) { - return ; + return ( + + + + ); } return ( @@ -1370,7 +1450,9 @@ function VerticalFlowConnector({ style={{ left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`, }} - /> + > + + ))}
); @@ -1442,10 +1524,12 @@ function RightWorkspace({ intentCompletedAt, assignedAgentIds, hasSelectedChain, + session, onConfigureChain, nodes, chain, onSelectNode, + onSessionUpdate, }: { selectedAgent: Agent | null; selectedAgentId: string | null; @@ -1455,10 +1539,12 @@ function RightWorkspace({ intentCompletedAt: string; assignedAgentIds: Set; hasSelectedChain: boolean; + session: PositionRoundtableSession | null; onConfigureChain?: () => void; nodes: PositionRoundtableNode[]; chain: BusinessChain | null; onSelectNode: (node: PositionRoundtableNode) => void; + onSessionUpdate: (session: PositionRoundtableSession) => void; }) { const [tab, setTab] = useState("agents"); @@ -1540,7 +1626,9 @@ function RightWorkspace({ intentCompletedAt={intentCompletedAt} nodes={nodes} chain={chain} + session={session} onSelectNode={onSelectNode} + onSessionUpdate={onSessionUpdate} />
)} @@ -1706,39 +1794,64 @@ function PositionAgentList({ ); } +type ResultKind = "intent" | "delivery" | "artifact" | "summary" | "action_plan"; + +interface ResultItem { + id: string; + kind: ResultKind; + node: PositionRoundtableNode | null; + agentName: string; + productName: string; + summary: string; + positionId: string | null; + updatedAt: string | undefined; + status?: NodeVisualStatus; + artifactPath?: string; + artifactMimeType?: string | null; +} + function AnalysisResults({ intentObjective, intentCompletedAt, nodes, chain, + session, onSelectNode, + onSessionUpdate, }: { intentObjective?: string; intentCompletedAt: string; nodes: PositionRoundtableNode[]; chain: BusinessChain | null; + session: PositionRoundtableSession | null; onSelectNode: (node: PositionRoundtableNode) => void; + onSessionUpdate: (session: PositionRoundtableSession) => void; }) { - type ResultKind = "intent" | "delivery" | "artifact"; - interface ResultItem { - id: string; - kind: ResultKind; - node: PositionRoundtableNode | null; - agentName: string; - productName: string; - summary: string; - positionId: string | null; - updatedAt: string | undefined; - status?: NodeVisualStatus; - } - const [positionFilter, setPositionFilter] = useState("all"); const [nodeFilter, setNodeFilter] = useState("all"); const [kindFilter, setKindFilter] = useState<"all" | ResultKind>("all"); + const [detailItem, setDetailItem] = useState(null); + const [rejectNode, setRejectNode] = useState(null); + const [rejectTitle, setRejectTitle] = useState(""); + const [rejectReason, setRejectReason] = useState(""); + const [rejectSubmitting, setRejectSubmitting] = useState(false); + const [summarySubmitting, setSummarySubmitting] = useState(false); + const [actionPlanSubmitting, setActionPlanSubmitting] = useState(false); + const [previewArtifact, setPreviewArtifact] = useState<{ + threadId: string; + path: string; + name: string; + mimeType: string | null; + } | null>(null); const seatsByAgentId = useMemo( () => new Map((chain?.seats ?? []).map((seat) => [seat.agent_id, seat])), [chain], ); + const allDelivered = allExecutableNodesDone(nodes); + const summaryDone = snapshotStatus(session?.summarySnapshot) === "done"; + const summaryStale = snapshotStatus(session?.summarySnapshot) === "stale"; + const actionPlanDone = snapshotStatus(session?.actionPlanSnapshot) === "done"; + const actionPlanStale = snapshotStatus(session?.actionPlanSnapshot) === "stale"; const resultItems = useMemo(() => { const items: ResultItem[] = []; if (intentObjective) { @@ -1773,7 +1886,9 @@ function AnalysisResults({ const summary = node.latestAnswer || (node.status === "locked" - ? "等待上游阶段完成后解锁。" + ? node.invalidatedBy + ? "上游产物已被驳回或重做,等待前序节点重新完成。" + : "等待上游阶段完成后解锁。" : node.status === "running" ? "智能体正在生成回答与产物。" : node.status === "stale" @@ -1814,11 +1929,49 @@ function AnalysisResults({ positionId: node.positionId, updatedAt: node.updatedAt, status: node.status, + artifactPath: path, + artifactMimeType: mimeType, }); }); } + const summarySnapshot = session?.summarySnapshot; + if (summarySnapshot) { + items.push({ + id: "builtin:summary", + kind: "summary", + node: null, + agentName: "方案总结智能体", + productName: snapshotText(summarySnapshot, "title") || "岗位会商方案总结报告", + summary: + snapshotText(summarySnapshot, "content") || + (snapshotStatus(summarySnapshot) === "stale" + ? "前序岗位产物已变更,需要重新生成方案总结。" + : "方案总结已生成。"), + positionId: "summary", + updatedAt: snapshotText(summarySnapshot, "generated_at") || undefined, + status: builtinNodeStatus(summarySnapshot, true), + }); + } + const actionPlanSnapshot = session?.actionPlanSnapshot; + if (actionPlanSnapshot) { + items.push({ + id: "builtin:action-plan", + kind: "action_plan", + node: null, + agentName: "行动规划智能体", + productName: snapshotText(actionPlanSnapshot, "title") || "岗位会商行动规划", + summary: + snapshotText(actionPlanSnapshot, "content") || + (snapshotStatus(actionPlanSnapshot) === "stale" + ? "前序岗位产物已变更,需要重新生成行动规划。" + : "行动规划已生成。"), + positionId: "action_plan", + updatedAt: snapshotText(actionPlanSnapshot, "generated_at") || undefined, + status: builtinNodeStatus(actionPlanSnapshot, true), + }); + } return items.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "")); - }, [intentObjective, nodes, seatsByAgentId]); + }, [intentObjective, nodes, seatsByAgentId, session?.actionPlanSnapshot, session?.summarySnapshot]); const positionOptions = useMemo( () => [ { label: "全部岗位", value: "all" }, @@ -1851,6 +2004,91 @@ function AnalysisResults({ [kindFilter, nodeFilter, positionFilter, resultItems], ); + const openDetail = useCallback( + (item: ResultItem) => { + setDetailItem(item); + if (item.node) { + onSelectNode(item.node); + } + }, + [onSelectNode], + ); + + const openRejectDialog = useCallback((node: PositionRoundtableNode) => { + setRejectNode(node); + setRejectTitle(nodeRejectionTitle(node)); + setRejectReason(""); + }, []); + + const handleRejectSubmit = useCallback(async () => { + if (!rejectNode) return; + const title = rejectTitle.trim(); + const reason = rejectReason.trim(); + if (!title || !reason) { + toast.warning("请填写驳回标题和理由"); + return; + } + setRejectSubmitting(true); + try { + const session = await rejectPositionNode(rejectNode.sessionId, rejectNode.nodeKey, { + title, + reason, + }); + onSessionUpdate(session); + setRejectNode(null); + setRejectTitle(""); + setRejectReason(""); + setDetailItem(null); + toast.success("已驳回该产物,后续阶段已标记为需重新完成"); + } catch (error) { + toast.error(`驳回失败:${error instanceof Error ? error.message : String(error)}`); + } finally { + setRejectSubmitting(false); + } + }, [onSessionUpdate, rejectNode, rejectReason, rejectTitle]); + + const handleGenerateSummary = useCallback(async () => { + if (!session) { + toast.warning("请先启动业务链"); + return; + } + if (!allDelivered) { + toast.warning("所有岗位产物完成后才能生成方案总结"); + return; + } + setSummarySubmitting(true); + try { + const next = await generatePositionSummary(session.id); + onSessionUpdate(next); + toast.success("方案总结已生成"); + } catch (error) { + toast.error(`生成方案总结失败:${error instanceof Error ? error.message : String(error)}`); + } finally { + setSummarySubmitting(false); + } + }, [allDelivered, onSessionUpdate, session]); + + const handleGenerateActionPlan = useCallback(async () => { + if (!session) { + toast.warning("请先启动业务链"); + return; + } + if (!summaryDone) { + toast.warning("请先生成有效的方案总结"); + return; + } + setActionPlanSubmitting(true); + try { + const next = await generatePositionActionPlan(session.id); + onSessionUpdate(next); + toast.success("行动规划已生成"); + } catch (error) { + toast.error(`生成行动规划失败:${error instanceof Error ? error.message : String(error)}`); + } finally { + setActionPlanSubmitting(false); + } + }, [onSessionUpdate, session, summaryDone]); + if (resultItems.length === 0) { return (
@@ -1864,123 +2102,431 @@ function AnalysisResults({ } return ( -
-
-
-

交付产物列表

-

按岗位节点与生成时间展示

+ <> +
+
+
+

交付产物列表

+

按岗位节点与生成时间展示

+
+ + {visibleItems.length}/{resultItems.length} 项结果 +
- - {visibleItems.length}/{resultItems.length} 项结果 - -
-
- setPositionFilter(String(value))} /> - setNodeFilter(String(value))} /> - setKindFilter(String(value) as "all" | ResultKind)} - /> -
- {visibleItems.length === 0 ? ( -
- 当前筛选条件下没有结果 +
+ setPositionFilter(String(value))} /> + setNodeFilter(String(value))} /> + setKindFilter(String(value) as "all" | ResultKind)} + />
- ) : ( - visibleItems.map((item) => { - const displayedTime = item.updatedAt - ? new Intl.DateTimeFormat("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }).format(new Date(item.updatedAt)) - : intentCompletedAt || "刚刚"; - const stale = item.node?.status === "stale"; - const status = item.status ? nodeStatusMeta(item.status) : null; - const StatusIcon = status?.Icon; - const contents = ( - <> -
- - {item.kind === "artifact" ? : } - -
-
-

{desensitize(item.agentName)}

- {status && StatusIcon && ( - - - {status.label} - - )} - {stale && ( - - 上游已更新 - - )} -
-

- {desensitize(item.productName)} -

-

- {desensitize(item.summary)} -

-
+ {nodes.length > 0 && ( +
+
+
+

+ 内置收口智能体 +

+

+ 全部岗位交付后生成方案总结,再生成行动规划 +

-
- - - {positionNameById(item.positionId)} - - - - {displayedTime} - -
- - ); - return item.node ? ( - - ) : ( -
- {contents} -
- ); - }) + + {nodes.filter((node) => node.status === "done").length}/{nodes.length} 已交付 + +
+
+ } + onClick={() => void handleGenerateSummary()} + > + {summaryDone ? "重新生成方案总结" : summaryStale ? "重新生成失效总结" : "生成方案总结"} + + } + onClick={() => void handleGenerateActionPlan()} + > + {actionPlanDone ? "重新生成行动规划" : actionPlanStale ? "重新生成失效规划" : "生成行动规划"} + +
+
+ )} + {visibleItems.length === 0 ? ( +
+ 当前筛选条件下没有结果 +
+ ) : ( + visibleItems.map((item) => { + const displayedTime = item.updatedAt + ? new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(item.updatedAt)) + : intentCompletedAt || "刚刚"; + const stale = item.node?.status === "stale"; + const invalidated = Boolean(item.node?.invalidatedBy); + const status = item.status ? nodeStatusMeta(item.status) : null; + const StatusIcon = status?.Icon; + const contents = ( + <> +
+ + {item.kind === "artifact" ? : } + +
+
+

{desensitize(item.agentName)}

+ {status && StatusIcon && ( + + + {status.label} + + )} + {(stale || invalidated) && ( + + 上游已更新 + + )} +
+

+ {desensitize(item.productName)} +

+

+ {desensitize(item.summary)} +

+
+
+
+ + + {resultPositionName(item.positionId)} + + + + {displayedTime} + +
+ + ); + return item.node ? ( + + ) : ( + + ); + }) + )} +
+ + { + if (!open) setDetailItem(null); + }} + onReject={(node) => openRejectDialog(node)} + onPreviewArtifact={(item) => { + if (!item.node?.threadId || !item.artifactPath) { + toast.info("该文件产物暂无可预览路径"); + return; + } + setPreviewArtifact({ + threadId: item.node.threadId, + path: item.artifactPath, + name: item.productName, + mimeType: item.artifactMimeType ?? null, + }); + }} + /> + + { + if (!open && !rejectSubmitting) { + setRejectNode(null); + setRejectTitle(""); + setRejectReason(""); + } + }} + onTitleChange={setRejectTitle} + onReasonChange={setRejectReason} + onSubmit={() => void handleRejectSubmit()} + /> + + {previewArtifact && ( + setPreviewArtifact(null)} + /> )} -
+ + ); +} + +function DeliverableDetailDialog({ + item, + onOpenChange, + onReject, + onPreviewArtifact, +}: { + item: ResultItem | null; + onOpenChange: (open: boolean) => void; + onReject: (node: PositionRoundtableNode) => void; + onPreviewArtifact: (item: ResultItem) => void; +}) { + const status = item?.status ? nodeStatusMeta(item.status) : null; + const StatusIcon = status?.Icon; + const canReject = item?.node ? canRejectNodeDelivery(item.node) : false; + const rejectionTitle = item?.node ? nodeRejectionTitle(item.node) : ""; + const rejectionReason = item?.node ? nodeRejectionReason(item.node) : ""; + const displayedTime = item?.updatedAt + ? new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(item.updatedAt)) + : ""; + + return ( + + + + + + 交付产物详情 + + + {!item ? null : ( +
+
+
+

+ {desensitize(item.productName)} +

+

+ {desensitize(item.agentName)} · {resultPositionName(item.positionId)} +

+
+ {status && StatusIcon && ( + + + {status.label} + + )} +
+ +
+
+ 岗位:{resultPositionName(item.positionId)} +
+
+ 时间:{displayedTime || "暂无"} +
+ {item.node && ( + <> +
+ 版本:第 {item.node.revision} 版 +
+
+ 驳回次数:{item.node.rejectionCount} +
+ + )} +
+ + {item.node?.invalidatedBy && ( +
+ 该产物受上游节点「{item.node.invalidatedBy}」影响,已不再作为后续总结或问答输入。 +
+ )} + + {rejectionTitle && ( +
+

最近驳回:{desensitize(rejectionTitle)}

+ {rejectionReason &&

{desensitize(rejectionReason)}

} +
+ )} + +
+
+

+ {item.kind === "artifact" ? "文件产物" : "产物内容"} +

+ {item.kind === "artifact" && ( + } + onClick={() => onPreviewArtifact(item)} + > + 查看文件 + + )} +
+

+ {desensitize(item.summary || "暂无产物正文")} +

+
+
+ )} + + {item?.node && ( + } + disabled={!canReject} + onClick={() => onReject(item.node!)} + > + 驳回产物 + + )} + onOpenChange(false)}> + 关闭 + + +
+
+ ); +} + +function RejectDeliveryDialog({ + node, + title, + reason, + submitting, + onOpenChange, + onTitleChange, + onReasonChange, + onSubmit, +}: { + node: PositionRoundtableNode | null; + title: string; + reason: string; + submitting: boolean; + onOpenChange: (open: boolean) => void; + onTitleChange: (value: string) => void; + onReasonChange: (value: string) => void; + onSubmit: () => void; +}) { + return ( + + + + + + 驳回交付产物 + + +
+
+ 驳回后,该节点会回到需重做状态;其后续阶段的产物会一起失效,但历史内容仍可查看。 +
+ +