ai-chatbot/components/canvas/nodes/AgentSlotNode.tsx

352 lines
12 KiB
TypeScript

"use client";
import {
FileText,
ImagePlus,
Layers,
MonitorSmartphone,
MousePointer2,
PencilLine,
Settings2,
Sparkles,
Video,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import type { AgentSlotNodeData, CanvasNode } from "@/types/canvas";
import { cn } from "@/lib/utils";
interface AgentSlotNodeProps {
node: CanvasNode;
isSelected: boolean;
onDataChange?: (updates: Partial<AgentSlotNodeData>) => void;
}
type SlotType = AgentSlotNodeData["slotType"];
type SlotFillPolicy = NonNullable<AgentSlotNodeData["fillPolicy"]>;
type SlotAcceptedSource = NonNullable<AgentSlotNodeData["acceptedSources"]>[number];
const SLOT_TYPE_OPTIONS: Array<{
value: SlotType;
label: string;
icon: typeof ImagePlus;
}> = [
{ value: "image", label: "图片槽位", icon: ImagePlus },
{ value: "video", label: "视频槽位", icon: Video },
{ value: "ui-screen", label: "UI 槽位", icon: MonitorSmartphone },
];
const FILL_POLICY_OPTIONS: Array<{
value: SlotFillPolicy;
label: string;
}> = [
{ value: "replace-empty", label: "填充空槽" },
{ value: "append-version", label: "追加版本" },
{ value: "replace-existing", label: "允许替换" },
];
const ACCEPTED_SOURCE_OPTIONS: Array<{
value: SlotAcceptedSource;
label: string;
icon: typeof FileText;
}> = [
{ value: "text", label: "文本", icon: FileText },
{ value: "image", label: "图片", icon: ImagePlus },
{ value: "selection", label: "选中对象", icon: MousePointer2 },
{ value: "annotation", label: "标注", icon: PencilLine },
];
const ASPECT_RATIO_OPTIONS = ["1:1", "4:3", "3:4", "16:9", "9:16"];
function getSlotIcon(slotType: AgentSlotNodeData["slotType"]) {
if (slotType === "video") return Video;
if (slotType === "ui-screen") return MonitorSmartphone;
return ImagePlus;
}
function getSlotTypeLabel(slotType: AgentSlotNodeData["slotType"]) {
if (slotType === "video") return "视频槽位";
if (slotType === "ui-screen") return "UI 槽位";
return "图片槽位";
}
function getStatusLabel(status: AgentSlotNodeData["status"]) {
if (status === "generating") return "生成中";
if (status === "completed") return "已填充";
if (status === "failed") return "失败";
return "等待生成";
}
function getFillPolicyLabel(fillPolicy: AgentSlotNodeData["fillPolicy"]) {
if (fillPolicy === "append-version") return "追加版本";
if (fillPolicy === "replace-existing") return "允许替换";
return "填充空槽";
}
function normalizeAcceptedSources(
acceptedSources: AgentSlotNodeData["acceptedSources"]
): SlotAcceptedSource[] {
const normalized = (Array.isArray(acceptedSources) ? acceptedSources : [])
.filter((value): value is SlotAcceptedSource =>
ACCEPTED_SOURCE_OPTIONS.some((option) => option.value === value)
);
if (normalized.length > 0) {
return Array.from(new Set(normalized));
}
return ACCEPTED_SOURCE_OPTIONS.map((option) => option.value);
}
function getAcceptedSourcesLabel(acceptedSources: SlotAcceptedSource[]) {
return ACCEPTED_SOURCE_OPTIONS.filter((option) =>
acceptedSources.includes(option.value)
)
.map((option) => option.label)
.join("、");
}
function isSlotType(value: string): value is SlotType {
return SLOT_TYPE_OPTIONS.some((option) => option.value === value);
}
function isSlotFillPolicy(value: string): value is SlotFillPolicy {
return FILL_POLICY_OPTIONS.some((option) => option.value === value);
}
export function AgentSlotNode({
node,
isSelected,
onDataChange,
}: AgentSlotNodeProps) {
const data = node.data as AgentSlotNodeData;
const slotType = data.slotType || "image";
const acceptedSources = normalizeAcceptedSources(data.acceptedSources);
const SlotIcon = getSlotIcon(slotType);
const filledCount = Array.isArray(data.filledNodeIds)
? data.filledNodeIds.length
: 0;
const fillPolicy = data.fillPolicy || "replace-empty";
const aspectRatio = data.aspectRatio || "1:1";
const canEdit = Boolean(onDataChange);
const updateSlotData = (updates: Partial<AgentSlotNodeData>) => {
if (!onDataChange) return;
onDataChange({
...updates,
metadata: {
...(data.metadata || {}),
...(updates.metadata || {}),
agentSlotConfiguredAt: new Date().toISOString(),
},
});
};
const toggleAcceptedSource = (
source: SlotAcceptedSource,
checked: boolean
) => {
const next = checked
? Array.from(new Set([...acceptedSources, source]))
: acceptedSources.filter((value) => value !== source);
if (next.length === 0) return;
updateSlotData({ acceptedSources: next });
};
return (
<div
className={cn(
"relative flex h-full w-full flex-col overflow-hidden rounded-none border-2 border-dashed bg-white/92 text-slate-700 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition-all dark:bg-zinc-950/92 dark:text-slate-100",
isSelected
? "border-[#5b76fe] ring-4 ring-[#5b76fe]/12"
: "border-[#b8c3d8] hover:border-[#8ea2d6] dark:border-zinc-700",
data.status === "generating" && "border-[#7c9cff] bg-[#f7f9ff]",
data.status === "failed" && "border-red-300 bg-red-50 text-red-900"
)}
data-agent-slot-node="true"
data-slot-type={slotType}
>
{canEdit ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="配置 Agent 槽位"
className={cn(
"absolute top-2 right-2 z-10 flex h-8 w-8 items-center justify-center border border-[#dbe2ee] bg-white/95 text-slate-500 shadow-sm transition hover:border-[#a9b8df] hover:text-[#5b76fe] dark:border-zinc-700 dark:bg-zinc-900 dark:text-slate-300",
isSelected
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
)}
data-canvas-no-drag="true"
title="配置 Agent 槽位"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Settings2 className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-72"
data-canvas-no-drag="true"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
sideOffset={8}
>
<div className="px-2 py-1.5">
<label
className="mb-1 block font-medium text-[12px] text-muted-foreground"
htmlFor={`agent-slot-label-${node.id}`}
>
槽位名称
</label>
<Input
id={`agent-slot-label-${node.id}`}
className="h-8 text-[13px]"
value={data.label || ""}
placeholder={getSlotTypeLabel(slotType)}
onChange={(event) =>
updateSlotData({ label: event.target.value })
}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[12px] text-muted-foreground">
槽位类型
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={slotType}
onValueChange={(value) => {
if (!isSlotType(value)) return;
updateSlotData({ slotType: value });
}}
>
{SLOT_TYPE_OPTIONS.map((option) => {
const OptionIcon = option.icon;
return (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<OptionIcon className="h-4 w-4" />
{option.label}
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[12px] text-muted-foreground">
画幅
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={aspectRatio}
onValueChange={(value) => {
if (!ASPECT_RATIO_OPTIONS.includes(value)) return;
updateSlotData({ aspectRatio: value });
}}
>
{ASPECT_RATIO_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option} value={option}>
<Layers className="h-4 w-4" />
{option}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[12px] text-muted-foreground">
填充策略
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={fillPolicy}
onValueChange={(value) => {
if (!isSlotFillPolicy(value)) return;
updateSlotData({ fillPolicy: value });
}}
>
{FILL_POLICY_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[12px] text-muted-foreground">
可用来源
</DropdownMenuLabel>
{ACCEPTED_SOURCE_OPTIONS.map((option) => {
const OptionIcon = option.icon;
const checked = acceptedSources.includes(option.value);
return (
<DropdownMenuCheckboxItem
key={option.value}
checked={checked}
disabled={checked && acceptedSources.length === 1}
onCheckedChange={(nextChecked) =>
toggleAcceptedSource(option.value, Boolean(nextChecked))
}
onSelect={(event) => event.preventDefault()}
>
<OptionIcon className="mr-2 h-4 w-4" />
{option.label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-5 py-4 text-center">
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full border border-[#dbe2ee] bg-white text-[#5b76fe] shadow-sm dark:border-zinc-700 dark:bg-zinc-900",
data.status === "generating" && "animate-pulse",
data.status === "failed" && "border-red-200 text-red-600"
)}
>
{data.status === "generating" ? (
<Sparkles className="h-5 w-5" />
) : (
<SlotIcon className="h-5 w-5" />
)}
</div>
<div className="min-w-0 space-y-1">
<div className="truncate font-semibold text-[15px] leading-5">
{data.label || getSlotTypeLabel(slotType)}
</div>
<div className="text-[12px] leading-5 text-slate-500 dark:text-slate-400">
{getStatusLabel(data.status)}
{filledCount > 0 ? ` · ${filledCount} 个结果` : ""}
</div>
</div>
</div>
<div className="flex items-center justify-between border-[#e6ebf5] border-t px-3 py-2 text-[11px] text-slate-500 dark:border-zinc-800 dark:text-slate-400">
<span className="truncate">{aspectRatio}</span>
<span className="min-w-0 flex-1 truncate px-2 text-center">
{getAcceptedSourcesLabel(acceptedSources)}
</span>
<span className="truncate">{getFillPolicyLabel(fillPolicy)}</span>
</div>
</div>
);
}