"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type SyntheticEvent, } from "react"; import useSWR from "swr"; import { motion, AnimatePresence } from "framer-motion"; import { ArrowUp, CheckCircle2, Droplet, RefreshCw, ChevronDown, ChevronUp, Download, ImagePlus, Loader2, Maximize2, Sparkles, Upload, UserRound, Trash2, X, Wand2, Settings2, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { ClientErrorBoundary } from "@/components/ui/client-error-boundary"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; 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 { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner"; import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog"; import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog"; import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog"; import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-style-performance-monitor"; import { ImageAnnotator } from "@/components/image-annotator"; import { useCanvasStore } from "@/store/canvas"; import { imageGenerationService } from "@/services/canvas"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { requestLongTaskResult } from "@/lib/client/long-task"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { extractImageUrlsFromText } from "@/lib/image-url"; import { AGENT_DETAIL_BUNDLE_SLOT_INDEXES, AGENT_DETAIL_FIELD_EXAMPLES, AGENT_DETAIL_MODEL_SLOT_INDEX, AGENT_DETAIL_PRODUCT_SLOT_INDEXES, AGENT_DETAIL_SLOT_CONFIGS, AGENT_DETAIL_TARGET_HEIGHT, AGENT_DETAIL_TARGET_WIDTH, createEmptyAgentDetailBriefForm, createEmptyAgentDetailSlots, extractAgentDetailFrameCount, normalizeAgentDetailGenerationMode, sanitizeAgentDetailBrief, sanitizeDetailImageModelPrompt, } from "@/lib/agents/detail-generation-config"; import { normalizeAgentPresetModeConfig, } from "@/lib/agents/preset-mode-config"; import { defaultAgentWorkflowBusinessModeSettings, normalizeAgentWorkflowBusinessModeSettings, normalizeAgentWorkflowPageFeatureSettings, } from "@/lib/agents/workflow-ui-config"; import { buildAgentSceneImageRolePrompt } from "@/lib/agents/scene-image-role-prompt"; import type { AgentPsdTemplateMode } from "@/lib/agents/psd-template-config"; import type { Attachment, ChatMessage } from "@/lib/types"; import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils"; import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; import type { AgentDetailBriefForm, AgentDetailGenerationProgressState, AgentDetailReplacementStrategy, AgentGenerationProjectWorkspaceState, AgentGenerationProjectResultCollections, AgentGenerationResultModeType, AgentModeReferenceCandidate, AgentModeWorkspaceState, AgentPreset, AgentTaskDetailReferenceSource, AgentWorkflowShowcaseConfig, AgentWorkflowComposerContext, AgentWorkflowBusinessModeKey, AgentWorkflowBusinessModeConfig, AgentWorkflowBusinessModeSettings, AgentWorkflowMaterialQueryType, AgentWorkflowProductDetailChannel, AgentWorkflowReferenceQueryType, AgentWorkflowPageFeatureSettings, AgentWorkflowSelectedBrandModel, } from "@/types/agent"; import { createEmptyAgentModeWorkspaceState, } from "@/lib/agents/mode-workspace-state"; import { createEmptyAgentGenerationProjectWorkspaceState, normalizeAgentGenerationProjectPersistedState, } from "@/lib/agents/generation-project-state"; export interface AgentGenerationWorkspaceDarkV2Props { chatId: string; projectId: string; selectedAgentPresetId?: string | null; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; workflowContext?: AgentWorkflowComposerContext | null; workflowBusinessModeSettings?: AgentWorkflowBusinessModeSettings | null; workflowFeatureSettings?: AgentWorkflowPageFeatureSettings | null; workflowPromptOptimizationEnabled?: boolean; getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null; onBack?: () => void; brandStorePanel?: ReactNode; materialSelectionPanel?: ReactNode; brandModelPanel?: ReactNode; siteName?: string | null; siteLogo?: string | null; siteLogoDark?: string | null; syncGeneratedResultsToCanvas?: boolean; agentModeState?: AgentModeWorkspaceState | null; onAgentModeStateChange?: (next: AgentModeWorkspaceState) => void; onOpenCanvasEditor?: () => void; workspaceState?: AgentGenerationProjectWorkspaceState | null; onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void; resultCollections?: AgentGenerationProjectResultCollections | null; deletedResultCollections?: AgentGenerationProjectResultCollections | null; onResultPreviewUrlsChange?: ( modeType: AgentGenerationResultModeType, urls: string[] ) => void; onDeleteResultImage?: ( modeType: AgentGenerationResultModeType, imageUrl: string ) => void; topNavRightSlot?: ReactNode; showcaseConfig?: AgentWorkflowShowcaseConfig | null; workflowStylePanel?: ReactNode; } interface AgentWorkflowModelOption { id: string; displayName: string; providerType: string; supportsImageGen?: boolean; isMultimodal?: boolean; imageGenConfig?: any; } interface PickedAttachment extends Attachment {} type BatchDownloadScope = "scene" | "detail" | "sku"; type BatchDownloadTaskStatus = | "idle" | "starting" | "pending" | "running" | "completed" | "failed"; type BatchDownloadTaskSnapshot = { id: string; folderName: string; status: Exclude; stage: string; totalCount: number; processedCount: number; successCount: number; failedCount: number; progress: number; error: string; downloadUrl: string; expiresAt: number; }; type BatchDownloadDialogState = { scope: BatchDownloadScope; zipName: string; status: BatchDownloadTaskStatus; taskId: string; totalCount: number; processedCount: number; successCount: number; failedCount: number; progress: number; stage: string; error: string; downloadUrl: string; expiresAt: number; }; type DetailResultLocalEditTarget = { cardId: string; imageUrl: string; title: string; }; type DetailResultLocalEditRect = { x: number; y: number; width: number; height: number; }; const MAX_CHAT_TEXT_PART_LENGTH = 2000; const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000; const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; const MAX_AGENT_MODE_QUERY_LENGTH = 64; const MAX_AGENT_MODE_REFERENCE_COUNT = 40; const DIRECT_MODEL_UPLOAD_SLOT_COUNT = 4; const PRODUCT_DETAIL_CHANNEL_OPTIONS: Array<{ value: AgentWorkflowProductDetailChannel; label: string; }> = [ { value: "taobao", label: "淘宝" }, { value: "1688", label: "1688" }, { value: "douyin", label: "抖音" }, { value: "jd", label: "京东" }, ]; const CHAT_UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const ALLOWED_CHAT_MEDIA_TYPES = new Set([ "image/jpeg", "image/png", "image/webp", "image/gif", "image/jpg", "image/bmp", "image/svg+xml", ]); function isValidChatUuid(value?: string | null): value is string { return Boolean(value && CHAT_UUID_REGEX.test(String(value).trim())); } function isValidChatHttpUrl(value?: string | null): value is string { if (!value) return false; try { const parsed = new URL(String(value).trim()); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } function clampChatRequestText(value?: string | null, maxLength = MAX_CHAT_TEXT_PART_LENGTH): string { return String(value || "").slice(0, maxLength); } function normalizeChatAttachmentName(value?: string | null): string { const normalized = String(value || "").trim() || "参考图"; return normalized.slice(0, MAX_CHAT_ATTACHMENT_NAME_LENGTH); } function normalizeChatMediaType(value?: string | null): string { const normalized = String(value || "").trim().toLowerCase(); if (normalized === "image/pjpeg") return "image/jpeg"; if (normalized === "image/x-png") return "image/png"; if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) { return normalized; } return "image/jpeg"; } function normalizeBatchDownloadTaskSnapshot( value: unknown ): BatchDownloadTaskSnapshot | null { const record = (value || {}) as Record; const id = String(record.id || "").trim(); const status = String(record.status || "").trim(); if ( !id || (status !== "pending" && status !== "running" && status !== "completed" && status !== "failed") ) { return null; } return { id, folderName: String(record.folderName || "").trim(), status, stage: String(record.stage || "").trim(), totalCount: Math.max(0, Number(record.totalCount || 0)), processedCount: Math.max(0, Number(record.processedCount || 0)), successCount: Math.max(0, Number(record.successCount || 0)), failedCount: Math.max(0, Number(record.failedCount || 0)), progress: Math.max(0, Math.min(100, Number(record.progress || 0))), error: String(record.error || "").trim(), downloadUrl: String(record.downloadUrl || "").trim(), expiresAt: Math.max(0, Number(record.expiresAt || 0)), }; } function getPsdTemplateModeByResultMode(modeType: AgentGenerationResultModeType): AgentPsdTemplateMode { return modeType === "sku_replacement" ? "sku" : "main"; } function sanitizeChatAttachment( attachment?: PickedAttachment | null, ): PickedAttachment | null { if (!attachment || !isValidChatHttpUrl(attachment.url)) { return null; } return { ...attachment, url: attachment.url, name: normalizeChatAttachmentName(attachment.name), contentType: normalizeChatMediaType(attachment.contentType), assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined, }; } interface GeneratedImageGroup { messageId: string; imageUrls: string[]; text: string; replacementCardId?: string | null; } interface AgentModeReferenceSearchResponse { success?: boolean; data?: { query: string; queryType: AgentWorkflowReferenceQueryType; target: AgentTaskDetailReferenceSource; matchedSpu?: string | null; matchedSku?: string | null; brandName?: string | null; categoryName?: string | null; shopName?: string | null; channel?: AgentWorkflowProductDetailChannel | null; candidateCount: number; candidates: AgentModeReferenceCandidate[]; selectedCandidateId?: string | null; references: AgentModeWorkspaceState["detailReplacement"]["references"]; }; error?: { code?: string; message?: string; }; } const AI_DETAIL_GENERATION_DISABLED_MESSAGE = "开发中"; const IS_AI_DETAIL_GENERATION_DISABLED = true; const DEFAULT_DETAIL_REPLACEMENT_STRATEGY: AgentDetailReplacementStrategy = "scene_replace"; function getTaskDetailReferenceSourceLabel(source: AgentTaskDetailReferenceSource) { return source === "main" ? "主图" : source === "sku" ? "SKU图" : "详情图"; } function getResultModeTypeByBusinessMode( mode: AgentWorkflowBusinessModeConfig | null | undefined, method: "reference_replace" | "ai_detail_generate" ): AgentGenerationResultModeType { if (mode?.key !== "detail") { return "scene_generation"; } const canReferenceReplace = mode.referenceReplaceEnabled !== false; const canAiDetailGenerate = mode.aiDetailGenerateEnabled !== false && !IS_AI_DETAIL_GENERATION_DISABLED; if (method === "ai_detail_generate" && canAiDetailGenerate) { return "detail_generation"; } if (canReferenceReplace) { return "detail_replacement"; } return canAiDetailGenerate ? "detail_generation" : "detail_replacement"; } function normalizeResultCollectionUrls(urls?: string[] | null) { return Array.from( new Set( (urls || []) .map((item) => String(item || "").trim()) .filter((item) => /^https?:\/\//i.test(item)) ) ); } function excludeDeletedResultUrls( urls: string[] | undefined | null, deletedUrls: string[] | undefined | null ) { const deletedUrlSet = new Set(normalizeResultCollectionUrls(deletedUrls)); return normalizeResultCollectionUrls(urls).filter((url) => !deletedUrlSet.has(url)); } function getResultModeLabel(modeType: AgentGenerationResultModeType) { return modeType === "detail_generation" ? "AI详情" : modeType === "detail_replacement" ? "参考图替换" : modeType === "sku_replacement" ? "SKU替换" : "场景图"; } function areImageGenerationParamsEqual( left: ImageGenerationParams, right: ImageGenerationParams ) { return ( left.aspectRatio === right.aspectRatio && left.imageSize === right.imageSize && (left.imageCount || 1) === (right.imageCount || 1) && left.size === right.size && left.negativePrompt === right.negativePrompt && left.promptExtend === right.promptExtend && left.watermark === right.watermark && left.seed === right.seed ); } function arePickedAttachmentsEqual( left: Array, right: Array ) { if (left === right) return true; if (left.length !== right.length) return false; for (let index = 0; index < left.length; index += 1) { const leftItem = left[index]; const rightItem = right[index]; if (!leftItem && !rightItem) continue; if (!leftItem || !rightItem) return false; if ( leftItem.url !== rightItem.url || leftItem.name !== rightItem.name || leftItem.contentType !== rightItem.contentType || leftItem.assetId !== rightItem.assetId ) { return false; } } return true; } function areAttachmentValuesEqual( left: | PickedAttachment | { url?: string | null; name?: string | null; contentType?: string | null; assetId?: string | null; } | null | undefined, right: | PickedAttachment | { url?: string | null; name?: string | null; contentType?: string | null; assetId?: string | null; } | null | undefined ) { return ( String(left?.url || "").trim() === String(right?.url || "").trim() && String(left?.name || "").trim() === String(right?.name || "").trim() && String(left?.contentType || "").trim() === String(right?.contentType || "").trim() && String(left?.assetId || "").trim() === String(right?.assetId || "").trim() ); } function mergePersistedHistoryMessages( currentMessages: ChatMessage[], persistedMessages: ChatMessage[] ) { if (persistedMessages.length === 0) { return currentMessages; } if (currentMessages.length === 0) { return persistedMessages; } const persistedIds = new Set(persistedMessages.map((message) => message.id)); const extraLocalMessages = currentMessages.filter( (message) => !persistedIds.has(message.id) ); const mergedMessages = [...persistedMessages, ...extraLocalMessages]; if ( mergedMessages.length === currentMessages.length && mergedMessages.every((message, index) => currentMessages[index]?.id === message.id) ) { return currentMessages; } return mergedMessages; } function getAttachmentIdentityKey( attachment: | PickedAttachment | { url?: string | null; assetId?: string | null; } | null | undefined ) { const assetId = String(attachment?.assetId || "").trim(); if (assetId) { return `asset:${assetId}`; } const url = String(attachment?.url || "").trim(); return url ? `url:${url}` : null; } function toWorkflowReferenceAttachment(params: { url?: string | null; name?: string | null; label?: string | null; mimeType?: string | null; assetId?: string | null; }): PickedAttachment | null { const url = String(params.url || "").trim(); if (!isValidChatHttpUrl(url)) { return null; } return { url, name: normalizeChatAttachmentName(params.name || params.label || "参考图"), contentType: normalizeChatMediaType(params.mimeType || "image/png"), assetId: isValidChatUuid(params.assetId) ? params.assetId || undefined : undefined, }; } type ToolStepKey = | "preset" | "brand-model" | "materials" | "prompt" | "settings"; function buildPreviewImageUrl(url: string, width: number) { return buildImageProxyUrl(url, { width, quality: 86, format: "webp" }); } const AGENT_DEFERRED_SECTION_STYLE = { contentVisibility: "auto", containIntrinsicSize: "1px 760px", } as CSSProperties; const AGENT_DEFERRED_RESULTS_STYLE = { contentVisibility: "auto", containIntrinsicSize: "1px 900px", } as CSSProperties; const AGENT_DEFERRED_LIST_CARD_STYLE = { contentVisibility: "auto", containIntrinsicSize: "1px 320px", } as CSSProperties; function handlePreviewImageLoadError( event: SyntheticEvent, originalUrl: string ) { const fallbackUrl = String(originalUrl || "").trim(); if (!fallbackUrl) { return; } const image = event.currentTarget; if (image.dataset.previewFallbackApplied === "1") { return; } image.dataset.previewFallbackApplied = "1"; image.src = fallbackUrl; } 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 normalizeAssistantText(value: string) { return String(value || "") .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") .replace(/\n*\n*/g, "") .replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "") .trim(); } function extractReplacementCardIdFromAssistantText(value: string) { const match = String(value || "").match(//); return match?.[1]?.trim() || null; } function hasLikelyGenerationErrorText(value: string | null | undefined) { const normalized = normalizeAssistantText(String(value || "")); if (!normalized) { return false; } return /失败|超时|重试|error|failed/i.test(normalized); } 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; }); } type DirectDetailGenerationImage = { originalUrl: string; thumbnailUrl: string; title?: string; subtitle?: string; copy?: string; }; type DirectDetailReviewResult = { reviewAction?: "pass" | "protocol_patch" | "regenerate_image"; revisedPrompt?: string; issues?: string[]; uiValidation?: { passed?: boolean; issues?: string[]; }; }; const AGENT_IMAGE_TASK_POLL_INTERVAL_MS = 1600; const AGENT_IMAGE_TASK_TIMEOUT_MS = 4 * 60 * 1000; const AGENT_DETAIL_IMAGE_PARALLEL_WORKER_COUNT = 4; function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function DetailResultLocalEditDialog({ submitting, target, onClose, onSubmit, }: { submitting: boolean; target: DetailResultLocalEditTarget | null; onClose: () => void; onSubmit: (params: { imageUrl: string; prompt: string; rect: DetailResultLocalEditRect; title: string; }) => void; }) { const [prompt, setPrompt] = useState(""); const [rect, setRect] = useState(null); const dragStartRef = useRef<{ x: number; y: number } | null>(null); useEffect(() => { if (!target) { setPrompt(""); setRect(null); dragStartRef.current = null; } }, [target]); const getPoint = useCallback((event: React.PointerEvent) => { const bounds = event.currentTarget.getBoundingClientRect(); const x = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0; const y = bounds.height > 0 ? (event.clientY - bounds.top) / bounds.height : 0; return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)), }; }, []); const updateRect = useCallback( (event: React.PointerEvent) => { const start = dragStartRef.current; if (!start) return; const point = getPoint(event); const left = Math.min(start.x, point.x); const top = Math.min(start.y, point.y); setRect({ x: left, y: top, width: Math.max(0.01, Math.abs(point.x - start.x)), height: Math.max(0.01, Math.abs(point.y - start.y)), }); }, [getPoint] ); const handleSubmit = useCallback(() => { if (!target || !rect) return; const normalizedPrompt = prompt.trim(); if (!normalizedPrompt) { toast.error("请填写局部编辑提示词"); return; } if (rect.width < 0.025 || rect.height < 0.025) { toast.error("请框选更明确的编辑区域"); return; } onSubmit({ imageUrl: target.imageUrl, prompt: normalizedPrompt, rect, title: target.title, }); }, [onSubmit, prompt, rect, target]); return ( (!open ? onClose() : undefined)}> 局部编辑详情图 在图片上拖拽框选需要编辑的位置,并填写局部修改要求。
{target?.imageUrl ? (
{ updateRect(event); dragStartRef.current = null; }} onPointerDown={(event) => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); const point = getPoint(event); dragStartRef.current = point; setRect({ x: point.x, y: point.y, width: 0.01, height: 0.01 }); }} onPointerMove={(event) => { if (!dragStartRef.current) return; event.preventDefault(); updateRect(event); }} onPointerUp={(event) => { updateRect(event); dragStartRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }} > {target.title} {rect ? (
) : null}
) : null}
框选区域后,输入你想改的内容。处理完成的新图会自动加入 AI详情结果区。