"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import useSWR from "swr"; import { motion, AnimatePresence } from "framer-motion"; import { ArrowUp, Download, ImagePlus, Loader2, Sparkles, Upload, UserRound, X, Wand2, Settings2, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; import { ImageGenerationLoading } from "@/components/image-generation-loading"; import { useCanvasStore } from "@/store/canvas"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { extractImageUrlsFromText } from "@/lib/image-url"; import type { Attachment, ChatMessage } from "@/lib/types"; import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils"; import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; import type { AgentPreset, AgentWorkflowComposerContext, AgentWorkflowSelectedBrandModel, } from "@/types/agent"; interface AgentGenerationWorkspaceDarkV2Props { chatId: string; projectId: string; selectedAgentPresetId?: string | null; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; workflowContext?: AgentWorkflowComposerContext | null; onBack?: () => void; presetPanel?: ReactNode; materialSelectionPanel?: ReactNode; brandModelPanel?: ReactNode; onClearPreset?: () => void; siteName?: string | null; siteLogo?: string | null; siteLogoDark?: string | null; } interface PickedAttachment extends Attachment {} interface GeneratedImageGroup { messageId: string; imageUrls: string[]; text: string; } type ToolStepKey = | "preset" | "brand-model" | "materials" | "slots" | "prompt" | "settings"; function buildPreviewImageUrl(url: string, width: number) { return buildImageProxyUrl(url, { width, quality: 86, format: "webp" }); } function buildDownloadFilename(url: string): string { const fallback = `canvas-image-${Date.now()}.png`; const raw = String(url || "").trim(); if (!raw) return fallback; try { const pathname = new URL(raw).pathname || ""; const candidate = pathname.split("/").pop() || ""; return candidate || fallback; } catch { const candidate = raw.split("/").pop()?.split("?")[0] || ""; return candidate || fallback; } } function normalizeAssistantText(value: string) { return String(value || "") .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") .replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "") .trim(); } function getImageDimensions(url: string): Promise<{ width: number; height: number; originalWidth: number; originalHeight: number; }> { return new Promise((resolve) => { const img = new Image(); img.onload = () => { const maxSize = 500; const originalWidth = img.naturalWidth; const originalHeight = img.naturalHeight; let width = originalWidth; let height = originalHeight; if (width > maxSize || height > maxSize) { const scale = Math.min(maxSize / width, maxSize / height); width = Math.round(width * scale); height = Math.round(height * scale); } const minSize = 150; if (width < minSize && height < minSize) { const scale = minSize / Math.min(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } resolve({ width, height, originalWidth, originalHeight }); }; img.onerror = () => { resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 }); }; img.src = url; }); } export function AgentGenerationWorkspaceDarkV2({ chatId, projectId, selectedAgentPresetId, selectedBrandModel = null, workflowContext = null, onBack, presetPanel, materialSelectionPanel, brandModelPanel, onClearPreset, siteName, siteLogo, siteLogoDark, }: AgentGenerationWorkspaceDarkV2Props) { const rawChatId = useMemo( () => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId), [chatId] ); const addNode = useCanvasStore((state) => state.addNode); const nodes = useCanvasStore((state) => state.nodes); const fileInputRefs = useRef>([]); const hiddenPromptRef = useRef(null); const generatedImageKeysRef = useRef>(new Set()); const lastSyncedBrandModelUrlRef = useRef(null); const [input, setInput] = useState(""); const [attachments, setAttachments] = useState>([]); const [slotUploading, setSlotUploading] = useState(null); const [selectedModelId, setSelectedModelId] = useState("auto"); const [selectedResultUrl, setSelectedResultUrl] = useState(null); const [fullscreenPreview, setFullscreenPreview] = useState(null); const [imageParams, setImageParams] = useState({ aspectRatio: "auto", imageSize: "2K", imageCount: 1, }); const { data: historyData } = useSWR<{ messages: ChatMessage[] }>( rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher ); const { data: modelsData } = useSWR<{ models: Array<{ id: string; displayName: string; providerType: string; supportsImageGen?: boolean; isMultimodal?: boolean; imageGenConfig?: any; }>; defaultModelId?: string | null; agentModel?: any; }>("/api/models", fetcher); const { data: agentPresetResponse } = useSWR<{ success?: boolean; data?: AgentPreset; }>(selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null, fetcher); const selectedAgentPreset = useMemo( () => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null), [agentPresetResponse] ); const historyMessages = useMemo(() => historyData?.messages || [], [historyData?.messages]); const referenceImages = workflowContext?.referenceImages || []; const hasAgentUploadSlots = Boolean(selectedAgentPreset && selectedAgentPreset.type !== "text"); const agentUploadSlots = useMemo( () => hasAgentUploadSlots ? [ { slotIndex: 0, label: "产品图1" }, { slotIndex: 1, label: "产品图2" }, { slotIndex: 2, label: "模特图" }, ] : [], [hasAgentUploadSlots] ); const requiredUploadCount = hasAgentUploadSlots ? 3 : 0; const selectableModels = useMemo(() => { const imageModels = (modelsData?.models || []).filter( (model) => model.supportsImageGen || model.isMultimodal ); const autoOption = { id: "auto", displayName: "自动选择", providerType: "auto", imageGenConfig: modelsData?.agentModel?.imageGenConfig || undefined, }; return [autoOption, ...imageModels]; }, [modelsData?.agentModel?.imageGenConfig, modelsData?.models]); const currentModel = useMemo( () => selectableModels.find((model) => model.id === selectedModelId) || selectableModels[0], [selectableModels, selectedModelId] ); const [activeToolStep, setActiveToolStep] = useState("preset"); const hasBrandModelStep = Boolean(brandModelPanel); const hasMaterialStep = Boolean(materialSelectionPanel); const { messages, setMessages, sendMessage, status } = useChat({ id: chatId, messages: historyMessages, experimental_throttle: 100, generateId: generateUUID, transport: new DefaultChatTransport({ api: "/api/chat", fetch: fetchWithErrorHandlers, prepareSendMessagesRequest(request) { return { body: { id: rawChatId, message: request.messages.at(-1), hiddenPrompt: hiddenPromptRef.current || undefined, agentPresetId: selectedAgentPresetId || undefined, selectedChatModel: selectedModelId, selectedVisibilityType: "private", imageParams, }, }; }, }), }); useEffect(() => { if (!messages.length && historyMessages.length) { setMessages(historyMessages); } }, [historyMessages, messages.length, setMessages]); useEffect(() => { if (selectedAgentPreset?.defaultModelId) { setSelectedModelId(selectedAgentPreset.defaultModelId); return; } if (modelsData?.defaultModelId) { setSelectedModelId(modelsData.defaultModelId); } }, [modelsData?.defaultModelId, selectedAgentPreset?.defaultModelId]); // 初始化附件 useEffect(() => { if (!hasAgentUploadSlots) { setAttachments([]); return; } const nextSlots: Array = Array.from( { length: agentUploadSlots.length }, () => null ); const primaryImages = referenceImages.filter((image) => image.source === "primary"); const secondaryImages = referenceImages.filter((image) => image.source === "secondary"); const brandModelImages = referenceImages.filter((image) => image.source === "brandModel"); const toAttachment = (image: (typeof referenceImages)[number] | undefined) => image ? { url: image.url, name: image.name || image.label, contentType: image.mimeType || "image/png", assetId: image.assetId || undefined } : null; nextSlots[0] = toAttachment(primaryImages[0] || secondaryImages[0] || referenceImages[0]); nextSlots[1] = toAttachment(secondaryImages[0] || primaryImages[1] || referenceImages[1]); nextSlots[2] = toAttachment(brandModelImages[0]); setAttachments((current) => { const result: Array = Array.from( { length: agentUploadSlots.length }, (_, index) => current[index] || null ); for (let index = 0; index < 3; index += 1) { if (!result[index] && nextSlots[index]) { result[index] = nextSlots[index] as PickedAttachment; } } return result; }); }, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]); useEffect(() => { if (!hasAgentUploadSlots) { lastSyncedBrandModelUrlRef.current = null; return; } const nextAttachment = selectedBrandModel?.imageUrl ? { url: selectedBrandModel.imageUrl, name: selectedBrandModel.displayName || `${selectedBrandModel.brandName} 模特图`, contentType: selectedBrandModel.mimeType || "image/png", assetId: selectedBrandModel.assetId || undefined, } : null; setAttachments((current) => { const slots: Array = Array.from( { length: Math.max(agentUploadSlots.length, 3) }, (_, index) => current[index] ?? null ); const currentSlot = slots[2] ?? null; const previousSyncedUrl = lastSyncedBrandModelUrlRef.current; if (nextAttachment) { slots[2] = nextAttachment; } else if (previousSyncedUrl && currentSlot?.url === previousSyncedUrl) { slots[2] = null; } return slots; }); lastSyncedBrandModelUrlRef.current = nextAttachment?.url || null; }, [ agentUploadSlots.length, hasAgentUploadSlots, selectedBrandModel?.assetId, selectedBrandModel?.brandName, selectedBrandModel?.displayName, selectedBrandModel?.id, selectedBrandModel?.imageUrl, selectedBrandModel?.mimeType, ]); const generatedGroups = useMemo(() => { return messages .filter((message) => message.role === "assistant") .map((message) => { const textParts = (message.parts || []) .filter((part: any) => part.type === "text" && part.text) .map((part: any) => String(part.text)); const imageUrls = Array.from( new Set(textParts.flatMap((text) => extractImageUrlsFromText(text))) ); return { messageId: message.id, imageUrls, text: normalizeAssistantText(textParts.join("\n\n")), }; }) .filter((group) => group.imageUrls.length > 0); }, [messages]); const allGeneratedImages = useMemo( () => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))), [generatedGroups] ); const latestGeneratedGroup = generatedGroups[generatedGroups.length - 1] || null; const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null; const isGenerating = status === "streaming" || status === "submitted"; const attachmentCount = attachments.filter(Boolean).length; const hasBrandModelSelection = Boolean( selectedBrandModel?.imageUrl || referenceImages.some((image) => image.source === "brandModel") ); const hasMaterialSelections = referenceImages.some( (image) => image.source === "primary" || image.source === "secondary" ); const hasSatisfiedSlotRequirement = !hasAgentUploadSlots || attachmentCount >= requiredUploadCount; const toolSteps = useMemo( () => [ { key: "preset" as const, label: "生图模式", description: "先选择一个模式,再继续后续配置", icon: Wand2, disabled: false, completed: Boolean(selectedAgentPresetId), optional: false, }, ...(hasBrandModelStep ? [ { key: "brand-model" as const, label: "品牌模特", description: "根据品牌选择模特图", icon: UserRound, disabled: !selectedAgentPresetId, completed: hasBrandModelSelection, optional: true, }, ] : []), ...(hasMaterialStep ? [ { key: "materials" as const, label: "SKU / SPU 选图", description: "搜索主图和搭配图", icon: Upload, disabled: !selectedAgentPresetId, completed: hasMaterialSelections, optional: true, }, ] : []), { key: "slots" as const, label: "图片槽位", description: "检查和补齐参考图槽位", icon: ImagePlus, disabled: !selectedAgentPresetId, completed: hasSatisfiedSlotRequirement, optional: false, }, { key: "prompt" as const, label: "补充要求", description: "补充风格、构图和细节要求", icon: Sparkles, disabled: !selectedAgentPresetId, completed: Boolean(input.trim()), optional: true, }, { key: "settings" as const, label: "生成参数", description: "选择模型并开始生成", icon: Settings2, disabled: !selectedAgentPresetId, completed: Boolean(selectedAgentPresetId), optional: false, }, ], [ hasBrandModelSelection, hasBrandModelStep, hasMaterialSelections, hasMaterialStep, hasSatisfiedSlotRequirement, input, selectedAgentPresetId, ] ); const activeToolStepIndex = Math.max( toolSteps.findIndex((step) => step.key === activeToolStep), 0 ); const activeToolStepMeta = toolSteps[activeToolStepIndex] || toolSteps[0]; const previousToolStep = activeToolStepIndex > 0 ? toolSteps[activeToolStepIndex - 1] : null; const nextToolStep = activeToolStepIndex < toolSteps.length - 1 ? toolSteps[activeToolStepIndex + 1] : null; useEffect(() => { if (!selectedResultUrl && latestGeneratedGroup?.imageUrls[0]) { setSelectedResultUrl(latestGeneratedGroup.imageUrls[0]); } }, [latestGeneratedGroup?.imageUrls, selectedResultUrl]); useEffect(() => { if (!selectedAgentPresetId) { setActiveToolStep("preset"); return; } setActiveToolStep((current) => { if (current !== "preset") return current; if (hasBrandModelStep) return "brand-model"; if (hasMaterialStep) return "materials"; return "slots"; }); }, [hasBrandModelStep, hasMaterialStep, selectedAgentPresetId]); useEffect(() => { if (!toolSteps.some((step) => step.key === activeToolStep)) { setActiveToolStep("preset"); } }, [activeToolStep, toolSteps]); useEffect(() => { const latestUrls = latestGeneratedGroup?.imageUrls || []; if (latestUrls.length === 0) return; latestUrls.forEach(async (url, index) => { if (!url || generatedImageKeysRef.current.has(url)) return; generatedImageKeysRef.current.add(url); try { const dimensions = await getImageDimensions(url); const nodeIndex = nodes.length + index; const spacing = 20; const x = 120 + (nodeIndex % 3) * (dimensions.width + spacing); const y = 120 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing); addNode({ type: "image", position: { x, y }, size: { width: dimensions.width, height: dimensions.height }, data: { src: url, status: "completed", title: "生成结果", prompt: latestGeneratedGroup.text || "生成结果", metadata: { originalWidth: dimensions.originalWidth, originalHeight: dimensions.originalHeight, }, }, connections: [], }); } catch (error) { console.warn("[AgentGenerationWorkspaceDarkV2] Failed to add generated image to canvas:", error); } }); }, [addNode, latestGeneratedGroup, nodes.length]); const updateAttachmentAtSlot = useCallback((slotIndex: number, attachment: PickedAttachment | null) => { const slotCount = Math.max(agentUploadSlots.length, 1); setAttachments((current) => { const slots: Array = Array.from( { length: slotCount }, (_, index) => current[index] ?? null ); slots[slotIndex] = attachment; return slots; }); }, [agentUploadSlots.length]); const handleSlotFileChange = useCallback( async (slotIndex: number, event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; if (!file.type.startsWith("image/")) { toast.error("请上传图片文件"); event.currentTarget.value = ""; return; } setSlotUploading(slotIndex); try { const formData = new FormData(); formData.append("file", file); const response = await fetchUploadWithRetry(formData); const data = await response.json(); if (!response.ok || !data?.url) { throw new Error(data?.error || "上传失败"); } updateAttachmentAtSlot(slotIndex, { url: data.url, name: file.name, contentType: file.type || "image/jpeg", assetId: typeof data.assetId === "string" ? data.assetId : undefined, }); toast.success(`${agentUploadSlots[slotIndex]?.label || `槽位${slotIndex + 1}`}上传成功`); } catch (error) { toast.error(error instanceof Error ? error.message : "上传失败"); } finally { setSlotUploading(null); event.currentTarget.value = ""; } }, [agentUploadSlots, updateAttachmentAtSlot] ); const handleSend = useCallback(() => { if (!selectedAgentPresetId) { toast.error("请先选择生图模式"); return; } const uploadedAttachmentCount = attachments.filter(Boolean).length; if (hasAgentUploadSlots && uploadedAttachmentCount < requiredUploadCount) { toast.error(`当前模式需要上传 ${requiredUploadCount} 张图片`); return; } const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = []; attachments.forEach((attachment) => { if (!attachment) return; parts.push({ type: "file", mediaType: attachment.contentType || "image/jpeg", name: attachment.name || "参考图", url: attachment.url, assetId: attachment.assetId }); }); let textContent = input.trim(); if (!textContent && selectedAgentPreset?.type === "text" && workflowContext?.summaryText) { textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`; } if (!textContent && selectedAgentPreset?.type === "text" && selectedAgentPreset) { textContent = `请按「${selectedAgentPreset.name}」输出内容`; } if (textContent) { parts.push({ type: "text", text: textContent }); } if (parts.length === 0) { toast.error("请至少补充要求或上传图片"); return; } hiddenPromptRef.current = workflowContext?.promptText ? `【工作流设定】\n${workflowContext.promptText}\n\n请严格遵循以上工作流设定输出内容,并保持整体结果一致。` : null; sendMessage({ role: "user", parts }); setInput(""); }, [attachments, hasAgentUploadSlots, input, requiredUploadCount, selectedAgentPreset, selectedAgentPresetId, sendMessage, workflowContext?.promptText, workflowContext?.summaryText]); const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => { const raw = String(imageUrl || "").trim(); if (!raw) { toast.error("图片地址无效"); return; } const filename = buildDownloadFilename(raw); try { const response = await fetch(raw); if (!response.ok) { throw new Error(`download failed: ${response.status}`); } const blob = await response.blob(); const blobUrl = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = blobUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(blobUrl); } catch { const link = document.createElement("a"); link.href = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; document.body.appendChild(link); link.click(); document.body.removeChild(link); toast.message("已打开原图,可右键另存为"); } }, []); const handleToolStepChange = useCallback( (nextKey: ToolStepKey) => { const nextStep = toolSteps.find((step) => step.key === nextKey); if (!nextStep) return; if (nextStep.disabled) { toast.error("请先选择生图模式"); return; } setActiveToolStep(nextKey); }, [toolSteps] ); return (
} /> {/* Main Content */}
Step Flow

第二步:生图模式执行流程

第一步:风格选择 → 第二步:生图模式
当前步骤 {activeToolStepIndex + 1} / {toolSteps.length}
{toolSteps.map((step, index) => { const Icon = step.icon; const isActive = activeToolStep === step.key; return ( {index < toolSteps.length - 1 ? (
) : null} ); })}
Current Module

{activeToolStepMeta.label}

{activeToolStepMeta.description}

{selectedAgentPreset ? (
{selectedAgentPreset.avatarUrl ? ( {selectedAgentPreset.name} ) : (
)}
{selectedAgentPreset.name}
{selectedAgentPreset.type === "image" ? "生图模式" : "视频模式"}
) : null} {selectedAgentPreset && onClearPreset ? ( ) : null}
{presetPanel ? (
{presetPanel}
) : (
暂无可用的生图模式配置。
)}
{hasBrandModelStep ? (
{brandModelPanel}
) : null} {hasMaterialStep ? (
{materialSelectionPanel}
) : null}

图片槽位

{agentUploadSlots.length > 0 ? (
{agentUploadSlots.map((slot, index) => ( fileInputRefs.current[index]?.click()} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} className={cn( "group relative aspect-square overflow-hidden rounded-[24px] transition-all", attachments[index] ? "ring-2 ring-emerald-400/45" : "border-2 border-dashed border-white/15 bg-black/25 hover:border-emerald-400/35" )} > { fileInputRefs.current[index] = el; }} type="file" accept="image/*" className="hidden" onChange={(e) => handleSlotFileChange(index, e)} /> {attachments[index] ? ( <> {attachments[index]!.name
{slot.label}
) : (
{slotUploading === index ? ( ) : ( <> {slot.label} )}
)}
))}
) : (
先选择一个生图模式,系统会自动展示产品图1、产品图2和模特图槽位。
)}

补充要求