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 ( + +
+
+ + {node.status === "locked" ? : } + +
+

+ {agentDisplayName} +

+

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

+
+
+ + {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 (
-
-
+
+
@@ -199,21 +854,75 @@ export default function PositionRoundtablePage() { 按岗位承接任务、对话与分析产物

+ {positionSession && ( + + {positionSession.status === "archived" + ? "会话已归档" + : positionSession.status === "completed" + ? "行动规划已生成" + : positionSession.status === "active" + ? "业务链已启动" + : "意图识别中"} + + )}
-
- +
+ } + onClick={handleCreateNewSession} + > + 新建会话 + + {positionSession && positionSession.status !== "archived" && ( + } + onClick={handleArchiveSession} + > + 归档 + + )} + 当前岗位 ({ label: position.name, value: position.id, }))} - onChange={(value) => setPositionId(String(value) as PositionId)} + onChange={(value) => { + // 右侧详情只属于刚才的岗位;切岗后回到该岗位的智能体列表, + // 其他岗位改用按需打开的抽屉,避免一进岗位就挤占对话空间。 + const nextPositionId = String(value) as PositionId; + // 中间区由 activeNode 的兜底规则自动定位到首个流程节点。 + setPositionId(nextPositionId); + setSelectedAgentId(null); + setActiveNodeKey(null); + setRightPanelOpen(nextPositionId === DEFAULT_POSITION_ID); + }} /> + {compactChrome && ( + <> + + + + )}
@@ -221,97 +930,619 @@ export default function PositionRoundtablePage() { -
-
- {intent.intentInitError && ( -
- - 对话初始化失败:{intent.intentInitError} - - - 重试 - -
- )} - - -
-

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

-

- {activePosition.description} -

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

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

+

{activePosition.description}

+
+
+ } + interactiveMessages={intent.interactiveMessages} + intentThreadId={intent.intentThreadId} + intentReady={intent.intentReady} + isIntentSending={intent.isIntentSending} + pendingIntentClarificationMsgId={intent.pendingIntentClarificationMsgId} + intentClarificationSelections={intent.intentClarificationSelections} + setIntentClarificationSelections={intent.setIntentClarificationSelections} + onSendMessage={(text, files) => { + setRightPanelOpen(false); + void intent.sendIntentMessage(text, files); + }} + onStopIntent={intent.stopIntentStream} + onSkipQuestion={handleSkipQuestion} + onFollowUp={handleFollowUp} + onEnterRoundtable={() => void handleActivateChain()} + enterButtonLabel={startingChain ? "正在启动业务链…" : "确认意图并启动业务链 >"} + onManualSave={() => undefined} + onTriggerToast={triggerToast} + submitIntentClarifications={intent.submitIntentClarifications} + /> + {(startingChain || sessionLoading) && ( +
+ + {startingChain ? "正在保存意图并生成岗位节点…" : "正在恢复岗位协同会话…"} +
+ )} + + ) + ) : positionSession && activeNode ? ( + setRightPanelOpen(false)} + onPreviewArtifact={handlePreviewArtifact} + /> + ) : ( + 0} + /> + )}
- + {isIntelligenceDeliveryOverview && !sandboxOpen && ( + + )} + {activePosition.isIntentPosition && !isIntelligenceDeliveryOverview && rightPanelOpen && !sandboxOpen && ( + + )} + {!activePosition.isIntentPosition && ( + setRightPanelOpen(false)} + > +
+ setSelectedAgentId(null)} + intentObjective={frozenIntent?.objective ?? intent.intentReady?.objective} + intentCompletedAt={intentCompletedAt} + assignedAgentIds={assignedAgentIds} + hasSelectedChain={Boolean(selectedChain)} + session={positionSession} + onConfigureChain={ + selectedChain ? () => openPositionConfig(selectedChain.id) : undefined + } + nodes={positionSession?.nodes ?? []} + chain={activeChain} + onSelectNode={(node) => { + setActiveNodeKey(node.nodeKey); + setSelectedAgentId(node.agentId); + }} + onSessionUpdate={setPositionSession} + onCollapse={() => setRightPanelOpen(false)} + onPreviewArtifact={handlePreviewArtifact} + selectedModel={selectedModel} + tab={rightTab} + onTabChange={setRightTab} + /> +
+
+ )} + {sandboxOpen && sandboxArtifact && artifactsCtx?.selectedArtifact && ( + + )} ); } +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)} +

+
+
+
+ + + {resultPositionName(item.positionId)} + + + + {formatResultTime(item.updatedAt)} + +
+
+ ); +} + /** 仅展示岗位会商任务,不在会商页编辑任务。 */ -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 ? ( +
+ 请先由情报分析岗选择业务链条 +
+ ) : ( + <> + + + + + + + 业务链条流程图 + + +
+ { + onSelectNode(node); + setFlowDialogOpen(false); + }} + onSelectBuiltin={(kind) => { + onSelectBuiltin(kind); + setFlowDialogOpen(false); + }} + className="h-full" + expanded + /> +
+
+
+ + )} +
+ ); +} + +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 && ( +
); @@ -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 ( -
-
-
-

分析结果列表

-

按生成时间倒序

-
- - 1 个产物 - -
-
-
- - - -
-

任务理解智能体

-

任务意图理解

-

- {desensitize(intentObjective)} -

+ <> +
+
+
+

交付产物列表

+

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

+ + {visibleItems.length}/{resultItems.length} 项结果 +
-
- - - 组织筹划岗 - - - - {intentCompletedAt || "刚刚"} - -
-
-
+
+ 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)} +

+
+
+
+ + + {resultPositionName(item.positionId)} + + + + {displayedTime} + +
+ + ); + 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 ( + + + + + + 交付产物详情 + + + {!item ? null : ( +
+
+
+

+ {desensitize(item.productName)} +

+

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

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

最近驳回:{desensitize(rejectionTitle)}

+ {rejectionReason &&

{desensitize(rejectionReason)}

} +
+ )} + +
+
+

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

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

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

+
+ {builtinKind && item.status === "done" && ( +
+

继续问答或修改交付

+