ai-chatbot/components/canvas/nodes/WorkflowNode.tsx
Your Name be5b9aef12
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
chore: sync latest workspace changes
2026-04-05 02:08:26 +08:00

548 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
Bot,
Clapperboard,
FileOutput,
Film,
LayoutPanelTop,
ScanSearch,
Sparkles,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
getVideoWorkflowNodeDefinition,
getVideoWorkflowQuickConnectOutputs,
} from "@/lib/video-workflow/catalog";
import type { CanvasNode, WorkflowNodeData } from "@/types/canvas";
interface WorkflowNodeProps {
node: CanvasNode;
isSelected: boolean;
}
function toAlphaColor(color: string | undefined, alpha: number) {
const normalized = String(color || "").trim();
if (!normalized.startsWith("#")) return undefined;
const hex = normalized.slice(1);
if (hex.length !== 3 && hex.length !== 6) return undefined;
const full = hex.length === 3 ? hex.split("").map((char) => char + char).join("") : hex;
const int = Number.parseInt(full, 16);
if (Number.isNaN(int)) return undefined;
const red = (int >> 16) & 255;
const green = (int >> 8) & 255;
const blue = int & 255;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
const GROUP_META = {
input: {
label: "输入",
icon: LayoutPanelTop,
},
analysis: {
label: "分析",
icon: ScanSearch,
},
storyboard: {
label: "分镜",
icon: Film,
},
generation: {
label: "生成",
icon: Sparkles,
},
character: {
label: "角色场景",
icon: Clapperboard,
},
tool: {
label: "工具",
icon: Bot,
},
output: {
label: "输出",
icon: FileOutput,
},
} as const;
const STATUS_LABELS = {
pending: "待配置",
generating: "处理中",
completed: "已完成",
failed: "异常",
} as const;
export function WorkflowNode({ node, isSelected }: WorkflowNodeProps) {
const data = node.data as WorkflowNodeData;
const definition = getVideoWorkflowNodeDefinition(data.templateType);
const metadata = (data.metadata || {}) as Record<string, any>;
const quickConnectOutputs =
data.templateType && definition
? getVideoWorkflowQuickConnectOutputs(definition.type)
: [];
const resolvedGroup = (data.group || definition?.group || "tool") as keyof typeof GROUP_META;
const groupMeta = GROUP_META[resolvedGroup] || GROUP_META.tool;
const GroupIcon = groupMeta.icon;
const accentColor = data.accentColor || definition?.accentColor || "#e2e8f0";
const title = data.title || definition?.label || "工作流节点";
const description = data.description || definition?.description || "等待补充节点配置。";
const previewUrl =
String(metadata.previewMediaUrl || metadata.resultMediaUrl || "").trim();
const previewKind =
String(metadata.previewMediaKind || metadata.resultMediaKind || "").trim() === "video"
? "video"
: "image";
const storyboardShots = Array.isArray(metadata.storyboardShots)
? metadata.storyboardShots.slice(0, 4)
: [];
const analysisHighlights = Array.isArray(metadata.analysisHighlights)
? metadata.analysisHighlights.slice(0, 4)
: [];
const compareItems = Array.isArray(metadata.compareItems)
? metadata.compareItems.slice(0, 4)
: [];
const tableColumns = Array.isArray(metadata.tableColumns)
? metadata.tableColumns.slice(0, 4)
: [];
const tableRows = Array.isArray(metadata.tableRows)
? metadata.tableRows.slice(0, 4)
: [];
const characters = Array.isArray(metadata.characters)
? metadata.characters.slice(0, 3)
: [];
const scenes = Array.isArray(metadata.scenes) ? metadata.scenes.slice(0, 3) : [];
const characterAssets = Array.isArray(metadata.characterAssets)
? metadata.characterAssets.slice(0, 3)
: [];
const sceneAssets = Array.isArray(metadata.sceneAssets)
? metadata.sceneAssets.slice(0, 3)
: [];
const agentPlan = Array.isArray(metadata.agentPlan)
? metadata.agentPlan.slice(0, 4)
: [];
const deliverables = Array.isArray(metadata.deliverables)
? metadata.deliverables.slice(0, 4)
: [];
const linkedAgentProjectId = String(metadata.linkedAgentProjectId || "").trim();
const linkedAgentProjectTitle = String(metadata.linkedAgentProjectTitle || "").trim();
const importedAgentResultCount = Math.max(Number(metadata.importedAgentResultCount || 0), 0);
const pendingAgentResultCount = Math.max(Number(metadata.pendingAgentResultCount || 0), 0);
const agentResultGroups = Array.isArray(metadata.agentResultGroups)
? metadata.agentResultGroups.slice(0, 4)
: [];
const batchShotCount = Math.max(
Number(metadata.batchShotCount || 0),
storyboardShots.length
);
const batchResultCount = Number(metadata.batchResultCount || 0);
const batchFailedCount = Number(metadata.batchFailedCount || 0);
const batchMode =
String(metadata.batchMode || "").trim() === "gen-video" ? "视频批量" : "图片批量";
const accentGlow = toAlphaColor(accentColor, 0.18) || "rgba(59, 130, 246, 0.12)";
const accentLine = toAlphaColor(accentColor, 0.3) || "rgba(59, 130, 246, 0.2)";
const sectionClassName =
"rounded-2xl border border-border/60 bg-background/74 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.3)]";
const mutedKickerClassName =
"text-[11px] uppercase tracking-[0.18em] text-muted-foreground/80";
const itemClassName = "rounded-xl border border-border/50 bg-background/88 px-3 py-2";
const badgeClassName = "border-border/60 bg-background/82 text-foreground/80";
return (
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-[28px] border border-border/60 bg-background/95 shadow-[0_22px_55px_-30px_rgba(15,23,42,0.38)] backdrop-blur-xl transition-all",
isSelected &&
"border-primary/55 ring-2 ring-primary/15 shadow-[0_26px_60px_-28px_rgba(59,130,246,0.28)]"
)}
>
<div
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at top left, ${accentGlow} 0%, transparent 42%), linear-gradient(180deg, ${accentLine} 0%, transparent 18%)`,
}}
/>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-border/70" />
<div className="relative flex h-full flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-border/60 bg-background/90 shadow-sm">
<GroupIcon className="h-4 w-4" />
</span>
<span>{groupMeta.label}</span>
</div>
<div>
<h3 className="text-base font-semibold leading-tight text-foreground">{title}</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p>
</div>
</div>
<Badge variant="outline" className={badgeClassName}>
{STATUS_LABELS[data.status] || STATUS_LABELS.pending}
</Badge>
</div>
<div className={sectionClassName}>
<div className={mutedKickerClassName}>节点标识</div>
<div className="mt-1 font-mono text-xs text-foreground/80">{data.templateType}</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{data.summary || "把这个节点接在上游素材之后,用来承接处理或输出。"}
</p>
</div>
{batchShotCount > 0 || batchResultCount > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>批量执行</div>
<div className="mt-2 flex flex-wrap gap-2">
{batchShotCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
镜头 {batchShotCount}
</Badge>
) : null}
{batchResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
结果 {batchResultCount}
</Badge>
) : null}
{batchFailedCount > 0 ? (
<Badge
variant="outline"
className="border-amber-200/70 bg-amber-50/80 text-amber-700"
>
失败 {batchFailedCount}
</Badge>
) : null}
{batchResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
{batchMode}
</Badge>
) : null}
</div>
</div>
) : null}
{previewUrl ? (
<div className="overflow-hidden rounded-2xl border border-border/60 bg-slate-950/95 shadow-[0_16px_40px_-28px_rgba(15,23,42,0.65)]">
{previewKind === "video" ? (
<video
src={previewUrl}
className="h-36 w-full object-cover"
controls
muted
playsInline
/>
) : (
<img
src={previewUrl}
alt={title}
className="h-36 w-full object-cover"
/>
)}
</div>
) : null}
{compareItems.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>对比结果</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{compareItems.map((item, index) => {
const compareItem = (item || {}) as Record<string, any>;
const kind =
String(compareItem.kind || "").trim() === "video" ? "video" : "image";
const url = String(compareItem.url || "").trim();
if (!url) {
return null;
}
return (
<div
key={`${data.templateType}-compare-${index}`}
className="overflow-hidden rounded-2xl border border-border/60 bg-background/88"
>
<div className="border-b border-border/60 px-3 py-2">
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
{String(compareItem.label || `对比 ${index + 1}`)}
</div>
<div className="mt-1 truncate text-xs font-medium text-foreground/90">
{String(compareItem.title || `素材 ${index + 1}`)}
</div>
</div>
{kind === "video" ? (
<video
src={url}
className="h-24 w-full object-cover"
controls
muted
playsInline
/>
) : (
<img
src={url}
alt={String(compareItem.title || `对比素材 ${index + 1}`)}
className="h-24 w-full object-cover"
/>
)}
</div>
);
})}
</div>
</div>
) : null}
{storyboardShots.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>镜头草案</div>
<div className="mt-2 space-y-2">
{storyboardShots.map((item, index) => {
const shot = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-shot-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(shot.title || `镜头 ${index + 1}`)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{String(shot.description || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{tableColumns.length > 0 && tableRows.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>结构化表格</div>
<div className="mt-2 flex flex-wrap gap-2">
{tableColumns.map((item, index) => {
const column = (item || {}) as Record<string, any>;
return (
<Badge
key={`${data.templateType}-column-${index}`}
variant="outline"
className={badgeClassName}
>
{String(column.label || column.key || `列 ${index + 1}`)}
</Badge>
);
})}
</div>
<div className="mt-2 space-y-2">
{tableRows.map((item, index) => {
const row = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-row-${index}`} className={itemClassName}>
{tableColumns.map((columnItem, columnIndex) => {
const column = (columnItem || {}) as Record<string, any>;
const columnKey = String(column.key || `col-${columnIndex}`).trim();
const label = String(column.label || columnKey || `列 ${columnIndex + 1}`).trim();
const value = String(row[columnKey] || "").trim();
if (!value) {
return null;
}
return (
<div
key={`${data.templateType}-row-${index}-column-${columnIndex}`}
className={columnIndex > 0 ? "mt-2" : ""}
>
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
{label}
</div>
<div className="mt-1 text-xs leading-5 text-foreground/85">
{value}
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
) : null}
{analysisHighlights.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>分析摘要</div>
<div className="mt-2 space-y-2">
{analysisHighlights.map((item, index) => (
<div
key={`${data.templateType}-analysis-${index}`}
className={cn(itemClassName, "text-xs leading-5 text-muted-foreground")}
>
{String(item || "")}
</div>
))}
</div>
</div>
) : null}
{characters.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>角色线索</div>
<div className="mt-2 space-y-2">
{characters.map((item, index) => {
const character = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-character-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(character.name || character.title || `角色 ${index + 1}`)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{String(character.description || character.prompt || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{scenes.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>场景线索</div>
<div className="mt-2 space-y-2">
{scenes.map((item, index) => {
const scene = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-scene-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(scene.name || scene.title || `场景 ${index + 1}`)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{String(scene.description || scene.prompt || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{characterAssets.length > 0 || sceneAssets.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>资产沉淀</div>
<div className="mt-2 space-y-2">
{[...characterAssets, ...sceneAssets].slice(0, 4).map((item, index) => {
const asset = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-asset-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(asset.name || asset.title || `资产 ${index + 1}`)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{String(asset.description || asset.prompt || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{agentPlan.length > 0 || deliverables.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>代理计划</div>
<div className="mt-2 space-y-2">
{agentPlan.map((item, index) => (
<div
key={`${data.templateType}-plan-${index}`}
className={cn(itemClassName, "text-xs leading-5 text-muted-foreground")}
>
{String(item || "")}
</div>
))}
{deliverables.map((item, index) => (
<div
key={`${data.templateType}-deliverable-${index}`}
className="rounded-xl border border-dashed border-border/60 bg-background/82 px-3 py-2 text-xs leading-5 text-muted-foreground"
>
交付物: {String(item || "")}
</div>
))}
</div>
</div>
) : null}
{linkedAgentProjectId ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>Agent 工作台</div>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="outline" className={badgeClassName}>
已连接
</Badge>
{importedAgentResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
已回流 {importedAgentResultCount}
</Badge>
) : null}
{pendingAgentResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
待回流 {pendingAgentResultCount}
</Badge>
) : null}
</div>
<div className="mt-2 text-xs font-medium leading-5 text-foreground/90">
{linkedAgentProjectTitle || linkedAgentProjectId}
</div>
</div>
) : null}
{agentResultGroups.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>Agent 结果</div>
<div className="mt-2 space-y-2">
{agentResultGroups.map((item, index) => {
const group = (item || {}) as Record<string, any>;
const label = String(group.label || `结果组 ${index + 1}`).trim();
const count = Math.max(Number(group.count || 0), 0);
const importedCount = Math.max(Number(group.importedCount || 0), 0);
return (
<div
key={`${data.templateType}-agent-group-${index}`}
className={itemClassName}
>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-semibold text-foreground/90">{label}</div>
<div className="text-[11px] text-muted-foreground">
{importedCount}/{count}
</div>
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
共 {count} 个结果,已回流 {importedCount} 个。
</div>
</div>
);
})}
</div>
</div>
) : null}
<div className="mt-auto space-y-2">
<div className={mutedKickerClassName}>下一步推荐</div>
{quickConnectOutputs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{quickConnectOutputs.slice(0, 4).map((item) => (
<span
key={`${data.templateType}-${item.type}`}
className="rounded-full border border-border/60 bg-background/90 px-2.5 py-1 text-xs text-foreground/80 shadow-sm"
>
{item.label}
</span>
))}
</div>
) : (
<div className="rounded-full border border-dashed border-border/70 bg-background/72 px-3 py-1.5 text-xs text-muted-foreground">
当前节点通常作为流程终点或独立处理节点
</div>
)}
</div>
</div>
</div>
);
}