"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowUp, Download, ImagePlus, Loader2, Sparkles, Upload, Wand2, X, ZoomIn, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import useSWR from "swr"; import { ImageGenerationLoading } from "@/components/image-generation-loading"; import { type ImageGenerationParams, ImageParamsBar, } from "@/components/image-params-bar"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; 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, fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { useCanvasStore } from "@/store/canvas"; import type { AgentPreset, AgentWorkflowComposerContext, AgentWorkflowSelectedBrandModel, } from "@/types/agent"; interface AgentGenerationWorkspaceNewProps { chatId: string; projectId: string; selectedAgentPresetId?: string | null; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; workflowContext?: AgentWorkflowComposerContext | null; } 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" }); } 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 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; }); } 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 ImageSlot({ slot, index, attachment, isUploading, onClick, onRemove, fileInputRef, onFileChange, }: { slot: { slotIndex: number; label: string }; index: number; attachment: PickedAttachment | null; isUploading: boolean; onClick: () => void; onRemove: (e: React.MouseEvent) => void; fileInputRef: (el: HTMLInputElement | null) => void; onFileChange: (e: React.ChangeEvent) => void; }) { return ( {attachment ? ( <> {attachment.name {/* 遮罩层 */}
{/* 标签 */}
{slot.label}
{/* 删除按钮 */} ) : (
{isUploading ? ( ) : ( <>
{slot.label} )}
)} ); } // 结果缩略图组件 function ResultThumbnail({ url, isSelected, onClick, }: { url: string; isSelected: boolean; onClick: () => void; }) { return ( 结果缩略图 ); } export function AgentGenerationWorkspaceNew({ chatId, projectId, selectedAgentPresetId, selectedBrandModel = null, workflowContext = null, }: AgentGenerationWorkspaceNewProps) { 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< Array >([]); 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: NonNullable["models"][number]) => 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 || selectedAgentPresetId || undefined, selectedChatModel: transportState.selectedModelId !== "auto" ? transportState.selectedModelId : selectedAgentPreset?.defaultModelId || transportState.selectedModelId, composerMode: selectedAgentPreset?.type === "image" ? ("image" as const) : ("agent" as const), selectedVisibilityType: "private", imageParams: transportState.imageParams, contextPayload: { source: "chat" as const, projectId, }, }, }; }, }), }); 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"; useEffect(() => { if (!selectedResultUrl && latestGeneratedGroup?.imageUrls[0]) { setSelectedResultUrl(latestGeneratedGroup.imageUrls[0]); } }, [latestGeneratedGroup?.imageUrls, selectedResultUrl]); 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( "[AgentGenerationWorkspace] 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("已打开原图,可右键另存为"); } }, []); return (
{/* 头部 */}

生图工作台

补齐图片槽位后补充要求并生成

{/* 左侧控制面板 */}
{/* 图片槽位 */}

图片槽位

{agentUploadSlots.map((slot, index) => ( { fileInputRefs.current[index] = el; }} index={index} isUploading={slotUploading === index} key={slot.label} onClick={() => fileInputRefs.current[index]?.click()} onFileChange={(e) => handleSlotFileChange(index, e)} onRemove={(e) => { e.stopPropagation(); updateAttachmentAtSlot(index, null); }} slot={slot} /> ))}
{/* 补充要求 */}

补充要求