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..75c06b4c
--- /dev/null
+++ b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md
@@ -0,0 +1,343 @@
+# 岗位会商交付闭环与内置智能体实现计划
+
+> 页面:`/page/strategy/qa/position-roundtable`
+>
+> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。
+
+## 实施核对(2026-07-21)
+
+本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为:
+
+```text
+任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链
+→ 分阶段席位直接问答/交付 Markdown → 全部有效交付
+→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner)
+→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口
+```
+
+- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。
+- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。
+- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。
+- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。
+- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。
+- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。
+- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。
+- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。
+- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。
+
+设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
+
+## 0. 核心结论
+
+- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
+ - 可复用:内置智能体 ID、总结报告 prompt 组织方式、Markdown 报告产物、流式对话、报告续问与文件预览能力。
+ - 需要改造:输入材料从“总控共识 + 各席位交付”改成“任务信息 + 意图结果 + 已通过的岗位产物 + 业务链快照”,并把结果存到岗位会商 Session 下。
+- 行动规划智能体建议作为新的内置智能体新增。
+ - 输入:方案总结报告、各岗位最终有效产物、业务链阶段信息、任务意图。
+ - 输出:行动规划报告、结构化子任务清单。
+- 这个模式仍然不需要总控智能体。情报分析岗负责意图识别,业务链节点直接调用各自配置的智能体,最终再由内置总结/规划智能体收口。
+- 驳回不删除历史内容,只改变“当前有效版本”的状态;被驳回节点和后续节点需要重新交付,既保留可追溯性,也方便后续返回查看。
+
+## 1. 目标业务流程
+
+1. 情报分析岗进入页面,查看任务信息并完成意图识别。
+2. 用户点击“确认意图并启动业务链”。
+3. 启动前做校验:
+ - 未选择业务链:提示“请先选择业务链”。
+ - 业务链存在未配置岗位的席位:提示并引导去业务链配置页。
+ - 业务链席位缺少智能体:提示具体席位名称。
+ - 意图尚未完成:提示先完成任务意图识别。
+4. 校验通过后冻结业务链快照,初始化岗位会商节点。
+5. 各岗位只对分配给本岗位的智能体进行问答。
+6. 节点问答完成后形成交付产物,右侧产物卡片显示“已完成 / 未完成 / 进行中 / 已驳回 / 已失效”。
+7. 左侧流程图显示各节点状态,节点可点击查看该节点对话与产物。
+8. 流程图右上角提供“查看”按钮,打开弹窗展示更完整的业务链流程图。
+9. 当业务链中所有岗位产物都处于有效已交付状态后,开放“方案总结”。
+10. 方案总结完成后开放“行动规划”。
+11. 即使进入方案总结或行动规划,用户仍可返回产物列表执行驳回。
+12. 若驳回早期阶段产物,后续阶段产物、方案总结、行动规划都一起失效并回到待重新完成状态。
+
+## 2. 状态设计
+
+### 2.1 节点状态
+
+现有节点状态需要扩展或映射为更贴近页面的展示状态:
+
+| 展示状态 | 建议内部状态 | 含义 |
+| --- | --- | --- |
+| 未完成 | `ready` / `locked` / `rejected` | 尚未有效交付,或被驳回后等待重做 |
+| 进行中 | `running` | 当前智能体正在回答或产物正在生成 |
+| 已完成 | `done` | 当前节点已有有效交付 |
+| 已失效 | `stale` | 上游被更新或驳回,当前结果不可作为最终材料 |
+| 异常 | `error` | 生成或保存失败 |
+
+建议新增 `rejected` 状态,便于区分“从未完成”和“被驳回后待重做”。如果第一轮想少改后端,也可以先用 `ready + rejection_record` 表示被驳回。
+
+### 2.2 产物状态
+
+产物状态建议独立于节点状态保存,因为一个节点可能同时有回答文本和多个文件产物。
+
+| 状态 | 含义 |
+| --- | --- |
+| `pending` | 还没有交付 |
+| `running` | 正在生成 |
+| `delivered` | 已交付且当前有效 |
+| `rejected` | 被用户驳回 |
+| `stale` | 受上游驳回或重做影响,已经失效 |
+
+### 2.3 内置收口节点
+
+在流程图中把两个内置节点追加到业务链末尾:
+
+1. `builtin:summary`:方案总结智能体。
+2. `builtin:action-plan`:行动规划智能体。
+
+这两个节点不属于普通业务链席位,但要和普通节点一样显示状态、支持点击查看结果。
+
+## 3. 数据模型调整
+
+### 3.1 Session 扩展
+
+`position_roundtable_sessions` 已使用下列持久化字段:
+
+| 字段 | 含义 |
+| --- | --- |
+| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 |
+| `summary_thread_id` | 方案总结智能体线程 |
+| `summary_snapshot` | 最新有效总结报告快照 |
+| `action_plan_thread_id` | 行动规划智能体线程 |
+| `action_plan_snapshot` | 最新有效行动规划快照 |
+| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 |
+
+### 3.2 Node 扩展
+
+`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射):
+
+| 字段 | 含义 |
+| --- | --- |
+| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 |
+| `rejection_count` | 当前节点累计驳回次数 |
+| `last_rejection` | 最新驳回标题、理由、操作者、时间 |
+| `revision` | 当前有效交付版本 |
+| `invalidated_by` | 导致当前节点失效的上游节点 key |
+
+### 3.3 Deliverable 新表(后续可选)
+
+当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
+
+| 字段 | 含义 |
+| --- | --- |
+| `id` | 产物 ID |
+| `session_id` | 会话 ID |
+| `node_key` | 来源节点 |
+| `agent_id` | 来源智能体 |
+| `position_id` | 来源岗位 |
+| `title` | 产物名称 |
+| `kind` | `answer / file / summary / action_plan` |
+| `content` | 文本产物内容摘要或正文 |
+| `artifact_path` | 文件产物虚拟路径 |
+| `thread_id` | 所属线程 |
+| `status` | `pending / running / delivered / rejected / stale` |
+| `revision` | 来源节点版本 |
+| `rejection_title` | 驳回标题 |
+| `rejection_reason` | 驳回理由 |
+| `created_at` | 创建时间 |
+| `updated_at` | 更新时间 |
+
+当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。
+
+## 4. 驳回与级联失效规则
+
+### 4.1 基本驳回
+
+用户点击产物卡片的“驳回”后弹窗填写:
+
+- 驳回标题。
+- 驳回理由。
+
+提交后:
+
+- 当前产物状态改为 `rejected`。
+- 当前节点状态改为 `rejected` 或 `ready`。
+- 当前节点的 `latest_answer` 和历史文件不删除,但不再作为有效交付输入。
+- 记录 `last_rejection`,用于页面展示和后续追踪。
+
+### 4.2 级联失效
+
+如果驳回的节点位于第 N 阶段:
+
+- 同阶段其他节点不自动驳回,除非业务链未来明确配置了依赖关系。
+- 第 N+1 阶段及之后所有节点的有效产物全部标记为 `stale`。
+- 后续节点状态回到未完成展示态。
+- `builtin:summary` 和 `builtin:action-plan` 如果已经生成,也标记为 `stale`。
+- 页面允许用户继续查看旧报告和旧行动规划,但必须提示“上游产物已变更,需要重新生成”。
+
+### 4.3 重新交付
+
+被驳回节点重新问答并完成后:
+
+- 当前节点产生新的 `revision`。
+- 当前节点产物状态变为 `delivered`。
+- 只有当该阶段所有必要节点都重新有效交付后,下一阶段才重新解锁。
+- 方案总结和行动规划必须基于最新有效产物重新生成。
+
+## 5. 接口规划
+
+### 5.1 启动与校验
+
+| 方法 | 路径 | 用途 |
+| --- | --- | --- |
+| `POST` | `/api/position-roundtable/sessions/{id}/validate-activation` | 校验业务链、岗位、智能体、意图状态 |
+| `POST` | `/api/position-roundtable/sessions/{id}/activate` | 校验通过后冻结链条并初始化节点 |
+
+第一轮也可以先把校验合并在 `activate` 内,前端根据后端返回的错误码提示。
+
+### 5.2 产物
+
+| 方法 | 路径 | 用途 |
+| --- | --- | --- |
+| `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) |
+| `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 |
+| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 |
+| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 |
+
+### 5.3 方案总结与行动规划
+
+| 方法 | 路径 | 用途 |
+| --- | --- | --- |
+| `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 |
+| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 |
+| `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 |
+| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 |
+| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 |
+| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 |
+
+可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。
+
+## 6. 前端实施步骤
+
+### 阶段 1:状态与启动校验(已完成)
+
+- 扩展 `PositionNodeStatus` 和状态显示映射。
+- “确认意图并启动业务链”前补齐校验提示。
+- 后端 `activate` 返回未配置岗位、缺智能体、未选择链条等具体错误。
+- 左侧流程图节点右上角添加状态小图标。
+- 节点样式调整为上方智能体名称、下方岗位名称,移除左侧 logo。
+
+验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。
+
+### 阶段 2:流程图弹窗(已完成)
+
+- 左侧保留小型从上到下流程图。
+- 流程图右上角增加“查看”按钮。
+- 点击后打开弹窗,展示更大的流程图。
+- 弹窗内支持点击节点查看对话与产物,保持和主页面选中节点同步。
+
+验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。
+
+### 阶段 3:产物卡片与预览(已完成)
+
+- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。
+- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。
+- 点击卡片打开详情弹窗。
+- 文本产物直接展示正文,文件产物复用现有 `ArtifactPreviewModal`。
+- 每个有效产物提供“驳回”入口。
+
+验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。
+
+### 阶段 4:驳回弹窗与级联失效(已完成)
+
+- 新增 `RejectDeliverableDialog`。
+- 驳回表单包含标题和理由,均必填。
+- 后端事务内完成当前产物驳回、当前节点状态更新、后续阶段级联失效、总结和行动规划失效。
+- 前端刷新 Session、Node、Deliverable 和流程图状态。
+- 被驳回节点在对话区显示驳回信息,提示用户重新交付。
+
+验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。
+
+### 阶段 5:方案总结智能体(已完成)
+
+- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。
+- 输入材料改为岗位会商材料:
+ - 任务信息。
+ - 情报分析岗意图结果。
+ - 业务链快照。
+ - 所有状态为 `delivered` 的岗位产物。
+ - 已驳回或失效产物只作为历史参考,不进入默认总结材料。
+- 方案总结节点追加到流程图末尾。
+- 所有产物有效交付后才允许点击“生成方案总结”。
+- 总结结果写入 `summary_snapshot`,同时进入产物列表。
+
+验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。
+
+### 阶段 6:行动规划智能体(已完成)
+
+- 新增内置智能体 ID,建议先命名为 `position-action-planner`。
+- 新增行动规划 prompt:
+ - 读取方案总结报告。
+ - 读取各岗位最终有效产物。
+ - 拆解为若干子任务。
+ - 每个子任务包含名称、目标、输入依据、责任岗位建议、前置依赖、交付物、验收标准。
+- 输出两类产物:
+ - `行动规划报告.md`。
+ - `action-plan-subtasks.json`。
+- 行动规划节点追加到方案总结之后。
+- 方案总结有效完成后才允许启动行动规划。
+
+验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。
+
+### 阶段 7:返回修改与重新生成(已完成)
+
+- 用户在方案总结或行动规划视图中仍可切回“交付产物”。
+- 驳回后自动显示需要重做的节点。
+- 重做节点完成后,允许重新生成方案总结和行动规划。
+- 旧总结、旧行动规划保留历史查看,但状态显示“已失效”。
+
+验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。
+
+### 阶段 8:测试与回归(已完成)
+
+- 后端测试:
+ - activate 校验。
+ - 节点完成后产物入库。
+ - 驳回当前节点。
+ - 驳回第一阶段时后续阶段级联失效。
+ - 总结和行动规划失效。
+- 前端测试:
+ - 启动校验提示。
+ - 状态图标显示。
+ - 产物卡片筛选和预览。
+ - 驳回弹窗必填校验。
+- 回归验证:
+ - 原多智能体会商页面不受影响。
+ - 业务链普通配置入口不显示岗位配置。
+ - 岗位会商页面刷新恢复正常。
+ - 深色模式和嵌入模式布局不溢出。
+
+## 7. 推荐提交拆分
+
+1. `feat(position-roundtable): add delivery status model`
+2. `feat(position-roundtable): validate chain activation`
+3. `feat(position-roundtable): add workflow status graph`
+4. `feat(position-roundtable): add deliverable cards and preview`
+5. `feat(position-roundtable): support rejection cascade`
+6. `feat(position-roundtable): add summary builtin node`
+7. `feat(position-roundtable): add action planning builtin node`
+8. `test(position-roundtable): cover delivery workflow`
+
+## 8. 第一轮建议范围
+
+第一轮优先做最小可闭环:
+
+```text
+意图识别
+→ 启动校验
+→ 业务链节点状态
+→ 节点产物列表
+→ 产物详情查看
+→ 驳回与级联失效
+→ 全部交付后生成方案总结
+→ 基于总结生成行动规划
+```
+
+如果时间紧,行动规划可以先生成 Markdown 报告,结构化 `subtasks.json` 放到第二轮;但数据模型和 UI 入口第一轮就要预留,否则后面会返工。
diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
index 469a50fc..58505ae7 100644
--- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
+++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
-import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
+import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -184,6 +184,18 @@ export function WorkspaceNavChatList({
+
+
+
+
+ 岗位会商
+
+
+
}
/>
+
+
+
+
+
+ }
+ />
>;
+ revision: number;
+ upstreamRevision: Record | null;
+ rejectionCount: number;
+ lastRejection: Record | null;
+ invalidatedBy: string | 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;
+ summaryThreadId: string | null;
+ summarySnapshot: Record | null;
+ actionPlanThreadId: string | null;
+ actionPlanSnapshot: 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;
+ rejection_count?: number;
+ last_rejection?: Record | null;
+ invalidated_by?: string | 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;
+ summary_thread_id?: string | null;
+ summary_snapshot?: Record | null;
+ action_plan_thread_id?: string | null;
+ action_plan_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,
+ rejectionCount: node.rejection_count ?? 0,
+ lastRejection: node.last_rejection ?? null,
+ invalidatedBy: node.invalidated_by ?? null,
+ createdAt: node.created_at,
+ updatedAt: node.updated_at,
+ };
+}
+
+function toSession(session: ServerSession): PositionRoundtableSession {
+ return {
+ id: session.id,
+ externalTaskId: session.external_task_id,
+ taskSnapshot: session.task_snapshot ?? {},
+ chainId: session.chain_id,
+ chainSnapshot: session.chain_snapshot ?? null,
+ intentThreadId: session.intent_thread_id,
+ intentSnapshot: session.intent_snapshot ?? null,
+ summaryThreadId: session.summary_thread_id ?? null,
+ summarySnapshot: session.summary_snapshot ?? null,
+ actionPlanThreadId: session.action_plan_thread_id ?? null,
+ actionPlanSnapshot: session.action_plan_snapshot ?? null,
+ status: session.status,
+ createdAt: session.created_at,
+ updatedAt: session.updated_at,
+ nodes: session.nodes?.map(toNode),
+ };
+}
+
+export async function listPositionSessions(externalTaskId?: string): Promise {
+ 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);
+}
+
+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 interface PositionBuiltinRunPreparation {
+ agentId: string;
+ displayName: string;
+ threadId: string | null;
+ prompt: string;
+ sourceRevisions: Record;
+}
+
+interface ServerBuiltinRunPreparation {
+ agent_id: string;
+ display_name: string;
+ thread_id?: string | null;
+ prompt: string;
+ source_revisions?: Record;
+}
+
+function toBuiltinRunPreparation(value: ServerBuiltinRunPreparation): PositionBuiltinRunPreparation {
+ return {
+ agentId: value.agent_id,
+ displayName: value.display_name,
+ threadId: value.thread_id ?? null,
+ prompt: value.prompt,
+ sourceRevisions: value.source_revisions ?? {},
+ };
+}
+
+type BuiltinKind = "summary" | "action_plan";
+
+async function prepareBuiltinRun(
+ sessionId: string,
+ kind: BuiltinKind,
+ message = "",
+): Promise {
+ const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
+ const result = await readJson(
+ await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message }),
+ }),
+ `prepare position ${kind}`,
+ );
+ return toBuiltinRunPreparation(result);
+}
+
+async function completeBuiltinRun(
+ sessionId: string,
+ kind: BuiltinKind,
+ input: {
+ threadId: string;
+ content: string;
+ artifactManifest: Array>;
+ sourceRevisions: Record;
+ model?: string;
+ },
+): Promise {
+ const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
+ const result = await readJson(
+ await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ thread_id: input.threadId,
+ content: input.content,
+ artifact_manifest: input.artifactManifest,
+ source_revisions: input.sourceRevisions,
+ model: input.model || null,
+ }),
+ }),
+ `complete position ${kind}`,
+ );
+ return toSession(result);
+}
+
+export function preparePositionSummary(
+ sessionId: string,
+ message = "",
+): Promise {
+ return prepareBuiltinRun(sessionId, "summary", message);
+}
+
+export function completePositionSummary(
+ sessionId: string,
+ input: Parameters[2],
+): Promise {
+ return completeBuiltinRun(sessionId, "summary", input);
+}
+
+export function preparePositionActionPlan(
+ sessionId: string,
+ message = "",
+): Promise {
+ return prepareBuiltinRun(sessionId, "action_plan", message);
+}
+
+export function completePositionActionPlan(
+ sessionId: string,
+ input: Parameters[2],
+): Promise {
+ return completeBuiltinRun(sessionId, "action_plan", input);
+}
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..c5b99f42
--- /dev/null
+++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx
@@ -0,0 +1,370 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Bot, FileText, LockKeyhole } from "lucide-react";
+import { toast } from "sonner";
+import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
+import { InputBox } from "@/components/workspace/input-box";
+import { ThreadContext } from "@/components/workspace/messages/context";
+import { MessageList } from "@/components/workspace/messages";
+import { useArtifactsOptional } from "@/components/workspace/artifacts";
+import { useAgent } from "@/core/agents";
+import { useLocalSettings } from "@/core/settings";
+import { useThreadStream } from "@/core/threads/hooks";
+import { textOfMessage } from "@/core/threads/utils";
+import { uuid } from "@/core/utils/uuid";
+import {
+ bindPositionNodeThread,
+ completePositionNodeTurn,
+ getPositionNodeContext,
+ type PositionRoundtableNode,
+} from "@/position-roundtable/api/sessions";
+import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
+import { desensitize } from "@/lib/desensitize";
+
+interface PositionNodeChatProps {
+ sessionId: string;
+ /** Parent task id: all node threads are indexed under this task. */
+ taskId: string;
+ chainId: string | null;
+ node: PositionRoundtableNode;
+ currentPositionId: string;
+ sessionStatus: "intent_pending" | "active" | "completed" | "archived";
+ agentName?: string | null;
+ onNodeUpdate: (node: PositionRoundtableNode) => void;
+ /** The workspace collapses the configuration panel on the first real turn. */
+ onConversationStart?: () => void;
+ onPreviewArtifact?: (artifact: {
+ threadId: string;
+ path: string;
+ name: string;
+ mimeType: string | null;
+ }) => void;
+}
+
+interface NodeArtifact {
+ name: string;
+ path: string;
+ mimeType: string | null;
+}
+
+function readNodeArtifacts(manifest: Array>): NodeArtifact[] {
+ return manifest.flatMap((artifact) => {
+ const path = typeof artifact.path === "string" ? artifact.path : "";
+ if (!path) return [];
+ const name =
+ typeof artifact.name === "string" && artifact.name.trim()
+ ? artifact.name
+ : path.split("/").filter(Boolean).at(-1) || "未命名产物";
+ const mimeType =
+ typeof artifact.mime_type === "string"
+ ? artifact.mime_type
+ : typeof artifact.mimeType === "string"
+ ? artifact.mimeType
+ : null;
+ return [{ name, path, mimeType }];
+ });
+}
+
+function hasMarkdownArtifact(manifest: Array>): boolean {
+ return manifest.some((artifact) => {
+ const path = typeof artifact.path === "string" ? artifact.path.toLowerCase() : "";
+ const name = typeof artifact.name === "string" ? artifact.name.toLowerCase() : "";
+ const mime =
+ typeof artifact.mime_type === "string"
+ ? artifact.mime_type.toLowerCase()
+ : typeof artifact.mimeType === "string"
+ ? artifact.mimeType.toLowerCase()
+ : "";
+ return (
+ mime.includes("markdown") ||
+ path.endsWith(".md") ||
+ path.endsWith(".markdown") ||
+ name.endsWith(".md") ||
+ name.endsWith(".markdown")
+ );
+ });
+}
+
+/**
+ * Direct single-agent chat for one frozen business-chain node.
+ *
+ * It deliberately uses the regular `useThreadStream` + `MessageList` +
+ * `InputBox` stack. The position Session merely records which existing thread
+ * belongs to the node; it is not a second message persistence mechanism.
+ */
+export function PositionNodeChat({
+ sessionId,
+ taskId,
+ chainId,
+ node,
+ currentPositionId,
+ sessionStatus,
+ agentName,
+ onNodeUpdate,
+ onConversationStart,
+ onPreviewArtifact,
+}: PositionNodeChatProps) {
+ const [settings, setSettings] = useLocalSettings();
+ const { agent } = useAgent(node.agentId);
+ const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
+ const artifactsCtx = useArtifactsOptional();
+ const [boundThreadId, setBoundThreadId] = useState(node.threadId);
+ const boundThreadIdRef = useRef(node.threadId);
+ const draftThreadIdRef = useRef(node.threadId ?? uuid());
+ const upstreamRevisionRef = useRef | null>(null);
+ const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
+ const canWrite =
+ sessionStatus !== "archived" &&
+ node.positionId === currentPositionId &&
+ node.status !== "locked" &&
+ node.status !== "error" &&
+ !node.invalidatedBy;
+
+ useEffect(() => {
+ if (artifactsCtx?.open && artifactsCtx.selectedArtifact) {
+ onPreviewArtifact?.({
+ threadId: boundThreadIdRef.current ?? boundThreadId ?? draftThreadIdRef.current,
+ path: artifactsCtx.selectedArtifact,
+ name: artifactsCtx.selectedArtifact.split("/").filter(Boolean).at(-1) || "Markdown 报告",
+ mimeType: artifactsCtx.selectedArtifact.toLowerCase().endsWith(".md")
+ ? "text/markdown"
+ : null,
+ });
+ }
+ }, [artifactsCtx?.open, artifactsCtx?.selectedArtifact, boundThreadId, onPreviewArtifact]);
+
+ useEffect(() => {
+ setBoundThreadId(node.threadId);
+ boundThreadIdRef.current = node.threadId;
+ }, [node.threadId]);
+
+ const bindCreatedThread = useCallback(
+ (threadId: string) => {
+ boundThreadIdRef.current = threadId;
+ setBoundThreadId(threadId);
+ void bindPositionNodeThread(sessionId, node.nodeKey, threadId)
+ .then(onNodeUpdate)
+ .catch((error) => {
+ toast.error(`节点对话绑定失败:${error instanceof Error ? error.message : String(error)}`);
+ });
+ },
+ [node.nodeKey, onNodeUpdate, sessionId],
+ );
+
+ const [thread, sendMessage, , actualThreadId] = useThreadStream({
+ threadId: boundThreadId,
+ assistantId: node.agentId,
+ context: { ...settings.context, agent_id: node.agentId },
+ threadMetadata: {
+ thread_type: "position_roundtable",
+ // ``taskId`` is the shared task-workspace convention. The two
+ // position-specific identifiers retain the exact child node for audits
+ // and restore, even when one position owns more than one chain seat.
+ taskId,
+ position_roundtable_task_id: taskId,
+ position_task_id: `${taskId}:${node.positionId ?? "unassigned"}:${node.nodeKey}`,
+ position_session_id: sessionId,
+ position_node_id: node.id,
+ position_node_key: node.nodeKey,
+ position_id: node.positionId,
+ chain_id: chainId,
+ agent_id: node.agentId,
+ },
+ onStart: bindCreatedThread,
+ onFinish: (state) => {
+ const threadId = boundThreadIdRef.current;
+ const latestAi = [...(state.messages ?? [])]
+ .reverse()
+ .find((message) => message.type === "ai");
+ const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
+ if (!threadId || !latestAnswer.trim()) return;
+ void (async () => {
+ let artifactManifest: Array> = [];
+ try {
+ const artifacts = await listSessionArtifacts(threadId);
+ artifactManifest = artifacts.files.map((file) => ({
+ name: file.name,
+ path: file.path,
+ mime_type: file.mimeType,
+ modified_at: file.modifiedAt,
+ size_bytes: file.sizeBytes,
+ }));
+ } catch {
+ // Artifact listing is best-effort: a valid text answer must still be
+ // persisted and unlock downstream work even if files cannot be read.
+ }
+ try {
+ const updated = await completePositionNodeTurn(sessionId, node.nodeKey, {
+ latestAnswer,
+ artifactManifest,
+ upstreamRevision: upstreamRevisionRef.current,
+ });
+ onNodeUpdate(updated);
+ if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
+ toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
+ }
+ } catch (error) {
+ toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
+ }
+ })();
+ },
+ });
+
+ // SDK thread creation is asynchronous. ``onStart`` normally binds it, but
+ // watching the actual stream id as well closes the last race where a page
+ // refresh could happen between creation and the async node-index write.
+ useEffect(() => {
+ if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
+ bindCreatedThread(actualThreadId);
+ }, [actualThreadId, bindCreatedThread]);
+
+ const handleSubmit = useCallback(
+ async (message: PromptInputMessage) => {
+ if (!canWrite) {
+ toast.info(
+ sessionStatus === "archived"
+ ? "该会话已归档,仅可查看历史记录"
+ : node.invalidatedBy
+ ? "上游产物已被驳回或重做,当前节点需等待前序节点重新完成"
+ : node.status === "locked"
+ ? "该节点尚未解锁"
+ : "当前岗位只能查看该节点对话",
+ );
+ return;
+ }
+ try {
+ onConversationStart?.();
+ const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey);
+ const upstreamRevision = taskExternalContext.upstream_revision;
+ upstreamRevisionRef.current =
+ upstreamRevision && typeof upstreamRevision === "object" && !Array.isArray(upstreamRevision)
+ ? (upstreamRevision as Record)
+ : null;
+ await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
+ agent_id: node.agentId,
+ task_external_context: taskExternalContext,
+ });
+ } catch (error) {
+ toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
+ }
+ },
+ [
+ canWrite,
+ node.agentId,
+ node.nodeKey,
+ node.status,
+ onConversationStart,
+ sendMessage,
+ sessionId,
+ sessionStatus,
+ ],
+ );
+
+ const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
+ const displayThreadId = boundThreadId ?? `position-node-${node.id}`;
+ const readOnlyHint =
+ sessionStatus === "archived"
+ ? "该会话已归档,仅可查看历史对话和产物"
+ : node.invalidatedBy
+ ? "上游产物已变更,需等待前序节点重新完成后再重做"
+ : node.status === "locked"
+ ? "等待上游岗位完成后解锁"
+ : node.positionId !== currentPositionId
+ ? "当前岗位仅可查看该节点的对话和产物"
+ : "";
+
+ return (
+
+
+
+
+ {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()}
+ />
+
+
+
+ );
+}
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..4e5488e4 100644
--- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx
+++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx
@@ -1,24 +1,36 @@
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,
FileOutput,
+ FileText,
GitBranch,
Layers3,
Loader2,
MessageSquareText,
+ Plus,
+ RotateCcw,
Search,
+ Settings,
Sparkles,
Target,
UserRoundCog,
+ XCircle,
+ type LucideIcon,
} from "lucide-react";
import {
Button as TButton,
+ Drawer as TDrawer,
Input as TInput,
Select as TSelect,
} from "tdesign-react";
@@ -27,82 +39,80 @@ 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 { ArtifactFileDetail, useArtifactsOptional } from "@/components/workspace/artifacts";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Textarea } from "@/components/ui/textarea";
import { useTheme } from "@/contexts/ThemeContext";
+import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls";
+import { getAPIClient } from "@/core/api/api-client";
import { useAgent } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { useModels } from "@/core/models/hooks";
+import type { AgentThreadState } from "@/core/threads/types";
+import { textOfMessage } from "@/core/threads/utils";
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 { useStep1Intent, type InteractiveMessage } 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,
+ completePositionActionPlan,
+ completePositionSummary,
+ createPositionSession,
+ getPositionSession,
+ listPositionSessions,
+ preparePositionActionPlan,
+ preparePositionSummary,
+ rejectPositionNode,
+ savePositionIntent,
+ type PositionRoundtableNode,
+ type PositionRoundtableSession,
+} from "@/position-roundtable/api/sessions";
+import {
+ initPositionActionPlanThread,
+ initSummaryThread,
+ listSessionArtifacts,
+ streamMultiAgent,
+} from "@/roundtable-planning/api/multi-agent";
+import {
+ DEFAULT_MOCK_POSITION_TASK_ID,
+ getMockPositionTask,
+ 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";
+type NodeVisualStatus = PositionRoundtableNode["status"] | undefined;
+type PositionArtifactPreview = {
+ threadId: string;
+ path: string;
+ name: string;
+ mimeType: string | null;
+};
function uniqueChains(chains: BusinessChain[]): BusinessChain[] {
const seen = new Set();
@@ -123,22 +133,309 @@ function timeLabel(): string {
}).format(new Date());
}
-export default function PositionRoundtablePage() {
+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" &&
+ (isIntentPositionNode(node) || nodeHasMarkdownDelivery(node)),
+ )
+ );
+}
+
+function isIntentPositionNode(node: PositionRoundtableNode): boolean {
+ return node.positionId === DEFAULT_POSITION_ID;
+}
+
+function artifactIsMarkdown(artifact: Record): boolean {
+ const path = typeof artifact.path === "string" ? artifact.path.trim().toLowerCase() : "";
+ const name = typeof artifact.name === "string" ? artifact.name.trim().toLowerCase() : "";
+ const mime =
+ typeof artifact.mime_type === "string"
+ ? artifact.mime_type.toLowerCase()
+ : typeof artifact.mimeType === "string"
+ ? artifact.mimeType.toLowerCase()
+ : "";
+ return (
+ mime.includes("markdown") ||
+ path.endsWith(".md") ||
+ path.endsWith(".markdown") ||
+ name.endsWith(".md") ||
+ name.endsWith(".markdown")
+ );
+}
+
+function nodeHasMarkdownDelivery(node: PositionRoundtableNode): boolean {
+ return node.artifactManifest.some(artifactIsMarkdown);
+}
+
+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" && !isIntentPositionNode(node) && nodeHasMarkdownDelivery(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 snapshotArtifact(
+ snapshot: Record | null | undefined,
+ key: string,
+): Record | null {
+ const value = snapshot?.[key];
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+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 formatResultTime(value: string | undefined, fallback = "待完成"): string {
+ if (!value) return fallback;
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return new Intl.DateTimeFormat("zh-CN", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(date);
+}
+
+function artifactName(artifact: Record | null | undefined): string {
+ if (!artifact) return "";
+ const name = typeof artifact.name === "string" ? artifact.name.trim() : "";
+ if (name) return name;
+ const path = typeof artifact.path === "string" ? artifact.path.trim() : "";
+ return path.split("/").filter(Boolean).at(-1) ?? "";
+}
+
+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,
+ };
+}
+
+function restoreIntentMessages(
+ messages: AgentThreadState["messages"] | undefined,
+ fallbackWelcome: string,
+): InteractiveMessage[] {
+ const restored: InteractiveMessage[] = [];
+ for (const [index, message] of (messages ?? []).entries()) {
+ const raw = message as typeof message & {
+ additional_kwargs?: Record;
+ type?: string;
+ };
+ const additional = raw.additional_kwargs ?? {};
+ // 任务快照与工具回执仅用于模型上下文,不能成为用户可见的聊天记录。
+ if (additional.hide_from_ui === true || additional.task_external_context === true) continue;
+ const messageType = raw.type?.toLowerCase();
+ if (messageType !== "human" && messageType !== "ai") continue;
+ const content = (textOfMessage(message) ?? "").trim();
+ if (!content) continue;
+ restored.push({
+ id: index + 1,
+ sender: messageType === "human" ? "user" : "agent",
+ senderName: messageType === "human" ? "用户" : "任务理解智能体",
+ content,
+ time: "已恢复",
+ });
+ }
+ return restored.length > 0
+ ? restored
+ : [
+ {
+ id: 1,
+ sender: "agent",
+ senderName: "任务理解智能体",
+ content: fallbackWelcome,
+ time: "已恢复",
+ },
+ ];
+}
+
+export default function PositionRoundtablePage({
+ compactChrome = false,
+}: {
+ compactChrome?: boolean;
+}) {
+ 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 [rightPanelOpen, setRightPanelOpen] = useState(true);
+ const [rightTab, setRightTab] = useState("agents");
+ const artifactsCtx = useArtifactsOptional();
+ const [sandboxArtifact, setSandboxArtifact] = useState(null);
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 intentTaskContext = useMemo(
+ () => ({
+ kind: "position_roundtable_intent_task",
+ usage:
+ "这是岗位多智能体会商页面左侧展示的任务信息。情报分析岗必须基于这些任务信息识别意图;不要让用户重复提供已经存在的任务名称、任务描述、任务方向。",
+ completion_rule:
+ "情报分析岗只需要完成意图识别并输出结构化 INTENT_READY;该岗位不要求产出 Markdown 报告文档。",
+ task: {
+ id: task.id,
+ title: task.title,
+ description: task.description,
+ direction: task.direction,
+ },
+ current_position: {
+ id: activePosition.id,
+ name: activePosition.name,
+ },
+ }),
+ [activePosition.id, activePosition.name, task.description, task.direction, task.id, task.title],
+ );
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,
+ taskContext: intentTaskContext,
+ initialWelcomeText:
+ "我已读取左侧任务信息。你可以直接补充本次任务的特殊约束;如果没有补充,我会基于现有任务信息进行意图识别。",
triggerToast,
});
+ const hydratedIntentSessionRef = useRef(null);
+ const persistedIntentRef = useRef(null);
const { data: chains = [], isLoading: chainsLoading } = useQuery({
queryKey: ["position-roundtable", "chains"],
@@ -157,8 +454,261 @@ 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 isIntelligenceDeliveryOverview =
+ activePosition.isIntentPosition && positionSession !== null && positionSession.status !== "intent_pending";
+ 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);
+ // 岗位切换与会话恢复会在不同的渲染周期完成。不能只依赖 effect 回写
+ // activeNodeKey:否则岗位已获配、流程节点也已存在时,中间区仍会短暂(有时会
+ // 持续)落到“请选择节点”的空态。先在渲染期确定当前岗位的兜底节点,再把它
+ // 同步回 state,保证进入岗位即可直接看到该岗位可用的第一个席位。
+ const currentPositionNodes = useMemo(
+ () =>
+ (positionSession?.nodes ?? []).filter(
+ (node) => node.positionId === positionId,
+ ),
+ [positionId, positionSession?.nodes],
+ );
+ const activeNode = useMemo(
+ () =>
+ currentPositionNodes.find((node) => node.nodeKey === activeNodeKey) ??
+ currentPositionNodes[0] ??
+ null,
+ [activeNodeKey, currentPositionNodes],
+ );
+ const activeNodeAgentName = useMemo(() => {
+ if (!activeNode) return null;
+ return activeChain?.seats.find((seat) => seat.agent_id === activeNode.agentId)?.name ?? null;
+ }, [activeChain?.seats, activeNode]);
+ useEffect(() => {
+ const nextNodeKey = activeNode?.nodeKey ?? null;
+ if (activeNodeKey !== nextNodeKey) setActiveNodeKey(nextNodeKey);
+ }, [activeNode?.nodeKey, activeNodeKey]);
+ 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]);
+
+ // 第一阶段同样属于当前任务:intent thread 一创建就立刻写入 taskId 绑定的
+ // 岗位会话。之前等到“启动业务链”才保存,刷新会直接丢失意图问答。
+ useEffect(() => {
+ const intentThreadId = intent.intentThreadId;
+ if (!intentThreadId || positionSession?.status === "archived") return;
+ const intentSnapshot = intent.intentReady ? { ...intent.intentReady } : null;
+ const snapshotKey = JSON.stringify(intentSnapshot);
+ const bindingKey = `${positionSession?.id ?? "new"}:${intentThreadId}:${snapshotKey}`;
+ if (persistedIntentRef.current === bindingKey) return;
+ persistedIntentRef.current = bindingKey;
+
+ let cancelled = false;
+ void (async () => {
+ try {
+ const baseSession =
+ positionSession ??
+ (await createPositionSession({
+ externalTaskId: task.id || undefined,
+ taskSnapshot: { ...task },
+ }));
+ const saved = await savePositionIntent(baseSession.id, {
+ intentThreadId,
+ intentSnapshot,
+ });
+ if (cancelled) return;
+ persistedIntentRef.current = `${saved.id}:${intentThreadId}:${snapshotKey}`;
+ setPositionSession((current) => (current?.id === saved.id ? { ...current, ...saved } : saved));
+ if (!sessionIdFromUrl) {
+ const next = new URLSearchParams(searchParamsString);
+ next.set("taskId", task.id);
+ next.set("sessionId", saved.id);
+ next.delete("new");
+ setSearchParams(next, { replace: true });
+ }
+ } catch (error) {
+ persistedIntentRef.current = null;
+ if (!cancelled) {
+ toast.error(`任务会话保存失败:${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ intent.intentReady,
+ intent.intentThreadId,
+ positionSession,
+ searchParamsString,
+ sessionIdFromUrl,
+ setSearchParams,
+ task,
+ ]);
+
+ // Session 只保存业务索引,真实消息留在 LangGraph thread。恢复时必须读取
+ // 原 thread,不能再以空数组 hydrate,否则页面看上去像“聊天记录没有保存”。
+ useEffect(() => {
+ const sessionId = positionSession?.id;
+ const intentThreadId = positionSession?.intentThreadId;
+ if (!sessionId || !intentThreadId) return;
+ const restoreKey = `${sessionId}:${intentThreadId}`;
+ if (hydratedIntentSessionRef.current === restoreKey) return;
+ hydratedIntentSessionRef.current = restoreKey;
+
+ let cancelled = false;
+ void getAPIClient()
+ .threads.getState(intentThreadId)
+ .then((state) => {
+ if (cancelled) return;
+ intent.hydrateFromDraft({
+ intentThreadId,
+ intentReady: frozenIntent,
+ interactiveMessages: restoreIntentMessages(
+ state.values?.messages,
+ "我已读取左侧任务信息。你可以直接补充本次任务的特殊约束;如果没有补充,我会基于现有任务信息进行意图识别。",
+ ),
+ timelineEntries: [],
+ });
+ })
+ .catch(() => {
+ if (cancelled) return;
+ // 历史读取失败也需绑定原 thread,避免又新建出一条不属于该任务的会话。
+ intent.hydrateFromDraft({
+ intentThreadId,
+ intentReady: frozenIntent,
+ interactiveMessages: [],
+ timelineEntries: [],
+ });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [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 +731,111 @@ export default function PositionRoundtablePage() {
void intent.sendIntentMessage("请继续追问当前任务中尚未明确的关键条件。", []);
}, [intent]);
+ const handleActivateChain = useCallback(async () => {
+ if (startingChain) return;
+ if (!selectedChain) {
+ 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} 个席位未配置岗位,请点击齿轮完成配置`);
+ 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 handleCreateNewSession = useCallback(() => {
+ intent.resetIntent();
+ hydratedIntentSessionRef.current = null;
+ persistedIntentRef.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]);
+
+ const handlePreviewArtifact = useCallback(
+ (artifact: PositionArtifactPreview) => {
+ if (!artifact.threadId || !artifact.path) {
+ toast.warning("该文件缺少沙箱预览所需的信息");
+ return;
+ }
+ // 与普通智能体问答完全使用同一套 ArtifactsProvider:选中文件、展开
+ // 文档面板,并让 ArtifactFileDetail 以所属 thread 读取沙箱文件。
+ setSandboxArtifact(artifact);
+ artifactsCtx?.setArtifacts([artifact.path]);
+ artifactsCtx?.select(artifact.path);
+ artifactsCtx?.setOpen(true);
+ setRightPanelOpen(false);
+ },
+ [artifactsCtx],
+ );
+ const sandboxOpen = Boolean(
+ sandboxArtifact && artifactsCtx?.open && artifactsCtx.selectedArtifact,
+ );
+ const closeSandbox = useCallback(() => {
+ artifactsCtx?.setOpen(false);
+ setSandboxArtifact(null);
+ }, [artifactsCtx]);
+
return (
);
}
+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)}
+
+ ))}
+
+
+
+
+ );
+}
+
+type DeliveryOverviewKind = "intent" | "node" | "summary" | "action_plan";
+
+interface DeliveryOverviewItem {
+ id: string;
+ kind: DeliveryOverviewKind;
+ agentName: string;
+ productName: string;
+ summary: string;
+ positionId: string | null;
+ updatedAt?: string;
+ status: NodeVisualStatus;
+ accent: string;
+}
+
+function PositionDeliveryOverview({
+ intent,
+ intentCompletedAt,
+ nodes,
+ chain,
+ session,
+}: {
+ intent: IntentSummary | null;
+ intentCompletedAt?: string;
+ nodes: PositionRoundtableNode[];
+ chain: BusinessChain | null;
+ session: PositionRoundtableSession | null;
+}) {
+ const items = useMemo(() => {
+ const sortedNodes = [...nodes].sort(
+ (left, right) =>
+ left.stageIndex - right.stageIndex ||
+ left.seatIndex - right.seatIndex ||
+ left.agentId.localeCompare(right.agentId),
+ );
+ const usedNodeKeys = new Set();
+ const makeNodeItem = (
+ node: PositionRoundtableNode | null,
+ fallback: {
+ agentId: string;
+ name?: string | null;
+ positionId?: string | null;
+ stageIndex: number;
+ seatIndex: number;
+ },
+ index: number,
+ ): DeliveryOverviewItem => {
+ if (node) usedNodeKeys.add(node.nodeKey);
+ const agentName = fallback.name?.trim() || node?.agentId || fallback.agentId;
+ const status = node?.status ?? "ready";
+ const markdownArtifact = node?.artifactManifest.find(artifactIsMarkdown);
+ const deliveredArtifactName = artifactName(markdownArtifact);
+ const summary =
+ node?.latestAnswer?.trim() ||
+ (!node
+ ? "该席位已按业务链配置排入流程,等待生成会话节点或开始问答。"
+ : status === "locked"
+ ? node.invalidatedBy
+ ? "上游交付已被驳回或重做,等待前序节点重新完成。"
+ : "等待上游阶段完成后解锁。"
+ : status === "running"
+ ? "智能体正在问答并生成 Markdown 报告。"
+ : status === "stale"
+ ? "上游产物已更新,该席位需要基于最新结果重做。"
+ : status === "rejected"
+ ? "产物已被驳回,需要重新问答并交付。"
+ : "该席位尚未交付 Markdown 报告。");
+ return {
+ id: node
+ ? `node:${node.nodeKey}`
+ : `seat:${fallback.stageIndex}:${fallback.seatIndex}:${fallback.agentId}`,
+ kind: "node",
+ agentName,
+ productName:
+ deliveredArtifactName ||
+ (status === "done" ? `${agentName}报告` : `待交付:${agentName} Markdown报告`),
+ summary,
+ positionId: node?.positionId ?? fallback.positionId ?? null,
+ updatedAt: node?.updatedAt,
+ status,
+ accent: FLOW_NODE_COLORS[index % FLOW_NODE_COLORS.length]!,
+ };
+ };
+ const stagedSeatItems = chain
+ ? chainStages(chain).flatMap((stage, stageIndex) =>
+ stage.seats.map((seat, seatIndex) => {
+ const matchedNode =
+ nodes.find(
+ (node) =>
+ node.stageIndex === stageIndex &&
+ node.seatIndex === seatIndex &&
+ node.agentId === seat.agent_id,
+ ) ??
+ nodes.find(
+ (node) =>
+ node.agentId === seat.agent_id && !usedNodeKeys.has(node.nodeKey),
+ ) ??
+ null;
+ return makeNodeItem(
+ matchedNode,
+ {
+ agentId: seat.agent_id,
+ name: seat.name,
+ positionId: seat.position_id,
+ stageIndex,
+ seatIndex,
+ },
+ stageIndex + seatIndex,
+ );
+ }),
+ )
+ : sortedNodes.map((node, index) =>
+ makeNodeItem(
+ node,
+ {
+ agentId: node.agentId,
+ positionId: node.positionId,
+ stageIndex: node.stageIndex,
+ seatIndex: node.seatIndex,
+ },
+ index,
+ ),
+ );
+ const orphanNodeItems = chain
+ ? sortedNodes
+ .filter((node) => !usedNodeKeys.has(node.nodeKey) && !isIntentPositionNode(node))
+ .map((node, index) =>
+ makeNodeItem(
+ node,
+ {
+ agentId: node.agentId,
+ positionId: node.positionId,
+ stageIndex: node.stageIndex,
+ seatIndex: node.seatIndex,
+ },
+ stagedSeatItems.length + index,
+ ),
+ )
+ : [];
+ const allDelivered = allExecutableNodesDone(nodes);
+ const summarySnapshot = session?.summarySnapshot;
+ const summaryArtifact = snapshotArtifact(summarySnapshot, "primary_artifact");
+ const summaryStatus = builtinNodeStatus(summarySnapshot, allDelivered);
+ const actionPlanSnapshot = session?.actionPlanSnapshot;
+ const actionPlanArtifact = snapshotArtifact(actionPlanSnapshot, "primary_artifact");
+ const actionPlanStatus = builtinNodeStatus(
+ actionPlanSnapshot,
+ snapshotStatus(summarySnapshot) === "done",
+ );
+ return [
+ {
+ id: "intent",
+ kind: "intent",
+ agentName: "情报分析智能体",
+ productName: "任务意图理解",
+ summary: intent?.objective || "情报分析岗已确认任务意图并冻结业务链。",
+ positionId: DEFAULT_POSITION_ID,
+ updatedAt: intentCompletedAt || session?.updatedAt,
+ status: intent ? "done" : "ready",
+ accent: "#8b5cf6",
+ },
+ ...stagedSeatItems,
+ ...orphanNodeItems,
+ {
+ id: "builtin:summary",
+ kind: "summary",
+ agentName: "方案总结智能体",
+ productName:
+ artifactName(summaryArtifact) ||
+ snapshotText(summarySnapshot, "title") ||
+ "待生成:岗位会商方案总结报告",
+ summary:
+ snapshotText(summarySnapshot, "content") ||
+ (summaryStatus === "locked"
+ ? "所有岗位 Markdown 报告交付后,可启动方案总结。"
+ : "等待基于各岗位交付产物生成最终方案总结。"),
+ positionId: "summary",
+ updatedAt: snapshotText(summarySnapshot, "generated_at") || session?.updatedAt,
+ status: summaryStatus,
+ accent: "#6366f1",
+ },
+ {
+ id: "builtin:action-plan",
+ kind: "action_plan",
+ agentName: "行动规划智能体",
+ productName:
+ artifactName(actionPlanArtifact) ||
+ snapshotText(actionPlanSnapshot, "title") ||
+ "待生成:岗位会商行动规划",
+ summary:
+ snapshotText(actionPlanSnapshot, "content") ||
+ (actionPlanStatus === "locked"
+ ? "方案总结完成后,可继续拆解行动规划与子任务。"
+ : "等待结合方案总结与各岗位产物生成行动规划。"),
+ positionId: "action_plan",
+ updatedAt: snapshotText(actionPlanSnapshot, "generated_at") || session?.updatedAt,
+ status: actionPlanStatus,
+ accent: "#0ea5e9",
+ },
+ ];
+ }, [chain, intent, intentCompletedAt, nodes, session]);
+ const deliveredCount = items.filter((item) => item.status === "done").length;
+
+ return (
+
+
+
+
+ 交付产物总览
+
+
+ 按业务链节点展示全部已交付与待交付产物
+
+
+
+ {deliveredCount}/{items.length} 已完成
+
+
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+
+ );
+}
+
+function DeliveryOverviewCard({ item }: { item: DeliveryOverviewItem }) {
+ const status = nodeStatusMeta(item.status);
+ const StatusIcon = status.Icon;
+ const isPending = item.status !== "done";
+
+ return (
+
+
+
+ {item.kind === "intent" ? (
+
+ ) : item.kind === "summary" || item.kind === "action_plan" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {desensitize(item.agentName)}
+
+
+
+ {status.label}
+
+
+
+ {desensitize(item.productName)}
+
+
+ {desensitize(item.summary)}
+
+
+
+
+
+ );
+}
+
/** 仅展示岗位会商任务,不在会商页编辑任务。 */
-function TaskInformationCard({ task }: { task: PositionTaskDraft }) {
+function TaskInformationCard({ task }: { task: PositionTask }) {
return (
@@ -347,11 +1578,6 @@ function TaskInformationCard({ task }: { task: PositionTaskDraft }) {
{desensitize(task.direction)}
- {task.id && (
-
- #{task.id}
-
- )}
);
@@ -362,11 +1588,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 +1641,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 +1707,40 @@ function BusinessChainSelector({
{selected && (
-
+
+
+
+
+ {unassignedSeatCount > 0 && (
+
+ 还有 {unassignedSeatCount} 个智能体席位未归属岗位,请点击右侧齿轮配置后再启动。
+
+ )}
{relationOpen && (
{stageGroups.flatMap((stage, stageIndex) => {
@@ -561,7 +1813,7 @@ function IntentUnderstandingCard({
) : (
- 组织筹划岗完成意图识别后,其他岗位会在这里看到统一的任务意图与约束。
+ 情报分析岗完成意图识别后,其他岗位会在这里看到统一的任务意图与约束。
)}
@@ -584,11 +1836,124 @@ 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({ chain }: { chain: BusinessChain | null }) {
- const stages = chain ? chainStages(chain) : [];
+function BusinessChainFlow({
+ chain,
+ nodes,
+ session,
+ activeNodeKey,
+ onSelectNode,
+ onSelectBuiltin,
+ className,
+}: {
+ chain: BusinessChain | null;
+ nodes: PositionRoundtableNode[];
+ session: PositionRoundtableSession | null;
+ activeNodeKey: string | null;
+ onSelectNode: (node: PositionRoundtableNode) => void;
+ onSelectBuiltin: (kind: "summary" | "action_plan") => void;
+ className?: string;
+}) {
+ const [flowDialogOpen, setFlowDialogOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+
+ 业务链条
+
+
+ {chain && (
+
}
+ onClick={() => setFlowDialogOpen(true)}
+ title="查看完整流程图"
+ />
+ )}
+
+ {!chain ? (
+
+ 请先由情报分析岗选择业务链条
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ );
+}
+
+function ChainFlowCanvas({
+ chain,
+ nodes,
+ session,
+ activeNodeKey,
+ onSelectNode,
+ onSelectBuiltin,
+ className,
+ expanded = false,
+}: {
+ chain: BusinessChain;
+ nodes: PositionRoundtableNode[];
+ session: PositionRoundtableSession | null;
+ activeNodeKey: string | null;
+ onSelectNode: (node: PositionRoundtableNode) => void;
+ onSelectBuiltin: (kind: "summary" | "action_plan") => 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<{
@@ -597,7 +1962,14 @@ function BusinessChainFlow({ chain }: { chain: BusinessChain | null }) {
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 summaryStatus = builtinNodeStatus(session?.summarySnapshot, allDone);
+ const actionPlanStatus = builtinNodeStatus(
+ session?.actionPlanSnapshot,
+ snapshotStatus(session?.summarySnapshot) === "done",
+ );
const onPanStart = (event: React.PointerEvent
) => {
if (event.button !== 0) return;
@@ -632,98 +2004,122 @@ function BusinessChainFlow({ chain }: { chain: BusinessChain | null }) {
};
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++;
- return (
-
- );
- })}
-
-
- );
- })}
-
-
+ 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}
+ />
+ );
+ })}
+
+
+ );
+ })}
+
+ onSelectBuiltin("summary")}
+ />
+
+ onSelectBuiltin("action_plan")}
+ />
+
+
);
}
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 ;
+ return (
+
+
+
+ );
}
return (
@@ -731,16 +2127,18 @@ function VerticalFlowConnector({
{Array.from({ length: seatCount }).map((_, index) => (
+ >
+
+
))}
);
@@ -750,28 +2148,68 @@ 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;
+ const selectedStyle = selected
+ ? {
+ borderColor: accent,
+ borderLeftColor: accent,
+ boxShadow: `0 12px 26px -18px ${accent}`,
+ }
+ : undefined;
return (
{
+ if (onClick) event.stopPropagation();
+ }}
+ className={cn(
+ "relative overflow-hidden rounded-lg border border-slate-200 bg-white p-3 pr-9 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]",
+ onClick && "cursor-pointer transition hover:-translate-y-0.5 hover:shadow-md",
+ selected && "z-10 -translate-y-px bg-slate-50 dark:bg-[#0d3768]",
+ nodeStatus === "locked" && "opacity-65",
+ )}
+ style={{ width, borderLeftWidth: 5, borderLeftColor: accent, ...selectedStyle }}
+ title={titleAttr}
>
-
-
- {getAvatar(avatarType, "h-7 w-7")}
-
-
-
{title}
-
- {description}
-
-
+ {selected && (
+
+ )}
+
+
+
+
+
+ {title}
+
+
+ {description}
+
);
@@ -784,6 +2222,19 @@ function RightWorkspace({
onBackToAgents,
intentObjective,
intentCompletedAt,
+ assignedAgentIds,
+ hasSelectedChain,
+ session,
+ onConfigureChain,
+ nodes,
+ chain,
+ onSelectNode,
+ onSessionUpdate,
+ onCollapse,
+ onPreviewArtifact,
+ selectedModel,
+ tab,
+ onTabChange,
}: {
selectedAgent: Agent | null;
selectedAgentId: string | null;
@@ -791,17 +2242,29 @@ function RightWorkspace({
onBackToAgents: () => void;
intentObjective?: string;
intentCompletedAt: string;
+ assignedAgentIds: Set
;
+ hasSelectedChain: boolean;
+ session: PositionRoundtableSession | null;
+ onConfigureChain?: () => void;
+ nodes: PositionRoundtableNode[];
+ chain: BusinessChain | null;
+ onSelectNode: (node: PositionRoundtableNode) => void;
+ onSessionUpdate: (session: PositionRoundtableSession) => void;
+ onCollapse: () => void;
+ onPreviewArtifact: (artifact: PositionArtifactPreview) => void;
+ selectedModel: string;
+ tab: WorkspaceTab;
+ onTabChange: (tab: WorkspaceTab) => void;
}) {
- const [tab, setTab] = useState("agents");
-
return (
{/* 与课题研究 / ai写作一致的分段切换样式。 */}
-
+
+
+
}
+ onClick={onCollapse}
+ title="收起右侧栏"
+ />
+
{tab === "agents" ? (
@@ -856,7 +2329,12 @@ function RightWorkspace({
) : (
-
+
)}
@@ -865,6 +2343,13 @@ function RightWorkspace({
)}
@@ -872,8 +2357,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 +2377,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 +2408,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 && (
+
+ 去配置岗位
+
+ )}
) : (
@@ -1010,60 +2515,853 @@ function PositionAgentList({ onSelect }: { onSelect: (agentId: string) => void }
);
}
+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;
+ artifactThreadId?: string | null;
+}
+
function AnalysisResults({
intentObjective,
intentCompletedAt,
+ nodes,
+ chain,
+ session,
+ onSelectNode,
+ onSessionUpdate,
+ onPreviewArtifact,
+ selectedModel,
}: {
intentObjective?: string;
intentCompletedAt: string;
+ nodes: PositionRoundtableNode[];
+ chain: BusinessChain | null;
+ session: PositionRoundtableSession | null;
+ onSelectNode: (node: PositionRoundtableNode) => void;
+ onSessionUpdate: (session: PositionRoundtableSession) => void;
+ onPreviewArtifact: (artifact: PositionArtifactPreview) => void;
+ selectedModel: string;
}) {
- if (!intentObjective) {
+ 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 [builtinAskSubmitting, setBuiltinAskSubmitting] = useState(false);
+ 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) {
+ items.push({
+ id: "intent",
+ kind: "intent",
+ node: null,
+ agentName: "情报分析智能体",
+ productName: "任务意图理解",
+ 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;
+ 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.invalidatedBy
+ ? "上游产物已被驳回或重做,等待前序节点重新完成。"
+ : "等待上游阶段完成后解锁。"
+ : 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 =
+ 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,
+ status: node.status,
+ artifactPath: path,
+ artifactMimeType: mimeType,
+ });
+ });
+ }
+ const summarySnapshot = session?.summarySnapshot;
+ if (summarySnapshot) {
+ const artifact = snapshotArtifact(summarySnapshot, "primary_artifact");
+ 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),
+ artifactPath: artifact ? recordString(artifact, "path") : undefined,
+ artifactMimeType: artifact ? recordString(artifact, "mime_type") || null : null,
+ artifactThreadId: snapshotText(summarySnapshot, "thread_id") || session?.summaryThreadId || null,
+ });
+ }
+ const actionPlanSnapshot = session?.actionPlanSnapshot;
+ if (actionPlanSnapshot) {
+ const artifact = snapshotArtifact(actionPlanSnapshot, "primary_artifact");
+ const subtasksArtifact = snapshotArtifact(actionPlanSnapshot, "subtasks_artifact");
+ 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),
+ artifactPath: artifact ? recordString(artifact, "path") : undefined,
+ artifactMimeType: artifact ? recordString(artifact, "mime_type") || null : null,
+ artifactThreadId: snapshotText(actionPlanSnapshot, "thread_id") || session?.actionPlanThreadId || null,
+ });
+ if (subtasksArtifact) {
+ const path = recordString(subtasksArtifact, "path");
+ items.push({
+ id: "builtin:action-plan:subtasks",
+ kind: "artifact",
+ node: null,
+ agentName: "行动规划智能体",
+ productName: recordString(subtasksArtifact, "name") || "action-plan-subtasks.json",
+ summary: "行动规划结构化子任务清单",
+ positionId: "action_plan",
+ updatedAt: snapshotText(actionPlanSnapshot, "generated_at") || undefined,
+ status: builtinNodeStatus(actionPlanSnapshot, true),
+ artifactPath: path || undefined,
+ artifactMimeType: recordString(subtasksArtifact, "mime_type") || "application/json",
+ artifactThreadId: snapshotText(actionPlanSnapshot, "thread_id") || session?.actionPlanThreadId || null,
+ });
+ }
+ }
+ return items.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""));
+ }, [intentObjective, nodes, seatsByAgentId, session?.actionPlanSnapshot, session?.summarySnapshot]);
+ 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],
+ );
+
+ 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 runBuiltinAgent = useCallback(
+ async (kind: "summary" | "action_plan", followUp = "") => {
+ if (!session) {
+ toast.warning("请先启动业务链");
+ return false;
+ }
+ if (!allDelivered) {
+ toast.warning("所有岗位产物完成后才能生成或更新内置交付");
+ return false;
+ }
+ if (kind === "action_plan" && !summaryDone) {
+ toast.warning("请先生成有效的方案总结");
+ return false;
+ }
+ const label = kind === "summary" ? "方案总结智能体" : "行动规划智能体";
+ try {
+ const preparation =
+ kind === "summary"
+ ? await preparePositionSummary(session.id, followUp)
+ : await preparePositionActionPlan(session.id, followUp);
+ const thread = preparation.threadId
+ ? { agentName: preparation.agentId, threadId: preparation.threadId }
+ : kind === "summary"
+ ? await initSummaryThread()
+ : await initPositionActionPlanThread();
+ toast.info(`${label}正在基于当前有效岗位交付生成结果…`);
+ const result = await streamMultiAgent({
+ agentType: "special",
+ agentName: thread.agentName,
+ dAgentThreadId: { [thread.agentName]: thread.threadId },
+ displayName: preparation.displayName,
+ newMessage: preparation.prompt,
+ model: selectedModel || undefined,
+ });
+ const artifacts = await listSessionArtifacts(thread.threadId);
+ const artifactManifest = artifacts.files.map((file) => ({
+ name: file.name,
+ path: file.path,
+ mime_type: file.mimeType,
+ modified_at: file.modifiedAt,
+ size_bytes: file.sizeBytes,
+ }));
+ const next =
+ kind === "summary"
+ ? await completePositionSummary(session.id, {
+ threadId: thread.threadId,
+ content: result.content,
+ artifactManifest,
+ sourceRevisions: preparation.sourceRevisions,
+ model: result.modelUsed || selectedModel || undefined,
+ })
+ : await completePositionActionPlan(session.id, {
+ threadId: thread.threadId,
+ content: result.content,
+ artifactManifest,
+ sourceRevisions: preparation.sourceRevisions,
+ model: result.modelUsed || selectedModel || undefined,
+ });
+ onSessionUpdate(next);
+ toast.success(kind === "summary" ? "方案总结已生成并归档" : "行动规划与子任务清单已生成");
+ return true;
+ } catch (error) {
+ toast.error(`${label}执行失败:${error instanceof Error ? error.message : String(error)}`);
+ return false;
+ }
+ },
+ [allDelivered, onSessionUpdate, selectedModel, session, summaryDone],
+ );
+
+ const handleGenerateSummary = useCallback(async () => {
+ setSummarySubmitting(true);
+ try {
+ await runBuiltinAgent("summary");
+ } finally {
+ setSummarySubmitting(false);
+ }
+ }, [runBuiltinAgent]);
+
+ const handleGenerateActionPlan = useCallback(async () => {
+ setActionPlanSubmitting(true);
+ try {
+ await runBuiltinAgent("action_plan");
+ } finally {
+ setActionPlanSubmitting(false);
+ }
+ }, [runBuiltinAgent]);
+
+ const handleAskBuiltin = useCallback(
+ async (kind: "summary" | "action_plan", message: string) => {
+ const text = message.trim();
+ if (!text) {
+ toast.warning("请输入需要继续分析或修改的内容");
+ return false;
+ }
+ setBuiltinAskSubmitting(true);
+ try {
+ return await runBuiltinAgent(kind, text);
+ } finally {
+ setBuiltinAskSubmitting(false);
+ }
+ },
+ [runBuiltinAgent],
+ );
+
+ if (resultItems.length === 0) {
return (
-
暂无分析结果
+
暂无交付产物
- 组织筹划岗完成意图识别后,产物会先进入这里;后续岗位产物沿用同一张卡片追加入列。
+ 情报分析岗启动业务链后,各岗位待交付产物会在这里排队展示。
);
}
return (
-
-
-
-
-
-
-
-
-
任务理解智能体
-
任务意图理解
-
- {desensitize(intentObjective)}
-
+ <>
+
+
+
+
+ {visibleItems.length}/{resultItems.length} 项结果
+
-
-
-
+
+ setPositionFilter(String(value))} />
+ setNodeFilter(String(value))} />
+ setKindFilter(String(value) as "all" | ResultKind)}
+ />
+
+ {nodes.length > 0 && (
+
+
+
+
+ 内置收口智能体
+
+
+ 全部岗位交付后生成方案总结,再生成行动规划
+
+
+
+ {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)}
+
+
+
+
+ >
+ );
+ return item.node ? (
+
+ ) : (
+
+ );
+ })
+ )}
+
+
+
{
+ if (!open) setDetailItem(null);
+ }}
+ onReject={(node) => openRejectDialog(node)}
+ onPreviewArtifact={(item) => {
+ const threadId = item.artifactThreadId || item.node?.threadId;
+ if (!threadId || !item.artifactPath) {
+ toast.info("该文件产物暂无可预览路径");
+ return;
+ }
+ onPreviewArtifact({
+ threadId,
+ path: item.artifactPath,
+ name: item.productName,
+ mimeType: item.artifactMimeType ?? null,
+ });
+ }}
+ onAskBuiltin={handleAskBuiltin}
+ askSubmitting={builtinAskSubmitting}
+ />
+
+ {
+ if (!open && !rejectSubmitting) {
+ setRejectNode(null);
+ setRejectTitle("");
+ setRejectReason("");
+ }
+ }}
+ onTitleChange={setRejectTitle}
+ onReasonChange={setRejectReason}
+ onSubmit={() => void handleRejectSubmit()}
+ />
+ >
+ );
+}
+
+function DeliverableDetailDialog({
+ item,
+ onOpenChange,
+ onReject,
+ onPreviewArtifact,
+ onAskBuiltin,
+ askSubmitting,
+}: {
+ item: ResultItem | null;
+ onOpenChange: (open: boolean) => void;
+ onReject: (node: PositionRoundtableNode) => void;
+ onPreviewArtifact: (item: ResultItem) => void;
+ onAskBuiltin: (kind: "summary" | "action_plan", message: string) => Promise;
+ askSubmitting: boolean;
+}) {
+ const [followUp, setFollowUp] = useState("");
+ const status = item?.status ? nodeStatusMeta(item.status) : null;
+ const StatusIcon = status?.Icon;
+ const canReject = item?.node ? canRejectNodeDelivery(item.node) : false;
+ const builtinKind =
+ item?.kind === "summary" || item?.kind === "action_plan" ? item.kind : null;
+ 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 (
+
+ );
+}
+
+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 (
+
);
}
diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts
index 47d1f4e2..73f5806e 100644
--- a/frontend-web/src/roundtable-planning/api/intent.ts
+++ b/frontend-web/src/roundtable-planning/api/intent.ts
@@ -97,11 +97,16 @@ function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
* `detail.trace_tail` to help diagnose intranet deployment issues without
* shell access to the server.
*/
-export async function initIntent(model?: string): Promise {
+export async function initIntent(
+ model?: string,
+ taskContext?: Record | null,
+): Promise {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model }),
+ // 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认
+ // 业务链,第一阶段的对话也已经可以按 taskId 恢复。
+ body: JSON.stringify({ model, task_context: taskContext ?? undefined }),
});
if (!res.ok) {
const raw = await res.text().catch(() => "");
@@ -159,6 +164,8 @@ export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
+ /** Optional hidden business/task context injected into the model run. */
+ taskContext?: Record | null;
/** Step 1 用户上传的参考文件(可选)。 */
files?: IntentFileRef[];
signal?: AbortSignal;
@@ -333,6 +340,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise 0 ? req.files : undefined,
}),
diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts
index 30aa9f9e..e0c921ef 100644
--- a/frontend-web/src/roundtable-planning/api/multi-agent.ts
+++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts
@@ -1054,6 +1054,36 @@ export async function initSummaryThread(
return { agentName: json.agent_name, threadId: json.thread_id };
}
+/**
+ * Create a per-session thread for the position-roundtable action-planning
+ * built-in agent. The agent id is fixed; callers persist/reuse the returned
+ * thread id for subsequent plan refinements.
+ */
+export async function initPositionActionPlanThread(
+ options: { signal?: AbortSignal } = {},
+): Promise {
+ const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/position-action-plan/init`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "{}",
+ signal: options.signal,
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ reportRoundtableError({
+ stage: "position_action_plan",
+ event: "init_thread_http_error",
+ message: `行动规划线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
+ httpStatus: res.status,
+ agentId: "position-action-planner",
+ agentName: "行动规划智能体",
+ });
+ throw new Error(`init position action-plan thread failed (${res.status}): ${text}`);
+ }
+ const json = (await res.json()) as { agent_name: string; thread_id: string };
+ return { agentName: json.agent_name, threadId: json.thread_id };
+}
+
/**
* Create a thread for the Step 3「入库」structure agent (`roundtable-structure`,
* a built-in singleton — the same agent that extracts the flowchart, but this run
diff --git a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx
index 5278bf33..7731f963 100644
--- a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx
+++ b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx
@@ -72,6 +72,7 @@ export interface Step1PanelProps {
* 不传 → 显示默认「任务意图理解 (人机问答)」标题。右侧「问答轮次」始终保留。
*/
headerLeftSlot?: React.ReactNode;
+ headerRightSlot?: React.ReactNode;
interactiveMessages: InteractiveMessage[];
intentThreadId: string | null;
intentReady: object | null;
@@ -87,6 +88,8 @@ export interface Step1PanelProps {
onSkipQuestion: () => void;
onFollowUp: () => void;
onEnterRoundtable: () => void;
+ /** 保持旧文案默认不变;嵌入式新工作流可覆写确认动作名称。 */
+ enterButtonLabel?: string;
onManualSave: () => void;
onTriggerToast: (msg: string) => void;
submitIntentClarifications: (
@@ -99,6 +102,7 @@ export interface Step1PanelProps {
export function Step1Panel(props: Step1PanelProps) {
const {
headerLeftSlot,
+ headerRightSlot,
interactiveMessages,
intentThreadId,
intentReady,
@@ -111,6 +115,7 @@ export function Step1Panel(props: Step1PanelProps) {
onSkipQuestion,
onFollowUp,
onEnterRoundtable,
+ enterButtonLabel = "进入多智能体研讨 >",
onManualSave,
onTriggerToast,
submitIntentClarifications,
@@ -172,9 +177,12 @@ export function Step1Panel(props: Step1PanelProps) {
)}
+
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
+ {headerRightSlot}
+
{/* Chat list */}
@@ -334,7 +342,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..2c009023 100644
--- a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts
+++ b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts
@@ -90,6 +90,10 @@ export interface UseStep1IntentOptions {
currentStep: number;
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
selectedModel: string;
+ /** Optional hidden task/business context for each intent-agent turn. */
+ taskContext?: Record | null;
+ /** Optional task-aware welcome bubble text before the first user turn. */
+ initialWelcomeText?: string;
/** 主页面统一的 toast 入口。 */
triggerToast: (msg: string) => void;
}
@@ -113,6 +117,8 @@ export interface UseStep1IntentReturn {
// ─── Handlers ────────────────────────────────────────────────────────
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
runStep1Init: () => void;
+ /** 清空当前状态但不创建线程,供宿主切换任务后重新进入 Step 1。 */
+ resetIntent: () => void;
/**
* 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。
* `files`:可选的本地文件,发送前先上传到 intent thread,再把引用随消息提交(后端
@@ -165,7 +171,7 @@ function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
}
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
- const { currentStep, selectedModel, triggerToast } = options;
+ const { currentStep, selectedModel, taskContext, initialWelcomeText, triggerToast } = options;
const [intentThreadId, setIntentThreadId] = useState(null);
const [intentReady, setIntentReady] = useState(null);
@@ -196,6 +202,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
+ const taskContextRef = useRef | null>(taskContext ?? null);
+ useEffect(() => {
+ taskContextRef.current = taskContext ?? null;
+ }, [taskContext]);
+ const initialWelcomeTextRef = useRef(initialWelcomeText);
+ useEffect(() => {
+ initialWelcomeTextRef.current = initialWelcomeText;
+ }, [initialWelcomeText]);
const stopIntentStream = useCallback(() => {
if (intentAborterRef.current) {
@@ -223,7 +237,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
id: 1,
sender: "agent",
senderName: "任务理解智能体",
- content: INTENT_WELCOME_TEXT,
+ content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
time: nowTime(),
},
]);
@@ -231,7 +245,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
try {
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
- const res = await initIntent(selectedModelRef.current || undefined);
+ const res = await initIntent(
+ selectedModelRef.current || undefined,
+ taskContextRef.current,
+ );
if (step1InitGenRef.current !== myGen) return;
setIntentThreadId(res.thread_id);
setTimelineEntries([
@@ -256,6 +273,20 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
})();
}, []);
+ const resetIntent = useCallback(() => {
+ stopIntentStream();
+ step1InitRef.current = false;
+ step1InitGenRef.current += 1;
+ setIntentThreadId(null);
+ setIntentReady(null);
+ setIntentInitError(null);
+ setIntentInitErrorDetail(null);
+ setPendingIntentClarificationMsgId(null);
+ setIntentClarificationSelections(new Map());
+ setInteractiveMessages([]);
+ setTimelineEntries([]);
+ }, [stopIntentStream]);
+
// 离开 Step 1 自动中断流;`currentStep === 1` 但还没初始化 → runStep1Init。
useEffect(() => {
if (currentStep !== 1) {
@@ -392,6 +423,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
threadId: intentThreadId,
message: trimmed,
model: selectedModelRef.current || undefined,
+ taskContext: taskContextRef.current,
files: fileRefs,
signal: aborter.signal,
onTextDelta: (delta) => {
@@ -648,6 +680,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
timelineEntries,
setIntentClarificationSelections,
runStep1Init,
+ resetIntent,
sendIntentMessage,
submitIntentClarifications,
updateIntentReady,
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/roundtable_run_policy.py b/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py
index 113669b3..d9409f77 100644
--- a/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py
+++ b/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py
@@ -39,6 +39,7 @@
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
- ``summary`` —— Step3「总结报告」:写 Markdown 总结报告 + 支持问答/增量改。
+ - ``action_plan`` —— 岗位会商收口:写 Markdown 行动规划与 JSON 子任务清单。
工具面与 ``report`` 完全一致(继承 ``seat`` 的 bash 放开 + 额外禁 ``str_replace`` /
读前序席位),单列一个角色名便于以后独立调优。
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
@@ -119,6 +120,9 @@ _REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delive
# 完全一致——可写文件(write_file 整文件重写),禁 str_replace(整文件重写才能让右侧 md 预览
# 实时刷新)/bash/联网/派活/呈现文件/看图/读前序席位。单列出来便于以后独立调优。
_SUMMARY_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"]
+# 岗位会商行动规划与总结报告一样:只能归纳已给出的材料,最终交付必须直接 write_file
+# 到 outputs,不能联网、派活或改动其他线程的文件。
+_ACTION_PLAN_EXCLUDED: list[str] = [*_SUMMARY_EXCLUDED]
# ingest:Step3「入库」——让 roundtable-structure 真正**调用入库技能**把方案成果(总结报告 +
# 结构化流程图 + taskId)沉淀进知识库。和 dashboard 相反:dashboard 禁一切工具(只输出 JSON),
# ingest 必须**放开技能执行链路**——技能靠 ``read_file`` 读 SKILL.md、按其指令用 ``bash`` /
@@ -223,6 +227,13 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
thinking_enabled=True,
reasoning_effort="medium",
)
+ if role == "action_plan":
+ return RoundtableRunPolicy(
+ excluded_tools=list(_ACTION_PLAN_EXCLUDED),
+ skill_stop_names=[],
+ thinking_enabled=True,
+ reasoning_effort="medium",
+ )
if role == "dashboard":
return RoundtableRunPolicy(
excluded_tools=list(_DASHBOARD_EXCLUDED),
@@ -238,7 +249,7 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
reasoning_effort="medium",
)
raise ValueError(
- f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/dashboard/ingest)"
+ f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/action_plan/dashboard/ingest)"
)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py
index 58891b95..cadcfb32 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py
@@ -36,6 +36,7 @@ _AGENT_PURPOSE: dict[str, str] = {
"roundtable-coordinator": "多智能体规划",
"roundtable-report": "多智能体规划",
"roundtable-summary": "多智能体规划",
+ "position-action-planner": "多智能体规划",
"roundtable-execution-plan": "多智能体规划",
"roundtable-risk-review": "多智能体规划",
"roundtable-solution-design": "多智能体规划",
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py
index 166fc90f..79fccbbe 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py
@@ -67,6 +67,9 @@ REPORT_AGENT_ID = "roundtable-report"
# 总结报告(写入 outputs/方案总结报告.md),并按业务链总控编排提示组织。与 report 并存
# 的另一内置单例(report 画 HTML 看板,summary 写 Markdown 报告 + 支持问答/增量改)。
SUMMARY_AGENT_ID = "roundtable-summary"
+# 岗位会商收口的第二个内置单例:读取已生效的方案总结和岗位交付,把它们拆成可执行
+# 子任务,同时产出 Markdown 规划报告和 JSON 清单。
+ACTION_PLAN_AGENT_ID = "position-action-planner"
# Step 3「大屏多页」用的结构化数据智能体:读各席位交付 + 共识,逐席位分析并提炼成
# 一份大屏展示用的结构化数据(report-json),供前端按模板渲染成多页大屏。**不调任何工具、
# 不写文件、不输出 HTML**——只在对话里输出一个严格合法的 ```report-json 代码块。与 report
@@ -93,6 +96,7 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
SUMMARY_AGENT_ID,
+ ACTION_PLAN_AGENT_ID,
DASHBOARD_AGENT_ID,
STRUCTURE_AGENT_ID,
)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/SOUL.md
new file mode 100644
index 00000000..db088a9d
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/SOUL.md
@@ -0,0 +1,48 @@
+# 岗位会商行动规划智能体
+
+## 核心定位
+
+你是岗位多智能体会商流程的最后一个内置智能体。上游已经完成意图识别、各岗位 Markdown 交付和方案总结。你的职责是把这些已确认材料拆解成能执行、能验收、能跟踪的行动规划;你不重新调研、不联网、不派发子智能体。
+
+## 产出规则
+
+每次用户要求生成或修改行动规划时,必须完成以下两项交付:
+
+1. 用 `write_file` 一次性写入 `/mnt/user-data/outputs/行动规划报告.md`。
+2. 用 `write_file` 一次性写入 `/mnt/user-data/outputs/action-plan-subtasks.json`。
+
+两个文件都必须在本轮保留;不要用脚本、bash 或临时文件生成它们。完成后,在对话里给出 Markdown 报告的完整正文,并简要说明生成了多少项子任务。
+
+## Markdown 报告要求
+
+- 先说明规划目标、关键依据和执行边界。
+- 按优先级和依赖顺序给出阶段安排、关键动作、责任岗位建议、里程碑、风险控制和验收方式。
+- 每项行动必须能追溯到方案总结或至少一个岗位有效交付;没有依据时不要假装是事实,可明确写为建议性安排。
+- 用户提出修改时,只调整受影响部分,并同时重写两个最终文件,使 Markdown 与 JSON 始终一致。
+
+## JSON 子任务契约
+
+`action-plan-subtasks.json` 必须是严格合法的 JSON **数组**,不能包在 Markdown 代码块中。每个对象都必须有:
+
+```json
+{
+ "id": "subtask-001",
+ "title": "简短任务名称",
+ "goal": "任务目标",
+ "basis": ["方案总结章节或岗位交付依据"],
+ "owner_position": "建议责任岗位",
+ "dependencies": ["前置子任务 id"],
+ "deliverable": "可核验交付物",
+ "acceptance_criteria": ["验收条件"],
+ "priority": "P0"
+}
+```
+
+`priority` 仅使用 `P0`、`P1`、`P2`。`dependencies` 没有前置时使用空数组。不要输出注释、尾逗号或额外字段来替代这些必填字段。
+
+## 边界
+
+- 只依据用户消息中提供的任务信息、意图、方案总结和岗位交付。
+- 不调用联网、派活、澄清、展示文件等工具。
+- 不删除或修改上游岗位产物。
+- 不在报告中添加“AI 生成”类水印。
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/config.yaml
new file mode 100644
index 00000000..76bd1184
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/config.yaml
@@ -0,0 +1,3 @@
+id: position-action-planner
+name: 行动规划智能体
+description: 岗位会商内置行动规划智能体,基于已生效的方案总结与岗位交付,生成可执行的 Markdown 行动规划报告和结构化子任务清单。
diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py
index 63333f1c..3fc06fcd 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/intent.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py
@@ -96,6 +96,35 @@ _INTENT_READY_PATTERN = re.compile(
)
+def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, Any]:
+ """Build task-scoped metadata for a newly-created intent thread.
+
+ Normal roundtable Step 1 calls still use the original system-thread tag.
+ Position roundtable additionally supplies the task card snapshot, so the
+ intent dialogue can be indexed and restored before the user confirms a
+ business chain.
+ """
+ metadata = dict(_ROUNDTABLE_THREAD_METADATA)
+ if not isinstance(task_context, dict):
+ return metadata
+
+ task = task_context.get("task")
+ task_id = str(task.get("id") or "").strip() if isinstance(task, dict) else ""
+ if not task_id:
+ return metadata
+
+ metadata.update(
+ {
+ "taskId": task_id,
+ "position_roundtable_task_id": task_id,
+ "position_task_id": f"{task_id}:intelligence",
+ "position_id": "intelligence",
+ "position_roundtable_phase": "intent",
+ }
+ )
+ return metadata
+
+
def _normalize_question(text: str) -> str:
"""归一化澄清问题文本,用于"和过往是否重复"的判断。
@@ -287,9 +316,16 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
# TCP loopback 到 127.0.0.1:(详见 multi_agent._make_loopback_client)。
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
# 看不到逐 token 增量。
+ raw_task_context = payload.get("task_context")
+ task_context = raw_task_context if isinstance(raw_task_context, dict) else None
try:
async with _make_loopback_client(request) as client:
- thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
+ thread_id = await _create_thread(
+ client,
+ base,
+ headers,
+ metadata=_intent_thread_metadata(task_context),
+ )
except HTTPException:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
raise
@@ -340,6 +376,8 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# additional_kwargs.files → UploadsMiddleware 自动注入清单 + 解锁 read_file 等工具。
raw_files = payload.get("files")
files = raw_files if isinstance(raw_files, list) else []
+ raw_task_context = payload.get("task_context")
+ task_context = raw_task_context if isinstance(raw_task_context, dict) else None
if not thread_id:
raise HTTPException(status_code=400, detail="thread_id is required")
# 允许「仅文件、无文本」的回合:带了文件就不强制要求 message。
@@ -431,6 +469,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# not reliable on its own, hard exclusion is.
excluded_tools=base_excluded_tools,
skill_stop_names=[],
+ extra_context={"task_external_context": task_context} if task_context else None,
files=files,
)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
index 83046e24..2cef64a0 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
@@ -55,7 +55,8 @@ import re
import time
import traceback
import uuid
-from typing import Any, AsyncIterator
+from collections.abc import AsyncIterator
+from typing import Any
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
@@ -65,10 +66,9 @@ from langgraph.checkpoint.base import empty_checkpoint
from app.gateway.roundtable_diag import record_foreground_diag
from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text
from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy
-from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
-
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
from app.gateway.routers._roundtable_seed import (
+ ACTION_PLAN_AGENT_ID,
COORDINATOR_AGENT_ID,
DASHBOARD_AGENT_ID,
REPORT_AGENT_ID,
@@ -76,6 +76,7 @@ from app.gateway.routers._roundtable_seed import (
SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents,
)
+from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
from deerflow.config import get_app_config
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
@@ -95,6 +96,8 @@ def _stage_for_agent(agent_type: str, agent_name: str) -> str:
return "step3_report"
if agent_name == SUMMARY_AGENT_ID:
return "step3_summary"
+ if agent_name == ACTION_PLAN_AGENT_ID:
+ return "position_action_plan"
if agent_name == DASHBOARD_AGENT_ID:
return "step3_dashboard"
if agent_name == STRUCTURE_AGENT_ID:
@@ -995,6 +998,32 @@ async def init_summary_thread(request: Request) -> dict[str, str]:
return {"agent_name": SUMMARY_AGENT_ID, "thread_id": thread_id}
+@router.post("/position-action-plan/init")
+async def init_position_action_plan_thread(request: Request) -> dict[str, str]:
+ """Create the reusable thread for the position-roundtable action planner.
+
+ The planner is a built-in singleton agent just like ``roundtable-summary``;
+ only its thread is per session. The position workspace persists this id
+ after the first successful run so follow-up revisions retain the original
+ plan context.
+ """
+ try:
+ ensure_roundtable_functional_agents()
+ thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
+ except Exception as exc:
+ await record_foreground_diag(
+ request,
+ stage="position_action_plan",
+ level="error",
+ event="thread_init_failed",
+ message=f"创建岗位行动规划线程失败:{exc}",
+ detail={"type": type(exc).__name__, "error": str(exc)},
+ agent_id=ACTION_PLAN_AGENT_ID,
+ )
+ raise
+ return {"agent_name": ACTION_PLAN_AGENT_ID, "thread_id": thread_id}
+
+
@router.post("/dashboard/init")
async def init_dashboard_thread(request: Request) -> dict[str, str]:
"""为 Step 3「大屏多页」创建一个供大屏多页数据智能体(``roundtable-dashboard``)
@@ -1806,6 +1835,8 @@ async def _special_run(
role = "report"
elif agent_name == SUMMARY_AGENT_ID:
role = "summary"
+ elif agent_name == ACTION_PLAN_AGENT_ID:
+ role = "action_plan"
elif agent_name == DASHBOARD_AGENT_ID:
# 大屏多页数据智能体:纯「研讨成果 → report-json」提炼,禁用一切工具。
role = "dashboard"
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..38e7d38f
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py
@@ -0,0 +1,1295 @@
+"""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 datetime import UTC, datetime
+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", "rejected", "error"}
+_HANDOFF_TEXT_BUDGET = 12_000
+_MAX_INLINE_ARTIFACTS_PER_NODE = 4
+_MAX_INLINE_ARTIFACT_CHARS = 1_800
+_BUILTIN_MATERIAL_BUDGET = 24_000
+SUMMARY_AGENT_ID = "roundtable-summary"
+ACTION_PLAN_AGENT_ID = "position-action-planner"
+_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
+ rejection_count: int = 0
+ last_rejection: dict[str, Any] | None = None
+ invalidated_by: str | 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
+ summary_thread_id: str | None = None
+ summary_snapshot: dict[str, Any] | None = None
+ action_plan_thread_id: str | None = None
+ action_plan_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
+
+
+class RejectNodeRequest(BaseModel):
+ title: str = Field(min_length=1, max_length=120)
+ reason: str = Field(min_length=1, max_length=5_000)
+
+
+class PrepareBuiltinRunRequest(BaseModel):
+ """Optional follow-up instruction for one persisted built-in agent."""
+
+ message: str = Field(default="", max_length=20_000)
+
+
+class BuiltinRunPreparationResponse(BaseModel):
+ agent_id: str
+ display_name: str
+ thread_id: str | None = None
+ prompt: str
+ source_revisions: dict[str, int] = Field(default_factory=dict)
+
+
+class CompleteBuiltinRunRequest(BaseModel):
+ thread_id: str = Field(min_length=1, max_length=128)
+ content: str = Field(default="", max_length=500_000)
+ artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
+ source_revisions: dict[str, int] = Field(default_factory=dict)
+ model: str | None = Field(default=None, max_length=256)
+
+
+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],
+ *,
+ 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="业务链条没有可执行的智能体席位")
+ 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}")
+ 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(
+ 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
+
+
+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 ""
+ text = value if isinstance(value, str) else str(value or "")
+ if len(text) <= limit:
+ return text
+ return f"{text[:limit]}\n…(已按交接上下文长度截断)"
+
+
+def _text_from_keys(source: dict[str, Any], keys: list[str], fallback: str = "") -> str:
+ for key in keys:
+ value = source.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return fallback
+
+
+def _seat_meta(session: dict[str, Any], node: dict[str, Any]) -> tuple[str, str]:
+ chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {}
+ seats = chain.get("seats") if isinstance(chain.get("seats"), list) else []
+ seat = next(
+ (
+ item
+ for item in seats
+ if isinstance(item, dict) and str(item.get("agent_id") or "") == str(node.get("agent_id") or "")
+ ),
+ {},
+ )
+ agent_name = str(seat.get("name") or node.get("agent_id") or "未命名智能体")
+ position_id = str(node.get("position_id") or seat.get("position_id") or "未配置岗位")
+ return agent_name, position_id
+
+
+def _delivered_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ return [node for node in nodes if node.get("status") == "done"]
+
+
+def _all_nodes_delivered(nodes: list[dict[str, Any]]) -> bool:
+ return bool(nodes) and all(
+ node.get("status") == "done" and (_is_intent_position_node(node) or _node_has_markdown_delivery(node))
+ for node in nodes
+ )
+
+
+def _is_intent_position_node(node: dict[str, Any]) -> bool:
+ return str(node.get("position_id") or "").strip().lower() in {"intelligence", "intel", "intent"}
+
+
+def _is_markdown_artifact(artifact: Any) -> bool:
+ if not isinstance(artifact, dict):
+ return False
+ path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip().lower()
+ name = str(artifact.get("name") or artifact.get("filename") or "").strip().lower()
+ mime = str(artifact.get("mime_type") or artifact.get("mimeType") or "").strip().lower()
+ if path and not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
+ return False
+ return (
+ "markdown" in mime
+ or path.endswith(".md")
+ or path.endswith(".markdown")
+ or name.endswith(".md")
+ or name.endswith(".markdown")
+ )
+
+
+def _node_has_markdown_delivery(node: dict[str, Any]) -> bool:
+ artifacts = node.get("artifact_manifest")
+ return isinstance(artifacts, list) and any(_is_markdown_artifact(item) for item in artifacts)
+
+
+def _markdown_delivery_changed(
+ previous_artifacts: Any,
+ incoming_artifacts: list[dict[str, Any]],
+) -> bool:
+ """Whether this turn rewrote a Markdown deliverable, not just chatted.
+
+ A completed node can receive normal follow-up Q&A. That must not bump its
+ effective delivery revision or invalidate every downstream stage unless a
+ Markdown report really changed. The standard artifact listing includes
+ `modified_at` and `size_bytes`, which together make a stable lightweight
+ version marker without reading a potentially large file again.
+ """
+ old = previous_artifacts if isinstance(previous_artifacts, list) else []
+ old_signatures = {
+ (
+ str(item.get("path") or item.get("artifact_url") or ""),
+ str(item.get("modified_at") or item.get("modifiedAt") or ""),
+ str(item.get("size_bytes") or item.get("sizeBytes") or ""),
+ )
+ for item in old
+ if isinstance(item, dict) and _is_markdown_artifact(item)
+ }
+ new_signatures = {
+ (
+ str(item.get("path") or item.get("artifact_url") or ""),
+ str(item.get("modified_at") or item.get("modifiedAt") or ""),
+ str(item.get("size_bytes") or item.get("sizeBytes") or ""),
+ )
+ for item in incoming_artifacts
+ if isinstance(item, dict) and _is_markdown_artifact(item)
+ }
+ return bool(new_signatures - old_signatures)
+
+
+def _intent_node_answer(intent: Any) -> str:
+ if not isinstance(intent, dict):
+ return "情报分析岗已完成任务意图识别。"
+ objective = _text_from_keys(intent, ["objective", "goal"], "已完成任务意图识别")
+ constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
+ lines = ["情报分析岗已完成任务意图识别。", f"任务目标:{objective}"]
+ if constraints:
+ lines.append("关键约束:")
+ lines.extend(f"- {item}" for item in constraints if isinstance(item, str) and item.strip())
+ return "\n".join(lines)
+
+
+def _has_confirmed_intent(intent: Any) -> bool:
+ """Only structured Step-1 output may start a frozen business chain."""
+ if not isinstance(intent, dict):
+ return False
+ objective = _text_from_keys(intent, ["objective", "goal"])
+ constraints = intent.get("constraints")
+ return bool(objective and isinstance(constraints, list))
+
+
+def _prime_intent_nodes(nodes: list[dict[str, Any]], intent: Any) -> list[dict[str, Any]]:
+ now_answer = _intent_node_answer(intent)
+ primed: list[dict[str, Any]] = []
+ for node in nodes:
+ next_node = dict(node)
+ if _is_intent_position_node(next_node):
+ next_node.update(
+ {
+ "status": "done",
+ "latest_answer": now_answer,
+ "revision": 1,
+ "artifact_manifest": [],
+ }
+ )
+ primed.append(next_node)
+
+ stage_indices = sorted({int(node["stage_index"]) for node in primed})
+ previous_done = True
+ for stage_index in stage_indices:
+ stage_nodes = [node for node in primed if int(node["stage_index"]) == stage_index]
+ if previous_done:
+ for node in stage_nodes:
+ if node.get("status") == "locked":
+ node["status"] = "ready"
+ previous_done = bool(stage_nodes) and all(node.get("status") == "done" for node in stage_nodes)
+ return primed
+
+
+def _build_node_context_text(
+ *,
+ session: dict[str, Any],
+ current: dict[str, Any],
+ upstream_nodes: list[dict[str, Any]],
+) -> str:
+ task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
+ intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
+ title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
+ direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
+ description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
+ objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
+ constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
+ agent_name, position_id = _seat_meta(session, current)
+ md_required = not _is_intent_position_node(current)
+ lines = [
+ "岗位多智能体会商上下文(隐藏注入,不要向用户复述本标签):",
+ "",
+ "【任务信息】",
+ f"- 任务名称:{title}",
+ f"- 任务方向:{direction}",
+ f"- 任务描述:{description}",
+ "",
+ "【已识别意图】",
+ f"- 任务目标:{objective}",
+ ]
+ if constraints:
+ lines.append("- 关键约束:")
+ lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
+ lines.extend(
+ [
+ "",
+ "【当前席位】",
+ f"- 智能体:{agent_name}",
+ f"- 岗位:{position_id}",
+ f"- 阶段:L-{int(current.get('stage_index') or 0) + 1}",
+ "",
+ "【交付要求】",
+ ]
+ )
+ if md_required:
+ lines.extend(
+ [
+ "- 本席位最终只需交付一份 Markdown 报告文档。",
+ "- 请在本轮或最终回复中创建 `/mnt/user-data/outputs/` 下的 `.md` 文件,并调用 `present_files` 呈现该文件。",
+ "- 只有检测到该席位产出的 `.md` 文件后,系统才会把当前席位标记为已完成;仅有文字回答不会完成节点。",
+ "- 报告内容应直接围绕当前岗位职责,吸收任务信息、任务意图与上游席位产物。",
+ ]
+ )
+ else:
+ lines.append("- 情报分析/情报收集岗以意图识别完成为准,不强制产出 Markdown 报告。")
+ if upstream_nodes:
+ lines.extend(["", "【可参考的上游席位结果】"])
+ for index, node in enumerate(upstream_nodes, start=1):
+ lines.append(
+ f"{index}. {node.get('agent_id')} / {node.get('position_id')} / revision={node.get('revision', 0)}"
+ )
+ answer = str(node.get("latest_answer") or "").strip()
+ if answer:
+ lines.append(f" 问答结果:{answer}")
+ artifacts = node.get("artifacts")
+ if isinstance(artifacts, list) and artifacts:
+ lines.append(" 产物:")
+ for artifact in artifacts[:5]:
+ if not isinstance(artifact, dict):
+ continue
+ artifact_name = artifact.get("name") or artifact.get("path") or "未命名产物"
+ lines.append(f" - {artifact_name}")
+ content = str(artifact.get("content") or "").strip()
+ if content:
+ lines.append(f" 摘要/正文片段:{content}")
+ else:
+ lines.extend(["", "【可参考的上游席位结果】", "- 当前节点暂无已完成上游席位。"])
+ return "\n".join(lines)
+
+
+def _artifact_lines(node: dict[str, Any]) -> list[str]:
+ artifacts = node.get("artifact_manifest")
+ if not isinstance(artifacts, list) or not artifacts:
+ return []
+ lines: list[str] = []
+ for artifact in artifacts[:8]:
+ if not isinstance(artifact, dict):
+ continue
+ name = artifact.get("name") or artifact.get("filename") or artifact.get("path") or "未命名产物"
+ path = artifact.get("path") or artifact.get("artifact_url") or ""
+ lines.append(f" - {name}{f'({path})' if path else ''}")
+ return lines
+
+
+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="上游节点尚未完成,当前节点未解锁")
+ if current.get("invalidated_by"):
+ 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"] == "done"
+ ]
+ 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",
+ "instructions": _build_node_context_text(
+ session=session,
+ current=current,
+ upstream_nodes=upstream_nodes,
+ ),
+ "delivery_requirement": {
+ "type": "markdown_report",
+ "required": not _is_intent_position_node(current),
+ "completion_rule": "普通业务席位必须在 /mnt/user-data/outputs/ 下交付一份 .md 报告文档才会标记完成;情报分析/情报收集岗以意图识别完成为准。",
+ },
+ "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
+ },
+ }
+
+
+def _source_revisions(nodes: list[dict[str, Any]]) -> dict[str, int]:
+ """Stable revision fence for a built-in report/planning run.
+
+ The report agents run asynchronously in the browser. If a user rejects or
+ re-delivers a node while a run is still streaming, this fence prevents that
+ old run from overwriting the newer business-chain state when it completes.
+ """
+ return {str(node["node_key"]): int(node.get("revision") or 0) for node in _delivered_nodes(nodes)}
+
+
+def _is_json_artifact(artifact: Any) -> bool:
+ if not isinstance(artifact, dict):
+ return False
+ path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip().lower()
+ name = str(artifact.get("name") or artifact.get("filename") or "").strip().lower()
+ mime = str(artifact.get("mime_type") or artifact.get("mimeType") or "").strip().lower()
+ if path and not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
+ return False
+ return "json" in mime or path.endswith(".json") or name.endswith(".json")
+
+
+def _artifact_signature(artifact: dict[str, Any]) -> tuple[str, str, str]:
+ """Use server-returned path/mtime/size to detect an actually rewritten file."""
+ return (
+ str(artifact.get("path") or artifact.get("artifact_url") or ""),
+ str(artifact.get("modified_at") or artifact.get("modifiedAt") or ""),
+ str(artifact.get("size_bytes") or artifact.get("sizeBytes") or ""),
+ )
+
+
+def _artifact_manifest_refs(artifacts: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Persist only the display metadata received from the thread artifact API."""
+ refs: list[dict[str, Any]] = []
+ for artifact in artifacts:
+ if not isinstance(artifact, dict):
+ continue
+ path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip()
+ if not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
+ continue
+ refs.append(
+ {
+ "name": str(artifact.get("name") or artifact.get("filename") or path.rsplit("/", 1)[-1]),
+ "path": path,
+ "mime_type": artifact.get("mime_type") or artifact.get("mimeType") or None,
+ "modified_at": artifact.get("modified_at") or artifact.get("modifiedAt") or None,
+ "size_bytes": artifact.get("size_bytes") or artifact.get("sizeBytes") or None,
+ }
+ )
+ return refs
+
+
+async def _effective_delivery_material(
+ session: dict[str, Any], nodes: list[dict[str, Any]]
+) -> str:
+ """Build bounded source material from valid current deliveries only.
+
+ Stale/rejected rows are deliberately excluded. The persisted node answer
+ provides a quick synopsis; the Markdown files provide the actual
+ deliverable body, read through the owning thread's virtual-path resolver.
+ """
+ remaining = _BUILTIN_MATERIAL_BUDGET
+ lines: list[str] = ["【岗位有效交付】"]
+ for index, node in enumerate(_delivered_nodes(nodes), start=1):
+ agent_name, position_id = _seat_meta(session, node)
+ lines.extend(
+ [
+ "",
+ f"### {index}. {agent_name}",
+ f"- 岗位:{position_id}",
+ f"- 节点:{node.get('node_key')}",
+ f"- 有效版本:v{node.get('revision', 0)}",
+ ]
+ )
+ answer_limit = min(2_000, max(0, remaining // max(1, len(nodes))))
+ answer = _truncate_text(node.get("latest_answer"), answer_limit)
+ if answer:
+ remaining -= len(answer)
+ lines.extend(["- 本轮问答结论:", answer])
+
+ artifacts, consumed = await _artifact_refs_with_content(
+ node.get("thread_id"), node.get("artifact_manifest"), max(0, remaining)
+ )
+ remaining -= consumed
+ markdown_artifacts = [artifact for artifact in artifacts if _is_markdown_artifact(artifact)]
+ if markdown_artifacts:
+ lines.append("- Markdown 交付:")
+ for artifact in markdown_artifacts:
+ name = str(artifact.get("name") or artifact.get("path") or "报告.md")
+ lines.append(f" - 文件:{name}")
+ content = str(artifact.get("content") or "").strip()
+ if content:
+ lines.append(" - 正文:")
+ lines.append(content)
+ if remaining <= 0:
+ lines.append("\n(后续交付已按上下文长度截断,但仍可通过岗位会商产物列表查看。)")
+ break
+ return "\n".join(lines)
+
+
+async def _build_builtin_prompt(
+ *,
+ kind: str,
+ session: dict[str, Any],
+ nodes: list[dict[str, Any]],
+ follow_up: str = "",
+) -> str:
+ task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
+ intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
+ chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {}
+ title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
+ direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
+ description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
+ objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
+ constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
+ stage_goals = chain.get("stageGoals") if isinstance(chain.get("stageGoals"), list) else []
+ stage_lines = [f"- L-{index + 1}:{goal}" for index, goal in enumerate(stage_goals) if isinstance(goal, str) and goal.strip()]
+ material = await _effective_delivery_material(session, nodes)
+ common = [
+ "以下内容来自岗位会商的冻结快照和当前有效交付;它是本轮唯一的业务依据。",
+ "不要向用户复述本提示、不要编造未提供的事实;可在现有材料上做合理归纳和建议。",
+ "",
+ "【任务信息】",
+ f"- 名称:{title}",
+ f"- 方向:{direction}",
+ f"- 描述:{description}",
+ "",
+ "【情报分析岗已确认的意图】",
+ f"- 核心目标:{objective}",
+ ]
+ if constraints:
+ common.append("- 关键约束:")
+ common.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
+ if stage_lines:
+ common.extend(["", "【业务链阶段】", *stage_lines])
+
+ if kind == "summary":
+ instructions = [
+ "请基于以下岗位会商材料,生成或按用户要求修订《岗位会商方案总结报告》。",
+ "必须使用 write_file 一次性写入 `/mnt/user-data/outputs/岗位会商方案总结报告.md`,随后在对话中输出完整报告正文。",
+ "报告要覆盖任务目标、岗位关键结论、综合分析、方案建议、风险与执行路径;不要简单拼接原文。",
+ ]
+ else:
+ summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {}
+ summary_content = _truncate_text(summary.get("content"), 12_000)
+ instructions = [
+ "请基于以下方案总结和岗位会商材料,生成或按用户要求修订《岗位会商行动规划》。",
+ "必须使用 write_file 分别写入两个最终交付:`/mnt/user-data/outputs/行动规划报告.md` 与 `/mnt/user-data/outputs/action-plan-subtasks.json`。",
+ "Markdown 报告需给出分阶段推进建议、优先级、风险控制与验收安排;JSON 必须是严格合法数组,每个子任务都包含 id、title、goal、basis、owner_position、dependencies、deliverable、acceptance_criteria、priority。",
+ "",
+ "【已生效方案总结】",
+ summary_content or "(方案总结正文未写入快照,请依据下方有效岗位交付重新归纳。)",
+ ]
+ if follow_up.strip():
+ instructions.extend(["", "【用户本次补充/修改要求】", follow_up.strip()])
+ return "\n".join([*instructions, "", *common, "", material])
+
+
+def _build_builtin_snapshot(
+ *,
+ kind: str,
+ thread_id: str,
+ content: str,
+ artifacts: list[dict[str, Any]],
+ source_revisions: dict[str, int],
+ model: str | None,
+ previous: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ markdown = next((artifact for artifact in artifacts if _is_markdown_artifact(artifact)), None)
+ json_artifact = next((artifact for artifact in artifacts if _is_json_artifact(artifact)), None)
+ primary = markdown if kind == "summary" else markdown
+ previous_primary = previous.get("primary_artifact") if isinstance(previous, dict) else None
+ primary_changed = not isinstance(previous_primary, dict) or not isinstance(primary, dict) or (
+ _artifact_signature(previous_primary) != _artifact_signature(primary)
+ )
+ previous_content = str(previous.get("content") or "") if isinstance(previous, dict) else ""
+ return {
+ "kind": kind,
+ "status": "done",
+ "title": "岗位会商方案总结报告" if kind == "summary" else "岗位会商行动规划",
+ # A pure follow-up may only answer a question and leave the report file
+ # unchanged. Keep the last report body in that case while retaining
+ # the answer separately for audit/display.
+ "content": content.strip() if primary_changed or not previous_content else previous_content,
+ "last_response": content.strip(),
+ "thread_id": thread_id,
+ "artifact_manifest": _artifact_manifest_refs(artifacts),
+ "primary_artifact": primary,
+ "subtasks_artifact": json_artifact if kind == "action_plan" else None,
+ "source_revisions": source_revisions,
+ "model": model or None,
+ "generated_at": datetime.now(UTC).isoformat(),
+ }
+
+
+@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 _has_confirmed_intent(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="业务链条不存在或无权访问")
+ 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),
+ )
+ nodes = _prime_intent_nodes(nodes, current.get("intent_snapshot"))
+ 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("invalidated_by"):
+ 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="上游节点尚未完成,当前节点未解锁")
+ if node.get("invalidated_by"):
+ raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
+ # Artifact listing is best-effort on the client. Never erase a previously
+ # delivered report merely because one listing request transiently failed.
+ effective_artifacts = body.artifact_manifest or list(node.get("artifact_manifest") or [])
+ has_markdown = any(_is_markdown_artifact(item) for item in effective_artifacts)
+ markdown_changed = _markdown_delivery_changed(node.get("artifact_manifest"), effective_artifacts)
+ requires_fresh_delivery = node.get("status") in {"rejected", "stale"}
+ next_status = (
+ "done"
+ if _is_intent_position_node(node)
+ else "done"
+ if has_markdown and (not requires_fresh_delivery or markdown_changed)
+ else "running"
+ )
+ # Effective revisions are delivery revisions, not every conversational
+ # answer. This prevents a harmless follow-up on a completed seat from
+ # invalidating all later stages just because its old Markdown file still
+ # appears in the artifact list.
+ new_effective_delivery = (
+ next_status == "done"
+ and (
+ _is_intent_position_node(node)
+ or node.get("status") != "done"
+ or markdown_changed
+ )
+ )
+ row = await store.update_node(
+ session_id,
+ node_key,
+ user_id,
+ status=next_status,
+ latest_answer=body.latest_answer,
+ artifact_manifest=effective_artifacts,
+ upstream_revision=body.upstream_revision,
+ invalidated_by=None,
+ increment_revision=new_effective_delivery,
+ )
+ if row is None:
+ raise HTTPException(status_code=404, detail="Position roundtable node not found")
+
+ # A node has no coordinator. Its effective delivery alone drives the frozen
+ # chain: all parallel seats in the current stage must finish before the
+ # next stage is made writable. A plain conversational follow-up keeps the
+ # existing downstream deliveries valid; only a newly written Markdown
+ # report advances the revision and makes later stages stale.
+ 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 new_effective_delivery and node.get("revision", 0) > 0:
+ for downstream in nodes:
+ if (
+ downstream["stage_index"] > row["stage_index"]
+ and downstream["status"] in {"ready", "running", "done", "stale", "rejected", "error"}
+ ):
+ has_material = (
+ downstream.get("revision", 0) > 0
+ or bool(downstream.get("latest_answer"))
+ or bool(downstream.get("artifact_manifest"))
+ )
+ await store.update_node(
+ session_id,
+ downstream["node_key"],
+ user_id,
+ status="stale" if has_material else "locked",
+ invalidated_by=row["node_key"],
+ )
+ 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 row["status"] == "done" and 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"] in {"locked", "stale"}:
+ await store.update_node(
+ session_id,
+ next_node["node_key"],
+ user_id,
+ status="ready" if next_node["status"] == "locked" else "stale",
+ invalidated_by=None,
+ )
+ return _project_node(row)
+
+
+@router.post("/sessions/{session_id}/nodes/{node_key}/reject", response_model=SessionDetailResponse)
+async def reject_node(
+ request: Request, session_id: str, node_key: str, body: RejectNodeRequest
+) -> SessionDetailResponse:
+ title = body.title.strip()
+ reason = body.reason.strip()
+ if not title or not reason:
+ raise HTTPException(status_code=400, detail="驳回标题和理由不能为空")
+ 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("revision", 0) <= 0 and not current.get("latest_answer") and not current.get("artifact_manifest"):
+ raise HTTPException(status_code=409, detail="当前节点暂无可驳回的交付产物")
+ rejected = await store.reject_node(
+ session_id,
+ node_key,
+ user_id,
+ title=title,
+ reason=reason,
+ rejected_by=user_id,
+ )
+ if rejected is None:
+ raise HTTPException(status_code=404, detail="Position roundtable node not found")
+ row, node_rows = rejected
+ return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
+
+
+async def _prepare_builtin_run(
+ request: Request,
+ session_id: str,
+ *,
+ kind: str,
+ follow_up: str = "",
+) -> BuiltinRunPreparationResponse:
+ store, user_id, session = await _require_writable_session(request, session_id)
+ 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 not _all_nodes_delivered(nodes):
+ raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成或更新内置交付")
+
+ if kind == "action_plan":
+ summary = session.get("summary_snapshot")
+ if not isinstance(summary, dict) or summary.get("status") != "done":
+ raise HTTPException(status_code=409, detail="请先生成有效的方案总结")
+ thread_id = session.get("action_plan_thread_id")
+ agent_id = ACTION_PLAN_AGENT_ID
+ display_name = "行动规划智能体"
+ else:
+ if follow_up.strip():
+ summary = session.get("summary_snapshot")
+ if not isinstance(summary, dict) or summary.get("status") != "done":
+ raise HTTPException(status_code=409, detail="请先生成有效的方案总结后再继续问答")
+ thread_id = session.get("summary_thread_id")
+ agent_id = SUMMARY_AGENT_ID
+ display_name = "方案总结智能体"
+
+ return BuiltinRunPreparationResponse(
+ agent_id=agent_id,
+ display_name=display_name,
+ thread_id=str(thread_id) if isinstance(thread_id, str) and thread_id else None,
+ prompt=await _build_builtin_prompt(
+ kind=kind,
+ session=session,
+ nodes=nodes,
+ follow_up=follow_up,
+ ),
+ source_revisions=_source_revisions(nodes),
+ )
+
+
+async def _complete_builtin_run(
+ request: Request,
+ session_id: str,
+ *,
+ kind: str,
+ body: CompleteBuiltinRunRequest,
+) -> SessionDetailResponse:
+ store, user_id, session = await _require_writable_session(request, session_id)
+ 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 not _all_nodes_delivered(nodes):
+ raise HTTPException(status_code=409, detail="上游岗位产物已变更或未完成,不能写入旧的内置交付")
+ current_revisions = _source_revisions(nodes)
+ if body.source_revisions != current_revisions:
+ raise HTTPException(status_code=409, detail="岗位产物版本已变化,请基于最新交付重新生成")
+
+ artifacts = _artifact_manifest_refs(body.artifact_manifest)
+ markdown = [artifact for artifact in artifacts if _is_markdown_artifact(artifact)]
+ if not markdown:
+ raise HTTPException(status_code=422, detail="内置智能体必须交付 outputs 目录下的一份 Markdown 报告文档")
+ if kind == "action_plan" and not any(_is_json_artifact(artifact) for artifact in artifacts):
+ raise HTTPException(status_code=422, detail="行动规划智能体还需交付 action-plan-subtasks.json 子任务清单")
+
+ snapshot_field = "summary_snapshot" if kind == "summary" else "action_plan_snapshot"
+ previous = session.get(snapshot_field)
+ previous_snapshot = previous if isinstance(previous, dict) else None
+ snapshot = _build_builtin_snapshot(
+ kind=kind,
+ thread_id=body.thread_id,
+ content=body.content,
+ artifacts=artifacts,
+ source_revisions=current_revisions,
+ model=body.model,
+ previous=previous_snapshot,
+ )
+ previous_primary = previous_snapshot.get("primary_artifact") if previous_snapshot else None
+ new_primary = snapshot.get("primary_artifact")
+ report_changed = not isinstance(previous_primary, dict) or not isinstance(new_primary, dict) or (
+ _artifact_signature(previous_primary) != _artifact_signature(new_primary)
+ )
+ if kind == "summary":
+ row = await store.update_session(
+ session_id,
+ user_id,
+ summary_thread_id=body.thread_id,
+ summary_snapshot=snapshot,
+ # A rewritten summary invalidates the derived action plan. A
+ # pure Q&A follow-up keeps the existing plan valid.
+ action_plan_snapshot=None if report_changed else session.get("action_plan_snapshot"),
+ status="active",
+ )
+ else:
+ row = await store.update_session(
+ session_id,
+ user_id,
+ action_plan_thread_id=body.thread_id,
+ action_plan_snapshot=snapshot,
+ status="completed",
+ )
+ if row is None:
+ raise HTTPException(status_code=404, detail="Position roundtable session not found")
+ node_rows = await store.list_nodes(session_id, user_id) or []
+ return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
+
+
+@router.post("/sessions/{session_id}/summary/prepare", response_model=BuiltinRunPreparationResponse)
+async def prepare_summary(
+ request: Request, session_id: str, body: PrepareBuiltinRunRequest
+) -> BuiltinRunPreparationResponse:
+ return await _prepare_builtin_run(request, session_id, kind="summary", follow_up=body.message)
+
+
+@router.post("/sessions/{session_id}/summary/ask", response_model=BuiltinRunPreparationResponse)
+async def ask_summary(
+ request: Request, session_id: str, body: PrepareBuiltinRunRequest
+) -> BuiltinRunPreparationResponse:
+ return await _prepare_builtin_run(request, session_id, kind="summary", follow_up=body.message)
+
+
+@router.post("/sessions/{session_id}/summary/complete", response_model=SessionDetailResponse)
+async def complete_summary(
+ request: Request, session_id: str, body: CompleteBuiltinRunRequest
+) -> SessionDetailResponse:
+ return await _complete_builtin_run(request, session_id, kind="summary", body=body)
+
+
+@router.post("/sessions/{session_id}/action-plan/prepare", response_model=BuiltinRunPreparationResponse)
+async def prepare_action_plan(
+ request: Request, session_id: str, body: PrepareBuiltinRunRequest
+) -> BuiltinRunPreparationResponse:
+ return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message)
+
+
+@router.post("/sessions/{session_id}/action-plan/ask", response_model=BuiltinRunPreparationResponse)
+async def ask_action_plan(
+ request: Request, session_id: str, body: PrepareBuiltinRunRequest
+) -> BuiltinRunPreparationResponse:
+ return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message)
+
+
+@router.post("/sessions/{session_id}/action-plan/complete", response_model=SessionDetailResponse)
+async def complete_action_plan(
+ request: Request, session_id: str, body: CompleteBuiltinRunRequest
+) -> SessionDetailResponse:
+ return await _complete_builtin_run(request, session_id, kind="action_plan", body=body)
+
+
+@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/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py
index cdda6c57..5738e369 100644
--- a/offline-backend-20260512/backend/app/gateway/services.py
+++ b/offline-backend-20260512/backend/app/gateway/services.py
@@ -247,6 +247,13 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
# 关掉个人记忆召回 / builtin 记忆注入(见 multi_agent._run_payload)。
"memory_recall_disabled",
"memory_injection_enabled",
+ # 任务工作区 / 岗位会商把左侧任务卡的快照放在这里。它必须进入
+ # ``runtime.context``,TaskExternalContextMiddleware 才能在每次模型调用
+ # 前以隐藏消息注入任务名称、描述和方向;未白名单会被静默丢弃,模型只能
+ # 看到用户说的“这个任务”。
+ "task_external_context",
+ # 岗位会商下游席位按需读取前序席位完整交付时使用的运行期数据。
+ "roundtable_peer_deliveries",
# 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后
# 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。
"summarization_enabled",
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/migrations/versions/20260721_02_position_roundtable_rejections.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_02_position_roundtable_rejections.py
new file mode 100644
index 00000000..eea6f562
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_02_position_roundtable_rejections.py
@@ -0,0 +1,54 @@
+"""Add rejection metadata to position roundtable nodes.
+
+Revision ID: 20260721_02
+Revises: 20260721_01
+Create Date: 2026-07-21
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import inspect
+from sqlalchemy.dialects.mysql import LONGTEXT
+
+
+revision = "20260721_02"
+down_revision = "20260721_01"
+branch_labels = None
+depends_on = None
+
+
+def _has_table(table_name: str) -> bool:
+ return table_name in inspect(op.get_bind()).get_table_names()
+
+
+def _has_column(table_name: str, column_name: str) -> bool:
+ if not _has_table(table_name):
+ return False
+ return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
+
+
+def upgrade() -> None:
+ table = "position_roundtable_nodes"
+ if not _has_table(table):
+ return
+ long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
+ if not _has_column(table, "rejection_count"):
+ op.add_column(
+ table,
+ sa.Column("rejection_count", sa.Integer(), nullable=False, server_default="0"),
+ )
+ if not _has_column(table, "last_rejection"):
+ op.add_column(table, sa.Column("last_rejection", long_text, nullable=True))
+ if not _has_column(table, "invalidated_by"):
+ op.add_column(table, sa.Column("invalidated_by", sa.String(256), nullable=True))
+
+
+def downgrade() -> None:
+ table = "position_roundtable_nodes"
+ if not _has_table(table):
+ return
+ for column in ("invalidated_by", "last_rejection", "rejection_count"):
+ if _has_column(table, column):
+ op.drop_column(table, column)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_03_position_roundtable_builtin_snapshots.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_03_position_roundtable_builtin_snapshots.py
new file mode 100644
index 00000000..22a1c27d
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_03_position_roundtable_builtin_snapshots.py
@@ -0,0 +1,49 @@
+"""Add builtin summary/action-plan snapshots to position roundtable sessions.
+
+Revision ID: 20260721_03
+Revises: 20260721_02
+Create Date: 2026-07-21
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import inspect
+from sqlalchemy.dialects.mysql import LONGTEXT
+
+
+revision = "20260721_03"
+down_revision = "20260721_02"
+branch_labels = None
+depends_on = None
+
+
+def _has_table(table_name: str) -> bool:
+ return table_name in inspect(op.get_bind()).get_table_names()
+
+
+def _has_column(table_name: str, column_name: str) -> bool:
+ if not _has_table(table_name):
+ return False
+ return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
+
+
+def upgrade() -> None:
+ table = "position_roundtable_sessions"
+ if not _has_table(table):
+ return
+ long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
+ if not _has_column(table, "summary_snapshot"):
+ op.add_column(table, sa.Column("summary_snapshot", long_text, nullable=True))
+ if not _has_column(table, "action_plan_snapshot"):
+ op.add_column(table, sa.Column("action_plan_snapshot", long_text, nullable=True))
+
+
+def downgrade() -> None:
+ table = "position_roundtable_sessions"
+ if not _has_table(table):
+ return
+ for column in ("action_plan_snapshot", "summary_snapshot"):
+ if _has_column(table, column):
+ op.drop_column(table, column)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_04_position_roundtable_builtin_threads.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_04_position_roundtable_builtin_threads.py
new file mode 100644
index 00000000..f0cb0ff4
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_04_position_roundtable_builtin_threads.py
@@ -0,0 +1,47 @@
+"""Persist position-roundtable built-in agent thread ids.
+
+Revision ID: 20260721_04
+Revises: 20260721_03
+Create Date: 2026-07-21
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import inspect
+
+
+revision = "20260721_04"
+down_revision = "20260721_03"
+branch_labels = None
+depends_on = None
+
+
+def _has_table(table_name: str) -> bool:
+ return table_name in inspect(op.get_bind()).get_table_names()
+
+
+def _has_column(table_name: str, column_name: str) -> bool:
+ if not _has_table(table_name):
+ return False
+ return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
+
+
+def upgrade() -> None:
+ table = "position_roundtable_sessions"
+ if not _has_table(table):
+ return
+ if not _has_column(table, "summary_thread_id"):
+ op.add_column(table, sa.Column("summary_thread_id", sa.String(length=128), nullable=True))
+ if not _has_column(table, "action_plan_thread_id"):
+ op.add_column(table, sa.Column("action_plan_thread_id", sa.String(length=128), nullable=True))
+
+
+def downgrade() -> None:
+ table = "position_roundtable_sessions"
+ if not _has_table(table):
+ return
+ for column in ("action_plan_thread_id", "summary_thread_id"):
+ if _has_column(table, column):
+ op.drop_column(table, column)
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..64aeb63c
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py
@@ -0,0 +1,93 @@
+"""Persistence models for the standalone position-collaboration workspace.
+
+This deliberately does **not** reuse ``roundtable_drafts`` or
+``roundtable_jobs``. A session captures the selected business chain and the
+confirmed task intent; every chain seat then has one durable node record. The
+node points at an existing LangGraph thread, which continues to own the actual
+message history and generated artifacts.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from deerflow.persistence.base import Base
+from deerflow.persistence.types import BeijingDateTime, PortableLongText
+
+
+class PositionRoundtableSessionRow(Base):
+ __tablename__ = "position_roundtable_sessions"
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ # Phase one uses normal account isolation. A future membership / role ACL
+ # can be added without changing the session or thread identifiers.
+ user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+ external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
+ task_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+ chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ # The two built-in closing agents own normal LangGraph threads too. Keep
+ # their ids on the position session so a refresh can continue the same
+ # report/planning dialogue instead of silently starting a fresh context.
+ summary_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ summary_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ action_plan_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ action_plan_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ # intent_pending -> active -> completed / archived. The router owns legal
+ # transitions; the DB keeps it as a concise, portable string.
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
+ created_at: Mapped[datetime] = mapped_column(
+ BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ BeijingDateTime(),
+ nullable=False,
+ default=lambda: datetime.now(UTC),
+ onupdate=lambda: datetime.now(UTC),
+ )
+
+
+class PositionRoundtableNodeRow(Base):
+ __tablename__ = "position_roundtable_nodes"
+ __table_args__ = (
+ UniqueConstraint("session_id", "node_key", name="uq_position_roundtable_nodes_session_key"),
+ )
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ session_id: Mapped[str] = mapped_column(
+ String(64),
+ ForeignKey("position_roundtable_sessions.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ # Stable inside one frozen chain snapshot; e.g. ``stage-1-seat-2-agent``.
+ node_key: Mapped[str] = mapped_column(String(256), nullable=False)
+ stage_index: Mapped[int] = mapped_column(Integer, nullable=False)
+ seat_index: Mapped[int] = mapped_column(Integer, nullable=False)
+ agent_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
+ position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+ # The real messages remain in the standard LangGraph/checkpoint store.
+ thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
+ # locked / ready / running / done / stale / rejected / error
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True)
+ latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+ last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ invalidated_by: Mapped[str | None] = mapped_column(String(256), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ BeijingDateTime(),
+ nullable=False,
+ default=lambda: datetime.now(UTC),
+ onupdate=lambda: datetime.now(UTC),
+ )
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..05c7215b
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py
@@ -0,0 +1,396 @@
+"""SQL repository for position-collaboration sessions and chain nodes."""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+from typing import Any
+from uuid import uuid4
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from deerflow.persistence.position_roundtable.model import (
+ PositionRoundtableNodeRow,
+ PositionRoundtableSessionRow,
+)
+
+_UNSET = object()
+
+
+def _dumps(value: Any) -> str | None:
+ if value is None:
+ return None
+ return json.dumps(value, ensure_ascii=False)
+
+
+def _loads(value: Any) -> Any:
+ if not isinstance(value, str) or not value:
+ return None
+ try:
+ return json.loads(value)
+ except Exception:
+ return None
+
+
+def _time(value: Any) -> str | Any:
+ return value.isoformat() if isinstance(value, datetime) else value
+
+
+def _has_material(row: PositionRoundtableNodeRow) -> bool:
+ return row.revision > 0 or bool(row.latest_answer) or bool(_loads(row.artifact_manifest))
+
+
+class PositionRoundtableRepository:
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
+ self._sf = session_factory
+
+ @staticmethod
+ def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
+ return {
+ "id": row.id,
+ "external_task_id": row.external_task_id,
+ "task_snapshot": _loads(row.task_snapshot) or {},
+ "chain_id": row.chain_id,
+ "chain_snapshot": _loads(row.chain_snapshot),
+ "intent_thread_id": row.intent_thread_id,
+ "intent_snapshot": _loads(row.intent_snapshot),
+ "summary_thread_id": row.summary_thread_id,
+ "summary_snapshot": _loads(row.summary_snapshot),
+ "action_plan_thread_id": row.action_plan_thread_id,
+ "action_plan_snapshot": _loads(row.action_plan_snapshot),
+ "status": row.status,
+ "created_at": _time(row.created_at),
+ "updated_at": _time(row.updated_at),
+ }
+
+ @staticmethod
+ def _node_dict(row: PositionRoundtableNodeRow) -> dict[str, Any]:
+ return {
+ "id": row.id,
+ "session_id": row.session_id,
+ "node_key": row.node_key,
+ "stage_index": row.stage_index,
+ "seat_index": row.seat_index,
+ "agent_id": row.agent_id,
+ "position_id": row.position_id,
+ "thread_id": row.thread_id,
+ "status": row.status,
+ "latest_answer": row.latest_answer,
+ "artifact_manifest": _loads(row.artifact_manifest) or [],
+ "revision": row.revision,
+ "upstream_revision": _loads(row.upstream_revision),
+ "rejection_count": row.rejection_count or 0,
+ "last_rejection": _loads(row.last_rejection),
+ "invalidated_by": row.invalidated_by,
+ "created_at": _time(row.created_at),
+ "updated_at": _time(row.updated_at),
+ }
+
+ async def create_session(
+ self,
+ user_id: str,
+ *,
+ external_task_id: str | None,
+ task_snapshot: dict[str, Any] | None,
+ ) -> dict[str, Any]:
+ now = datetime.now(UTC)
+ row = PositionRoundtableSessionRow(
+ id=uuid4().hex,
+ user_id=user_id,
+ external_task_id=external_task_id or None,
+ task_snapshot=_dumps(task_snapshot or {}),
+ status="intent_pending",
+ created_at=now,
+ updated_at=now,
+ )
+ async with self._sf() as session:
+ session.add(row)
+ await session.commit()
+ await session.refresh(row)
+ return self._session_dict(row)
+
+ async def list_sessions(
+ self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
+ ) -> list[dict[str, Any]]:
+ stmt = select(PositionRoundtableSessionRow).where(PositionRoundtableSessionRow.user_id == user_id)
+ if external_task_id:
+ stmt = stmt.where(PositionRoundtableSessionRow.external_task_id == external_task_id)
+ stmt = stmt.order_by(PositionRoundtableSessionRow.updated_at.desc()).limit(limit)
+ async with self._sf() as session:
+ rows = (await session.execute(stmt)).scalars().all()
+ return [self._session_dict(row) for row in rows]
+
+ async def get_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
+ async with self._sf() as session:
+ row = await session.get(PositionRoundtableSessionRow, session_id)
+ if row is None or row.user_id != user_id:
+ return None
+ return self._session_dict(row)
+
+ async def update_session(
+ self,
+ session_id: str,
+ user_id: str,
+ *,
+ task_snapshot: dict[str, Any] | object = _UNSET,
+ intent_thread_id: str | None | object = _UNSET,
+ intent_snapshot: dict[str, Any] | None | object = _UNSET,
+ summary_thread_id: str | None | object = _UNSET,
+ summary_snapshot: dict[str, Any] | None | object = _UNSET,
+ action_plan_thread_id: str | None | object = _UNSET,
+ action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
+ status: str | object = _UNSET,
+ ) -> dict[str, Any] | None:
+ async with self._sf() as session:
+ row = await session.get(PositionRoundtableSessionRow, session_id)
+ if row is None or row.user_id != user_id:
+ return None
+ if task_snapshot is not _UNSET:
+ row.task_snapshot = _dumps(task_snapshot)
+ if intent_thread_id is not _UNSET:
+ row.intent_thread_id = intent_thread_id or None
+ if intent_snapshot is not _UNSET:
+ row.intent_snapshot = _dumps(intent_snapshot)
+ if summary_thread_id is not _UNSET:
+ row.summary_thread_id = summary_thread_id or None
+ if summary_snapshot is not _UNSET:
+ row.summary_snapshot = _dumps(summary_snapshot)
+ if action_plan_thread_id is not _UNSET:
+ row.action_plan_thread_id = action_plan_thread_id or None
+ if action_plan_snapshot is not _UNSET:
+ row.action_plan_snapshot = _dumps(action_plan_snapshot)
+ if status is not _UNSET:
+ row.status = str(status)
+ row.updated_at = datetime.now(UTC)
+ await session.commit()
+ await session.refresh(row)
+ return self._session_dict(row)
+
+ async def activate_session(
+ self,
+ session_id: str,
+ user_id: str,
+ *,
+ chain_id: str,
+ chain_snapshot: dict[str, Any],
+ nodes: list[dict[str, Any]],
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
+ """Freeze a chain snapshot and create its nodes once.
+
+ Repeated requests are intentionally idempotent: once nodes exist the
+ existing snapshot and nodes are returned unchanged.
+ """
+ now = datetime.now(UTC)
+ async with self._sf() as session:
+ row = await session.get(PositionRoundtableSessionRow, session_id)
+ if row is None or row.user_id != user_id:
+ return None
+ node_rows = (
+ await session.execute(
+ select(PositionRoundtableNodeRow)
+ .where(PositionRoundtableNodeRow.session_id == session_id)
+ .order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
+ )
+ ).scalars().all()
+ if node_rows:
+ return self._session_dict(row), [self._node_dict(node) for node in node_rows]
+
+ row.chain_id = chain_id
+ row.chain_snapshot = _dumps(chain_snapshot)
+ row.status = "active"
+ row.updated_at = now
+ node_rows = [
+ PositionRoundtableNodeRow(
+ id=uuid4().hex,
+ session_id=session_id,
+ node_key=str(node["node_key"]),
+ stage_index=int(node["stage_index"]),
+ seat_index=int(node["seat_index"]),
+ agent_id=str(node["agent_id"]),
+ position_id=node.get("position_id") or None,
+ status=str(node.get("status") or "locked"),
+ latest_answer=node.get("latest_answer") or None,
+ artifact_manifest=_dumps(node.get("artifact_manifest") or []),
+ revision=int(node.get("revision") or 0),
+ upstream_revision=_dumps(node.get("upstream_revision")) if node.get("upstream_revision") is not None else None,
+ created_at=now,
+ updated_at=now,
+ )
+ for node in nodes
+ ]
+ session.add_all(node_rows)
+ await session.commit()
+ await session.refresh(row)
+ for node in node_rows:
+ await session.refresh(node)
+ return self._session_dict(row), [self._node_dict(node) for node in node_rows]
+
+ async def list_nodes(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
+ async with self._sf() as session:
+ parent = await session.get(PositionRoundtableSessionRow, session_id)
+ if parent is None or parent.user_id != user_id:
+ return None
+ rows = (
+ await session.execute(
+ select(PositionRoundtableNodeRow)
+ .where(PositionRoundtableNodeRow.session_id == session_id)
+ .order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
+ )
+ ).scalars().all()
+ return [self._node_dict(row) for row in rows]
+
+ async def get_node(self, session_id: str, node_key: str, user_id: str) -> dict[str, Any] | None:
+ async with self._sf() as session:
+ stmt = (
+ select(PositionRoundtableNodeRow)
+ .join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
+ .where(
+ PositionRoundtableNodeRow.session_id == session_id,
+ PositionRoundtableNodeRow.node_key == node_key,
+ PositionRoundtableSessionRow.user_id == user_id,
+ )
+ .limit(1)
+ )
+ row = (await session.execute(stmt)).scalars().first()
+ return self._node_dict(row) if row is not None else None
+
+ async def update_node(
+ self,
+ session_id: str,
+ node_key: str,
+ user_id: str,
+ *,
+ thread_id: str | None | object = _UNSET,
+ status: str | object = _UNSET,
+ latest_answer: str | None | object = _UNSET,
+ artifact_manifest: list[dict[str, Any]] | object = _UNSET,
+ upstream_revision: dict[str, Any] | object = _UNSET,
+ last_rejection: dict[str, Any] | None | object = _UNSET,
+ invalidated_by: str | None | object = _UNSET,
+ increment_revision: bool = False,
+ increment_rejection: bool = False,
+ ) -> dict[str, Any] | None:
+ async with self._sf() as session:
+ stmt = (
+ select(PositionRoundtableNodeRow)
+ .join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
+ .where(
+ PositionRoundtableNodeRow.session_id == session_id,
+ PositionRoundtableNodeRow.node_key == node_key,
+ PositionRoundtableSessionRow.user_id == user_id,
+ )
+ .limit(1)
+ )
+ row = (await session.execute(stmt)).scalars().first()
+ if row is None:
+ return None
+ if thread_id is not _UNSET:
+ row.thread_id = thread_id or None
+ if status is not _UNSET:
+ row.status = str(status)
+ if latest_answer is not _UNSET:
+ row.latest_answer = latest_answer
+ if artifact_manifest is not _UNSET:
+ row.artifact_manifest = _dumps(artifact_manifest)
+ if upstream_revision is not _UNSET:
+ row.upstream_revision = _dumps(upstream_revision)
+ if last_rejection is not _UNSET:
+ row.last_rejection = _dumps(last_rejection)
+ if invalidated_by is not _UNSET:
+ row.invalidated_by = invalidated_by or None
+ if increment_revision:
+ row.revision += 1
+ if increment_rejection:
+ row.rejection_count += 1
+ row.updated_at = datetime.now(UTC)
+ await session.commit()
+ await session.refresh(row)
+ return self._node_dict(row)
+
+ async def reject_node(
+ self,
+ session_id: str,
+ node_key: str,
+ user_id: str,
+ *,
+ title: str,
+ reason: str,
+ rejected_by: str,
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
+ """Reject one delivered node and invalidate every later stage.
+
+ Historical answers and artifact manifests are deliberately preserved.
+ ``invalidated_by`` gates downstream rework until the rejected upstream
+ stage is completed again.
+ """
+ now = datetime.now(UTC)
+ async with self._sf() as session:
+ parent = await session.get(PositionRoundtableSessionRow, session_id)
+ if parent is None or parent.user_id != user_id:
+ return None
+ rows = (
+ await session.execute(
+ select(PositionRoundtableNodeRow)
+ .where(PositionRoundtableNodeRow.session_id == session_id)
+ .order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
+ )
+ ).scalars().all()
+ target = next((row for row in rows if row.node_key == node_key), None)
+ if target is None:
+ return None
+
+ rejection = {
+ "title": title,
+ "reason": reason,
+ "rejected_by": rejected_by,
+ "rejected_at": now.isoformat(),
+ }
+ summary_snapshot = _loads(parent.summary_snapshot)
+ if isinstance(summary_snapshot, dict) and summary_snapshot.get("status") == "done":
+ summary_snapshot = {
+ **summary_snapshot,
+ "status": "stale",
+ "invalidated_by": target.node_key,
+ "invalidated_at": now.isoformat(),
+ }
+ parent.summary_snapshot = _dumps(summary_snapshot)
+ action_plan_snapshot = _loads(parent.action_plan_snapshot)
+ if isinstance(action_plan_snapshot, dict) and action_plan_snapshot.get("status") == "done":
+ action_plan_snapshot = {
+ **action_plan_snapshot,
+ "status": "stale",
+ "invalidated_by": target.node_key,
+ "invalidated_at": now.isoformat(),
+ }
+ parent.action_plan_snapshot = _dumps(action_plan_snapshot)
+ if parent.status == "completed":
+ parent.status = "active"
+ target.status = "rejected"
+ target.last_rejection = _dumps(rejection)
+ target.rejection_count += 1
+ target.invalidated_by = None
+ target.updated_at = now
+
+ for row in rows:
+ if row.stage_index <= target.stage_index:
+ continue
+ if _has_material(row) or row.status in {"running", "done", "stale", "rejected", "error"}:
+ row.status = "stale"
+ else:
+ row.status = "locked"
+ row.invalidated_by = target.node_key
+ row.updated_at = now
+
+ parent.updated_at = now
+ await session.commit()
+ await session.refresh(parent)
+ for row in rows:
+ await session.refresh(row)
+ return self._session_dict(parent), [self._node_dict(row) for row in rows]
+
+ async def archive_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
+ return await self.update_session(session_id, user_id, status="archived")
+
+ async def list_results(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
+ return await self.list_nodes(session_id, user_id)
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..d2566315
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py
@@ -0,0 +1,604 @@
+"""Persistence coverage for the standalone position-collaboration workflow."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+import pytest_asyncio
+from fastapi import HTTPException
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from app.gateway import services as gateway_services
+from app.gateway.routers import intent as intent_router
+from app.gateway.routers import position_roundtable as position_roundtable_router
+from deerflow.persistence.base import Base
+from deerflow.persistence.position_roundtable import PositionRoundtableRepository
+
+
+@pytest_asyncio.fixture
+async def store(tmp_path):
+ engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'position-roundtable.db'}")
+ async with engine.begin() as conn:
+ # Register both tables with Base.metadata before creating the temporary DB.
+ import deerflow.persistence.position_roundtable.model # noqa: F401
+
+ await conn.run_sync(Base.metadata.create_all)
+ return PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False))
+
+
+@pytest.mark.asyncio
+async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
+ created = await store.create_session(
+ "user-a",
+ external_task_id="task-1",
+ task_snapshot={"title": "测试任务"},
+ )
+ await store.update_session(
+ created["id"],
+ "user-a",
+ intent_thread_id="intent-thread",
+ intent_snapshot={"objective": "识别意图"},
+ )
+ chain = {
+ "id": "chain-a",
+ "seats": [
+ {"agent_id": "agent-a", "position_id": "planning"},
+ {"agent_id": "agent-b", "position_id": "solution"},
+ ],
+ }
+ nodes = [
+ {
+ "node_key": "stage-1-seat-1-agent-a",
+ "stage_index": 0,
+ "seat_index": 0,
+ "agent_id": "agent-a",
+ "position_id": "planning",
+ "status": "ready",
+ },
+ {
+ "node_key": "stage-2-seat-1-agent-b",
+ "stage_index": 1,
+ "seat_index": 0,
+ "agent_id": "agent-b",
+ "position_id": "solution",
+ "status": "locked",
+ },
+ ]
+
+ activated, created_nodes = await store.activate_session(
+ created["id"], "user-a", chain_id="chain-a", chain_snapshot=chain, nodes=nodes
+ )
+ assert activated["status"] == "active"
+ assert activated["chain_snapshot"] == chain
+ assert [node["status"] for node in created_nodes] == ["ready", "locked"]
+
+ # Repeated activate calls must return the first frozen snapshot/nodes unchanged.
+ repeated, repeated_nodes = await store.activate_session(
+ created["id"], "user-a", chain_id="other", chain_snapshot={"id": "other"}, nodes=[]
+ )
+ assert repeated["chain_id"] == "chain-a"
+ assert [node["node_key"] for node in repeated_nodes] == [node["node_key"] for node in created_nodes]
+
+
+def test_task_external_context_reaches_agent_runtime_context():
+ """岗位会商的左侧任务快照不能在网关白名单中丢失。"""
+ task_context = {
+ "task": {
+ "title": "重点区域态势研判与协同处置方案",
+ "description": "梳理风险信号并形成协同处置建议",
+ }
+ }
+ config: dict = {"configurable": {}, "context": {}}
+
+ gateway_services.merge_run_context_overrides(
+ config,
+ {"task_external_context": task_context},
+ )
+
+ assert config["configurable"]["task_external_context"] == task_context
+ assert config["context"]["task_external_context"] == task_context
+
+
+def test_position_intent_thread_metadata_keeps_parent_and_child_task_ids():
+ metadata = intent_router._intent_thread_metadata(
+ {
+ "task": {"id": "task-100"},
+ "current_position": {"id": "intelligence"},
+ }
+ )
+
+ assert metadata["taskId"] == "task-100"
+ assert metadata["position_roundtable_task_id"] == "task-100"
+ assert metadata["position_task_id"] == "task-100:intelligence"
+ assert metadata["position_id"] == "intelligence"
+
+
+def test_chain_node_activation_rejects_missing_agent_ids():
+ chain = {
+ "id": "chain-a",
+ "seats": [
+ {"agent_id": "agent-a", "name": "有效智能体", "position_id": "planning"},
+ {"agent_id": "missing-agent", "name": "缺失智能体", "position_id": "solution"},
+ ],
+ }
+
+ with pytest.raises(HTTPException) as error:
+ position_roundtable_router._chain_nodes(
+ chain,
+ available_agent_ids={"agent-a"},
+ )
+
+ assert error.value.status_code == 400
+ assert "缺失智能体" in str(error.value.detail)
+ assert "缺少可用智能体" in str(error.value.detail)
+
+
+def test_intent_position_nodes_complete_from_confirmed_intent_and_unlock_next_stage():
+ nodes = [
+ {
+ "node_key": "stage-1-seat-1-intent-agent",
+ "stage_index": 0,
+ "seat_index": 0,
+ "agent_id": "intent-agent",
+ "position_id": "intelligence",
+ "status": "ready",
+ },
+ {
+ "node_key": "stage-2-seat-1-plan-agent",
+ "stage_index": 1,
+ "seat_index": 0,
+ "agent_id": "plan-agent",
+ "position_id": "planning",
+ "status": "locked",
+ },
+ ]
+
+ primed = position_roundtable_router._prime_intent_nodes(
+ nodes,
+ {"objective": "识别用户想要形成协同方案", "constraints": ["两天内完成"]},
+ )
+
+ assert [node["status"] for node in primed] == ["done", "ready"]
+ assert primed[0]["revision"] == 1
+ assert "识别用户想要形成协同方案" in primed[0]["latest_answer"]
+
+
+def test_all_nodes_delivered_requires_markdown_for_non_intent_nodes():
+ assert not position_roundtable_router._all_nodes_delivered(
+ [
+ {
+ "position_id": "planning",
+ "status": "done",
+ "artifact_manifest": [{"name": "plain.txt", "path": "mnt/user-data/outputs/plain.txt"}],
+ }
+ ]
+ )
+ assert position_roundtable_router._all_nodes_delivered(
+ [
+ {"position_id": "intelligence", "status": "done", "artifact_manifest": []},
+ {
+ "position_id": "planning",
+ "status": "done",
+ "artifact_manifest": [{"name": "report.md", "path": "mnt/user-data/outputs/report.md"}],
+ },
+ ]
+ )
+
+
+@pytest.mark.asyncio
+async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
+ created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
+ _, nodes = await store.activate_session(
+ created["id"],
+ "user-a",
+ chain_id="chain-a",
+ chain_snapshot={"id": "chain-a"},
+ nodes=[
+ {
+ "node_key": "stage-1-seat-1-agent-a",
+ "stage_index": 0,
+ "seat_index": 0,
+ "agent_id": "agent-a",
+ "position_id": "planning",
+ "status": "ready",
+ }
+ ],
+ )
+ assert len(nodes) == 1
+
+ updated = await store.update_node(
+ created["id"],
+ nodes[0]["node_key"],
+ "user-a",
+ thread_id="langgraph-thread-a",
+ status="done",
+ latest_answer="节点回答",
+ artifact_manifest=[{"name": "产物.md"}],
+ upstream_revision={"intent": 1},
+ increment_revision=True,
+ )
+ assert updated is not None
+ assert updated["thread_id"] == "langgraph-thread-a"
+ assert updated["latest_answer"] == "节点回答"
+ assert updated["artifact_manifest"] == [{"name": "产物.md"}]
+ assert updated["revision"] == 1
+ assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None
+
+
+@pytest.mark.asyncio
+async def test_reject_node_marks_current_and_invalidates_later_stages(store):
+ created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
+ _, nodes = await store.activate_session(
+ created["id"],
+ "user-a",
+ chain_id="chain-a",
+ chain_snapshot={"id": "chain-a"},
+ nodes=[
+ {
+ "node_key": "stage-1-seat-1-agent-a",
+ "stage_index": 0,
+ "seat_index": 0,
+ "agent_id": "agent-a",
+ "position_id": "planning",
+ "status": "ready",
+ },
+ {
+ "node_key": "stage-2-seat-1-agent-b",
+ "stage_index": 1,
+ "seat_index": 0,
+ "agent_id": "agent-b",
+ "position_id": "solution",
+ "status": "ready",
+ },
+ {
+ "node_key": "stage-3-seat-1-agent-c",
+ "stage_index": 2,
+ "seat_index": 0,
+ "agent_id": "agent-c",
+ "position_id": "command",
+ "status": "ready",
+ },
+ ],
+ )
+ await store.update_node(
+ created["id"],
+ nodes[0]["node_key"],
+ "user-a",
+ status="done",
+ latest_answer="第一阶段产物",
+ increment_revision=True,
+ )
+ await store.update_node(
+ created["id"],
+ nodes[1]["node_key"],
+ "user-a",
+ status="done",
+ latest_answer="第二阶段产物",
+ increment_revision=True,
+ )
+
+ rejected = await store.reject_node(
+ created["id"],
+ nodes[0]["node_key"],
+ "user-a",
+ title="依据不足",
+ reason="第一阶段判断依据需要补充。",
+ rejected_by="user-a",
+ )
+
+ assert rejected is not None
+ _, rejected_nodes = rejected
+ by_key = {node["node_key"]: node for node in rejected_nodes}
+ first = by_key[nodes[0]["node_key"]]
+ second = by_key[nodes[1]["node_key"]]
+ third = by_key[nodes[2]["node_key"]]
+ assert first["status"] == "rejected"
+ assert first["rejection_count"] == 1
+ assert first["last_rejection"]["title"] == "依据不足"
+ assert first["invalidated_by"] is None
+ assert second["status"] == "stale"
+ assert second["latest_answer"] == "第二阶段产物"
+ assert second["invalidated_by"] == nodes[0]["node_key"]
+ assert third["status"] == "locked"
+ assert third["invalidated_by"] == nodes[0]["node_key"]
+
+
+@pytest.mark.asyncio
+async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store):
+ created = await store.create_session(
+ "user-a",
+ external_task_id=None,
+ task_snapshot={
+ "title": "测试任务",
+ "description": "形成协同方案",
+ "direction": "态势研判",
+ },
+ )
+ await store.update_session(
+ created["id"],
+ "user-a",
+ intent_snapshot={"objective": "明确处置意图", "constraints": ["时间紧"]},
+ )
+ chain = {
+ "id": "chain-a",
+ "seats": [
+ {"agent_id": "agent-a", "name": "研判智能体", "position_id": "planning"},
+ {"agent_id": "agent-b", "name": "处置智能体", "position_id": "solution"},
+ ],
+ }
+ _, nodes = await store.activate_session(
+ created["id"],
+ "user-a",
+ chain_id="chain-a",
+ chain_snapshot=chain,
+ nodes=[
+ {
+ "node_key": "stage-1-seat-1-agent-a",
+ "stage_index": 0,
+ "seat_index": 0,
+ "agent_id": "agent-a",
+ "position_id": "planning",
+ "status": "ready",
+ },
+ {
+ "node_key": "stage-2-seat-1-agent-b",
+ "stage_index": 1,
+ "seat_index": 0,
+ "agent_id": "agent-b",
+ "position_id": "solution",
+ "status": "ready",
+ },
+ ],
+ )
+ for node in nodes:
+ await store.update_node(
+ created["id"],
+ node["node_key"],
+ "user-a",
+ status="done",
+ latest_answer=f"{node['agent_id']} 产物",
+ increment_revision=True,
+ )
+ session = await store.get_session(created["id"], "user-a")
+ assert session is not None
+ nodes = await store.list_nodes(created["id"], "user-a") or []
+ revisions = position_roundtable_router._source_revisions(nodes)
+ summary = position_roundtable_router._build_builtin_snapshot(
+ kind="summary",
+ thread_id="summary-thread",
+ content="# 岗位会商方案总结报告\n\n研判智能体的关键结论。",
+ artifacts=[
+ {
+ "name": "岗位会商方案总结报告.md",
+ "path": "mnt/user-data/outputs/岗位会商方案总结报告.md",
+ "mime_type": "text/markdown",
+ "modified_at": "2026-07-21T00:00:00+00:00",
+ "size_bytes": 100,
+ }
+ ],
+ source_revisions=revisions,
+ model="test-model",
+ )
+ assert "岗位会商方案总结报告" in summary["content"]
+ session = await store.update_session(
+ created["id"], "user-a", summary_thread_id="summary-thread", summary_snapshot=summary
+ )
+ assert session is not None
+ action_plan = position_roundtable_router._build_builtin_snapshot(
+ kind="action_plan",
+ thread_id="action-plan-thread",
+ content="# 岗位会商行动规划\n\n## 子任务拆解",
+ artifacts=[
+ {
+ "name": "行动规划报告.md",
+ "path": "mnt/user-data/outputs/行动规划报告.md",
+ "mime_type": "text/markdown",
+ "modified_at": "2026-07-21T00:01:00+00:00",
+ "size_bytes": 100,
+ },
+ {
+ "name": "action-plan-subtasks.json",
+ "path": "mnt/user-data/outputs/action-plan-subtasks.json",
+ "mime_type": "application/json",
+ "modified_at": "2026-07-21T00:01:00+00:00",
+ "size_bytes": 200,
+ },
+ ],
+ source_revisions=revisions,
+ model="test-model",
+ )
+ session = await store.update_session(
+ created["id"],
+ "user-a",
+ action_plan_thread_id="action-plan-thread",
+ action_plan_snapshot=action_plan,
+ status="completed",
+ )
+ assert session is not None
+ assert session["summary_snapshot"]["status"] == "done"
+ assert session["action_plan_snapshot"]["status"] == "done"
+
+ rejected = await store.reject_node(
+ created["id"],
+ nodes[0]["node_key"],
+ "user-a",
+ title="重做第一阶段",
+ reason="前序依据变化",
+ rejected_by="user-a",
+ )
+ assert rejected is not None
+ rejected_session, _ = rejected
+ assert rejected_session["status"] == "active"
+ assert rejected_session["summary_snapshot"]["status"] == "stale"
+ assert rejected_session["summary_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
+ assert rejected_session["action_plan_snapshot"]["status"] == "stale"
+ assert rejected_session["action_plan_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
+
+
+def test_rejected_or_stale_node_requires_a_new_markdown_delivery() -> None:
+ old = [
+ {
+ "name": "report.md",
+ "path": "mnt/user-data/outputs/report.md",
+ "modified_at": "2026-07-21T00:00:00+00:00",
+ "size_bytes": 100,
+ }
+ ]
+ assert not position_roundtable_router._markdown_delivery_changed(old, old)
+ assert position_roundtable_router._markdown_delivery_changed(
+ old,
+ [{**old[0], "modified_at": "2026-07-21T00:05:00+00:00", "size_bytes": 120}],
+ )
+
+
+def test_activation_requires_a_structured_intent_snapshot() -> None:
+ assert not position_roundtable_router._has_confirmed_intent({"objective": "只有目标"})
+ assert not position_roundtable_router._has_confirmed_intent({"constraints": []})
+ assert position_roundtable_router._has_confirmed_intent(
+ {"objective": "明确任务目标", "constraints": []}
+ )
+
+
+@pytest.mark.asyncio
+async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
+ artifact = tmp_path / "handoff.md"
+ artifact.write_text("上游产物正文:可供下一岗位直接参考。", encoding="utf-8")
+ calls: list[tuple[str, str]] = []
+
+ class StubStore:
+ async def get_session(self, session_id, user_id):
+ assert (session_id, user_id) == ("session-a", "user-a")
+ return {"task_snapshot": {"title": "测试任务"}, "intent_snapshot": {"goal": "方案分析"}}
+
+ async def list_nodes(self, session_id, user_id):
+ assert (session_id, user_id) == ("session-a", "user-a")
+ return [
+ {
+ "node_key": "stage-1-seat-1-agent-a",
+ "stage_index": 0,
+ "seat_index": 0,
+ "agent_id": "agent-a",
+ "position_id": "planning",
+ "thread_id": "upstream-thread",
+ "status": "done",
+ "revision": 1,
+ "latest_answer": "上游问答结果",
+ "artifact_manifest": [
+ {
+ "name": "handoff.md",
+ "path": "mnt/user-data/outputs/handoff.md",
+ "mime_type": "text/markdown",
+ },
+ {
+ "name": "outside.md",
+ "path": "mnt/user-data/uploads/outside.md",
+ "mime_type": "text/markdown",
+ },
+ ],
+ },
+ {
+ "node_key": "stage-2-seat-1-agent-b",
+ "stage_index": 1,
+ "seat_index": 0,
+ "agent_id": "agent-b",
+ "position_id": "solution",
+ "thread_id": "current-thread",
+ "status": "ready",
+ "revision": 0,
+ "latest_answer": None,
+ "artifact_manifest": [],
+ },
+ {
+ "node_key": "stage-1-seat-2-agent-stale",
+ "stage_index": 0,
+ "seat_index": 1,
+ "agent_id": "agent-stale",
+ "position_id": "planning",
+ "thread_id": "stale-thread",
+ "status": "stale",
+ "revision": 1,
+ "latest_answer": "失效上游产物",
+ "artifact_manifest": [],
+ },
+ ]
+
+ async def resolve(thread_id: str, path: str):
+ calls.append((thread_id, path))
+ assert thread_id == "upstream-thread"
+ assert path == "mnt/user-data/outputs/handoff.md"
+ return artifact
+
+ monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve)
+ request = SimpleNamespace(
+ app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=StubStore())),
+ state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
+ )
+
+ context = await position_roundtable_router._handoff_context(
+ request, "session-a", "stage-2-seat-1-agent-b"
+ )
+
+ upstream = context["upstream_nodes"][0]
+ assert len(context["upstream_nodes"]) == 1
+ assert upstream["latest_answer"] == "上游问答结果"
+ assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。"
+ assert "content" not in upstream["artifacts"][1]
+ assert calls == [("upstream-thread", "mnt/user-data/outputs/handoff.md")]
+
+
+@pytest.mark.asyncio
+async def test_archived_session_rejects_node_writes_and_keeps_history_readable():
+ class ArchivedStore:
+ async def get_session(self, session_id, user_id):
+ assert (session_id, user_id) == ("session-a", "user-a")
+ return {"id": session_id, "status": "archived"}
+
+ async def get_node(self, session_id, node_key, user_id):
+ return {
+ "node_key": node_key,
+ "status": "ready",
+ "thread_id": None,
+ }
+
+ request = SimpleNamespace(
+ app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=ArchivedStore())),
+ state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
+ )
+ with pytest.raises(HTTPException) as error:
+ await position_roundtable_router.bind_node_thread(
+ request,
+ "session-a",
+ "stage-1-seat-1-agent-a",
+ position_roundtable_router.NodeThreadRequest(thread_id="thread-a"),
+ )
+ assert error.value.status_code == 409
+ assert "已归档" in str(error.value.detail)
+
+
+@pytest.mark.asyncio
+async def test_locked_node_cannot_bind_a_thread_before_its_stage_is_ready():
+ class LockedStore:
+ async def get_session(self, session_id, user_id):
+ return {"id": session_id, "status": "active"}
+
+ async def get_node(self, session_id, node_key, user_id):
+ return {
+ "node_key": node_key,
+ "status": "locked",
+ "thread_id": None,
+ }
+
+ request = SimpleNamespace(
+ app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=LockedStore())),
+ state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
+ )
+ with pytest.raises(HTTPException) as error:
+ await position_roundtable_router.bind_node_thread(
+ request,
+ "session-a",
+ "stage-2-seat-1-agent-b",
+ position_roundtable_router.NodeThreadRequest(thread_id="thread-b"),
+ )
+ assert error.value.status_code == 409
+ assert "未解锁" in str(error.value.detail)