"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { ArrowUp, Download, ImagePlus, Loader2, Sparkles, Upload, X, Play, 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 type { AgentPreset, AgentWorkflowComposerContext, AgentWorkflowSelectedBrandModel, } from "@/types/agent"; interface AgentGenerationWorkspaceDarkProps { chatId: string; projectId: string; selectedAgentPresetId?: string | null; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; workflowContext?: AgentWorkflowComposerContext | null; onBack?: () => void; } interface PickedAttachment extends Attachment {} interface GeneratedImageGroup { messageId: string; imageUrls: string[]; text: string; } function buildPreviewImageUrl(url: string, width: number) { return buildImageProxyUrl(url, { width, quality: 86, format: "webp" }); } export function AgentGenerationWorkspaceDark({ chatId, projectId, selectedAgentPresetId, selectedBrandModel = null, workflowContext = null, onBack, }: AgentGenerationWorkspaceDarkProps) { 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 [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 transportStateRef = useRef<{ selectedAgentPresetId: string | null; selectedModelId: string; imageParams: ImageGenerationParams; }>({ selectedAgentPresetId: null, selectedModelId: "auto", imageParams: { aspectRatio: "auto", imageSize: "2K", imageCount: 1, }, }); transportStateRef.current = { selectedAgentPresetId: selectedAgentPresetId || null, selectedModelId, imageParams, }; 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) { const transportState = transportStateRef.current; return { body: { id: rawChatId, message: request.messages.at(-1), hiddenPrompt: hiddenPromptRef.current || undefined, agentPresetId: transportState.selectedAgentPresetId || undefined, selectedChatModel: transportState.selectedModelId, selectedVisibilityType: "private", imageParams: transportState.imageParams, }, }; }, }), }); // 初始化附件 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]); 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: textParts.join("\n\n").replace(/!\[[^\]]*\]\([^)]+\)/g, "").trim() }; }) .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"; useEffect(() => { if (!selectedResultUrl && latestGeneratedGroup?.imageUrls[0]) { setSelectedResultUrl(latestGeneratedGroup.imageUrls[0]); } }, [latestGeneratedGroup?.imageUrls, selectedResultUrl]); 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]); return (
{/* Header */}

生图工作台

配置图片槽位并开始生成

{onBack && ( )}
{/* Main Content */}
{/* Left Panel - Controls */}
{/* Image Slots */}

图片槽位

{agentUploadSlots.map((slot, index) => ( fileInputRefs.current[index]?.click()} initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: index * 0.1 }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} className={cn( "group relative aspect-square overflow-hidden rounded-xl", attachments[index] ? "ring-2 ring-violet-500/30" : "border-2 border-dashed border-white/10 hover:border-violet-500/30" )} > (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} )}
)}
))}
{/* Prompt Input */}

补充要求