352 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|