feat(position-roundtable): add delivery workflow scaffolding
This commit is contained in:
1 parent
97ce19973d
commit
2ca8a23102
4 files changed
+416
-129
No files matched your search
@@ -13,6 +13,7 @@ export type PositionNodeStatus =
|
|||||||
| "running"
|
| "running"
|
||||||
| "done"
|
| "done"
|
||||||
| "stale"
|
| "stale"
|
||||||
|
| "rejected"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
export interface PositionRoundtableNode {
|
export interface PositionRoundtableNode {
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Archive,
|
Archive,
|
||||||
Bot,
|
Bot,
|
||||||
BriefcaseBusiness,
|
BriefcaseBusiness,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Circle,
|
||||||
|
CircleDashed,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
Eye,
|
||||||
FileOutput,
|
FileOutput,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Layers3,
|
Layers3,
|
||||||
@@ -20,6 +24,8 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
UserRoundCog,
|
UserRoundCog,
|
||||||
|
XCircle,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button as TButton,
|
Button as TButton,
|
||||||
@@ -31,6 +37,12 @@ import { toast } from "sonner";
|
|||||||
import { agentColor } from "@/components/workspace/agent-icon-color";
|
import { agentColor } from "@/components/workspace/agent-icon-color";
|
||||||
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
|
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
|
||||||
import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-module-sidebar";
|
import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-module-sidebar";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useAgent } from "@/core/agents";
|
import { useAgent } from "@/core/agents";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent } from "@/core/agents";
|
||||||
@@ -75,6 +87,7 @@ import {
|
|||||||
*/
|
*/
|
||||||
const POSITIONS = POSITION_OPTIONS;
|
const POSITIONS = POSITION_OPTIONS;
|
||||||
type WorkspaceTab = "agents" | "results";
|
type WorkspaceTab = "agents" | "results";
|
||||||
|
type NodeVisualStatus = PositionRoundtableNode["status"] | undefined;
|
||||||
|
|
||||||
function uniqueChains(chains: BusinessChain[]): BusinessChain[] {
|
function uniqueChains(chains: BusinessChain[]): BusinessChain[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -95,6 +108,72 @@ function timeLabel(): string {
|
|||||||
}).format(new Date());
|
}).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 {
|
function parseIntentSummary(snapshot: Record<string, unknown> | null | undefined): IntentSummary | null {
|
||||||
if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) {
|
if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -355,6 +434,15 @@ export default function PositionRoundtablePage() {
|
|||||||
toast.warning("请先选择业务链条");
|
toast.warning("请先选择业务链条");
|
||||||
return;
|
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());
|
const unassignedSeats = selectedChain.seats.filter((seat) => !seat.position_id?.trim());
|
||||||
if (unassignedSeats.length > 0) {
|
if (unassignedSeats.length > 0) {
|
||||||
toast.warning(`该业务链还有 ${unassignedSeats.length} 个席位未配置岗位,请点击齿轮完成配置`);
|
toast.warning(`该业务链还有 ${unassignedSeats.length} 个席位未配置岗位,请点击齿轮完成配置`);
|
||||||
@@ -1016,7 +1104,6 @@ function chainStages(chain: BusinessChain) {
|
|||||||
const FLOW_NODE_COLORS = ["#3b82f6", "#a855f7", "#10b981", "#f59e0b", "#ec4899"];
|
const FLOW_NODE_COLORS = ["#3b82f6", "#a855f7", "#10b981", "#f59e0b", "#ec4899"];
|
||||||
const FLOW_NODE_WIDTH = 158;
|
const FLOW_NODE_WIDTH = 158;
|
||||||
const FLOW_NODE_GAP = 10;
|
const FLOW_NODE_GAP = 10;
|
||||||
const FLOW_NODE_CENTER = FLOW_NODE_WIDTH / 2;
|
|
||||||
|
|
||||||
/** 以业务链配置页的节点卡片语义绘制窄栏版,不显示连线依赖说明。 */
|
/** 以业务链配置页的节点卡片语义绘制窄栏版,不显示连线依赖说明。 */
|
||||||
function BusinessChainFlow({
|
function BusinessChainFlow({
|
||||||
@@ -1030,7 +1117,88 @@ function BusinessChainFlow({
|
|||||||
activeNodeKey: string | null;
|
activeNodeKey: string | null;
|
||||||
onSelectNode: (node: PositionRoundtableNode) => void;
|
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 [view, setView] = useState({ tx: 0, ty: 0 });
|
||||||
const [panning, setPanning] = useState(false);
|
const [panning, setPanning] = useState(false);
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
@@ -1039,7 +1207,9 @@ function BusinessChainFlow({
|
|||||||
tx: number;
|
tx: number;
|
||||||
ty: number;
|
ty: number;
|
||||||
} | null>(null);
|
} | 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>) => {
|
const onPanStart = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
@@ -1074,105 +1244,114 @@ function BusinessChainFlow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
||||||
<div className="mb-3 flex items-center gap-2">
|
onPointerDown={onPanStart}
|
||||||
<span className="flex size-5 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
onPointerMove={onPanMove}
|
||||||
<Layers3 className="size-3" strokeWidth={2.5} />
|
onPointerUp={onPanEnd}
|
||||||
</span>
|
onPointerCancel={onPanEnd}
|
||||||
<h2 className="text-sm font-semibold tracking-wide text-slate-900 dark:text-[#e8edf5]">
|
onDoubleClick={() => setView({ tx: 0, ty: 0 })}
|
||||||
业务链条
|
className={cn(
|
||||||
</h2>
|
"overflow-hidden rounded-xl border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75",
|
||||||
</div>
|
panning ? "cursor-grabbing" : "cursor-grab",
|
||||||
{!chain ? (
|
className,
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</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({
|
function VerticalFlowConnector({
|
||||||
seatCount,
|
seatCount,
|
||||||
width,
|
width,
|
||||||
|
nodeWidth = FLOW_NODE_WIDTH,
|
||||||
|
nodeGap = FLOW_NODE_GAP,
|
||||||
}: {
|
}: {
|
||||||
seatCount: number;
|
seatCount: number;
|
||||||
width: number;
|
width: number;
|
||||||
|
nodeWidth?: number;
|
||||||
|
nodeGap?: number;
|
||||||
}) {
|
}) {
|
||||||
|
const nodeCenter = nodeWidth / 2;
|
||||||
if (seatCount <= 1) {
|
if (seatCount <= 1) {
|
||||||
return <span className="h-7 w-px bg-slate-300 dark:bg-[#2b8ffe]/55" />;
|
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 left-1/2 top-0 h-3 w-px -translate-x-1/2 bg-slate-300 dark:bg-[#2b8ffe]/55" />
|
||||||
<span
|
<span
|
||||||
className="absolute top-3 h-px bg-slate-300 dark:bg-[#2b8ffe]/55"
|
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) => (
|
{Array.from({ length: seatCount }).map((_, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="absolute top-3 bottom-0 w-px -translate-x-1/2 bg-slate-300 dark:bg-[#2b8ffe]/55"
|
className="absolute top-3 bottom-0 w-px -translate-x-1/2 bg-slate-300 dark:bg-[#2b8ffe]/55"
|
||||||
style={{
|
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,
|
title,
|
||||||
description,
|
description,
|
||||||
accent,
|
accent,
|
||||||
avatarType,
|
|
||||||
selected = false,
|
selected = false,
|
||||||
nodeStatus,
|
nodeStatus,
|
||||||
onClick,
|
onClick,
|
||||||
|
width = FLOW_NODE_WIDTH,
|
||||||
|
titleAttr,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
avatarType: string;
|
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
nodeStatus?: PositionRoundtableNode["status"];
|
nodeStatus?: PositionRoundtableNode["status"];
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
width?: number;
|
||||||
|
titleAttr?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const status = nodeStatusMeta(nodeStatus);
|
||||||
|
const StatusIcon = status.Icon;
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -1221,23 +1404,30 @@ function CompactChainNode({
|
|||||||
if (onClick) event.stopPropagation();
|
if (onClick) event.stopPropagation();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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",
|
onClick && "cursor-pointer transition hover:-translate-y-0.5 hover:shadow-md",
|
||||||
selected && "ring-2 ring-primary/50 dark:ring-[#2b8ffe]",
|
selected && "ring-2 ring-primary/50 dark:ring-[#2b8ffe]",
|
||||||
nodeStatus === "locked" && "opacity-65",
|
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
|
||||||
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-slate-50 shadow-sm dark:bg-[#042b59]">
|
className={cn(
|
||||||
{getAvatar(avatarType, "h-7 w-7")}
|
"absolute right-2 top-2 flex size-5 items-center justify-center rounded-full shadow-sm",
|
||||||
</span>
|
status.iconClassName,
|
||||||
<div className="min-w-0 flex-1">
|
)}
|
||||||
<p className="truncate text-[11px] font-bold text-slate-900 dark:text-[#e8edf5]">{title}</p>
|
title={status.label}
|
||||||
<p className="mt-0.5 truncate text-[9px] font-medium leading-4" style={{ color: accent }}>
|
>
|
||||||
{description}
|
<StatusIcon className={cn("size-3", status.spin && "animate-spin")} strokeWidth={2.5} />
|
||||||
</p>
|
</span>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -1301,7 +1491,7 @@ function RightWorkspace({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FileOutput className="size-3.5" />
|
<FileOutput className="size-3.5" />
|
||||||
分析结果
|
交付产物
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1529,7 +1719,7 @@ function AnalysisResults({
|
|||||||
chain: BusinessChain | null;
|
chain: BusinessChain | null;
|
||||||
onSelectNode: (node: PositionRoundtableNode) => void;
|
onSelectNode: (node: PositionRoundtableNode) => void;
|
||||||
}) {
|
}) {
|
||||||
type ResultKind = "intent" | "answer" | "artifact";
|
type ResultKind = "intent" | "delivery" | "artifact";
|
||||||
interface ResultItem {
|
interface ResultItem {
|
||||||
id: string;
|
id: string;
|
||||||
kind: ResultKind;
|
kind: ResultKind;
|
||||||
@@ -1539,6 +1729,7 @@ function AnalysisResults({
|
|||||||
summary: string;
|
summary: string;
|
||||||
positionId: string | null;
|
positionId: string | null;
|
||||||
updatedAt: string | undefined;
|
updatedAt: string | undefined;
|
||||||
|
status?: NodeVisualStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [positionFilter, setPositionFilter] = useState("all");
|
const [positionFilter, setPositionFilter] = useState("all");
|
||||||
@@ -1560,23 +1751,47 @@ function AnalysisResults({
|
|||||||
summary: intentObjective,
|
summary: intentObjective,
|
||||||
positionId: DEFAULT_POSITION_ID,
|
positionId: DEFAULT_POSITION_ID,
|
||||||
updatedAt: undefined,
|
updatedAt: undefined,
|
||||||
|
status: "done",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const seat = seatsByAgentId.get(node.agentId);
|
const seat = seatsByAgentId.get(node.agentId);
|
||||||
const agentName = seat?.name ?? node.agentId;
|
const agentName = seat?.name ?? node.agentId;
|
||||||
if (node.latestAnswer) {
|
const artifactCount = node.artifactManifest.length;
|
||||||
items.push({
|
const productName =
|
||||||
id: `${node.nodeKey}:answer:${node.revision}`,
|
node.status === "done"
|
||||||
kind: "answer",
|
? artifactCount > 0
|
||||||
node,
|
? `已交付 ${artifactCount} 个产物`
|
||||||
agentName,
|
: "问答结果"
|
||||||
productName: "问答结果",
|
: node.status === "running"
|
||||||
summary: node.latestAnswer,
|
? "交付生成中"
|
||||||
positionId: node.positionId,
|
: node.status === "stale"
|
||||||
updatedAt: node.updatedAt,
|
? "产物需重做"
|
||||||
});
|
: 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) => {
|
node.artifactManifest.forEach((artifact, index) => {
|
||||||
const path = typeof artifact.path === "string" ? artifact.path : "";
|
const path = typeof artifact.path === "string" ? artifact.path : "";
|
||||||
const name =
|
const name =
|
||||||
@@ -1598,6 +1813,7 @@ function AnalysisResults({
|
|||||||
summary: `${mimeType}${node.status === "stale" ? " · 上游已更新,请基于最新结果复核" : ""}`,
|
summary: `${mimeType}${node.status === "stale" ? " · 上游已更新,请基于最新结果复核" : ""}`,
|
||||||
positionId: node.positionId,
|
positionId: node.positionId,
|
||||||
updatedAt: node.updatedAt,
|
updatedAt: node.updatedAt,
|
||||||
|
status: node.status,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1639,9 +1855,9 @@ function AnalysisResults({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[260px] flex-col items-center justify-center rounded-xl border border-dashed p-6 text-center">
|
<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" />
|
<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 className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
|
||||||
情报分析岗完成意图识别后,产物会先进入这里;后续岗位的问答结果也会沿用同一张卡片追加入列。
|
情报分析岗启动业务链后,各岗位待交付产物会在这里排队展示。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="space-y-3 rounded-xl border border-slate-200 p-3.5 dark:border-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold">分析结果列表</h3>
|
<h3 className="text-sm font-bold">交付产物列表</h3>
|
||||||
<p className="text-[11px] text-muted-foreground">按生成时间倒序</p>
|
<p className="text-[11px] text-muted-foreground">按岗位节点与生成时间展示</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-600">
|
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-600">
|
||||||
{visibleItems.length}/{resultItems.length} 项结果
|
{visibleItems.length}/{resultItems.length} 项结果
|
||||||
@@ -1667,7 +1883,7 @@ function AnalysisResults({
|
|||||||
options={[
|
options={[
|
||||||
{ label: "全部类型", value: "all" },
|
{ label: "全部类型", value: "all" },
|
||||||
{ label: "意图理解", value: "intent" },
|
{ label: "意图理解", value: "intent" },
|
||||||
{ label: "问答结果", value: "answer" },
|
{ label: "节点交付", value: "delivery" },
|
||||||
{ label: "文件产物", value: "artifact" },
|
{ label: "文件产物", value: "artifact" },
|
||||||
]}
|
]}
|
||||||
onChange={(value) => setKindFilter(String(value) as "all" | ResultKind)}
|
onChange={(value) => setKindFilter(String(value) as "all" | ResultKind)}
|
||||||
@@ -1689,6 +1905,8 @@ function AnalysisResults({
|
|||||||
}).format(new Date(item.updatedAt))
|
}).format(new Date(item.updatedAt))
|
||||||
: intentCompletedAt || "刚刚";
|
: intentCompletedAt || "刚刚";
|
||||||
const stale = item.node?.status === "stale";
|
const stale = item.node?.status === "stale";
|
||||||
|
const status = item.status ? nodeStatusMeta(item.status) : null;
|
||||||
|
const StatusIcon = status?.Icon;
|
||||||
const contents = (
|
const contents = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -1707,6 +1925,20 @@ function AnalysisResults({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="truncate text-sm font-bold">{desensitize(item.agentName)}</p>
|
<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 && (
|
{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">
|
<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"])
|
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
|
_HANDOFF_TEXT_BUDGET = 12_000
|
||||||
_MAX_INLINE_ARTIFACTS_PER_NODE = 4
|
_MAX_INLINE_ARTIFACTS_PER_NODE = 4
|
||||||
_MAX_INLINE_ARTIFACT_CHARS = 1_800
|
_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 []])
|
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 []
|
seats = chain.get("seats") if isinstance(chain.get("seats"), list) else []
|
||||||
if not seats:
|
if not seats:
|
||||||
raise HTTPException(status_code=400, detail="业务链条没有可执行的智能体席位")
|
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)
|
seat = seat_by_agent_id.get(agent_id)
|
||||||
if seat is None:
|
if seat is None:
|
||||||
raise HTTPException(status_code=400, detail=f"业务链条阶段包含不存在的席位: {agent_id}")
|
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")
|
position_id = seat.get("position_id")
|
||||||
if not isinstance(position_id, str) or not position_id.strip():
|
if not isinstance(position_id, str) or not position_id.strip():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -194,6 +203,23 @@ def _chain_nodes(chain: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
return result
|
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:
|
def _truncate_text(value: Any, limit: int) -> str:
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
return ""
|
return ""
|
||||||
@@ -435,7 +461,15 @@ async def activate_session(
|
|||||||
)
|
)
|
||||||
if chain is None:
|
if chain is None:
|
||||||
raise HTTPException(status_code=404, detail="业务链条不存在或无权访问")
|
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(
|
activated = await store.activate_session(
|
||||||
session_id,
|
session_id,
|
||||||
user_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]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
|
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={})
|
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
|
||||||
|
|||||||
Reference in new issue
Block a user