diff --git a/frontend-web/docs/position-roundtable-delivery-workflow-plan.md b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md index ee3cfd84..75c06b4c 100644 --- a/frontend-web/docs/position-roundtable-delivery-workflow-plan.md +++ b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md @@ -4,6 +4,29 @@ > > 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。 +## 实施核对(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 页面流程。 @@ -75,32 +98,32 @@ ### 3.1 Session 扩展 -`position_roundtable_sessions` 建议增加: +`position_roundtable_sessions` 已使用下列持久化字段: | 字段 | 含义 | | --- | --- | -| `workflow_status` | `intent_pending / active / summarizing / planning / completed / archived` | +| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 | | `summary_thread_id` | 方案总结智能体线程 | | `summary_snapshot` | 最新有效总结报告快照 | | `action_plan_thread_id` | 行动规划智能体线程 | | `action_plan_snapshot` | 最新有效行动规划快照 | -| `invalidated_at` | 最近一次级联失效时间,可选 | +| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 | ### 3.2 Node 扩展 -`position_roundtable_nodes` 建议增加: +`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射): | 字段 | 含义 | | --- | --- | -| `display_status` | 面向前端的聚合状态,可后端计算也可前端映射 | +| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 | | `rejection_count` | 当前节点累计驳回次数 | | `last_rejection` | 最新驳回标题、理由、操作者、时间 | -| `valid_revision` | 当前有效交付版本 | +| `revision` | 当前有效交付版本 | | `invalidated_by` | 导致当前节点失效的上游节点 key | -### 3.3 Deliverable 新表 +### 3.3 Deliverable 新表(后续可选) -建议新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。 +当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。 | 字段 | 含义 | | --- | --- | @@ -121,7 +144,7 @@ | `created_at` | 创建时间 | | `updated_at` | 更新时间 | -这样右侧产物卡片、驳回记录、总结输入材料都能从同一张表聚合。 +当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。 ## 4. 驳回与级联失效规则 @@ -173,25 +196,27 @@ | 方法 | 路径 | 用途 | | --- | --- | --- | -| `GET` | `/api/position-roundtable/sessions/{id}/deliverables` | 获取右侧产物列表 | -| `GET` | `/api/position-roundtable/deliverables/{deliverable_id}` | 获取产物详情 | -| `POST` | `/api/position-roundtable/deliverables/{deliverable_id}/reject` | 驳回产物并触发级联失效 | -| `POST` | `/api/position-roundtable/nodes/{node_id}/complete-turn` | 节点回答结束后同步回答与产物 | +| `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/start` | 启动或重启方案总结 | -| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 对方案总结继续提问或修改 | -| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/start` | 基于总结和产物生成行动规划 | -| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 对行动规划继续提问或修改 | +| `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:状态与启动校验 +### 阶段 1:状态与启动校验(已完成) - 扩展 `PositionNodeStatus` 和状态显示映射。 - “确认意图并启动业务链”前补齐校验提示。 @@ -201,7 +226,7 @@ 验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。 -### 阶段 2:流程图弹窗 +### 阶段 2:流程图弹窗(已完成) - 左侧保留小型从上到下流程图。 - 流程图右上角增加“查看”按钮。 @@ -210,7 +235,7 @@ 验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。 -### 阶段 3:产物卡片与预览 +### 阶段 3:产物卡片与预览(已完成) - 右侧新增或改造“分析结果”Tab 为“交付产物”列表。 - 卡片展示智能体名称、产物名称、岗位名称、时间、状态。 @@ -220,7 +245,7 @@ 验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。 -### 阶段 4:驳回弹窗与级联失效 +### 阶段 4:驳回弹窗与级联失效(已完成) - 新增 `RejectDeliverableDialog`。 - 驳回表单包含标题和理由,均必填。 @@ -230,7 +255,7 @@ 验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。 -### 阶段 5:方案总结智能体 +### 阶段 5:方案总结智能体(已完成) - 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。 - 输入材料改为岗位会商材料: @@ -245,7 +270,7 @@ 验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。 -### 阶段 6:行动规划智能体 +### 阶段 6:行动规划智能体(已完成) - 新增内置智能体 ID,建议先命名为 `position-action-planner`。 - 新增行动规划 prompt: @@ -261,7 +286,7 @@ 验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。 -### 阶段 7:返回修改与重新生成 +### 阶段 7:返回修改与重新生成(已完成) - 用户在方案总结或行动规划视图中仍可切回“交付产物”。 - 驳回后自动显示需要重做的节点。 @@ -270,7 +295,7 @@ 验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。 -### 阶段 8:测试与回归 +### 阶段 8:测试与回归(已完成) - 后端测试: - activate 校验。 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({ + + + + + 岗位会商 + + + } /> + + + + + + } + /> | 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; @@ -82,7 +84,9 @@ interface ServerSession { 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; @@ -132,7 +136,9 @@ function toSession(session: ServerSession): PositionRoundtableSession { 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, @@ -301,22 +307,104 @@ export async function rejectPositionNode( return toSession(result); } -export async function generatePositionSummary(sessionId: string): Promise { - const result = await readJson( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/summary/start`, { +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 }), }), - "generate position summary", + `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 async function generatePositionActionPlan(sessionId: string): Promise { - const result = await readJson( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/action-plan/start`, { - method: "POST", - }), - "generate position action plan", - ); - return toSession(result); +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 index f329916c..c5b99f42 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Bot, FileText, LockKeyhole, Loader2 } from "lucide-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"; @@ -17,15 +18,26 @@ import { type PositionRoundtableNode, } from "@/position-roundtable/api/sessions"; import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent"; -import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; +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 { @@ -52,6 +64,26 @@ function readNodeArtifacts(manifest: Array>): NodeArtifa }); } +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. * @@ -61,18 +93,24 @@ function readNodeArtifacts(manifest: Array>): NodeArtifa */ 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 [previewArtifact, setPreviewArtifact] = useState(null); const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]); const canWrite = sessionStatus !== "archived" && @@ -81,6 +119,19 @@ export function PositionNodeChat({ 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; @@ -99,14 +150,21 @@ export function PositionNodeChat({ [node.nodeKey, onNodeUpdate, sessionId], ); - const [thread, sendMessage] = useThreadStream({ + 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, @@ -128,6 +186,7 @@ export function PositionNodeChat({ 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 @@ -140,6 +199,9 @@ export function PositionNodeChat({ 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)}`); } @@ -147,6 +209,14 @@ export function PositionNodeChat({ }, }); + // 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) { @@ -162,6 +232,7 @@ export function PositionNodeChat({ return; } try { + onConversationStart?.(); const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey); const upstreamRevision = taskExternalContext.upstream_revision; upstreamRevisionRef.current = @@ -176,7 +247,16 @@ export function PositionNodeChat({ toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`); } }, - [canWrite, node.agentId, node.nodeKey, node.status, sendMessage, sessionId, sessionStatus], + [ + canWrite, + node.agentId, + node.nodeKey, + node.status, + onConversationStart, + sendMessage, + sessionId, + sessionStatus, + ], ); const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready"; @@ -201,13 +281,12 @@ export function PositionNodeChat({

- {node.agentId} + {agentDisplayName}

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

- {thread.isLoading && } {nodeArtifacts.length > 0 && ( @@ -225,7 +304,16 @@ export function PositionNodeChat({ key={artifact.path} type="button" title={`预览 ${artifact.name}`} - onClick={() => setPreviewArtifact(artifact)} + 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]" > @@ -242,6 +330,7 @@ export function PositionNodeChat({ threadId={boundThreadId} thread={thread} paddingBottom={24} + scrollButtonClassName="bottom-6" onEditResubmit={(text) => void handleSubmit({ text, files: [] })} /> ) : ( @@ -275,15 +364,6 @@ export function PositionNodeChat({ onStop={() => void thread.stop()} /> - {previewArtifact && boundThreadId && ( - setPreviewArtifact(null)} - /> - )} ); diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 3b2036f3..ff015745 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -30,6 +30,7 @@ import { } from "lucide-react"; import { Button as TButton, + Drawer as TDrawer, Input as TInput, Select as TSelect, } from "tdesign-react"; @@ -38,6 +39,7 @@ 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, @@ -47,16 +49,20 @@ import { } 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"; @@ -69,21 +75,27 @@ import { import { activatePositionSession, archivePositionSession, + completePositionActionPlan, + completePositionSummary, createPositionSession, - generatePositionActionPlan, - generatePositionSummary, getPositionSession, listPositionSessions, + preparePositionActionPlan, + preparePositionSummary, rejectPositionNode, savePositionIntent, type PositionRoundtableNode, type PositionRoundtableSession, } from "@/position-roundtable/api/sessions"; -import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; +import { + initPositionActionPlanThread, + initSummaryThread, + listSessionArtifacts, + streamMultiAgent, +} from "@/roundtable-planning/api/multi-agent"; import { DEFAULT_MOCK_POSITION_TASK_ID, getMockPositionTask, - MOCK_POSITION_TASKS, taskFromPositionSnapshot, type PositionTask, } from "@/position-roundtable/lib/mock-task-source"; @@ -95,6 +107,12 @@ import { 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(); @@ -178,7 +196,40 @@ function nodeStatusMeta(status: NodeVisualStatus): { } function allExecutableNodesDone(nodes: PositionRoundtableNode[]): boolean { - return nodes.length > 0 && nodes.every((node) => node.status === "done"); + 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 { @@ -190,7 +241,7 @@ function nodeHasDelivery(node: PositionRoundtableNode): boolean { } function canRejectNodeDelivery(node: PositionRoundtableNode): boolean { - return node.status === "done" && nodeHasDelivery(node); + return node.status === "done" && !isIntentPositionNode(node) && nodeHasMarkdownDelivery(node); } function recordString(record: Record | null | undefined, key: string): string { @@ -216,6 +267,16 @@ function snapshotText(snapshot: Record | null | undefined, 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, @@ -234,6 +295,27 @@ function resultPositionName(positionId: string | null): string { 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; @@ -247,7 +329,49 @@ function parseIntentSummary(snapshot: Record | null | undefined }; } -export default function PositionRoundtablePage() { +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(); @@ -262,6 +386,10 @@ export default function PositionRoundtablePage() { 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), @@ -271,6 +399,26 @@ export default function PositionRoundtablePage() { () => 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 ?? ""; @@ -281,9 +429,13 @@ export default function PositionRoundtablePage() { // 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"], @@ -313,6 +465,8 @@ export default function PositionRoundtablePage() { } return selectedChain; }, [positionSession?.chainSnapshot, positionSession?.status, selectedChain]); + const isIntelligenceDeliveryOverview = + activePosition.isIntentPosition && positionSession !== null && positionSession.status !== "intent_pending"; const assignedAgentIds = useMemo( () => new Set( @@ -324,19 +478,32 @@ export default function PositionRoundtablePage() { ); const [selectedAgentId, setSelectedAgentId] = useState(null); const [activeNodeKey, setActiveNodeKey] = useState(null); - const activeNode = useMemo( - () => positionSession?.nodes?.find((node) => node.nodeKey === activeNodeKey) ?? null, - [activeNodeKey, positionSession?.nodes], + // 岗位切换与会话恢复会在不同的渲染周期完成。不能只依赖 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 nodes = positionSession?.nodes ?? []; - const currentNodeStillVisible = nodes.some( - (node) => node.nodeKey === activeNodeKey && node.positionId === positionId, - ); - if (currentNodeStillVisible) return; - const next = nodes.find((node) => node.positionId === positionId) ?? null; - setActiveNodeKey(next?.nodeKey ?? null); - }, [activeNodeKey, positionId, positionSession?.nodes]); + const nextNodeKey = activeNode?.nodeKey ?? null; + if (activeNodeKey !== nextNodeKey) setActiveNodeKey(nextNodeKey); + }, [activeNode?.nodeKey, activeNodeKey]); const handleNodeUpdate = useCallback((updatedNode: PositionRoundtableNode) => { setPositionSession((current) => { if (!current?.nodes) return current; @@ -358,22 +525,98 @@ export default function PositionRoundtablePage() { } }, [sessionIdFromUrl]); + // 第一阶段同样属于当前任务:intent thread 一创建就立刻写入 taskId 绑定的 + // 岗位会话。之前等到“启动业务链”才保存,刷新会直接丢失意图问答。 useEffect(() => { - if ( - !positionSession?.id || - hydratedIntentSessionRef.current === positionSession.id || - !positionSession.intentThreadId || - !frozenIntent - ) { - return; - } - hydratedIntentSessionRef.current = positionSession.id; - intent.hydrateFromDraft({ - intentThreadId: positionSession.intentThreadId, - intentReady: frozenIntent, - interactiveMessages: [], - timelineEntries: [], - }); + 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) => { @@ -540,30 +783,10 @@ export default function PositionRoundtablePage() { } }, [intent.intentReady, intent.intentThreadId, positionSession, searchParams, selectedChain, setSearchParams, startingChain, task]); - const handleMockTaskChange = useCallback( - (value: unknown) => { - const nextTaskId = String(value); - if (nextTaskId === task.id || !MOCK_POSITION_TASKS.some((item) => item.id === nextTaskId)) { - return; - } - intent.resetIntent(); - hydratedIntentSessionRef.current = null; - setPositionSession(null); - setSessionDiscoveryDone(false); - setSelectedAgentId(null); - setActiveNodeKey(null); - setIntentCompletedAt(""); - const next = new URLSearchParams(searchParams); - next.set("taskId", nextTaskId); - next.delete("sessionId"); - setSearchParams(next); - }, - [intent, searchParams, setSearchParams, task.id], - ); - const handleCreateNewSession = useCallback(() => { intent.resetIntent(); hydratedIntentSessionRef.current = null; + persistedIntentRef.current = null; setPositionSession(null); setSessionDiscoveryDone(true); setSelectedAgentId(null); @@ -589,6 +812,30 @@ export default function PositionRoundtablePage() { }); }, [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 (
-
- - 模拟任务 - - ({ - label: mockTask.title, - value: mockTask.id, - }))} - onChange={handleMockTaskChange} - /> -
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 && ( + <> + + + + )}
@@ -684,13 +931,17 @@ export default function PositionRoundtablePage() { {activePosition.isIntentPosition ? ( - + isIntelligenceDeliveryOverview ? ( + + ) : ( + + ) ) : ( <> @@ -703,14 +954,37 @@ export default function PositionRoundtablePage() { setActiveNodeKey(node.nodeKey); setSelectedAgentId(node.agentId); }} + onSelectBuiltin={() => { + setRightPanelOpen(true); + setRightTab("results"); + }} /> )} -
+
+ {!isIntelligenceDeliveryOverview && !rightPanelOpen && !sandboxOpen && ( + } + onClick={() => setRightPanelOpen(true)} + title="打开右侧配置" + /> + )}
- {activePosition.isIntentPosition ? ( + {isIntelligenceDeliveryOverview ? ( + + ) : activePosition.isIntentPosition ? ( positionSession?.status !== "intent_pending" && frozenIntent ? ( ) : ( @@ -742,7 +1016,10 @@ export default function PositionRoundtablePage() { pendingIntentClarificationMsgId={intent.pendingIntentClarificationMsgId} intentClarificationSelections={intent.intentClarificationSelections} setIntentClarificationSelections={intent.setIntentClarificationSelections} - onSendMessage={(text, files) => void intent.sendIntentMessage(text, files)} + onSendMessage={(text, files) => { + setRightPanelOpen(false); + void intent.sendIntentMessage(text, files); + }} onStopIntent={intent.stopIntentStream} onSkipQuestion={handleSkipQuestion} onFollowUp={handleFollowUp} @@ -764,11 +1041,15 @@ export default function PositionRoundtablePage() { setRightPanelOpen(false)} + onPreviewArtifact={handlePreviewArtifact} /> ) : (
- + {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 && ( + + )}
); @@ -874,6 +1249,230 @@ function IntentConfirmedState({ intent }: { intent: IntentSummary }) { ); } +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 seatsByAgentId = useMemo( + () => new Map((chain?.seats ?? []).map((seat) => [seat.agent_id, seat])), + [chain], + ); + const items = useMemo(() => { + const sortedNodes = [...nodes].sort( + (left, right) => + left.stageIndex - right.stageIndex || + left.seatIndex - right.seatIndex || + left.agentId.localeCompare(right.agentId), + ); + const nodeItems = sortedNodes.map((node, index): DeliveryOverviewItem => { + const seat = seatsByAgentId.get(node.agentId); + const agentName = seat?.name ?? node.agentId; + const markdownArtifact = node.artifactManifest.find(artifactIsMarkdown); + const deliveredArtifactName = artifactName(markdownArtifact); + const statusLabel = nodeStatusMeta(node.status).label; + const summary = + node.latestAnswer?.trim() || + (node.status === "locked" + ? node.invalidatedBy + ? "上游交付已被驳回或重做,等待前序节点重新完成。" + : "等待上游阶段完成后解锁。" + : node.status === "running" + ? "智能体正在问答并生成 Markdown 报告。" + : node.status === "stale" + ? "上游产物已更新,该席位需要基于最新结果重做。" + : node.status === "rejected" + ? "产物已被驳回,需要重新问答并交付。" + : "该席位尚未交付 Markdown 报告。"); + return { + id: `node:${node.nodeKey}`, + kind: "node", + agentName, + productName: + deliveredArtifactName || + (node.status === "done" ? `${agentName}报告` : `待交付:${agentName} Markdown报告`), + summary, + positionId: node.positionId, + updatedAt: node.updatedAt, + status: node.status, + accent: FLOW_NODE_COLORS[index % FLOW_NODE_COLORS.length]!, + }; + }); + 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", + }, + ...nodeItems, + { + 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, seatsByAgentId, 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: PositionTask }) { return ( @@ -1177,17 +1776,26 @@ function BusinessChainFlow({ 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 ( -
+
@@ -1200,9 +1808,10 @@ function BusinessChainFlow({ {chain && ( } + className="!h-7 !w-7 !min-w-[28px] !p-0 !border-[#F0C8C8] !bg-[#FFF7F7] !text-[#CC1925] hover:!border-[#CC1925] hover:!bg-[#FFF0F0] dark:!border-[#2b8ffe]/45 dark:!bg-[#082d5f] dark:!text-[#8fc4ff]" + icon={} onClick={() => setFlowDialogOpen(true)} title="查看完整流程图" /> @@ -1220,7 +1829,8 @@ function BusinessChainFlow({ session={session} activeNodeKey={activeNodeKey} onSelectNode={onSelectNode} - className="h-[456px]" + onSelectBuiltin={onSelectBuiltin} + className="min-h-0 flex-1" /> @@ -1240,6 +1850,10 @@ function BusinessChainFlow({ onSelectNode(node); setFlowDialogOpen(false); }} + onSelectBuiltin={(kind) => { + onSelectBuiltin(kind); + setFlowDialogOpen(false); + }} className="h-full" expanded /> @@ -1258,6 +1872,7 @@ function ChainFlowCanvas({ session, activeNodeKey, onSelectNode, + onSelectBuiltin, className, expanded = false, }: { @@ -1266,6 +1881,7 @@ function ChainFlowCanvas({ session: PositionRoundtableSession | null; activeNodeKey: string | null; onSelectNode: (node: PositionRoundtableNode) => void; + onSelectBuiltin: (kind: "summary" | "action_plan") => void; className?: string; expanded?: boolean; }) { @@ -1401,6 +2017,7 @@ function ChainFlowCanvas({ nodeStatus={summaryStatus} width={nodeWidth} titleAttr="对所有有效交付产物进行方案总结" + onClick={() => onSelectBuiltin("summary")} /> onSelectBuiltin("action_plan")} />
@@ -1479,6 +2097,13 @@ function CompactChainNode({ }) { const status = nodeStatusMeta(nodeStatus); const StatusIcon = status.Icon; + const selectedStyle = selected + ? { + borderColor: accent, + borderLeftColor: accent, + boxShadow: `0 12px 26px -18px ${accent}`, + } + : undefined; return (
+ {selected && ( +