From f764767ba4046a9ceebc57bcb310b3f50253bbe9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Mar 2026 00:24:32 +0800 Subject: [PATCH] feat: refresh agent workflow dark ui --- app/canvas/[id]/page.tsx | 217 ++---- .../agents/AgentGenerationWorkspaceDark.tsx | 627 +++++++++++++++ .../agents/AgentGenerationWorkspaceDarkV2.tsx | 733 ++++++++++++++++++ components/agents/AgentPageDark.tsx | 136 ++++ components/agents/AgentPresetCardDark.tsx | 260 +++++++ components/agents/AgentPresetPanelDark.tsx | 278 +++++++ components/agents/AgentPresetPanelDarkV2.tsx | 467 +++++++++++ components/agents/AgentWorkflowPanelDark.tsx | 440 +++++++++++ .../agents/AgentWorkflowPanelDarkV2.tsx | 511 ++++++++++++ components/agents/index-dark-v2.ts | 7 + components/agents/index-dark.ts | 9 + 11 files changed, 3517 insertions(+), 168 deletions(-) create mode 100644 components/agents/AgentGenerationWorkspaceDark.tsx create mode 100644 components/agents/AgentGenerationWorkspaceDarkV2.tsx create mode 100644 components/agents/AgentPageDark.tsx create mode 100644 components/agents/AgentPresetCardDark.tsx create mode 100644 components/agents/AgentPresetPanelDark.tsx create mode 100644 components/agents/AgentPresetPanelDarkV2.tsx create mode 100644 components/agents/AgentWorkflowPanelDark.tsx create mode 100644 components/agents/AgentWorkflowPanelDarkV2.tsx create mode 100644 components/agents/index-dark-v2.ts create mode 100644 components/agents/index-dark.ts diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 16a968790..65b59d7b1 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -66,6 +66,10 @@ import { AgentGenerationWorkspaceNew } from "@/components/agents/AgentGeneration import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker"; import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel"; import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew"; +// 深色主题组件 V2 (新版布局) +import { AgentPresetPanelDarkV2 } from "@/components/agents/AgentPresetPanelDarkV2"; +import { AgentWorkflowPanelDarkV2 } from "@/components/agents/AgentWorkflowPanelDarkV2"; +import { AgentGenerationWorkspaceDarkV2 } from "@/components/agents/AgentGenerationWorkspaceDarkV2"; import { CanvasWorkspace } from "@/components/canvas"; import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel"; import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel"; @@ -992,175 +996,52 @@ export default function CanvasEditorPage({ params }: PageProps) { ) : null; if (isAgentMode) { + // 深色主题智能体模式 V2 - 完整功能版本 + + // 第一步:工作流配置(品牌/店铺/步骤选择) + if (currentAgentStage === "workflow") { + return ( + setCurrentAgentStage("mode")} + onSkipStep={() => setCurrentAgentStage("mode")} + onReset={() => { + setWorkflowInputs({ primarySku: "", secondarySku: "", brand: "", store: "" }); + setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections()); + setWorkflowSelections({}); + setSelectedBrandModel(null); + setSelectedAgentPresetId(null); + setCurrentAgentStage("workflow"); + }} + /> + ); + } + + // 第二步:智能体选择 + 生图工作台 return ( - -
- {brandEntry} - {agentDisabledNotice} -
- - {currentAgentStage === "workflow" ? ( -
-
- setCurrentAgentStage("mode")} - onSkipStep={() => setCurrentAgentStage("mode")} - onReset={() => { - setWorkflowInputs({ - primarySku: "", - secondarySku: "", - brand: "", - store: "", - }); - setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections()); - setWorkflowSelections({}); - setSelectedBrandModel(null); - setSelectedAgentPresetId(null); - setCurrentAgentStage("workflow"); - }} - /> -
-
- ) : ( -
-
-
-
-
-
-
-
- - 生图模式 -
-
- 先在本页选择生图模式,再按品牌选择模特图并搜索 SKU / SPU 图片,右侧全屏工作区会自动进入对应图片槽位并展示结果。 -
-
- 第 2 步 / 2 步 -
-
- -
- {!workflowProgress.isComplete ? ( -
- 第一步尚未完成,你也可以在当前直接继续生图模式选择。 -
- ) : null} -
-
-
- - - -
-
-
-
- -
-
-
-
-
执行工作区
-
- 这里不再是侧边对话框,而是全屏生图工作台。生成结果、槽位操作和补充指令都在这里完成。 -
-
-
- {selectedAgentPresetId ? ( -
- 当前模式已选 -
- ) : ( -
- 请选择左侧生图模式 -
- )} -
-
-
- {selectedAgentPresetId ? ( - -
-
工作台加载失败
-
- 当前生图工作台发生了前端异常,我已经切换到安全兜底状态。你可以先返回上一步再重新进入,或者告诉我继续修复。 -
-
-
- } - > - - - ) : ( -
-
-
- -
-
请先选择左侧生图模式
-
- 选择生图模式后,这里才会加载独立的生图工作台,并显示图片槽位、补充要求和生成结果。 -
-
-
- )} -
-
-
-
-
- )} -
-
+ setCurrentAgentStage("workflow")} + onClearPreset={() => setSelectedAgentPresetId(null)} + // 嵌入的智能体选择面板 + presetPanel={ + + } + /> ); } diff --git a/components/agents/AgentGenerationWorkspaceDark.tsx b/components/agents/AgentGenerationWorkspaceDark.tsx new file mode 100644 index 000000000..81addbbe9 --- /dev/null +++ b/components/agents/AgentGenerationWorkspaceDark.tsx @@ -0,0 +1,627 @@ +"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 { 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 (!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 */} +
+

+ + 补充要求 +

+