This commit is contained in:
董宏凯 committed 2026-07-22 15:43:13 +08:00
commit ae184f434d
37 files changed
+6908 -351

No files matched your search

@@ -0,0 +1,343 @@
# 岗位会商交付闭环与内置智能体实现计划
> 页面:`/page/strategy/qa/position-roundtable`
>
> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。
## 实施核对(2026-07-21)
本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为:
```text
任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链
→ 分阶段席位直接问答/交付 Markdown → 全部有效交付
→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner)
→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口
```
- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。
- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。
- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。
- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。
- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。
- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。
- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。
- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。
- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。
设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
## 0. 核心结论
- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
- 可复用:内置智能体 ID、总结报告 prompt 组织方式、Markdown 报告产物、流式对话、报告续问与文件预览能力。
- 需要改造:输入材料从“总控共识 + 各席位交付”改成“任务信息 + 意图结果 + 已通过的岗位产物 + 业务链快照”,并把结果存到岗位会商 Session 下。
- 行动规划智能体建议作为新的内置智能体新增。
- 输入:方案总结报告、各岗位最终有效产物、业务链阶段信息、任务意图。
- 输出:行动规划报告、结构化子任务清单。
- 这个模式仍然不需要总控智能体。情报分析岗负责意图识别,业务链节点直接调用各自配置的智能体,最终再由内置总结/规划智能体收口。
- 驳回不删除历史内容,只改变“当前有效版本”的状态;被驳回节点和后续节点需要重新交付,既保留可追溯性,也方便后续返回查看。
## 1. 目标业务流程
1. 情报分析岗进入页面,查看任务信息并完成意图识别。
2. 用户点击“确认意图并启动业务链”。
3. 启动前做校验:
- 未选择业务链:提示“请先选择业务链”。
- 业务链存在未配置岗位的席位:提示并引导去业务链配置页。
- 业务链席位缺少智能体:提示具体席位名称。
- 意图尚未完成:提示先完成任务意图识别。
4. 校验通过后冻结业务链快照,初始化岗位会商节点。
5. 各岗位只对分配给本岗位的智能体进行问答。
6. 节点问答完成后形成交付产物,右侧产物卡片显示“已完成 / 未完成 / 进行中 / 已驳回 / 已失效”。
7. 左侧流程图显示各节点状态,节点可点击查看该节点对话与产物。
8. 流程图右上角提供“查看”按钮,打开弹窗展示更完整的业务链流程图。
9. 当业务链中所有岗位产物都处于有效已交付状态后,开放“方案总结”。
10. 方案总结完成后开放“行动规划”。
11. 即使进入方案总结或行动规划,用户仍可返回产物列表执行驳回。
12. 若驳回早期阶段产物,后续阶段产物、方案总结、行动规划都一起失效并回到待重新完成状态。
## 2. 状态设计
### 2.1 节点状态
现有节点状态需要扩展或映射为更贴近页面的展示状态:
| 展示状态 | 建议内部状态 | 含义 |
| --- | --- | --- |
| 未完成 | `ready` / `locked` / `rejected` | 尚未有效交付,或被驳回后等待重做 |
| 进行中 | `running` | 当前智能体正在回答或产物正在生成 |
| 已完成 | `done` | 当前节点已有有效交付 |
| 已失效 | `stale` | 上游被更新或驳回,当前结果不可作为最终材料 |
| 异常 | `error` | 生成或保存失败 |
建议新增 `rejected` 状态,便于区分“从未完成”和“被驳回后待重做”。如果第一轮想少改后端,也可以先用 `ready + rejection_record` 表示被驳回。
### 2.2 产物状态
产物状态建议独立于节点状态保存,因为一个节点可能同时有回答文本和多个文件产物。
| 状态 | 含义 |
| --- | --- |
| `pending` | 还没有交付 |
| `running` | 正在生成 |
| `delivered` | 已交付且当前有效 |
| `rejected` | 被用户驳回 |
| `stale` | 受上游驳回或重做影响,已经失效 |
### 2.3 内置收口节点
在流程图中把两个内置节点追加到业务链末尾:
1. `builtin:summary`:方案总结智能体。
2. `builtin:action-plan`:行动规划智能体。
这两个节点不属于普通业务链席位,但要和普通节点一样显示状态、支持点击查看结果。
## 3. 数据模型调整
### 3.1 Session 扩展
`position_roundtable_sessions` 已使用下列持久化字段:
| 字段 | 含义 |
| --- | --- |
| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 |
| `summary_thread_id` | 方案总结智能体线程 |
| `summary_snapshot` | 最新有效总结报告快照 |
| `action_plan_thread_id` | 行动规划智能体线程 |
| `action_plan_snapshot` | 最新有效行动规划快照 |
| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 |
### 3.2 Node 扩展
`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射):
| 字段 | 含义 |
| --- | --- |
| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 |
| `rejection_count` | 当前节点累计驳回次数 |
| `last_rejection` | 最新驳回标题、理由、操作者、时间 |
| `revision` | 当前有效交付版本 |
| `invalidated_by` | 导致当前节点失效的上游节点 key |
### 3.3 Deliverable 新表(后续可选)
当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
| 字段 | 含义 |
| --- | --- |
| `id` | 产物 ID |
| `session_id` | 会话 ID |
| `node_key` | 来源节点 |
| `agent_id` | 来源智能体 |
| `position_id` | 来源岗位 |
| `title` | 产物名称 |
| `kind` | `answer / file / summary / action_plan` |
| `content` | 文本产物内容摘要或正文 |
| `artifact_path` | 文件产物虚拟路径 |
| `thread_id` | 所属线程 |
| `status` | `pending / running / delivered / rejected / stale` |
| `revision` | 来源节点版本 |
| `rejection_title` | 驳回标题 |
| `rejection_reason` | 驳回理由 |
| `created_at` | 创建时间 |
| `updated_at` | 更新时间 |
当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。
## 4. 驳回与级联失效规则
### 4.1 基本驳回
用户点击产物卡片的“驳回”后弹窗填写:
- 驳回标题。
- 驳回理由。
提交后:
- 当前产物状态改为 `rejected`。
- 当前节点状态改为 `rejected` 或 `ready`。
- 当前节点的 `latest_answer` 和历史文件不删除,但不再作为有效交付输入。
- 记录 `last_rejection`,用于页面展示和后续追踪。
### 4.2 级联失效
如果驳回的节点位于第 N 阶段:
- 同阶段其他节点不自动驳回,除非业务链未来明确配置了依赖关系。
- 第 N+1 阶段及之后所有节点的有效产物全部标记为 `stale`。
- 后续节点状态回到未完成展示态。
- `builtin:summary` 和 `builtin:action-plan` 如果已经生成,也标记为 `stale`。
- 页面允许用户继续查看旧报告和旧行动规划,但必须提示“上游产物已变更,需要重新生成”。
### 4.3 重新交付
被驳回节点重新问答并完成后:
- 当前节点产生新的 `revision`。
- 当前节点产物状态变为 `delivered`。
- 只有当该阶段所有必要节点都重新有效交付后,下一阶段才重新解锁。
- 方案总结和行动规划必须基于最新有效产物重新生成。
## 5. 接口规划
### 5.1 启动与校验
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `POST` | `/api/position-roundtable/sessions/{id}/validate-activation` | 校验业务链、岗位、智能体、意图状态 |
| `POST` | `/api/position-roundtable/sessions/{id}/activate` | 校验通过后冻结链条并初始化节点 |
第一轮也可以先把校验合并在 `activate` 内,前端根据后端返回的错误码提示。
### 5.2 产物
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) |
| `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 |
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 |
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 |
### 5.3 方案总结与行动规划
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 |
| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 |
| `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 |
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 |
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 |
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 |
可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。
## 6. 前端实施步骤
### 阶段 1:状态与启动校验(已完成)
- 扩展 `PositionNodeStatus` 和状态显示映射。
- “确认意图并启动业务链”前补齐校验提示。
- 后端 `activate` 返回未配置岗位、缺智能体、未选择链条等具体错误。
- 左侧流程图节点右上角添加状态小图标。
- 节点样式调整为上方智能体名称、下方岗位名称,移除左侧 logo。
验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。
### 阶段 2:流程图弹窗(已完成)
- 左侧保留小型从上到下流程图。
- 流程图右上角增加“查看”按钮。
- 点击后打开弹窗,展示更大的流程图。
- 弹窗内支持点击节点查看对话与产物,保持和主页面选中节点同步。
验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。
### 阶段 3:产物卡片与预览(已完成)
- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。
- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。
- 点击卡片打开详情弹窗。
- 文本产物直接展示正文,文件产物复用现有 `ArtifactPreviewModal`。
- 每个有效产物提供“驳回”入口。
验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。
### 阶段 4:驳回弹窗与级联失效(已完成)
- 新增 `RejectDeliverableDialog`。
- 驳回表单包含标题和理由,均必填。
- 后端事务内完成当前产物驳回、当前节点状态更新、后续阶段级联失效、总结和行动规划失效。
- 前端刷新 Session、Node、Deliverable 和流程图状态。
- 被驳回节点在对话区显示驳回信息,提示用户重新交付。
验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。
### 阶段 5:方案总结智能体(已完成)
- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。
- 输入材料改为岗位会商材料:
- 任务信息。
- 情报分析岗意图结果。
- 业务链快照。
- 所有状态为 `delivered` 的岗位产物。
- 已驳回或失效产物只作为历史参考,不进入默认总结材料。
- 方案总结节点追加到流程图末尾。
- 所有产物有效交付后才允许点击“生成方案总结”。
- 总结结果写入 `summary_snapshot`,同时进入产物列表。
验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。
### 阶段 6:行动规划智能体(已完成)
- 新增内置智能体 ID,建议先命名为 `position-action-planner`。
- 新增行动规划 prompt:
- 读取方案总结报告。
- 读取各岗位最终有效产物。
- 拆解为若干子任务。
- 每个子任务包含名称、目标、输入依据、责任岗位建议、前置依赖、交付物、验收标准。
- 输出两类产物:
- `行动规划报告.md`。
- `action-plan-subtasks.json`。
- 行动规划节点追加到方案总结之后。
- 方案总结有效完成后才允许启动行动规划。
验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。
### 阶段 7:返回修改与重新生成(已完成)
- 用户在方案总结或行动规划视图中仍可切回“交付产物”。
- 驳回后自动显示需要重做的节点。
- 重做节点完成后,允许重新生成方案总结和行动规划。
- 旧总结、旧行动规划保留历史查看,但状态显示“已失效”。
验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。
### 阶段 8:测试与回归(已完成)
- 后端测试:
- activate 校验。
- 节点完成后产物入库。
- 驳回当前节点。
- 驳回第一阶段时后续阶段级联失效。
- 总结和行动规划失效。
- 前端测试:
- 启动校验提示。
- 状态图标显示。
- 产物卡片筛选和预览。
- 驳回弹窗必填校验。
- 回归验证:
- 原多智能体会商页面不受影响。
- 业务链普通配置入口不显示岗位配置。
- 岗位会商页面刷新恢复正常。
- 深色模式和嵌入模式布局不溢出。
## 7. 推荐提交拆分
1. `feat(position-roundtable): add delivery status model`
2. `feat(position-roundtable): validate chain activation`
3. `feat(position-roundtable): add workflow status graph`
4. `feat(position-roundtable): add deliverable cards and preview`
5. `feat(position-roundtable): support rejection cascade`
6. `feat(position-roundtable): add summary builtin node`
7. `feat(position-roundtable): add action planning builtin node`
8. `test(position-roundtable): cover delivery workflow`
## 8. 第一轮建议范围
第一轮优先做最小可闭环:
```text
意图识别
→ 启动校验
→ 业务链节点状态
→ 节点产物列表
→ 产物详情查看
→ 驳回与级联失效
→ 全部交付后生成方案总结
→ 基于总结生成行动规划
```
如果时间紧,行动规划可以先生成 Markdown 报告,结构化 `subtasks.json` 放到第二轮;但数据模型和 UI 入口第一轮就要预留,否则后面会返工。
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -184,6 +184,18 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/roundtable/position">
<BriefcaseBusiness />
<span>岗位会商</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -54,6 +54,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
@@ -448,6 +449,16 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="roundtable/position"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<PositionRoundtablePage compactChrome={headerHidden} />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="roundtable/chains"
element={
@@ -0,0 +1,410 @@
/** Client for the independent position-collaboration Session / Node API.
*
* Messages themselves stay in LangGraph threads. These endpoints only persist
* task/intent snapshots, frozen chain topology, node states and indexes that
* let the UI recover the correct thread after a refresh.
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export type PositionNodeStatus =
| "locked"
| "ready"
| "running"
| "done"
| "stale"
| "rejected"
| "error";
export interface PositionRoundtableNode {
id: string;
sessionId: string;
nodeKey: string;
stageIndex: number;
seatIndex: number;
agentId: string;
positionId: string | null;
threadId: string | null;
status: PositionNodeStatus;
latestAnswer: string | null;
artifactManifest: Array<Record<string, unknown>>;
revision: number;
upstreamRevision: Record<string, unknown> | null;
rejectionCount: number;
lastRejection: Record<string, unknown> | null;
invalidatedBy: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface PositionRoundtableSession {
id: string;
externalTaskId: string | null;
taskSnapshot: Record<string, unknown>;
chainId: string | null;
chainSnapshot: Record<string, unknown> | null;
intentThreadId: string | null;
intentSnapshot: Record<string, unknown> | null;
summaryThreadId: string | null;
summarySnapshot: Record<string, unknown> | null;
actionPlanThreadId: string | null;
actionPlanSnapshot: Record<string, unknown> | null;
status: "intent_pending" | "active" | "completed" | "archived";
createdAt?: string;
updatedAt?: string;
nodes?: PositionRoundtableNode[];
}
interface ServerNode {
id: string;
session_id: string;
node_key: string;
stage_index: number;
seat_index: number;
agent_id: string;
position_id: string | null;
thread_id: string | null;
status: PositionNodeStatus;
latest_answer: string | null;
artifact_manifest?: Array<Record<string, unknown>>;
revision?: number;
upstream_revision?: Record<string, unknown> | null;
rejection_count?: number;
last_rejection?: Record<string, unknown> | null;
invalidated_by?: string | null;
created_at?: string;
updated_at?: string;
}
interface ServerSession {
id: string;
external_task_id: string | null;
task_snapshot?: Record<string, unknown>;
chain_id: string | null;
chain_snapshot?: Record<string, unknown> | null;
intent_thread_id: string | null;
intent_snapshot?: Record<string, unknown> | null;
summary_thread_id?: string | null;
summary_snapshot?: Record<string, unknown> | null;
action_plan_thread_id?: string | null;
action_plan_snapshot?: Record<string, unknown> | null;
status: PositionRoundtableSession["status"];
created_at?: string;
updated_at?: string;
nodes?: ServerNode[];
}
const BASE = () => `${getBackendBaseURL()}/api/position-roundtable`;
async function readJson<T>(response: Response, label: string): Promise<T> {
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`${label} failed (${response.status}): ${detail}`);
}
return response.json() as Promise<T>;
}
function toNode(node: ServerNode): PositionRoundtableNode {
return {
id: node.id,
sessionId: node.session_id,
nodeKey: node.node_key,
stageIndex: node.stage_index,
seatIndex: node.seat_index,
agentId: node.agent_id,
positionId: node.position_id,
threadId: node.thread_id,
status: node.status,
latestAnswer: node.latest_answer,
artifactManifest: node.artifact_manifest ?? [],
revision: node.revision ?? 0,
upstreamRevision: node.upstream_revision ?? null,
rejectionCount: node.rejection_count ?? 0,
lastRejection: node.last_rejection ?? null,
invalidatedBy: node.invalidated_by ?? null,
createdAt: node.created_at,
updatedAt: node.updated_at,
};
}
function toSession(session: ServerSession): PositionRoundtableSession {
return {
id: session.id,
externalTaskId: session.external_task_id,
taskSnapshot: session.task_snapshot ?? {},
chainId: session.chain_id,
chainSnapshot: session.chain_snapshot ?? null,
intentThreadId: session.intent_thread_id,
intentSnapshot: session.intent_snapshot ?? null,
summaryThreadId: session.summary_thread_id ?? null,
summarySnapshot: session.summary_snapshot ?? null,
actionPlanThreadId: session.action_plan_thread_id ?? null,
actionPlanSnapshot: session.action_plan_snapshot ?? null,
status: session.status,
createdAt: session.created_at,
updatedAt: session.updated_at,
nodes: session.nodes?.map(toNode),
};
}
export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> {
const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : "";
const result = await readJson<ServerSession[]>(
await apiFetch(`${BASE()}/sessions${params}`),
"list position sessions",
);
return result.map(toSession);
}
export async function createPositionSession(input: {
externalTaskId?: string;
taskSnapshot: Record<string, unknown>;
}): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
external_task_id: input.externalTaskId || null,
task_snapshot: input.taskSnapshot,
}),
}),
"create position session",
);
return toSession(result);
}
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`),
"get position session",
);
return toSession(result);
}
export async function savePositionIntent(
sessionId: string,
input: { intentThreadId?: string | null; intentSnapshot: object | null },
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent_thread_id: input.intentThreadId || null,
intent_snapshot: input.intentSnapshot,
}),
}),
"save position intent",
);
return toSession(result);
}
export async function activatePositionSession(
sessionId: string,
chainId: string,
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chain_id: chainId }),
}),
"activate position session",
);
return toSession(result);
}
export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
method: "POST",
}),
"archive position session",
);
return toSession(result);
}
export async function bindPositionNodeThread(
sessionId: string,
nodeKey: string,
threadId: string,
): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ thread_id: threadId }),
},
),
"bind position node thread",
);
return toNode(result);
}
/** Fetch the bounded hidden task/intent/upstream handoff before one node turn. */
export async function getPositionNodeContext(
sessionId: string,
nodeKey: string,
): Promise<Record<string, unknown>> {
return readJson<Record<string, unknown>>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`,
{ method: "POST" },
),
"build position node context",
);
}
export async function completePositionNodeTurn(
sessionId: string,
nodeKey: string,
input: {
latestAnswer: string;
artifactManifest?: Array<Record<string, unknown>>;
upstreamRevision?: Record<string, unknown> | null;
},
): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
latest_answer: input.latestAnswer,
artifact_manifest: input.artifactManifest ?? [],
upstream_revision: input.upstreamRevision ?? null,
}),
},
),
"complete position node turn",
);
return toNode(result);
}
export async function rejectPositionNode(
sessionId: string,
nodeKey: string,
input: { title: string; reason: string },
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: input.title,
reason: input.reason,
}),
},
),
"reject position node",
);
return toSession(result);
}
export interface PositionBuiltinRunPreparation {
agentId: string;
displayName: string;
threadId: string | null;
prompt: string;
sourceRevisions: Record<string, number>;
}
interface ServerBuiltinRunPreparation {
agent_id: string;
display_name: string;
thread_id?: string | null;
prompt: string;
source_revisions?: Record<string, number>;
}
function toBuiltinRunPreparation(value: ServerBuiltinRunPreparation): PositionBuiltinRunPreparation {
return {
agentId: value.agent_id,
displayName: value.display_name,
threadId: value.thread_id ?? null,
prompt: value.prompt,
sourceRevisions: value.source_revisions ?? {},
};
}
type BuiltinKind = "summary" | "action_plan";
async function prepareBuiltinRun(
sessionId: string,
kind: BuiltinKind,
message = "",
): Promise<PositionBuiltinRunPreparation> {
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
const result = await readJson<ServerBuiltinRunPreparation>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
}),
`prepare position ${kind}`,
);
return toBuiltinRunPreparation(result);
}
async function completeBuiltinRun(
sessionId: string,
kind: BuiltinKind,
input: {
threadId: string;
content: string;
artifactManifest: Array<Record<string, unknown>>;
sourceRevisions: Record<string, number>;
model?: string;
},
): Promise<PositionRoundtableSession> {
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: input.threadId,
content: input.content,
artifact_manifest: input.artifactManifest,
source_revisions: input.sourceRevisions,
model: input.model || null,
}),
}),
`complete position ${kind}`,
);
return toSession(result);
}
export function preparePositionSummary(
sessionId: string,
message = "",
): Promise<PositionBuiltinRunPreparation> {
return prepareBuiltinRun(sessionId, "summary", message);
}
export function completePositionSummary(
sessionId: string,
input: Parameters<typeof completeBuiltinRun>[2],
): Promise<PositionRoundtableSession> {
return completeBuiltinRun(sessionId, "summary", input);
}
export function preparePositionActionPlan(
sessionId: string,
message = "",
): Promise<PositionBuiltinRunPreparation> {
return prepareBuiltinRun(sessionId, "action_plan", message);
}
export function completePositionActionPlan(
sessionId: string,
input: Parameters<typeof completeBuiltinRun>[2],
): Promise<PositionRoundtableSession> {
return completeBuiltinRun(sessionId, "action_plan", input);
}
@@ -0,0 +1,370 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bot, FileText, LockKeyhole } from "lucide-react";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages";
import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import {
bindPositionNodeThread,
completePositionNodeTurn,
getPositionNodeContext,
type PositionRoundtableNode,
} from "@/position-roundtable/api/sessions";
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
import { desensitize } from "@/lib/desensitize";
interface PositionNodeChatProps {
sessionId: string;
/** Parent task id: all node threads are indexed under this task. */
taskId: string;
chainId: string | null;
node: PositionRoundtableNode;
currentPositionId: string;
sessionStatus: "intent_pending" | "active" | "completed" | "archived";
agentName?: string | null;
onNodeUpdate: (node: PositionRoundtableNode) => void;
/** The workspace collapses the configuration panel on the first real turn. */
onConversationStart?: () => void;
onPreviewArtifact?: (artifact: {
threadId: string;
path: string;
name: string;
mimeType: string | null;
}) => void;
}
interface NodeArtifact {
name: string;
path: string;
mimeType: string | null;
}
function readNodeArtifacts(manifest: Array<Record<string, unknown>>): NodeArtifact[] {
return manifest.flatMap((artifact) => {
const path = typeof artifact.path === "string" ? artifact.path : "";
if (!path) return [];
const name =
typeof artifact.name === "string" && artifact.name.trim()
? artifact.name
: path.split("/").filter(Boolean).at(-1) || "未命名产物";
const mimeType =
typeof artifact.mime_type === "string"
? artifact.mime_type
: typeof artifact.mimeType === "string"
? artifact.mimeType
: null;
return [{ name, path, mimeType }];
});
}
function hasMarkdownArtifact(manifest: Array<Record<string, unknown>>): boolean {
return manifest.some((artifact) => {
const path = typeof artifact.path === "string" ? artifact.path.toLowerCase() : "";
const name = typeof artifact.name === "string" ? artifact.name.toLowerCase() : "";
const mime =
typeof artifact.mime_type === "string"
? artifact.mime_type.toLowerCase()
: typeof artifact.mimeType === "string"
? artifact.mimeType.toLowerCase()
: "";
return (
mime.includes("markdown") ||
path.endsWith(".md") ||
path.endsWith(".markdown") ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
});
}
/**
* Direct single-agent chat for one frozen business-chain node.
*
* It deliberately uses the regular `useThreadStream` + `MessageList` +
* `InputBox` stack. The position Session merely records which existing thread
* belongs to the node; it is not a second message persistence mechanism.
*/
export function PositionNodeChat({
sessionId,
taskId,
chainId,
node,
currentPositionId,
sessionStatus,
agentName,
onNodeUpdate,
onConversationStart,
onPreviewArtifact,
}: PositionNodeChatProps) {
const [settings, setSettings] = useLocalSettings();
const { agent } = useAgent(node.agentId);
const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
const artifactsCtx = useArtifactsOptional();
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
const boundThreadIdRef = useRef<string | null>(node.threadId);
const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
const canWrite =
sessionStatus !== "archived" &&
node.positionId === currentPositionId &&
node.status !== "locked" &&
node.status !== "error" &&
!node.invalidatedBy;
useEffect(() => {
if (artifactsCtx?.open && artifactsCtx.selectedArtifact) {
onPreviewArtifact?.({
threadId: boundThreadIdRef.current ?? boundThreadId ?? draftThreadIdRef.current,
path: artifactsCtx.selectedArtifact,
name: artifactsCtx.selectedArtifact.split("/").filter(Boolean).at(-1) || "Markdown 报告",
mimeType: artifactsCtx.selectedArtifact.toLowerCase().endsWith(".md")
? "text/markdown"
: null,
});
}
}, [artifactsCtx?.open, artifactsCtx?.selectedArtifact, boundThreadId, onPreviewArtifact]);
useEffect(() => {
setBoundThreadId(node.threadId);
boundThreadIdRef.current = node.threadId;
}, [node.threadId]);
const bindCreatedThread = useCallback(
(threadId: string) => {
boundThreadIdRef.current = threadId;
setBoundThreadId(threadId);
void bindPositionNodeThread(sessionId, node.nodeKey, threadId)
.then(onNodeUpdate)
.catch((error) => {
toast.error(`节点对话绑定失败:${error instanceof Error ? error.message : String(error)}`);
});
},
[node.nodeKey, onNodeUpdate, sessionId],
);
const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId,
assistantId: node.agentId,
context: { ...settings.context, agent_id: node.agentId },
threadMetadata: {
thread_type: "position_roundtable",
// ``taskId`` is the shared task-workspace convention. The two
// position-specific identifiers retain the exact child node for audits
// and restore, even when one position owns more than one chain seat.
taskId,
position_roundtable_task_id: taskId,
position_task_id: `${taskId}:${node.positionId ?? "unassigned"}:${node.nodeKey}`,
position_session_id: sessionId,
position_node_id: node.id,
position_node_key: node.nodeKey,
position_id: node.positionId,
chain_id: chainId,
agent_id: node.agentId,
},
onStart: bindCreatedThread,
onFinish: (state) => {
const threadId = boundThreadIdRef.current;
const latestAi = [...(state.messages ?? [])]
.reverse()
.find((message) => message.type === "ai");
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
if (!threadId || !latestAnswer.trim()) return;
void (async () => {
let artifactManifest: Array<Record<string, unknown>> = [];
try {
const artifacts = await listSessionArtifacts(threadId);
artifactManifest = artifacts.files.map((file) => ({
name: file.name,
path: file.path,
mime_type: file.mimeType,
modified_at: file.modifiedAt,
size_bytes: file.sizeBytes,
}));
} catch {
// Artifact listing is best-effort: a valid text answer must still be
// persisted and unlock downstream work even if files cannot be read.
}
try {
const updated = await completePositionNodeTurn(sessionId, node.nodeKey, {
latestAnswer,
artifactManifest,
upstreamRevision: upstreamRevisionRef.current,
});
onNodeUpdate(updated);
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
}
} catch (error) {
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
}
})();
},
});
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
// watching the actual stream id as well closes the last race where a page
// refresh could happen between creation and the async node-index write.
useEffect(() => {
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
bindCreatedThread(actualThreadId);
}, [actualThreadId, bindCreatedThread]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (!canWrite) {
toast.info(
sessionStatus === "archived"
? "该会话已归档,仅可查看历史记录"
: node.invalidatedBy
? "上游产物已被驳回或重做,当前节点需等待前序节点重新完成"
: node.status === "locked"
? "该节点尚未解锁"
: "当前岗位只能查看该节点对话",
);
return;
}
try {
onConversationStart?.();
const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey);
const upstreamRevision = taskExternalContext.upstream_revision;
upstreamRevisionRef.current =
upstreamRevision && typeof upstreamRevision === "object" && !Array.isArray(upstreamRevision)
? (upstreamRevision as Record<string, unknown>)
: null;
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
agent_id: node.agentId,
task_external_context: taskExternalContext,
});
} catch (error) {
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
}
},
[
canWrite,
node.agentId,
node.nodeKey,
node.status,
onConversationStart,
sendMessage,
sessionId,
sessionStatus,
],
);
const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
const displayThreadId = boundThreadId ?? `position-node-${node.id}`;
const readOnlyHint =
sessionStatus === "archived"
? "该会话已归档,仅可查看历史对话和产物"
: node.invalidatedBy
? "上游产物已变更,需等待前序节点重新完成后再重做"
: node.status === "locked"
? "等待上游岗位完成后解锁"
: node.positionId !== currentPositionId
? "当前岗位仅可查看该节点的对话和产物"
: "";
return (
<ThreadContext.Provider value={{ thread }}>
<div className="flex min-h-0 flex-1 flex-col">
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-slate-100 px-4 dark:border-[#2b8ffe]/20">
<span className="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
{node.status === "locked" ? <LockKeyhole className="size-3.5" /> : <Bot className="size-4" />}
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{agentDisplayName}
</h2>
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
{readOnlyHint || `节点状态:${node.status}`}
</p>
</div>
</header>
{nodeArtifacts.length > 0 && (
<section className="shrink-0 border-b border-slate-100 bg-slate-50/70 px-4 py-2.5 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/50">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-slate-600 dark:text-[#c5cfe0]">
<FileText className="size-3.5 text-primary" />
节点产物
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
{nodeArtifacts.length}
</span>
</div>
<div className="flex max-h-20 flex-wrap gap-1.5 overflow-y-auto pr-1">
{nodeArtifacts.map((artifact) => (
<button
key={artifact.path}
type="button"
title={`预览 ${artifact.name}`}
onClick={() => {
if (boundThreadId && onPreviewArtifact) {
onPreviewArtifact({
threadId: boundThreadId,
path: artifact.path,
name: artifact.name,
mimeType: artifact.mimeType,
});
}
}}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
>
<FileText className="size-3 shrink-0" />
<span className="max-w-48 truncate">{artifact.name}</span>
</button>
))}
</div>
</section>
)}
{boundThreadId ? (
<MessageList
className="min-h-0 flex-1"
threadId={boundThreadId}
thread={thread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
<span className="mb-3 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Bot className="size-5" />
</span>
<p className="text-sm font-semibold text-slate-800 dark:text-[#e8edf5]">
{canWrite ? "向该岗位智能体发起第一次问答" : "该节点尚无可查看的对话"}
</p>
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground dark:text-[#8aa0c4]">
{canWrite
? "系统会自动传入任务意图和已完成上游节点的问答结果。"
: readOnlyHint || "请选择已启动的岗位节点。"}
</p>
</div>
)}
<div className="shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<InputBox
className="w-full"
isNewThread={!boundThreadId}
threadId={displayThreadId}
assistantId={node.agentId}
status={status}
context={settings.context}
disabled={!canWrite}
hideAdminRecommendedQuestions
onContextChange={(context) => setSettings("context", context)}
onSubmit={(message) => void handleSubmit(message)}
onStop={() => void thread.stop()}
/>
</div>
</div>
</ThreadContext.Provider>
);
}
@@ -0,0 +1,72 @@
/**
* Temporary task source for the standalone position-collaboration workspace.
*
* The real task-system adapter should replace only this module. The page and
* Session API consume the normalized `PositionTask` shape, so they do not need
* to know whether data came from an external task platform or this local demo.
*/
export interface PositionTask {
id: string;
title: string;
description: string;
direction: string;
source: "mock";
}
export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
{
id: "mock-position-task-001",
title: "重点区域态势研判与协同处置方案",
description:
"围绕重点区域近期态势,梳理风险信号、影响范围与处置约束,形成可执行的协同方案及评估建议。",
direction: "态势研判",
source: "mock",
},
{
id: "mock-position-task-002",
title: "重大活动保障资源统筹任务",
description:
"针对重大活动保障场景,识别关键保障目标、现有资源和跨部门协同难点,输出分阶段资源统筹方案。",
direction: "组织筹划",
source: "mock",
},
{
id: "mock-position-task-003",
title: "专项行动效果复盘与优化建议",
description:
"汇总专项行动过程信息与阶段产物,研判实际效果、剩余风险和优化路径,形成后续行动建议。",
direction: "综合评估",
source: "mock",
},
] as const;
export const DEFAULT_MOCK_POSITION_TASK_ID = MOCK_POSITION_TASKS[0].id;
export function getMockPositionTask(taskId?: string | null): PositionTask {
return (
MOCK_POSITION_TASKS.find((task) => task.id === taskId) ??
MOCK_POSITION_TASKS[0]
);
}
/** Normalizes a persisted Session task snapshot without exposing source internals. */
export function taskFromPositionSnapshot(
snapshot: Record<string, unknown> | null | undefined,
fallback: PositionTask,
): PositionTask {
if (!snapshot || Object.keys(snapshot).length === 0) return fallback;
const value = (keys: string[], defaultValue: string) => {
const found = keys
.map((key) => snapshot[key])
.find((item) => typeof item === "string" && item.trim());
return typeof found === "string" ? found : defaultValue;
};
return {
id: value(["id", "taskId", "externalTaskId"], fallback.id),
title: value(["title", "taskName", "name"], fallback.title),
description: value(["description", "taskContent", "overview", "content"], fallback.description),
direction: value(["direction", "taskDirection", "category"], fallback.direction),
source: "mock",
};
}
@@ -0,0 +1,39 @@
/**
* 岗位协同的第一版岗位目录。
*
* 当前岗位仅代表页面工作视角;后续接入组织岗位 / 权限系统时,保留这里的稳定 id,
* 只替换数据源即可。业务链条席位用 `position_id` 持久化这组 id。
*/
export const POSITION_OPTIONS = [
{
id: "intelligence",
name: "情报分析岗",
description: "识别任务意图、补齐约束并确认业务链条",
isIntentPosition: true,
},
{
id: "planning",
name: "组织筹划岗",
description: "承接上游信息并进行组织筹划",
isIntentPosition: false,
},
{
id: "solution",
name: "方案设计岗",
description: "承接上游结果并形成方案产物",
isIntentPosition: false,
},
{
id: "evaluation",
name: "综合评估岗",
description: "评估链条产物并提出综合建议",
isIntentPosition: false,
},
] as const;
export type PositionId = (typeof POSITION_OPTIONS)[number]["id"];
export const DEFAULT_POSITION_ID: PositionId = "intelligence";
export const positionNameById = (positionId: string | null | undefined): string =>
POSITION_OPTIONS.find((position) => position.id === positionId)?.name ?? "未分配岗位";
File diff suppressed because it is too large. Load diff
@@ -97,11 +97,16 @@ function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
* `detail.trace_tail` to help diagnose intranet deployment issues without
* shell access to the server.
*/
export async function initIntent(model?: string): Promise<IntentInitResponse> {
export async function initIntent(
model?: string,
taskContext?: Record<string, unknown> | null,
): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
// 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认
// 业务链,第一阶段的对话也已经可以按 taskId 恢复。
body: JSON.stringify({ model, task_context: taskContext ?? undefined }),
});
if (!res.ok) {
const raw = await res.text().catch(() => "");
@@ -159,6 +164,8 @@ export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
/** Optional hidden business/task context injected into the model run. */
taskContext?: Record<string, unknown> | null;
/** Step 1 用户上传的参考文件(可选)。 */
files?: IntentFileRef[];
signal?: AbortSignal;
@@ -333,6 +340,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
thread_id: req.threadId,
message: req.message,
model: req.model,
task_context: req.taskContext ?? undefined,
// 仅在有文件时带上,空数组后端也安全忽略。
files: req.files && req.files.length > 0 ? req.files : undefined,
}),
@@ -1054,6 +1054,36 @@ export async function initSummaryThread(
return { agentName: json.agent_name, threadId: json.thread_id };
}
/**
* Create a per-session thread for the position-roundtable action-planning
* built-in agent. The agent id is fixed; callers persist/reuse the returned
* thread id for subsequent plan refinements.
*/
export async function initPositionActionPlanThread(
options: { signal?: AbortSignal } = {},
): Promise<ReportThreadResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/position-action-plan/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
signal: options.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "position_action_plan",
event: "init_thread_http_error",
message: `行动规划线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status,
agentId: "position-action-planner",
agentName: "行动规划智能体",
});
throw new Error(`init position action-plan thread failed (${res.status}): ${text}`);
}
const json = (await res.json()) as { agent_name: string; thread_id: string };
return { agentName: json.agent_name, threadId: json.thread_id };
}
/**
* Create a thread for the Step 3「入库」structure agent (`roundtable-structure`,
* a built-in singleton — the same agent that extracts the flowchart, but this run
@@ -72,6 +72,7 @@ export interface Step1PanelProps {
* 不传 → 显示默认「任务意图理解 (人机问答)」标题。右侧「问答轮次」始终保留。
*/
headerLeftSlot?: React.ReactNode;
headerRightSlot?: React.ReactNode;
interactiveMessages: InteractiveMessage[];
intentThreadId: string | null;
intentReady: object | null;
@@ -87,6 +88,8 @@ export interface Step1PanelProps {
onSkipQuestion: () => void;
onFollowUp: () => void;
onEnterRoundtable: () => void;
/** 保持旧文案默认不变;嵌入式新工作流可覆写确认动作名称。 */
enterButtonLabel?: string;
onManualSave: () => void;
onTriggerToast: (msg: string) => void;
submitIntentClarifications: (
@@ -99,6 +102,7 @@ export interface Step1PanelProps {
export function Step1Panel(props: Step1PanelProps) {
const {
headerLeftSlot,
headerRightSlot,
interactiveMessages,
intentThreadId,
intentReady,
@@ -111,6 +115,7 @@ export function Step1Panel(props: Step1PanelProps) {
onSkipQuestion,
onFollowUp,
onEnterRoundtable,
enterButtonLabel = "进入多智能体研讨 >",
onManualSave,
onTriggerToast,
submitIntentClarifications,
@@ -172,9 +177,12 @@ export function Step1Panel(props: Step1PanelProps) {
</h3>
</div>
)}
<div className="flex shrink-0 items-center gap-2">
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
</span>
{headerRightSlot}
</div>
</div>
{/* Chat list */}
@@ -334,7 +342,7 @@ export function Step1Panel(props: Step1PanelProps) {
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
进入多智能体研讨 &gt;
{enterButtonLabel}
</button>
</div>
@@ -90,6 +90,10 @@ export interface UseStep1IntentOptions {
currentStep: number;
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
selectedModel: string;
/** Optional hidden task/business context for each intent-agent turn. */
taskContext?: Record<string, unknown> | null;
/** Optional task-aware welcome bubble text before the first user turn. */
initialWelcomeText?: string;
/** 主页面统一的 toast 入口。 */
triggerToast: (msg: string) => void;
}
@@ -113,6 +117,8 @@ export interface UseStep1IntentReturn {
// ─── Handlers ────────────────────────────────────────────────────────
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
runStep1Init: () => void;
/** 清空当前状态但不创建线程,供宿主切换任务后重新进入 Step 1。 */
resetIntent: () => void;
/**
* 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。
* `files`:可选的本地文件,发送前先上传到 intent thread,再把引用随消息提交(后端
@@ -165,7 +171,7 @@ function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
}
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
const { currentStep, selectedModel, triggerToast } = options;
const { currentStep, selectedModel, taskContext, initialWelcomeText, triggerToast } = options;
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
@@ -196,6 +202,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
useEffect(() => {
taskContextRef.current = taskContext ?? null;
}, [taskContext]);
const initialWelcomeTextRef = useRef(initialWelcomeText);
useEffect(() => {
initialWelcomeTextRef.current = initialWelcomeText;
}, [initialWelcomeText]);
const stopIntentStream = useCallback(() => {
if (intentAborterRef.current) {
@@ -223,7 +237,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
id: 1,
sender: "agent",
senderName: "任务理解智能体",
content: INTENT_WELCOME_TEXT,
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
time: nowTime(),
},
]);
@@ -231,7 +245,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
try {
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
const res = await initIntent(selectedModelRef.current || undefined);
const res = await initIntent(
selectedModelRef.current || undefined,
taskContextRef.current,
);
if (step1InitGenRef.current !== myGen) return;
setIntentThreadId(res.thread_id);
setTimelineEntries([
@@ -256,6 +273,20 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
})();
}, []);
const resetIntent = useCallback(() => {
stopIntentStream();
step1InitRef.current = false;
step1InitGenRef.current += 1;
setIntentThreadId(null);
setIntentReady(null);
setIntentInitError(null);
setIntentInitErrorDetail(null);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setInteractiveMessages([]);
setTimelineEntries([]);
}, [stopIntentStream]);
// 离开 Step 1 自动中断流;`currentStep === 1` 但还没初始化 → runStep1Init。
useEffect(() => {
if (currentStep !== 1) {
@@ -392,6 +423,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
threadId: intentThreadId,
message: trimmed,
model: selectedModelRef.current || undefined,
taskContext: taskContextRef.current,
files: fileRefs,
signal: aborter.signal,
onTextDelta: (delta) => {
@@ -648,6 +680,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
timelineEntries,
setIntentClarificationSelections,
runStep1Init,
resetIntent,
sendIntentMessage,
submitIntentClarifications,
updateIntentReady,
@@ -45,6 +45,11 @@ export interface ChainSeat {
* 数组在 `api/chains.ts` 不做 camel↔snake 转换,键名须与后端 schema 逐字匹配)。
*/
seat_mode?: SeatMode | null;
/**
* 岗位协同专用的席位归属。普通圆桌会商不读取该字段;它跟随 `seats` JSON 保存,
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
*/
position_id?: string | null;
}
/** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */
@@ -77,8 +77,13 @@ import { Switch } from "@/components/ui/switch";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { Select as TSelect } from "tdesign-react";
import { listAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import {
POSITION_OPTIONS,
type PositionId,
} from "@/position-roundtable/lib/positions";
import {
filterRecommendCandidates,
@@ -152,6 +157,14 @@ type ActiveDrag =
export default function BusinessChainEditorPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// 岗位配置只由岗位协同页入口开启,普通圆桌编辑器完全不显示也不依赖该控件。
// `returnTo` 只接受站内绝对路径,避免把导航带往外部地址。
const positionConfigMode = searchParams.get("mode") === "position";
const requestedReturnTo = searchParams.get("returnTo");
const returnPath =
positionConfigMode && requestedReturnTo?.startsWith("/")
? requestedReturnTo
: "/page/workspace/roundtable/planning";
// 整片脱敏:链条配置页展示文本(链条名/描述/席位名/标题)。可编辑的 input/textarea 不脱敏(Hook 跳过),保证编辑用原文。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
@@ -415,6 +428,18 @@ export default function BusinessChainEditorPage() {
setStageGroups((prev) => setSeatModeOnGroups(prev, agentId, mode));
}, []);
// 岗位协同专用席位归属。保留在同一个 ChainSeat JSON 内,普通会商不会读取它。
const setSeatPositionCb = useCallback((agentId: string, positionId: PositionId | null) => {
setStageGroups((prev) =>
prev.map((group) => ({
...group,
seats: group.seats.map((seat) =>
seat.agent_id === agentId ? { ...seat, position_id: positionId } : seat,
),
})),
);
}, []);
// ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)────────────────────────
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
@@ -617,6 +642,8 @@ export default function BusinessChainEditorPage() {
seat_skill_directive: s.seat_skill_directive ?? false,
// 席位「推理深度覆盖」:随席位一起落库(null = 跟随链条级 seatMode)。
seat_mode: s.seat_mode ?? null,
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
position_id: s.position_id ?? null,
};
});
// 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
@@ -893,14 +920,14 @@ export default function BusinessChainEditorPage() {
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
<button
type="button"
onClick={() => navigate("/page/workspace/roundtable/planning")}
onClick={() => navigate(returnPath)}
className={cn(
"flex items-center gap-1.5 border border-border bg-card px-3 py-1.5 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground active:scale-[0.98]",
R_CTRL,
)}
>
<ArrowLeft className="size-4" />
返回圆桌
{positionConfigMode ? "返回岗位协同" : "返回圆桌"}
</button>
{/* 品牌标识 */}
@@ -921,6 +948,17 @@ export default function BusinessChainEditorPage() {
</div>
</div>
{positionConfigMode && (
<span
className={cn(
"border border-primary/30 bg-primary/10 px-2 py-1 text-[11px] font-semibold text-primary",
R_CTRL,
)}
>
岗位协同配置
</span>
)}
{/* 链条选择下拉 */}
<div className="relative ml-2">
<button
@@ -1205,6 +1243,8 @@ export default function BusinessChainEditorPage() {
onSeatModelChange={setSeatModelCb}
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
onSeatModeChange={setSeatModeCb}
positionConfigMode={positionConfigMode}
onSeatPositionChange={setSeatPositionCb}
onStageGoalChange={setGoal}
goalOptimizingIdx={goalOptimizingIdx}
onOptimizeGoal={handleOptimizeStageGoal}
@@ -1385,6 +1425,8 @@ function StagesColumn({
onSeatModelChange,
onSeatSkillDirectiveChange,
onSeatModeChange,
positionConfigMode,
onSeatPositionChange,
onStageGoalChange,
goalOptimizingIdx,
onOptimizeGoal,
@@ -1403,6 +1445,9 @@ function StagesColumn({
onSeatModelChange: (agentId: string, model: string | null) => void;
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
/** 仅岗位协同入口启用;普通圆桌编辑器不显示岗位控件。 */
positionConfigMode: boolean;
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void;
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
goalOptimizingIdx: number | null;
@@ -1439,6 +1484,11 @@ function StagesColumn({
<span className={cn("border border-border bg-muted px-2 py-0.5 text-[10px] font-bold text-muted-foreground", R_CTRL)}>
同层并行 · 层间串行
</span>
{positionConfigMode && (
<span className={cn("border border-primary/25 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary", R_CTRL)}>
为每个席位指定岗位
</span>
)}
</div>
<div className="flex select-none items-center gap-2 text-xs">
@@ -1503,6 +1553,8 @@ function StagesColumn({
onSeatModelChange={onSeatModelChange}
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
onSeatModeChange={onSeatModeChange}
positionConfigMode={positionConfigMode}
onSeatPositionChange={onSeatPositionChange}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
goalOptimizing={goalOptimizingIdx === stageIdx}
anyGoalOptimizing={goalOptimizingIdx !== null}
@@ -1534,6 +1586,8 @@ function StageSection({
onSeatModelChange,
onSeatSkillDirectiveChange,
onSeatModeChange,
positionConfigMode,
onSeatPositionChange,
onGoalChange,
goalOptimizing,
anyGoalOptimizing,
@@ -1554,6 +1608,8 @@ function StageSection({
onSeatModelChange: (agentId: string, model: string | null) => void;
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
positionConfigMode: boolean;
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
onGoalChange: (goal: string) => void;
/** 本 Stage 的阶段目标正在 AI 优化中。 */
goalOptimizing: boolean;
@@ -1717,6 +1773,8 @@ function StageSection({
onModelChange={(model) => onSeatModelChange(seat.agent_id, model)}
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
showPositionConfig={positionConfigMode}
onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)}
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
/>
))}
@@ -1742,6 +1800,8 @@ function SeatStrip({
onModelChange,
onSkillDirectiveChange,
onSeatModeChange,
showPositionConfig,
onPositionChange,
onDetail,
}: {
seat: ChainSeat;
@@ -1760,6 +1820,9 @@ function SeatStrip({
onSkillDirectiveChange: (enabled: boolean) => void;
/** 席位「推理深度覆盖」变更(null = 跟随链条级 seatMode)。 */
onSeatModeChange: (mode: SeatMode | null) => void;
/** 仅岗位协同入口展示的岗位配置。 */
showPositionConfig: boolean;
onPositionChange: (positionId: PositionId | null) => void;
onDetail: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
@@ -1810,6 +1873,29 @@ function SeatStrip({
)}
</div>
{/* 岗位协同从专用入口进来时才展示。使用 TDesign Select,未分配不会阻塞普通圆桌使用。 */}
{showPositionConfig && (
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2.5">
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">岗位</span>
<TSelect
size="small"
value={seat.position_id ?? ""}
options={[
{ label: "未分配", value: "" },
...POSITION_OPTIONS.map((position) => ({
label: position.name,
value: position.id,
})),
]}
onChange={(value) =>
onPositionChange(value ? (String(value) as PositionId) : null)
}
className="w-[132px]"
popupProps={{ overlayClassName: "z-[80]" }}
/>
</div>
)}
{/* 席位模型覆盖(右侧操作区):空 = 跟随全局所选模型;选了则该席位作为子智能体
执行时用此模型。技能重的席位配强模型、边角席位配快模型,配合技能白名单实现
「强弱搭配」。放到右侧操作区,与名称/描述解耦,卡片更整洁。 */}