From 50a2d84d2c7f3b1cab75000656f2b7b546855e7d Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 17 Mar 2026 21:48:05 +0800 Subject: [PATCH] feat: replace agent chat panel with generation workspace --- app/canvas/[id]/page.tsx | 7 +- .../agents/AgentGenerationWorkspace.tsx | 801 ++++++++++++++++++ 2 files changed, 804 insertions(+), 4 deletions(-) create mode 100644 components/agents/AgentGenerationWorkspace.tsx diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 073ddf677..f1c5e90cb 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -62,6 +62,7 @@ import { AgentWorkflowPanel, type AgentWorkflowInputs, } from "@/components/agents/AgentWorkflowPanel"; +import { AgentGenerationWorkspace } from "@/components/agents/AgentGenerationWorkspace"; import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel"; import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel"; import { CanvasWorkspace } from "@/components/canvas"; @@ -1040,13 +1041,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
-
diff --git a/components/agents/AgentGenerationWorkspace.tsx b/components/agents/AgentGenerationWorkspace.tsx new file mode 100644 index 000000000..d403ef326 --- /dev/null +++ b/components/agents/AgentGenerationWorkspace.tsx @@ -0,0 +1,801 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import useSWR from "swr"; +import { + ArrowUp, + Download, + ImagePlus, + Loader2, + Sparkles, + Upload, +} 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 } from "@/types/agent"; + +interface AgentGenerationWorkspaceProps { + chatId: string; + projectId: string; + selectedAgentPresetId?: string | 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(); +} + +export function AgentGenerationWorkspace({ + chatId, + projectId, + selectedAgentPresetId, + workflowContext = null, +}: AgentGenerationWorkspaceProps) { + 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?: { + maxImages?: number; + supportsSize?: boolean; + supports1K?: boolean; + supports2K?: boolean; + supports4K?: boolean; + supportsPromptExtend?: boolean; + supportsNegativePrompt?: boolean; + supportsWatermark?: boolean; + supportsSeed?: boolean; + creditCostPerImage?: number; + } | null; + }>; + defaultModelId?: string | null; + agentModel?: { + id: string; + displayName: string; + providerType: string; + isMultimodal?: boolean; + imageGenConfig?: { + maxImages?: number; + supportsSize?: boolean; + supports1K?: boolean; + supports2K?: boolean; + supports4K?: boolean; + supportsPromptExtend?: boolean; + supportsNegativePrompt?: boolean; + supportsWatermark?: boolean; + supportsSeed?: boolean; + creditCostPerImage?: number; + } | null; + } | null; + }>("/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 { 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 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]); + + setAttachments((current) => { + const result: Array = Array.from( + { length: agentUploadSlots.length }, + (_, index) => current[index] || null + ); + for (let index = 0; index < 2; index += 1) { + if (!result[index] && nextSlots[index]) { + result[index] = nextSlots[index] as PickedAttachment; + } + } + return result.filter((item): item is PickedAttachment => Boolean(item)); + }); + }, [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: 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; + while (slots.length > 0 && !slots[slots.length - 1]) { + slots.pop(); + } + return slots.filter((item): item is PickedAttachment => Boolean(item)); + }); + }, [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; + } + + if (hasAgentUploadSlots && attachments.length < 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) => { + parts.push({ + type: "file", + mediaType: attachment.contentType || "image/jpeg", + name: attachment.name || "参考图", + url: attachment.url, + assetId: attachment.assetId, + }); + }); + + let textContent = input.trim(); + if (!textContent && workflowContext?.summaryText) { + textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`; + } + if (!textContent && selectedAgentPreset) { + textContent = + selectedAgentPreset.type === "text" + ? `请按「${selectedAgentPreset.name}」输出内容` + : `请按「${selectedAgentPreset.name}」模式处理产品图1、产品图2和模特图。`; + } + 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 ( +
+
+
生图工作台
+
+ 这里是独立的生图工作台,不再使用聊天对话框。请先补齐图片槽位,再补充要求并生成。 +
+
+ +
+
+
+
+
图片槽位
+
+ 主 SKU / 搭配 SKU 选中的图片会优先进入前两个槽位,模特图可手动上传。 +
+
+ {agentUploadSlots.map((slot, index) => { + const attachment = attachments[index]; + const isUploading = slotUploading === index; + return ( + + + ) : ( +
+ {isUploading ? ( + + ) : ( + + )} +
{slot.label}
+
+ )} + + ); + })} +
+
+ +
+
补充要求
+
+ 输入本次生成要求,例如风格、镜头、氛围、颜色和生成张数。 +
+