feat(position-roundtable): add delivery workflow scaffolding

This commit is contained in:
fengxiangdebaozi committed 2026-07-21 19:48:39 +08:00
1 parent 97ce19973d
commit 2ca8a23102
4 files changed
+416 -129

No files matched your search

@@ -13,6 +13,7 @@ export type PositionNodeStatus =
| "running"
| "done"
| "stale"
| "rejected"
| "error";
export interface PositionRoundtableNode {
@@ -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<string>();
@@ -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<string, unknown> | 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 (
<section className="h-[540px] shrink-0 overflow-hidden rounded-2xl border border-[#ECE7E4] bg-white p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Layers3 className="size-3" strokeWidth={2.5} />
</span>
<h2 className="truncate text-sm font-semibold tracking-wide text-slate-900 dark:text-[#e8edf5]">
业务链条
</h2>
</div>
{chain && (
<TButton
size="small"
variant="text"
icon={<Eye className="size-3.5" />}
onClick={() => setFlowDialogOpen(true)}
>
查看
</TButton>
)}
</div>
{!chain ? (
<div className="rounded-xl border border-dashed border-[#E8D0CC] p-4 text-center text-xs text-slate-400 dark:border-[#2b8ffe]/30 dark:text-[#8aa0c4]">
请先由情报分析岗选择业务链条
</div>
) : (
<>
<ChainFlowCanvas
chain={chain}
nodes={nodes}
activeNodeKey={activeNodeKey}
onSelectNode={onSelectNode}
className="h-[456px]"
/>
<Dialog open={flowDialogOpen} onOpenChange={setFlowDialogOpen}>
<DialogContent className="flex h-[82vh] max-w-[1040px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="flex items-center gap-2 text-base">
<Layers3 className="size-4 text-primary" />
业务链条流程图
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 p-4">
<ChainFlowCanvas
chain={chain}
nodes={nodes}
activeNodeKey={activeNodeKey}
onSelectNode={(node) => {
onSelectNode(node);
setFlowDialogOpen(false);
}}
className="h-full"
expanded
/>
</div>
</DialogContent>
</Dialog>
</>
)}
</section>
);
}
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<HTMLDivElement>) => {
if (event.button !== 0) return;
@@ -1074,105 +1244,114 @@ function BusinessChainFlow({
};
return (
<section className="h-[540px] shrink-0 overflow-hidden rounded-2xl border border-[#ECE7E4] bg-white p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none">
<div className="mb-3 flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Layers3 className="size-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900 dark:text-[#e8edf5]">
业务链条
</h2>
</div>
{!chain ? (
<div className="rounded-xl border border-dashed border-[#E8D0CC] p-4 text-center text-xs text-slate-400 dark:border-[#2b8ffe]/30 dark:text-[#8aa0c4]">
请先由情报分析岗选择业务链条
</div>
) : (
<div
onPointerDown={onPanStart}
onPointerMove={onPanMove}
onPointerUp={onPanEnd}
onPointerCancel={onPanEnd}
onDoubleClick={() => 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="按住鼠标拖动画布;双击复位"
>
<div
className="flex min-h-full min-w-max flex-col items-center p-5"
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
>
<CompactChainNode
title="情报分析岗"
description="任务意图识别"
accent="#3b82f6"
avatarType="human"
/>
{stages.map((stage, stageIndex) => {
const stageWidth = Math.max(
FLOW_NODE_WIDTH,
stage.seats.length * (FLOW_NODE_WIDTH + FLOW_NODE_GAP) - FLOW_NODE_GAP,
);
return (
<div
key={`${stage.goal ?? "stage"}-${stageIndex}`}
className="flex flex-col items-center"
>
<VerticalFlowConnector
seatCount={stage.seats.length}
width={stageWidth}
/>
<div className="flex items-start justify-center gap-2.5">
{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 (
<CompactChainNode
key={seat.agent_id}
title={desensitize(seat.name || seat.agent_id)}
description={desensitize(seat.description || "智能体席位")}
accent={
FLOW_NODE_COLORS[
(stageIndex + seatIndex) % FLOW_NODE_COLORS.length
]!
}
avatarType={getAvatarTypeFor(seat.agent_id, order)}
selected={node?.nodeKey === activeNodeKey}
nodeStatus={node?.status}
onClick={node ? () => onSelectNode(node) : undefined}
/>
);
})}
</div>
</div>
);
})}
</div>
</div>
<div
onPointerDown={onPanStart}
onPointerMove={onPanMove}
onPointerUp={onPanEnd}
onPointerCancel={onPanEnd}
onDoubleClick={() => 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,
)}
</section>
style={{
backgroundImage: "radial-gradient(rgba(148,163,184,.55) 1px, transparent 1px)",
backgroundSize: "16px 16px",
}}
title="按住鼠标拖动画布;双击复位"
>
<div
className="flex min-h-full min-w-max flex-col items-center p-5"
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
>
<CompactChainNode
title="情报分析智能体"
description="情报分析岗"
accent="#3b82f6"
nodeStatus={nodes.length > 0 ? "done" : "ready"}
width={nodeWidth}
/>
{stages.map((stage, stageIndex) => {
const stageWidth = Math.max(
nodeWidth,
stage.seats.length * (nodeWidth + nodeGap) - nodeGap,
);
return (
<div
key={`${stage.goal ?? "stage"}-${stageIndex}`}
className="flex flex-col items-center"
>
<VerticalFlowConnector
seatCount={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
/>
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
{stage.seats.map((seat, seatIndex) => {
const node = nodes.find(
(item) =>
item.stageIndex === stageIndex &&
item.seatIndex === seatIndex &&
item.agentId === seat.agent_id,
);
return (
<CompactChainNode
key={seat.agent_id}
title={desensitize(seat.name || seat.agent_id)}
description={positionNameById(seat.position_id)}
accent={
FLOW_NODE_COLORS[
(stageIndex + seatIndex) % FLOW_NODE_COLORS.length
]!
}
selected={node?.nodeKey === activeNodeKey}
nodeStatus={node?.status}
onClick={node ? () => onSelectNode(node) : undefined}
width={nodeWidth}
/>
);
})}
</div>
</div>
);
})}
<VerticalFlowConnector seatCount={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
<CompactChainNode
title="方案总结智能体"
description="内置总结节点"
accent="#6366f1"
nodeStatus={allDone ? "ready" : "locked"}
width={nodeWidth}
titleAttr="待接入:对所有有效交付产物进行方案总结"
/>
<VerticalFlowConnector seatCount={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
<CompactChainNode
title="行动规划智能体"
description="内置规划节点"
accent="#0ea5e9"
nodeStatus="locked"
width={nodeWidth}
titleAttr="待接入:基于方案总结和岗位产物拆解子任务"
/>
</div>
</div>
);
}
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 <span className="h-7 w-px bg-slate-300 dark:bg-[#2b8ffe]/55" />;
}
@@ -1182,14 +1361,14 @@ function VerticalFlowConnector({
<span className="absolute left-1/2 top-0 h-3 w-px -translate-x-1/2 bg-slate-300 dark:bg-[#2b8ffe]/55" />
<span
className="absolute top-3 h-px bg-slate-300 dark:bg-[#2b8ffe]/55"
style={{ left: FLOW_NODE_CENTER, right: FLOW_NODE_CENTER }}
style={{ left: nodeCenter, right: nodeCenter }}
/>
{Array.from({ length: seatCount }).map((_, index) => (
<span
key={index}
className="absolute top-3 bottom-0 w-px -translate-x-1/2 bg-slate-300 dark:bg-[#2b8ffe]/55"
style={{
left: `${((index * (FLOW_NODE_WIDTH + FLOW_NODE_GAP) + FLOW_NODE_CENTER) / width) * 100}%`,
left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`,
}}
/>
))}
@@ -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 (
<article
onClick={onClick}
@@ -1221,23 +1404,30 @@ function CompactChainNode({
if (onClick) event.stopPropagation();
}}
className={cn(
"w-[158px] rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]",
"relative rounded-lg border border-slate-200 bg-white p-3 pr-9 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]",
onClick && "cursor-pointer transition hover:-translate-y-0.5 hover:shadow-md",
selected && "ring-2 ring-primary/50 dark:ring-[#2b8ffe]",
nodeStatus === "locked" && "opacity-65",
)}
style={{ borderLeftWidth: 5, borderLeftColor: accent }}
style={{ width, borderLeftWidth: 5, borderLeftColor: accent }}
title={titleAttr}
>
<div className="flex items-start gap-2">
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-slate-50 shadow-sm dark:bg-[#042b59]">
{getAvatar(avatarType, "h-7 w-7")}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-bold text-slate-900 dark:text-[#e8edf5]">{title}</p>
<p className="mt-0.5 truncate text-[9px] font-medium leading-4" style={{ color: accent }}>
{description}
</p>
</div>
<span
className={cn(
"absolute right-2 top-2 flex size-5 items-center justify-center rounded-full shadow-sm",
status.iconClassName,
)}
title={status.label}
>
<StatusIcon className={cn("size-3", status.spin && "animate-spin")} strokeWidth={2.5} />
</span>
<div className="min-w-0">
<p className="truncate text-[12px] font-extrabold leading-5 text-slate-900 dark:text-[#e8edf5]">
{title}
</p>
<p className="mt-0.5 truncate text-[10px] font-semibold leading-4" style={{ color: accent }}>
{description}
</p>
</div>
</article>
);
@@ -1301,7 +1491,7 @@ function RightWorkspace({
)}
>
<FileOutput className="size-3.5" />
分析结果
交付产物
</button>
</div>
</div>
@@ -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 (
<div className="flex h-full min-h-[260px] flex-col items-center justify-center rounded-xl border border-dashed p-6 text-center">
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
<p className="text-sm font-semibold">暂无分析结果</p>
<p className="text-sm font-semibold">暂无交付产物</p>
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
情报分析岗完成意图识别后,产物会先进入这里;后续岗位的问答结果也会沿用同一张卡片追加入列。
情报分析岗启动业务链后,各岗位待交付产物会在这里排队展示。
</p>
</div>
);
@@ -1651,8 +1867,8 @@ function AnalysisResults({
<div className="space-y-3 rounded-xl border border-slate-200 p-3.5 dark:border-border">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-bold">分析结果列表</h3>
<p className="text-[11px] text-muted-foreground">按生成时间倒序</p>
<h3 className="text-sm font-bold">交付产物列表</h3>
<p className="text-[11px] text-muted-foreground">按岗位节点与生成时间展示</p>
</div>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-600">
{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 = (
<>
<div className="flex items-start gap-3">
@@ -1707,6 +1925,20 @@ function AnalysisResults({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-bold">{desensitize(item.agentName)}</p>
{status && StatusIcon && (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold",
status.badgeClassName,
)}
>
<StatusIcon
className={cn("size-2.5", status.spin && "animate-spin")}
strokeWidth={2.5}
/>
{status.label}
</span>
)}
{stale && (
<span className="shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700 dark:text-amber-300">
上游已更新
@@ -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,
@@ -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={})