Align LibTV add node modules
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-18 17:06:27 +08:00
parent d348be844b
commit 46cd4a5899

View File

@ -12,16 +12,15 @@ import {
Image as ImageIcon,
Layers,
Loader2,
MousePointer2,
Music2,
Plus,
Play,
Save,
Search,
Scissors,
Settings2,
Share2,
Sparkles,
Trash2,
Upload,
UserRound,
Video,
X,
@ -179,15 +178,8 @@ type ComposerSettings = {
resolution: string;
};
type CanvasCreateKind =
| "image-input"
| "video-input"
| "audio-input"
| "text-input"
| "image-model"
| "video-model"
| "audio-model"
| "text-model";
type AddModuleKind = "text" | "image" | "video" | "video-clip" | "audio" | "script";
type CanvasCreateKind = AddModuleKind;
type LiblibVideoCanvasReplicaProps = {
canvasId: string;
@ -209,7 +201,20 @@ const TOOL_TYPE_META: Record<
compose: { label: "组合", icon: GitBranch, accent: "#ffb8c8" },
};
const PANEL_TABS = ["全部", "图片", "视频", "文本", "音频", "组合"] as const;
const ADD_NODE_MODULES: Array<{
badge?: string;
description: string;
icon: LucideIcon;
kind: AddModuleKind;
label: string;
}> = [
{ kind: "text", label: "文本", description: "剧本、广告词、品牌文案", icon: FileText },
{ kind: "image", label: "图片", description: "海报、分镜、角色设计", icon: ImageIcon },
{ kind: "video", label: "视频", description: "创意广告、动画、电影", icon: Video },
{ kind: "video-clip", label: "视频合成", description: "多个视频片段合为一个", icon: Scissors, badge: "Beta" },
{ kind: "audio", label: "音频", description: "音效、配音、音乐", icon: Music2 },
{ kind: "script", label: "脚本", description: "创意脚本、生成故事板", icon: Layers, badge: "Beta" },
];
const STARTER_CARDS = [
{
@ -645,6 +650,152 @@ function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: numb
};
}
function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index: number): CloneNode {
if (kind === "script") {
return {
id: makeId("script"),
kind: "script",
title: `脚本 ${index}`,
subtitle: "脚本生成",
inputType: "text",
x,
y,
width: 520,
height: 360,
accent: "#f0d06b",
draftValues: {
action: "SCRIPT_GENERATE",
generatorType: "DEFAULT",
nodeType: "script",
rows: [],
scriptRole: "generator",
viewMode: "table",
},
};
}
if (kind === "video-clip") {
return {
id: makeId("video-clip"),
kind: "model",
title: `视频合成 ${index}`,
subtitle: "视频合成",
inputType: "video",
x,
y,
width: 210,
height: 116,
accent: "#ffb8c8",
draftValues: {
action: "VIDEO_CLIP_RESOURCE",
nodeType: "video-clip",
params: {},
url: [],
},
};
}
const meta: Record<Exclude<AddModuleKind, "script" | "video-clip">, {
action: string;
accent: string;
kind: CloneNodeKind;
nodeType: NonNullable<CloneNode["inputType"]>;
subtitle: string;
title: string;
}> = {
audio: {
action: "AUDIO_GENERATE",
accent: "#9ee6bb",
kind: "audio",
nodeType: "audio",
subtitle: "音频生成",
title: `音频节点 ${index}`,
},
image: {
action: "IMAGE_GENERATE",
accent: "#8b96ff",
kind: "model",
nodeType: "image",
subtitle: "图片生成",
title: `图片节点 ${index}`,
},
text: {
action: "TEXT_GENERATE",
accent: "#f0d06b",
kind: "text",
nodeType: "text",
subtitle: "文本生成",
title: `文本节点 ${index}`,
},
video: {
action: "VIDEO_GENERATE",
accent: "#8b96ff",
kind: "model",
nodeType: "video",
subtitle: "视频生成",
title: `视频节点 ${index}`,
},
};
const item = meta[kind];
return {
id: makeId(kind),
kind: item.kind,
title: item.title,
subtitle: item.subtitle,
inputType: item.nodeType,
x,
y,
width: item.nodeType === "text" ? 210 : 194,
height: item.nodeType === "text" ? 122 : 116,
accent: item.accent,
draftValues: {
action: item.action,
alt: item.nodeType === "image" ? `图片 ${index}` : undefined,
content: item.nodeType === "text" ? [] : undefined,
generatorType: "DEFAULT",
nodeType: item.nodeType,
poster: item.nodeType === "video" ? "" : undefined,
url: item.nodeType === "text" ? undefined : [],
},
};
}
function createUploadedMediaNode(file: File, mediaUrl: string, x: number, y: number, index: number): CloneNode {
const mediaType: NonNullable<CloneNode["inputType"]> = file.type.startsWith("video/")
? "video"
: file.type.startsWith("audio/")
? "audio"
: "image";
const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : "图片";
const fallbackName = `${label}节点 ${index}`;
const title = file.name.replace(/\.[^.]+$/, "").trim() || fallbackName;
return {
id: makeId(mediaType),
kind: "media",
title,
subtitle: `${label}资源`,
inputType: mediaType,
x,
y,
width: mediaType === "audio" ? 430 : 420,
height: mediaType === "audio" ? 170 : 280,
accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff",
draftValues: {
action: `${mediaType.toUpperCase()}_RESOURCE`,
alt: file.name,
generated: false,
mediaType,
mediaUrl,
nodeType: mediaType,
resourceMeta: {
items: [{ name: file.name, size: file.size, type: file.type }],
},
url: [mediaUrl],
},
};
}
function getDraftString(node: CloneNode | null | undefined, key: string) {
const value = node?.draftValues?.[key];
return typeof value === "string" ? value : "";
@ -893,8 +1044,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const [draftTitle, setDraftTitle] = useState("未命名");
const [isTitleEditing, setIsTitleEditing] = useState(false);
const [activePanel, setActivePanel] = useState<ActivePanel>(null);
const [toolTab, setToolTab] = useState<(typeof PANEL_TABS)[number]>("全部");
const [toolSearch, setToolSearch] = useState("");
const [isProjectLoading, setIsProjectLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [, setShareEnabled] = useState(false);
@ -912,27 +1061,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
[nodeContextMenu?.nodeId, nodes]
);
const filteredTools = useMemo(() => {
const search = toolSearch.trim().toLowerCase();
return capturedTools.filter((tool) => {
const type = getToolType(tool);
const tabMatched =
toolTab === "全部" ||
(toolTab === "图片" && type === "image") ||
(toolTab === "视频" && type === "video") ||
(toolTab === "文本" && type === "text") ||
(toolTab === "音频" && type === "audio") ||
(toolTab === "组合" && type === "compose");
if (!tabMatched) return false;
if (!search) return true;
return [tool.modelName, tool.vendor, tool.scene, tool.modelKey, tool.toolKey, ...(tool.modes || [])]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(search);
});
}, [toolSearch, toolTab]);
useEffect(() => {
let active = true;
@ -1008,30 +1136,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
if (last) setSelectedNodeId(last.id);
}, []);
const addToolFlow = useCallback(
(tool: CapturedTool) => {
const type = getToolType(tool);
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
const origin = { x: 420 + nodes.length * 18, y: 330 + nodes.length * 10 };
const newNodes: CloneNode[] = [];
const newEdges: CloneEdge[] = [];
if (meta.inputType && type !== "text") {
const input = createInputNode(meta.inputType, origin.x, origin.y);
const model = createToolNode(tool, origin.x + 300, origin.y + 18);
newNodes.push(input, model);
newEdges.push({ id: makeId("edge"), source: input.id, target: model.id });
} else {
newNodes.push(createToolNode(tool, origin.x + 120, origin.y + 12));
}
addNodes(newNodes, newEdges);
setActivePanel(null);
toast.success(`已添加 ${getToolLabel(tool)}`);
},
[addNodes, nodes.length]
);
const addWorkflowPreset = useCallback(
(workflow: WorkflowPreset, index = 0) => {
const node = createWorkflowNode(workflow, 440 + index * 24, 350 + index * 18);
@ -1050,6 +1154,57 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
[viewport.x, viewport.y, viewport.zoom]
);
const addTargetModule = useCallback(
(kind: AddModuleKind, point?: { x: number; y: number }) => {
const center = point || toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
const node = {
...created,
x: Math.round(center.x - created.width / 2),
y: Math.round(center.y - created.height / 2),
};
addNodes([node], []);
setActivePanel(null);
setCanvasCommand(null);
toast.success(`已添加 ${node.title}`);
},
[addNodes, nodes.length, toCanvasPoint]
);
const addUploadedFilesAtPoint = useCallback(
(files: File[], center: { x: number; y: number }) => {
if (files.length === 0) return;
const newNodes = files.slice(0, 10).map((file, index) => {
const created = createUploadedMediaNode(file, URL.createObjectURL(file), 0, 0, nodes.length + index + 1);
return {
...created,
x: Math.round(center.x - created.width / 2 + index * 450),
y: Math.round(center.y - created.height / 2),
};
});
addNodes(newNodes, []);
setActivePanel(null);
setCanvasCommand(null);
toast.success(files.length === 1 ? `已添加 ${newNodes[0]?.title || "素材"}` : `已添加 ${newNodes.length} 个素材`);
},
[addNodes, nodes.length]
);
const addUploadedFiles = useCallback(
(files: File[]) => {
addUploadedFilesAtPoint(files, toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2));
},
[addUploadedFilesAtPoint, toCanvasPoint]
);
const addCanvasCommandUploadedFiles = useCallback(
(files: File[]) => {
if (!canvasCommand) return;
addUploadedFilesAtPoint(files, { x: canvasCommand.canvasX, y: canvasCommand.canvasY });
},
[addUploadedFilesAtPoint, canvasCommand]
);
const openCanvasCommand = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return;
@ -1083,26 +1238,17 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const createFreeNode = useCallback(
(kind: CanvasCreateKind) => {
if (!canvasCommand) return;
const createAtPoint = (node: CloneNode) => ({
...node,
x: Math.round(canvasCommand.canvasX - node.width / 2),
y: Math.round(canvasCommand.canvasY - node.height / 2),
});
const type = kind.split("-")[0] as NonNullable<CloneNode["inputType"]>;
const tool = capturedTools.find((item) => getToolType(item) === type);
const node =
kind.endsWith("input")
? createAtPoint(createInputNode(type, canvasCommand.canvasX, canvasCommand.canvasY))
: createAtPoint(
tool
? createToolNode(tool, canvasCommand.canvasX, canvasCommand.canvasY)
: createSimpleNode(`${TOOL_TYPE_META[type]?.label || "工具"}生成`, type === "text" ? "text" : "model", canvasCommand.canvasX, canvasCommand.canvasY)
);
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
const node = {
...created,
x: Math.round(canvasCommand.canvasX - created.width / 2),
y: Math.round(canvasCommand.canvasY - created.height / 2),
};
addNodes([node], []);
setCanvasCommand(null);
toast.success(`已添加 ${node.title}`);
},
[addNodes, canvasCommand]
[addNodes, canvasCommand, nodes.length]
);
const configureNode = useCallback((nodeId: string) => {
@ -1422,10 +1568,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
state={canvasCommand}
onClose={() => setCanvasCommand(null)}
onCreate={createFreeNode}
onOpenAdd={() => {
setActivePanel("add");
onGallerySelect={() => {
setActivePanel("assets");
setCanvasCommand(null);
}}
onUploadFiles={addCanvasCommandUploadedFiles}
/>
) : null}
@ -1475,13 +1622,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
{activePanel === "add" ? (
<AddModulePanel
filteredTools={filteredTools}
search={toolSearch}
tab={toolTab}
onAddTool={addToolFlow}
onAddModule={addTargetModule}
onClose={() => setActivePanel(null)}
onSearchChange={setToolSearch}
onTabChange={setToolTab}
onGallerySelect={() => setActivePanel("assets")}
onUploadFiles={addUploadedFiles}
/>
) : null}
@ -3014,80 +3158,107 @@ function ComposerControl({
function CanvasCommandPalette({
onClose,
onCreate,
onOpenAdd,
onGallerySelect,
onUploadFiles,
state,
}: {
onClose: () => void;
onCreate: (kind: CanvasCreateKind) => void;
onOpenAdd: () => void;
onGallerySelect: () => void;
onUploadFiles: (files: File[]) => void;
state: NonNullable<CanvasCommandState>;
}) {
const items: Array<{ kind: CanvasCreateKind; icon: LucideIcon; label: string; note: string }> = [
{ kind: "image-input", icon: ImageIcon, label: "图片输入", note: "上传参考图/角色图" },
{ kind: "video-input", icon: Video, label: "视频输入", note: "上传视频素材" },
{ kind: "audio-input", icon: Music2, label: "音频输入", note: "上传音乐/语音" },
{ kind: "text-input", icon: FileText, label: "文本输入", note: "脚本/提示词" },
{ kind: "image-model", icon: Sparkles, label: "图片生成", note: "调用图片模型" },
{ kind: "video-model", icon: Play, label: "视频生成", note: "调用视频模型" },
{ kind: "audio-model", icon: Music2, label: "音频生成", note: "调用音频模型" },
{ kind: "text-model", icon: FileText, label: "脚本生成", note: "调用文本模型" },
];
const uploadInputRef = useRef<HTMLInputElement | null>(null);
return (
<div
className="fixed z-[90] w-[270px] rounded-[8px] border border-white/10 bg-[#202020]/98 p-2 text-white shadow-[0_28px_80px_rgba(0,0,0,0.56)] backdrop-blur-[10px]"
className="fixed z-[90] flex w-[240px] flex-col gap-2 rounded-[16px] border border-white/10 bg-[#202020]/96 p-2 text-white shadow-[0_28px_80px_rgba(0,0,0,0.56)] backdrop-blur-[32px]"
data-canvas-overlay="true"
onPointerDown={(event) => event.stopPropagation()}
style={{
left: `min(${state.screenX}px, calc(100vw - 286px))`,
top: `min(${state.screenY}px, calc(100vh - 384px))`,
left: `min(${state.screenX}px, calc(100vw - 256px))`,
top: `min(${state.screenY}px, calc(100vh - 540px))`,
}}
>
<input
accept="image/*,video/*,audio/*"
className="hidden"
multiple
onChange={(event) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) onUploadFiles(files);
event.currentTarget.value = "";
}}
ref={uploadInputRef}
type="file"
/>
<div className="mb-2 flex items-center justify-between px-2 py-1">
<div>
<div className="flex items-center gap-2 text-[13px] font-semibold text-white/88">
<MousePointer2 className="h-4 w-4 text-white/58" />
自由生成节点
</div>
<div className="mt-1 text-[11px] text-white/36">
X {state.canvasX} / Y {state.canvasY}
</div>
</div>
<h4 className="text-[14px] font-medium leading-5 text-white/60">添加节点</h4>
<button className="flex h-7 w-7 items-center justify-center rounded-[8px] text-white/48 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
<X className="h-4 w-4" />
</button>
</div>
<div className="grid gap-1">
{items.map((item) => {
const Icon = item.icon;
return (
<button
className="flex w-full items-center gap-3 rounded-[8px] px-2 py-2 text-left transition-colors hover:bg-white/8"
key={item.kind}
onClick={() => onCreate(item.kind)}
type="button"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border border-white/8 bg-white/7 text-white/72">
<Icon className="h-4 w-4" />
{ADD_NODE_MODULES.map((item) => {
const Icon = item.icon;
return (
<button
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
key={item.kind}
onClick={() => onCreate(item.kind)}
type="button"
>
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
<Icon className="h-5 w-5" />
</span>
<span className="h-full min-w-0 flex-1 overflow-hidden">
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
<span className="flex items-center gap-1.5 text-[14px] font-medium leading-5">
{item.label}
{item.badge ? <span className="rounded-[4px] bg-white/10 px-1.5 py-0.5 text-[10px] leading-3 text-white/50">{item.badge}</span> : null}
</span>
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
{item.description}
</span>
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium text-white/86">{item.label}</span>
<span className="block truncate text-[11px] text-white/36">{item.note}</span>
</span>
</button>
);
})}
</div>
<div className="my-2 h-px bg-white/8" />
</span>
</button>
);
})}
<h4 className="px-2 py-1 text-[14px] font-medium leading-5 text-white/60">添加资源</h4>
<button
className="flex h-9 w-full items-center justify-center gap-2 rounded-[8px] bg-white text-[13px] font-semibold text-black transition hover:bg-white/90"
onClick={onOpenAdd}
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
onClick={() => uploadInputRef.current?.click()}
type="button"
>
<Search className="h-4 w-4" />
打开全部模块
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
<Upload className="h-5 w-5" />
</span>
<span className="h-full min-w-0 flex-1 overflow-hidden">
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
<span className="text-[14px] font-medium leading-5">上传</span>
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
可上传图片、视频、音频文件
</span>
</span>
</span>
</button>
<button
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
onClick={onGallerySelect}
type="button"
>
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
<Layers className="h-5 w-5" />
</span>
<span className="h-full min-w-0 flex-1 overflow-hidden">
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
<span className="text-[14px] font-medium leading-5">从图库选择</span>
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
从历史生成中选择素材
</span>
</span>
</span>
</button>
</div>
);
@ -3221,7 +3392,8 @@ function CloneCanvasNode({
);
}
const Icon = node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon;
const nodeType = getDraftString(node, "nodeType");
const Icon = nodeType === "video-clip" ? Scissors : node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon;
const isInput = node.kind === "input";
const isWarm = node.kind === "text" || node.kind === "audio";
const previewUrl = getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
@ -3480,97 +3652,110 @@ function PresetScriptCanvasNode({ node, selected }: { node: CloneNode; selected:
}
function AddModulePanel({
filteredTools,
onAddTool,
onAddModule,
onClose,
onSearchChange,
onTabChange,
search,
tab,
onGallerySelect,
onUploadFiles,
}: {
filteredTools: CapturedTool[];
onAddTool: (tool: CapturedTool) => void;
onAddModule: (kind: AddModuleKind) => void;
onClose: () => void;
onSearchChange: (value: string) => void;
onTabChange: (tab: (typeof PANEL_TABS)[number]) => void;
search: string;
tab: (typeof PANEL_TABS)[number];
onGallerySelect: () => void;
onUploadFiles: (files: File[]) => void;
}) {
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const triggerUpload = () => {
uploadInputRef.current?.click();
};
return (
<section
className="absolute left-[88px] top-[110px] z-50 flex h-[min(720px,calc(100vh-140px))] w-[408px] flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] shadow-[0_28px_90px_rgba(0,0,0,0.48)]"
className="fixed left-[88px] top-[300px] z-50 flex w-[240px] flex-col gap-2 rounded-[16px] border border-white/10 bg-[#202020]/96 p-2 text-white shadow-[0_24px_80px_rgba(0,0,0,0.52)] backdrop-blur-[32px]"
data-canvas-overlay="true"
>
<div className="border-white/8 border-b p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-[16px] text-white">添加节点</div>
<div className="mt-1 text-[12px] text-white/42">工具 {capturedCounts.tools || capturedTools.length} / 工作流 {capturedCounts.workflows || capturedWorkflows.length}</div>
</div>
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-4 flex items-center gap-2 rounded-[8px] border border-white/10 bg-black/24 px-3 py-2">
<Search className="h-4 w-4 text-white/38" />
<input
className="min-w-0 flex-1 bg-transparent text-[13px] text-white outline-none placeholder:text-white/30"
onChange={(event) => onSearchChange(event.target.value)}
placeholder="搜索模型、厂商、模式"
value={search}
/>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{PANEL_TABS.map((item) => (
<button
className={cn(
"rounded-[8px] border border-white/8 px-2.5 py-1 text-[12px] text-white/58 hover:bg-white/8 hover:text-white",
tab === item && "border-white/20 bg-white text-black hover:bg-white hover:text-black"
)}
key={item}
onClick={() => onTabChange(item)}
type="button"
>
{item}
</button>
))}
</div>
<input
accept="image/*,video/*,audio/*"
className="hidden"
multiple
onChange={(event) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) onUploadFiles(files);
event.currentTarget.value = "";
}}
ref={uploadInputRef}
type="file"
/>
<div className="flex items-center justify-between px-2 py-1">
<h4 className="text-[14px] font-medium leading-5 text-white/60">添加节点</h4>
<button className="flex h-7 w-7 items-center justify-center rounded-[8px] text-white/42 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2">
{filteredTools.map((tool) => {
const type = getToolType(tool);
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
const Icon = meta.icon;
return (
<button
className="group rounded-[8px] border border-white/8 bg-[#2a2a2a] p-3 text-left transition hover:border-white/18 hover:bg-[#323232]"
key={`${tool.toolKey || tool.modelKey}-${tool.modelName}`}
onClick={() => onAddTool(tool)}
type="button"
>
<div className="flex items-start gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] bg-white text-black">
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-[14px] text-white">{getToolLabel(tool)}</span>
<span className="mt-1 flex flex-wrap gap-1.5">
<span className="rounded-[6px] bg-white/8 px-1.5 py-0.5 text-[11px] text-white/54">{tool.vendor || meta.label}</span>
{getToolModes(tool).map((mode) => (
<span className="rounded-[6px] bg-white/8 px-1.5 py-0.5 text-[11px] text-white/46" key={mode}>
{mode}
</span>
))}
{ADD_NODE_MODULES.map((item) => {
const Icon = item.icon;
return (
<button
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
key={item.kind}
onClick={() => onAddModule(item.kind)}
type="button"
>
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
<Icon className="h-5 w-5" />
</span>
<span className="h-full min-w-0 flex-1 overflow-hidden">
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
<span className="flex items-center gap-1.5 text-[14px] font-medium leading-5">
{item.label}
{item.badge ? (
<span className="rounded-[4px] bg-white/10 px-1.5 py-0.5 text-[10px] leading-3 text-white/50">
{item.badge}
</span>
</span>
<Plus className="mt-1 h-4 w-4 text-white/34 group-hover:text-white" />
</div>
</button>
);
})}
</div>
</div>
) : null}
</span>
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
{item.description}
</span>
</span>
</span>
</button>
);
})}
<h4 className="px-2 py-1 text-[14px] font-medium leading-5 text-white/60">添加资源</h4>
<button
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
onClick={triggerUpload}
type="button"
>
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
<Upload className="h-5 w-5" />
</span>
<span className="h-full min-w-0 flex-1 overflow-hidden">
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
<span className="text-[14px] font-medium leading-5">上传</span>
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
可上传图片、视频、音频文件
</span>
</span>
</span>
</button>
<button
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
onClick={onGallerySelect}
type="button"
>
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
<Layers className="h-5 w-5" />
</span>
<span className="h-full min-w-0 flex-1 overflow-hidden">
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
<span className="text-[14px] font-medium leading-5">从图库选择</span>
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
从历史生成中选择素材
</span>
</span>
</span>
</button>
</section>
);
}