"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import { Loader2, Plus, Trash2, Upload } from "lucide-react"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn, fetcher } from "@/lib/utils"; import { isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; import { defaultAgentPresetModeConfig, normalizeAgentPresetModeConfig, patchAgentDefaultParamsWithModeConfig, } from "@/lib/agents/preset-mode-config"; import { buildAgentPresetCategoryPath, getEnabledAgentPresetLibraryNodes, getEnabledAgentPresetLibrarySections, normalizeAgentPresetLibraryConfig, resolveAgentPresetCategoryPath, } from "@/lib/agents/preset-library-config"; import { imageGenerationService } from "@/services/canvas"; import { agentService } from "@/services/agents"; import type { AgentPresetLibraryConfig, AgentCreatePayload, AgentPresetModeType, AgentPresetModeConfig, AgentTaskDetailReferenceSource, AgentPresetType, AgentPreset, AgentPresetSlot, AgentUpdatePayload, } from "@/types/agent"; type DialogSlotState = AgentPresetSlot & { localReferenceAssets: Array<{ assetId: string; url: string; name?: string | null; }>; }; interface AgentPresetDialogProps { open: boolean; onOpenChange: (open: boolean) => void; initialPreset?: AgentPreset | null; onSaved?: (preset: AgentPreset) => void; entityLabel?: string; theme?: "light" | "dark"; } function createDefaultDialogSlot(): DialogSlotState { return { slotIndex: 0, title: "第1张", promptTemplate: "", requireUpload: true, uploadLabel: "第1张", defaultParams: null, referenceAssetIds: [], localReferenceAssets: [], }; } const IMAGE_MODE_OPTIONS: Array<{ value: AgentPresetModeType; label: string; description: string; }> = [ { value: "scene_generation", label: "场景图生成", description: "输入白底图和模特图,生成多张场景图结果。", }, { value: "detail_replacement", label: "详情替换", description: "用输入图逐张替换 SPU 详情参考图,生成详情结果。", }, { value: "sku_replacement", label: "SKU替换", description: "让输入图与参考图一对一替换,批量完成 SKU 场景替换。", }, ]; const TASK_DETAIL_REFERENCE_SOURCE_OPTIONS: Array<{ value: AgentTaskDetailReferenceSource; label: string; description: string; }> = [ { value: "main", label: "主图", description: "获取任务整套里的主图作为参考图来源。", }, { value: "sku", label: "SKU图", description: "获取任务整套里的 SKU 图作为参考图来源。", }, { value: "detail", label: "详情图", description: "获取任务整套里的详情图作为参考图来源。", }, ]; export function AgentPresetDialog({ open, onOpenChange, initialPreset, onSaved, entityLabel = "风格化", theme = "light", }: AgentPresetDialogProps) { const { data: modelsData } = useSWR<{ models?: Array<{ id: string; displayName: string; modelId?: string; supportsImageGen?: boolean; providerType?: string; capabilities?: unknown; isMultimodal?: boolean; }>; }>("/api/models", fetcher); const { data: siteSettingsData } = useSWR<{ agentPresetLibraryConfig?: AgentPresetLibraryConfig; }>("/api/site-settings?scope=agent-presets", fetcher); const [name, setName] = useState(""); const [type, setType] = useState<"image" | "video" | "text">("image"); const [visibility, setVisibility] = useState<"public" | "private">("private"); const [defaultModelId, setDefaultModelId] = useState(""); const [description, setDescription] = useState(""); const [avatarAssetId, setAvatarAssetId] = useState(""); const [avatarUrl, setAvatarUrl] = useState(""); const [modeConfig, setModeConfig] = useState({ ...defaultAgentPresetModeConfig, modeType: "scene_generation", }); const [isSubmitting, setIsSubmitting] = useState(false); const [uploadingSlotIndex, setUploadingSlotIndex] = useState(null); const [avatarUploading, setAvatarUploading] = useState(false); const [slots, setSlots] = useState([createDefaultDialogSlot()]); const [selectedPresetSectionId, setSelectedPresetSectionId] = useState(""); const [selectedPresetLevel1Id, setSelectedPresetLevel1Id] = useState(""); const [selectedPresetLevel2Id, setSelectedPresetLevel2Id] = useState(""); const [selectedPresetLevel3Id, setSelectedPresetLevel3Id] = useState(""); const fileInputRefs = useRef>([]); const avatarInputRef = useRef(null); const isDark = theme === "dark"; const entityNameLabel = `${entityLabel}名称`; const dialogTitle = initialPreset ? `编辑${entityLabel}` : `新建${entityLabel}`; const dialogDescription = `配置${entityLabel}名称、预览图、模型和槽位提示词。`; const submitLabel = initialPreset ? `保存${entityLabel}` : `创建${entityLabel}`; const darkSelectContentClass = isDark ? "border-white/10 bg-zinc-950 text-white shadow-2xl [--popover:hsl(240_10%_3.9%)] [--popover-foreground:hsl(0_0%_98%)] [--accent:rgba(255,255,255,0.08)] [--accent-foreground:hsl(0_0%_98%)] [--muted:rgba(255,255,255,0.04)] [--muted-foreground:rgba(255,255,255,0.56)]" : ""; const isStructuredImageModeBuilder = type === "image" && modeConfig.modeType !== "legacy"; const isSceneModeBuilder = type === "image" && modeConfig.modeType === "scene_generation"; const isDetailReplacementModeBuilder = type === "image" && modeConfig.modeType === "detail_replacement"; const isSkuReplacementModeBuilder = type === "image" && modeConfig.modeType === "sku_replacement"; const selectedImageModeMeta = IMAGE_MODE_OPTIONS.find((item) => item.value === modeConfig.modeType) || IMAGE_MODE_OPTIONS[0]; const imageModeSelectOptions = modeConfig.modeType === "legacy" ? [ { value: "legacy" as AgentPresetModeType, label: "旧版兼容", description: "保留历史模板的多槽位执行方式。", }, ...IMAGE_MODE_OPTIONS, ] : IMAGE_MODE_OPTIONS; const presetLibraryConfig = useMemo( () => normalizeAgentPresetLibraryConfig(siteSettingsData?.agentPresetLibraryConfig), [siteSettingsData?.agentPresetLibraryConfig] ); const presetLibrarySections = useMemo( () => getEnabledAgentPresetLibrarySections(presetLibraryConfig), [presetLibraryConfig] ); const selectedPresetSection = useMemo( () => presetLibrarySections.find((section) => section.id === selectedPresetSectionId) || null, [presetLibrarySections, selectedPresetSectionId] ); const presetLevel1Options = useMemo( () => getEnabledAgentPresetLibraryNodes(selectedPresetSection?.categories || []), [selectedPresetSection] ); const selectedPresetLevel1 = useMemo( () => presetLevel1Options.find((node) => node.id === selectedPresetLevel1Id) || null, [presetLevel1Options, selectedPresetLevel1Id] ); const presetLevel2Options = useMemo( () => getEnabledAgentPresetLibraryNodes(selectedPresetLevel1?.children || []), [selectedPresetLevel1] ); const selectedPresetLevel2 = useMemo( () => presetLevel2Options.find((node) => node.id === selectedPresetLevel2Id) || null, [presetLevel2Options, selectedPresetLevel2Id] ); const presetLevel3Options = useMemo( () => getEnabledAgentPresetLibraryNodes(selectedPresetLevel2?.children || []), [selectedPresetLevel2] ); const selectedPresetLevel3 = useMemo( () => presetLevel3Options.find((node) => node.id === selectedPresetLevel3Id) || null, [presetLevel3Options, selectedPresetLevel3Id] ); const filteredModels = useMemo(() => { const models = modelsData?.models || []; if (type === "video") { return models.filter((model) => isVideoModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) ); } if (type === "text") { return models.filter( (model) => model.isMultimodal === true && !isVideoModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) ); } return models.filter( (model) => isImageGenerationModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) && !isVideoModelDescriptor({ providerType: model.providerType, modelId: model.modelId, capabilities: model.capabilities, supportsImageGen: model.supportsImageGen, }) ); }, [modelsData?.models, type]); useEffect(() => { if (!open) return; if (filteredModels.length === 0) { setDefaultModelId(""); return; } if (!filteredModels.some((model) => model.id === defaultModelId)) { setDefaultModelId(filteredModels[0]?.id || ""); } }, [defaultModelId, filteredModels, open]); useEffect(() => { if (!open) return; setSlots((current) => current.map((slot, index) => ({ ...slot, slotIndex: index, title: slot.title || `第${index + 1}张`, uploadLabel: slot.uploadLabel || `第${index + 1}张`, })) ); }, [open]); const resetState = () => { setName(""); setType("image"); setVisibility("private"); setDefaultModelId(""); setDescription(""); setAvatarAssetId(""); setAvatarUrl(""); setModeConfig({ ...defaultAgentPresetModeConfig, modeType: "scene_generation", }); setSlots([createDefaultDialogSlot()]); setSelectedPresetSectionId(""); setSelectedPresetLevel1Id(""); setSelectedPresetLevel2Id(""); setSelectedPresetLevel3Id(""); setUploadingSlotIndex(null); }; useEffect(() => { if (!open) return; if (!initialPreset) { resetState(); return; } setName(initialPreset.name || ""); setType(initialPreset.type); setVisibility(initialPreset.visibility); setDefaultModelId(initialPreset.defaultModelId || ""); setDescription(initialPreset.description || ""); setAvatarAssetId(initialPreset.avatarAssetId || ""); setAvatarUrl(initialPreset.avatarUrl || ""); setModeConfig(normalizeAgentPresetModeConfig(initialPreset.defaultParams)); setSlots( (initialPreset.slots || []).map((slot, index) => ({ slotIndex: index, title: slot.title || `第${index + 1}张`, promptTemplate: slot.promptTemplate || "", requireUpload: slot.requireUpload !== false, uploadLabel: slot.uploadLabel || `第${index + 1}张`, defaultParams: slot.defaultParams || null, referenceAssetIds: slot.referenceAssetIds || [], localReferenceAssets: (slot.referenceAssets || []).map((asset) => ({ assetId: asset.assetId, url: asset.thumbnailUrl || asset.previewUrl || asset.originalUrl, name: asset.displayName, })), })) ); const resolvedCategoryPath = resolveAgentPresetCategoryPath({ categoryPath: initialPreset.categoryPath || null, config: presetLibraryConfig, }); setSelectedPresetSectionId(resolvedCategoryPath.sectionId || ""); setSelectedPresetLevel1Id(resolvedCategoryPath.level1Id || ""); setSelectedPresetLevel2Id(resolvedCategoryPath.level2Id || ""); setSelectedPresetLevel3Id(resolvedCategoryPath.level3Id || ""); }, [initialPreset, open, presetLibraryConfig]); useEffect(() => { if (type !== "image" || modeConfig.modeType === "legacy") { return; } setSlots((current) => { const primary = current[0] || createDefaultDialogSlot(); return [ { ...primary, slotIndex: 0, title: primary.title || (modeConfig.modeType === "detail_replacement" ? "详情替换" : modeConfig.modeType === "sku_replacement" ? "SKU替换" : "场景图生成"), requireUpload: true, uploadLabel: primary.uploadLabel || (modeConfig.modeType === "detail_replacement" ? "详情替换输入图" : modeConfig.modeType === "sku_replacement" ? "SKU替换输入图" : "输入图片"), localReferenceAssets: primary.localReferenceAssets || [], referenceAssetIds: primary.referenceAssetIds || [], }, ]; }); }, [modeConfig.modeType, type]); useEffect(() => { if ( selectedPresetSectionId && !presetLibrarySections.some((section) => section.id === selectedPresetSectionId) ) { setSelectedPresetSectionId(""); setSelectedPresetLevel1Id(""); setSelectedPresetLevel2Id(""); setSelectedPresetLevel3Id(""); } }, [presetLibrarySections, selectedPresetSectionId]); useEffect(() => { if ( selectedPresetLevel1Id && !presetLevel1Options.some((node) => node.id === selectedPresetLevel1Id) ) { setSelectedPresetLevel1Id(""); setSelectedPresetLevel2Id(""); setSelectedPresetLevel3Id(""); } }, [presetLevel1Options, selectedPresetLevel1Id]); useEffect(() => { if ( selectedPresetLevel2Id && !presetLevel2Options.some((node) => node.id === selectedPresetLevel2Id) ) { setSelectedPresetLevel2Id(""); setSelectedPresetLevel3Id(""); } }, [presetLevel2Options, selectedPresetLevel2Id]); useEffect(() => { if ( selectedPresetLevel3Id && !presetLevel3Options.some((node) => node.id === selectedPresetLevel3Id) ) { setSelectedPresetLevel3Id(""); } }, [presetLevel3Options, selectedPresetLevel3Id]); const handleSlotChange = (slotIndex: number, updates: Partial) => { setSlots((current) => current.map((slot, index) => index === slotIndex ? { ...slot, ...updates, slotIndex: index } : slot ) ); }; const handleAddSlot = () => { setSlots((current) => [ ...current, { slotIndex: current.length, title: `第${current.length + 1}张`, promptTemplate: "", requireUpload: true, uploadLabel: `第${current.length + 1}张`, defaultParams: null, referenceAssetIds: [], localReferenceAssets: [], }, ]); }; const handleRemoveSlot = (slotIndex: number) => { setSlots((current) => current .filter((_, index) => index !== slotIndex) .map((slot, index) => ({ ...slot, slotIndex: index, title: slot.title || `第${index + 1}张`, uploadLabel: slot.uploadLabel || `第${index + 1}张`, })) ); }; const handleAvatarUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; setAvatarUploading(true); try { const result = await imageGenerationService.uploadImage(file); if (!result.success || !result.data?.assetId || !result.data.url) { throw new Error(result.error || "预览图上传失败"); } setAvatarAssetId(result.data.assetId); setAvatarUrl(result.data.url); toast.success("预览图上传成功"); } catch (error) { toast.error(error instanceof Error ? error.message : "预览图上传失败"); } finally { setAvatarUploading(false); event.target.value = ""; } }; const handleUploadReferenceImages = async ( slotIndex: number, event: React.ChangeEvent ) => { const files = Array.from(event.target.files || []); if (files.length === 0) return; setUploadingSlotIndex(slotIndex); try { const uploadedAssets: Array<{ assetId: string; url: string; name?: string | null }> = []; for (const file of files) { const result = await imageGenerationService.uploadImage(file); if (!result.success || !result.data?.assetId || !result.data.url) { throw new Error(result.error || "参考图上传失败"); } uploadedAssets.push({ assetId: result.data.assetId, url: result.data.url, name: file.name, }); } setSlots((current) => current.map((slot, index) => { if (index !== slotIndex) return slot; const nextAssets = [...slot.localReferenceAssets, ...uploadedAssets]; return { ...slot, referenceAssetIds: nextAssets.map((item) => item.assetId), localReferenceAssets: nextAssets, }; }) ); toast.success( uploadedAssets.length === 1 ? "参考图上传成功" : `已上传 ${uploadedAssets.length} 张参考图` ); } catch (error) { toast.error(error instanceof Error ? error.message : "参考图上传失败"); } finally { setUploadingSlotIndex(null); event.target.value = ""; } }; const handleCreate = async () => { if (!name.trim()) { toast.error(`请输入${entityNameLabel}`); return; } if (!defaultModelId) { toast.error("请选择对应模型"); return; } const effectiveSlots = isStructuredImageModeBuilder ? [ { ...(slots[0] || createDefaultDialogSlot()), slotIndex: 0, title: modeConfig.modeType === "detail_replacement" ? "详情替换" : modeConfig.modeType === "sku_replacement" ? "SKU替换" : "场景图生成", requireUpload: true, uploadLabel: modeConfig.modeType === "detail_replacement" ? "详情替换输入图" : modeConfig.modeType === "sku_replacement" ? "SKU替换输入图" : "输入图片", }, ] : slots; if (effectiveSlots.some((slot) => !slot.promptTemplate.trim())) { toast.error( isStructuredImageModeBuilder ? `请输入${selectedImageModeMeta.label}提示词` : "每个槽位都需要填写提示词" ); return; } setIsSubmitting(true); try { const nextDefaultParams = isStructuredImageModeBuilder ? patchAgentDefaultParamsWithModeConfig(initialPreset?.defaultParams || null, modeConfig) : undefined; const nextCategoryPath = buildAgentPresetCategoryPath({ section: selectedPresetSection, level1: selectedPresetLevel1, level2: selectedPresetLevel2, level3: selectedPresetLevel3, }); const createCategoryPath = nextCategoryPath || undefined; const basePayload: AgentCreatePayload = { name: name.trim(), type, visibility, categoryPath: createCategoryPath, description: description.trim() || undefined, defaultModelId, defaultParams: nextDefaultParams || undefined, slots: effectiveSlots.map((slot, index) => ({ slotIndex: index, title: slot.title || null, promptTemplate: slot.promptTemplate.trim(), requireUpload: slot.requireUpload !== false, uploadLabel: slot.uploadLabel || null, defaultParams: slot.defaultParams || undefined, referenceAssetIds: slot.referenceAssetIds || [], })), avatarAssetId: avatarAssetId || undefined, }; const result = initialPreset ? await agentService.updatePreset(initialPreset.id, { ...basePayload, categoryPath: nextCategoryPath, secondaryType: null, } satisfies AgentUpdatePayload) : await agentService.createPreset(basePayload); if (!result.success || !result.data) { throw new Error(result.error || (initialPreset ? `更新${entityLabel}失败` : `创建${entityLabel}失败`)); } toast.success(initialPreset ? `${entityLabel}更新成功` : `${entityLabel}创建成功`); onSaved?.(result.data); onOpenChange(false); resetState(); } catch (error) { toast.error(error instanceof Error ? error.message : `创建${entityLabel}失败`); } finally { setIsSubmitting(false); } }; return ( { onOpenChange(nextOpen); if (!nextOpen) resetState(); }} > button]:rounded-full [&>button]:border [&>button]:border-white/10 [&>button]:bg-white/[0.03]", "[&>button]:text-white/58 [&>button]:opacity-100 [&>button:hover]:bg-white/[0.08] [&>button:hover]:text-white", ] : "border-slate-200 bg-white dark:border-slate-800 dark:bg-zinc-950" )} > {dialogTitle} {dialogDescription}
预览图
{avatarUrl ? ( {`${entityLabel}预览图`} ) : (
未上传
)}
{entityNameLabel}
setName(event.target.value)} className={cn( "h-10", isDark ? "border-white/10 bg-black/30 text-white placeholder:text-white/25 focus-visible:ring-emerald-400/35" : "" )} />
可见性
{type === "image" ? (
模式类型
) : null}
类型
对应模型
{type === "image" ? (
生图模式分类
选择该生图模式显示在哪个分类页,以及对应的一级、二级、三级分类。
{presetLibrarySections.length > 0 ? (
顶部分类页
一级分类
二级分类
三级分类
) : (
还没有配置任何生图分类,请先到“风格化工作流配置 - 生图分类”中新增顶部分类页和三级分类树。
)}
) : null} {type === "image" ? (
当前模式:{modeConfig.modeType === "legacy" ? "旧版兼容" : selectedImageModeMeta.label}
{modeConfig.modeType === "legacy" ? "当前模板沿用旧版多槽位执行逻辑。切换到新模式后,将按新的业务流程配置。" : selectedImageModeMeta.description}
) : null} {type === "image" && modeConfig.modeType !== "legacy" ? (
{selectedImageModeMeta.label}配置
{isSceneModeBuilder ? "第一步提示词组合 + 自定义提示词 + 输入图片 + 模板参考图,共同组成生图输入。" : isDetailReplacementModeBuilder ? "详情替换会使用 SPU 获取到的详情参考图逐张替换,不走原来的详情页生成规划流程。" : "SKU替换会按输入图与参考图一对一关系执行替换,并保留后续批量下载与文字编辑能力。"}
{isSceneModeBuilder ? (
产品图槽位数量
模特图槽位
开启后会额外保留 1 个模特图槽位
setModeConfig((prev) => ({ ...prev, modeType: "scene_generation", scene: { ...prev.scene, includeModelSlot: checked, }, })) } className="shrink-0" />
允许模板参考图
不强制,但可以预置风格参考图
setModeConfig((prev) => ({ ...prev, modeType: "scene_generation", scene: { ...prev.scene, allowTemplateReferenceImages: checked, }, })) } className="shrink-0" />
允许用户切换模型
关闭后第二步直接锁定模板模型
setModeConfig((prev) => ({ ...prev, modeType: "scene_generation", scene: { ...prev.scene, allowModelOverride: checked, }, })) } className="shrink-0" />
) : (
产品图槽位数量
参考图来源
{TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find( (option) => option.value === (isDetailReplacementModeBuilder ? modeConfig.detail.referenceImageSource : modeConfig.sku.referenceImageSource) )?.description || "根据模式配置自动获取对应参考图"}
模特图槽位
开启后额外保留 1 个模特图槽位
setModeConfig((prev) => ({ ...prev, modeType: isDetailReplacementModeBuilder ? "detail_replacement" : "sku_replacement", detail: { ...prev.detail, includeModelSlot: isDetailReplacementModeBuilder ? checked : prev.detail.includeModelSlot, }, sku: { ...prev.sku, includeModelSlot: isSkuReplacementModeBuilder ? checked : prev.sku.includeModelSlot, }, })) } />
启用后续文字编辑
结果图生成后,可调用现有文字编辑能力逐屏修改。
setModeConfig((prev) => ({ ...prev, modeType: isDetailReplacementModeBuilder ? "detail_replacement" : "sku_replacement", detail: { ...prev.detail, enableTextEditing: isDetailReplacementModeBuilder ? checked : prev.detail.enableTextEditing, }, sku: { ...prev.sku, enableTextEditing: isSkuReplacementModeBuilder ? checked : prev.sku.enableTextEditing, }, })) } />
{isDetailReplacementModeBuilder ? `详情替换模式不需要上传模板参考图,第二步会通过 SPU 获取${TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find((option) => option.value === modeConfig.detail.referenceImageSource)?.label || "详情图"},再把输入图逐张替换到对应参考图中。` : `SKU替换模式会把用户选中的白底图与${TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find((option) => option.value === modeConfig.sku.referenceImageSource)?.label || "SKU图"}建立一对一关系,后续在第二步专用工作台中执行替换。`}
)}
) : null}
描述