From 2e21d1b274f4cbba76feac772fa0e8d5ca47ddb8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 17 Mar 2026 23:40:42 +0800 Subject: [PATCH] feat: refresh agent workflow frontend --- app/canvas/[id]/page.tsx | 14 +- .../agents/AgentGenerationWorkspaceNew.tsx | 886 ++++++++++++++++++ components/agents/AgentPanelHeader.tsx | 97 ++ components/agents/AgentPresetCardNew.tsx | 249 +++++ components/agents/AgentPresetPanelNew.tsx | 238 +++++ components/agents/AgentSectionHeader.tsx | 43 + components/agents/AgentWorkflowPanelNew.tsx | 490 ++++++++++ components/agents/index-new.ts | 10 + 8 files changed, 2020 insertions(+), 7 deletions(-) create mode 100644 components/agents/AgentGenerationWorkspaceNew.tsx create mode 100644 components/agents/AgentPanelHeader.tsx create mode 100644 components/agents/AgentPresetCardNew.tsx create mode 100644 components/agents/AgentPresetPanelNew.tsx create mode 100644 components/agents/AgentSectionHeader.tsx create mode 100644 components/agents/AgentWorkflowPanelNew.tsx create mode 100644 components/agents/index-new.ts diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 438b0101e..16a968790 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -59,13 +59,13 @@ import { toast } from "sonner"; import { useCanvasStore, canvasSelectors } from "@/store/canvas"; import { canvasService, imageGenerationService } from "@/services/canvas"; import { - AgentWorkflowPanel, + AgentWorkflowPanelNew, type AgentWorkflowInputs, -} from "@/components/agents/AgentWorkflowPanel"; -import { AgentGenerationWorkspace } from "@/components/agents/AgentGenerationWorkspace"; +} from "@/components/agents/AgentWorkflowPanelNew"; +import { AgentGenerationWorkspaceNew } from "@/components/agents/AgentGenerationWorkspaceNew"; import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker"; import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel"; -import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel"; +import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew"; import { CanvasWorkspace } from "@/components/canvas"; import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel"; import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel"; @@ -1002,7 +1002,7 @@ export default function CanvasEditorPage({ params }: PageProps) { {currentAgentStage === "workflow" ? (
- - } > - { + 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>([]); + 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 { 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"; + + 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]?.click()} + onRemove={(e) => { + e.stopPropagation(); + updateAttachmentAtSlot(index, null); + }} + fileInputRef={(el) => (fileInputRefs.current[index] = el)} + onFileChange={(e) => handleSlotFileChange(index, e)} + /> + ))} +
+
+ + {/* 补充要求 */} +
+

+ + 补充要求 +

+