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"
|
||||
| "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={})
|
||||
|
||||
Reference in new issue
Block a user