From 2ca8a23102520f8b8f74125a071c4f5d7b77ab95 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 19:48:39 +0800 Subject: [PATCH] feat(position-roundtable): add delivery workflow scaffolding --- .../src/position-roundtable/api/sessions.ts | 1 + .../pages/PositionRoundtablePage.tsx | 484 +++++++++++++----- .../gateway/routers/position_roundtable.py | 40 +- .../test_position_roundtable_sessions.py | 20 + 4 files changed, 416 insertions(+), 129 deletions(-) diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts index 793eb5b0..fbb10e10 100644 --- a/frontend-web/src/position-roundtable/api/sessions.ts +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -13,6 +13,7 @@ export type PositionNodeStatus = | "running" | "done" | "stale" + | "rejected" | "error"; export interface PositionRoundtableNode { diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index beb32ddc..468b24ea 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -2,13 +2,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "react-router-dom"; import { + AlertTriangle, ArrowLeft, Archive, Bot, BriefcaseBusiness, CheckCircle2, ChevronDown, + Circle, + CircleDashed, Clock3, + Eye, FileOutput, GitBranch, Layers3, @@ -20,6 +24,8 @@ import { Sparkles, Target, UserRoundCog, + XCircle, + type LucideIcon, } from "lucide-react"; import { Button as TButton, @@ -31,6 +37,12 @@ import { toast } from "sonner"; import { agentColor } from "@/components/workspace/agent-icon-color"; import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel"; import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-module-sidebar"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useTheme } from "@/contexts/ThemeContext"; import { useAgent } from "@/core/agents"; import type { Agent } from "@/core/agents"; @@ -75,6 +87,7 @@ import { */ const POSITIONS = POSITION_OPTIONS; type WorkspaceTab = "agents" | "results"; +type NodeVisualStatus = PositionRoundtableNode["status"] | undefined; function uniqueChains(chains: BusinessChain[]): BusinessChain[] { const seen = new Set(); @@ -95,6 +108,72 @@ function timeLabel(): string { }).format(new Date()); } +function nodeStatusMeta(status: NodeVisualStatus): { + label: string; + Icon: LucideIcon; + badgeClassName: string; + iconClassName: string; + spin?: boolean; +} { + switch (status) { + case "done": + return { + label: "已完成", + Icon: CheckCircle2, + badgeClassName: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + iconClassName: "bg-emerald-500 text-white", + }; + case "running": + return { + label: "进行中", + Icon: Loader2, + badgeClassName: "bg-sky-500/10 text-sky-700 dark:text-sky-300", + iconClassName: "bg-sky-500 text-white", + spin: true, + }; + case "stale": + return { + label: "需重做", + Icon: AlertTriangle, + badgeClassName: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + iconClassName: "bg-amber-500 text-white", + }; + case "rejected": + return { + label: "已驳回", + Icon: XCircle, + badgeClassName: "bg-rose-500/10 text-rose-700 dark:text-rose-300", + iconClassName: "bg-rose-500 text-white", + }; + case "error": + return { + label: "异常", + Icon: XCircle, + badgeClassName: "bg-destructive/10 text-destructive", + iconClassName: "bg-destructive text-destructive-foreground", + }; + case "locked": + return { + label: "未完成", + Icon: CircleDashed, + badgeClassName: "bg-slate-500/10 text-slate-500 dark:text-[#8aa0c4]", + iconClassName: "bg-slate-300 text-white dark:bg-[#315a89]", + }; + case "ready": + default: + return { + label: "未完成", + Icon: Circle, + badgeClassName: "bg-slate-500/10 text-slate-600 dark:text-[#9ebfe2]", + iconClassName: "bg-white text-slate-400 ring-1 ring-slate-300 dark:bg-[#0a315d] dark:text-[#8aa0c4] dark:ring-[#2b8ffe]/45", + }; + } +} + +function allExecutableNodesDone(nodes: PositionRoundtableNode[]): boolean { + return nodes.length > 0 && nodes.every((node) => node.status === "done"); +} + function parseIntentSummary(snapshot: Record | null | undefined): IntentSummary | null { if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) { return null; @@ -355,6 +434,15 @@ export default function PositionRoundtablePage() { toast.warning("请先选择业务链条"); return; } + if (selectedChain.seats.length === 0) { + toast.warning("当前业务链条没有可执行的智能体席位"); + return; + } + const missingAgentSeats = selectedChain.seats.filter((seat) => !seat.agent_id?.trim()); + if (missingAgentSeats.length > 0) { + toast.warning(`该业务链还有 ${missingAgentSeats.length} 个席位缺少智能体,请先完成配置`); + return; + } const unassignedSeats = selectedChain.seats.filter((seat) => !seat.position_id?.trim()); if (unassignedSeats.length > 0) { toast.warning(`该业务链还有 ${unassignedSeats.length} 个席位未配置岗位,请点击齿轮完成配置`); @@ -1016,7 +1104,6 @@ function chainStages(chain: BusinessChain) { const FLOW_NODE_COLORS = ["#3b82f6", "#a855f7", "#10b981", "#f59e0b", "#ec4899"]; const FLOW_NODE_WIDTH = 158; const FLOW_NODE_GAP = 10; -const FLOW_NODE_CENTER = FLOW_NODE_WIDTH / 2; /** 以业务链配置页的节点卡片语义绘制窄栏版,不显示连线依赖说明。 */ function BusinessChainFlow({ @@ -1030,7 +1117,88 @@ function BusinessChainFlow({ activeNodeKey: string | null; onSelectNode: (node: PositionRoundtableNode) => void; }) { - const stages = chain ? chainStages(chain) : []; + const [flowDialogOpen, setFlowDialogOpen] = useState(false); + + return ( +
+
+
+ + + +

+ 业务链条 +

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

- 业务链条 -

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

{title}

-

- {description} -

-
+ + + +
+

+ {title} +

+

+ {description} +

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

暂无分析结果

+

暂无交付产物

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

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

分析结果列表

-

按生成时间倒序

+

交付产物列表

+

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

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

{desensitize(item.agentName)}

+ {status && StatusIcon && ( + + + {status.label} + + )} {stale && ( 上游已更新 diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py index c05afb34..808e608f 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -20,7 +20,7 @@ from deerflow.runtime.user_context import get_effective_user_id router = APIRouter(prefix="/api/position-roundtable", tags=["position-roundtable"]) -NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "error"} +NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "error"} _HANDOFF_TEXT_BUDGET = 12_000 _MAX_INLINE_ARTIFACTS_PER_NODE = 4 _MAX_INLINE_ARTIFACT_CHARS = 1_800 @@ -152,7 +152,11 @@ async def _load_detail(request: Request, session_id: str) -> SessionDetailRespon return SessionDetailResponse(**row, nodes=[_project_node(node) for node in nodes or []]) -def _chain_nodes(chain: dict[str, Any]) -> list[dict[str, Any]]: +def _chain_nodes( + chain: dict[str, Any], + *, + available_agent_ids: set[str] | None = None, +) -> list[dict[str, Any]]: seats = chain.get("seats") if isinstance(chain.get("seats"), list) else [] if not seats: raise HTTPException(status_code=400, detail="业务链条没有可执行的智能体席位") @@ -175,6 +179,11 @@ def _chain_nodes(chain: dict[str, Any]) -> list[dict[str, Any]]: seat = seat_by_agent_id.get(agent_id) if seat is None: raise HTTPException(status_code=400, detail=f"业务链条阶段包含不存在的席位: {agent_id}") + if available_agent_ids is not None and agent_id not in available_agent_ids: + raise HTTPException( + status_code=400, + detail=f"业务链条席位「{seat.get('name') or agent_id}」缺少可用智能体,请先重新配置业务链条", + ) position_id = seat.get("position_id") if not isinstance(position_id, str) or not position_id.strip(): raise HTTPException( @@ -194,6 +203,23 @@ def _chain_nodes(chain: dict[str, Any]) -> list[dict[str, Any]]: return result +async def _available_agent_ids(request: Request, agent_ids: set[str]) -> set[str] | None: + """Return the configured agent ids if the agent store is available. + + Position collaboration freezes a business-chain snapshot before execution. + Validating seat agent ids here gives the user a concrete configuration + error instead of failing later when a node tries to create its chat thread. + """ + agent_store = getattr(request.app.state, "agent_store", None) + if agent_store is None: + return None + available: set[str] = set() + for agent_id in agent_ids: + if await agent_store.get_any(agent_id) is not None: + available.add(agent_id) + return available + + def _truncate_text(value: Any, limit: int) -> str: if limit <= 0: return "" @@ -435,7 +461,15 @@ async def activate_session( ) if chain is None: raise HTTPException(status_code=404, detail="业务链条不存在或无权访问") - nodes = _chain_nodes(chain) + seat_ids = { + str(seat.get("agent_id")) + for seat in (chain.get("seats") if isinstance(chain.get("seats"), list) else []) + if seat.get("agent_id") + } + nodes = _chain_nodes( + chain, + available_agent_ids=await _available_agent_ids(request, seat_ids), + ) activated = await store.activate_session( session_id, user_id, diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py index 365120cd..a0ea1335 100644 --- a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py +++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py @@ -79,6 +79,26 @@ async def test_session_activation_freezes_chain_and_creates_nodes_once(store): assert [node["node_key"] for node in repeated_nodes] == [node["node_key"] for node in created_nodes] +def test_chain_node_activation_rejects_missing_agent_ids(): + chain = { + "id": "chain-a", + "seats": [ + {"agent_id": "agent-a", "name": "有效智能体", "position_id": "planning"}, + {"agent_id": "missing-agent", "name": "缺失智能体", "position_id": "solution"}, + ], + } + + with pytest.raises(HTTPException) as error: + position_roundtable_router._chain_nodes( + chain, + available_agent_ids={"agent-a"}, + ) + + assert error.value.status_code == 400 + assert "缺失智能体" in str(error.value.detail) + assert "缺少可用智能体" in str(error.value.detail) + + @pytest.mark.asyncio async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store): created = await store.create_session("user-a", external_task_id=None, task_snapshot={})