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 (
+
+ );
+ })}
+
+
+
+
+
补充要求
+
+ 输入本次生成要求,例如风格、镜头、氛围、颜色和生成张数。
+
+
+
+
+
+
+
+
生成结果
+
+ 这里仅展示生图结果,不再显示聊天欢迎语、快捷功能或对话气泡。
+
+
+ {displayedResultUrl ? (
+
void handleDownloadOriginalImage(displayedResultUrl)}
+ >
+
+ 下载原图
+
+ ) : null}
+
+
+
+ {isGenerating ? (
+
+
+
+ ) : displayedResultUrl ? (
+
+

setFullscreenPreview(displayedResultUrl)}
+ />
+
+ ) : (
+
+
+
+
+
+
+ {selectedAgentPresetId ? "准备开始生成" : "请先在左侧选择生图模式"}
+
+
+ {selectedAgentPresetId
+ ? "补齐图片槽位后点击开始生成,结果会显示在这里。"
+ : "选择生图模式后,再从左侧 SKU / SPU 获取图片并开始生成。"}
+
+
+
+ )}
+
+
+
+
+
结果预览
+
+ 共 {allGeneratedImages.length} 张
+
+
+ {allGeneratedImages.length > 0 ? (
+
+ {allGeneratedImages.map((url) => (
+
setSelectedResultUrl(url)}
+ className={cn(
+ "overflow-hidden rounded-2xl border transition-all",
+ selectedResultUrl === url || (!selectedResultUrl && displayedResultUrl === url)
+ ? "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
+ : "border-border hover:border-primary/50"
+ )}
+ >
+
+
+ ))}
+
+ ) : (
+
+ 生成完成后,结果缩略图会固定展示在这里。
+
+ )}
+
+
+
+
+
+ {fullscreenPreview ? (
+
setFullscreenPreview(null)}
+ >
+

event.stopPropagation()}
+ />
+
+ ) : null}
+
+ );
+}