"use client"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode, type SyntheticEvent, } from "react"; import useSWR from "swr"; import { motion, AnimatePresence } from "framer-motion"; import { ArrowUp, CheckCircle2, Droplet, RefreshCw, ChevronDown, ChevronUp, Download, GitCompareArrows, ImagePlus, LayoutTemplate, 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 { AgentAiTemplateDialog, type AgentAiTemplateResult, } from "@/components/agents/AgentAiTemplateDialog"; import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog"; import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; import { applyUploadedDetailReferenceToCard, createUploadedDetailReferenceCard, uploadDetailReferenceImageFile, type UploadedDetailReferenceImage, } from "@/components/agents/detail-reference-upload"; 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 { LOCAL_EDIT_COLORS, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION, LOCAL_EDIT_MAX_REGIONS, createLocalEditReferenceImageId, createLocalEditRegionId, hasScaleAdjustTargetChanged, reindexLocalEditRegions, type LocalEditReferenceImage, type LocalEditRegion, } from "@/components/canvas/nodes/image-node/image-node-local-edit"; 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, AgentGenerationProjectReplacementResultState, AgentGenerationReplacementResultRecord, 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, normalizeAgentGenerationProjectReplacementResultState, } 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; replacementResultState?: AgentGenerationProjectReplacementResultState | null; onReplacementResultStateChange?: ( next: AgentGenerationProjectReplacementResultState ) => void; onResultPreviewUrlsChange?: ( modeType: AgentGenerationResultModeType, urls: string[] ) => void; onDeleteResultImage?: ( modeType: AgentGenerationResultModeType, imageUrl: string ) => void; onPersistProject?: () => Promise | boolean; 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; mode?: DetailResultLocalEditMode; title: string; resultModeType: "detail_generation" | "detail_replacement"; }; type DetailResultLocalEditMode = "local_edit" | "scale_adjust"; type DetailResultLocalEditImageSize = "1K" | "2K"; type DetailResultLocalEditRect = { x: number; y: number; width: number; height: number; }; type DetailResultLocalEditRegionHandle = "move" | "nw" | "ne" | "sw" | "se"; type DetailResultLocalEditRegionInteractionTarget = "source" | "scaleTarget"; type DetailResultLocalEditRegionInteraction = { handle: DetailResultLocalEditRegionHandle; pointerId: number; regionId: string; startPoint: { x: number; y: number }; startRect: DetailResultLocalEditRect; target: DetailResultLocalEditRegionInteractionTarget; }; type DetailResultLocalEditSubmitParams = { imageUrl: string; imageSize: DetailResultLocalEditImageSize; mode: DetailResultLocalEditMode; title: string; regions: LocalEditRegion[]; }; type DetailResultLocalEditPreviewResult = { cardId: string; resultModeType: "detail_generation" | "detail_replacement"; sourceImageUrl: string; resultImageUrl: string; mode: DetailResultLocalEditMode; title: string; }; 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 DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = 30 * 1024 * 1024; const REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES = 4; const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025; const DETAIL_SCALE_TARGET_MIN_PERCENT = -80; const DETAIL_SCALE_TARGET_MAX_PERCENT = 200; 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 areResultUrlListsEqual(left?: string[] | null, right?: string[] | null) { const normalizedLeft = normalizeResultCollectionUrls(left); const normalizedRight = normalizeResultCollectionUrls(right); return ( normalizedLeft.length === normalizedRight.length && normalizedLeft.every((url, index) => normalizedRight[index] === url) ); } 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; compareSourceUrl?: string | null; }; 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 = 15 * 60 * 1000; const AGENT_DETAIL_IMAGE_PARALLEL_WORKER_COUNT = 4; function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function isHttpImageUrl(url?: string) { return /^https?:\/\//i.test(String(url || "").trim()); } function normalizeResultCompareSourceUrl( resultUrl?: string | null, sourceUrl?: string | null ) { const normalizedResultUrl = String(resultUrl || "").trim(); const normalizedSourceUrl = String(sourceUrl || "").trim(); if (!isHttpImageUrl(normalizedSourceUrl)) { return null; } if (normalizedResultUrl && normalizedSourceUrl === normalizedResultUrl) { return null; } return normalizedSourceUrl; } function getDetailLocalEditUploadFileName(file: File) { const extension = file.name.toLowerCase().split(".").pop() || "jpg"; const baseName = file.name .replace(/\.[^.]+$/, "") .replace(/[^\u4e00-\u9fa5a-zA-Z0-9_-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48) || "detail-local-edit-ref"; return `${baseName}-${Date.now()}.${extension}`; } async function uploadDetailLocalEditReferenceImage( file: File ): Promise { const isImageFile = file.type.startsWith("image/") || /\.(jpe?g|png|webp|heic|heif)$/i.test(file.name); if (!isImageFile) { throw new Error("请上传图片文件"); } if (file.size > DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES) { throw new Error("参考图过大,请先压缩到 30MB 以内"); } const formData = new FormData(); formData.append("file", file, getDetailLocalEditUploadFileName(file)); formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.url) { throw new Error(payload?.error || "参考图上传失败"); } const uploadedUrl = String(payload.url || "").trim(); if (!isHttpImageUrl(uploadedUrl)) { throw new Error("参考图上传后未返回可用地址"); } return { id: createLocalEditReferenceImageId(), url: uploadedUrl, name: file.name || "区域参考图", source: "upload", createdAt: Date.now(), }; } function getDetailScaleAdjustRegionPrompt( _scalePercent: number | null, hasTargetPosition = false ) { return [ "按目标框线对框选范围内的主物体执行等比比例调整。", hasTargetPosition ? "主体框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出主体框,并由周边背景补全衔接。" : "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。", "只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。", "目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。", ].join(" "); } function deriveDetailScalePercentFromRects( sourceRect: DetailResultLocalEditRect, targetRect: DetailResultLocalEditRect | null | undefined ) { if (!targetRect || sourceRect.width <= 0 || sourceRect.height <= 0) { return 0; } const widthRatio = Math.max(0.05, targetRect.width / sourceRect.width); const heightRatio = Math.max(0.05, targetRect.height / sourceRect.height); const uniformRatio = Math.sqrt(widthRatio * heightRatio); const percent = Math.round((uniformRatio - 1) * 100); return Math.min( DETAIL_SCALE_TARGET_MAX_PERCENT, Math.max(DETAIL_SCALE_TARGET_MIN_PERCENT, percent) ); } function DetailResultLocalEditDialog({ previewResult, submitting, target, onClose, onConfirmResult, onResetResult, onSubmit, }: { previewResult: DetailResultLocalEditPreviewResult | null; submitting: boolean; target: DetailResultLocalEditTarget | null; onClose: () => void; onConfirmResult: () => void; onResetResult: () => void; onSubmit: (params: DetailResultLocalEditSubmitParams) => void; }) { const [regions, setRegions] = useState([]); const [draftRect, setDraftRect] = useState(null); const [activeRegionId, setActiveRegionId] = useState(null); const [regionInteraction, setRegionInteraction] = useState(null); const [uploadingRegionId, setUploadingRegionId] = useState(null); const [drawingTargetRegionId, setDrawingTargetRegionId] = useState(null); const [mode, setMode] = useState("local_edit"); const [imageSize, setImageSize] = useState("1K"); const dragStartRef = useRef<{ x: number; y: number } | null>(null); const editSurfaceRef = useRef(null); const isScaleAdjustMode = mode === "scale_adjust"; const activePreviewResult = previewResult && target && previewResult.cardId === target.cardId && previewResult.mode === mode ? previewResult : null; const isBusy = submitting || Boolean(uploadingRegionId); const pendingScaleTargetRegion = isScaleAdjustMode ? regions.find((region) => !region.targetRect) : undefined; const isDrawingScaleTarget = isScaleAdjustMode && Boolean(drawingTargetRegionId); useEffect(() => { setMode(target?.mode === "scale_adjust" ? "scale_adjust" : "local_edit"); setRegions([]); setDraftRect(null); setActiveRegionId(null); setRegionInteraction(null); setUploadingRegionId(null); setDrawingTargetRegionId(null); setImageSize("1K"); dragStartRef.current = null; }, [target?.cardId, target?.imageUrl, target?.mode]); const handleModeChange = useCallback( (nextMode: DetailResultLocalEditMode) => { if (nextMode === mode || submitting || activePreviewResult) return; setMode(nextMode); setRegions([]); setDraftRect(null); setActiveRegionId(null); setRegionInteraction(null); setUploadingRegionId(null); setDrawingTargetRegionId(null); dragStartRef.current = null; }, [activePreviewResult, mode, submitting] ); useEffect(() => { if (regions.length === 0) { if (activeRegionId) setActiveRegionId(null); return; } if (!activeRegionId || !regions.some((region) => region.id === activeRegionId)) { setActiveRegionId(regions[0]?.id || null); } }, [activeRegionId, regions]); const getPoint = useCallback((event: React.PointerEvent) => { const pointSource = editSurfaceRef.current || event.currentTarget; const bounds = pointSource.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 createRectFromPoints = useCallback( (start: { x: number; y: number }, point: { x: number; y: number }) => { const left = Math.min(start.x, point.x); const top = Math.min(start.y, point.y); return { x: left, y: top, width: Math.min( 1 - left, Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, Math.abs(point.x - start.x)) ), height: Math.min( 1 - top, Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, Math.abs(point.y - start.y)) ), }; }, [] ); const clampRegionRect = useCallback((rect: DetailResultLocalEditRect) => { const width = Math.min(1, Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, rect.width)); const height = Math.min(1, Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, rect.height)); return { x: Math.min(1 - width, Math.max(0, rect.x)), y: Math.min(1 - height, Math.max(0, rect.y)), width, height, }; }, []); const updateRegionRect = useCallback( ( regionId: string, rect: DetailResultLocalEditRect, targetKind: DetailResultLocalEditRegionInteractionTarget = "source" ) => { const nextRect = clampRegionRect(rect); setRegions((current) => current.map((region) => region.id === regionId ? targetKind === "scaleTarget" ? { ...region, targetRect: nextRect, prompt: getDetailScaleAdjustRegionPrompt(null, true), } : { ...region, rect: nextRect, prompt: isScaleAdjustMode && region.targetRect ? getDetailScaleAdjustRegionPrompt( null, true ) : region.prompt, } : region ) ); }, [clampRegionRect, isScaleAdjustMode] ); const calculateInteractionRect = useCallback( ( interaction: DetailResultLocalEditRegionInteraction, point: { x: number; y: number } ) => { const dx = point.x - interaction.startPoint.x; const dy = point.y - interaction.startPoint.y; const start = interaction.startRect; if (interaction.handle === "move") { return clampRegionRect({ ...start, x: start.x + dx, y: start.y + dy, }); } const leftHandle = interaction.handle === "nw" || interaction.handle === "sw"; const rightHandle = interaction.handle === "ne" || interaction.handle === "se"; const topHandle = interaction.handle === "nw" || interaction.handle === "ne"; const bottomHandle = interaction.handle === "sw" || interaction.handle === "se"; let left = start.x; let right = start.x + start.width; let top = start.y; let bottom = start.y + start.height; if (leftHandle) left = Math.min(right - DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, start.x + dx); if (rightHandle) right = Math.max(left + DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, right + dx); if (topHandle) top = Math.min(bottom - DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, start.y + dy); if (bottomHandle) bottom = Math.max(top + DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, bottom + dy); left = Math.max(0, left); top = Math.max(0, top); right = Math.min(1, right); bottom = Math.min(1, bottom); return clampRegionRect({ x: left, y: top, width: right - left, height: bottom - top, }); }, [clampRegionRect] ); const updateDraftRect = useCallback( (event: React.PointerEvent) => { const start = dragStartRef.current; if (!start) return; const point = getPoint(event); setDraftRect(createRectFromPoints(start, point)); }, [createRectFromPoints, getPoint] ); const updateRegionPrompt = useCallback((regionId: string, prompt: string) => { setRegions((current) => current.map((region) => (region.id === regionId ? { ...region, prompt } : region)) ); }, []); const removeRegion = useCallback((regionId: string) => { setRegions((current) => reindexLocalEditRegions(current.filter((region) => region.id !== regionId)) ); }, []); const removeReferenceImage = useCallback((regionId: string, referenceId: string) => { setRegions((current) => current.map((region) => region.id === regionId ? { ...region, referenceImages: (region.referenceImages || []).filter( (reference) => reference.id !== referenceId ), } : region ) ); }, []); const handleReferenceUpload = useCallback( async (regionId: string, fileList: FileList | null) => { const files = Array.from(fileList || []).filter( (file) => file.type.startsWith("image/") || /\.(jpe?g|png|webp|heic|heif)$/i.test(file.name) ); if (files.length === 0) return; const currentRegion = regions.find((region) => region.id === regionId); if (!currentRegion) return; const currentReferences = currentRegion.referenceImages || []; const remainingSlots = LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION - currentReferences.length; if (remainingSlots <= 0) { toast.info( `每个局部编辑区域最多添加 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图` ); return; } const selectedFiles = files.slice(0, remainingSlots); if (files.length > selectedFiles.length) { toast.info( `已自动保留前 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图` ); } setUploadingRegionId(regionId); try { const uploadedReferences = await Promise.all( selectedFiles.map((file) => uploadDetailLocalEditReferenceImage(file)) ); setRegions((current) => reindexLocalEditRegions( current.map((region) => region.id === regionId ? { ...region, referenceImages: [ ...(region.referenceImages || []), ...uploadedReferences, ].slice(0, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION), } : region ) ) ); toast.success( uploadedReferences.length > 1 ? `已添加 ${uploadedReferences.length} 张区域参考图` : "已添加区域参考图" ); } catch (error) { toast.error(error instanceof Error ? error.message : "参考图上传失败"); } finally { setUploadingRegionId(null); } }, [regions] ); const handleSubmit = useCallback(() => { if (!target) return; if (uploadingRegionId) { toast.info("参考图上传中,请稍候"); return; } const validRegions = reindexLocalEditRegions( regions.map((region) => ({ ...region, prompt: isScaleAdjustMode ? getDetailScaleAdjustRegionPrompt( deriveDetailScalePercentFromRects(region.rect, region.targetRect), Boolean(region.targetRect) ) : region.prompt.trim(), referenceImages: (region.referenceImages || []).slice( 0, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION ), })) ); if (validRegions.length === 0) { toast.error("请先框选至少一个局部编辑区域"); return; } const invalidRegion = validRegions.find( (region) => region.rect.width < 0.025 || region.rect.height < 0.025 ); if (invalidRegion) { setActiveRegionId(invalidRegion.id); toast.error(`区域 ${invalidRegion.index} 太小,请重新框选`); return; } if (isScaleAdjustMode) { const missingTargetRegion = validRegions.find((region) => !region.targetRect); if (missingTargetRegion) { setActiveRegionId(missingTargetRegion.id); toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`); return; } const unchangedRegion = validRegions.find( (region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect) ); if (unchangedRegion) { setActiveRegionId(unchangedRegion.id); toast.error(`区域 ${unchangedRegion.index} 的目标框大小和位置均未变化`); return; } } else { const emptyPromptRegion = validRegions.find((region) => !region.prompt); if (emptyPromptRegion) { setActiveRegionId(emptyPromptRegion.id); toast.error(`请填写区域 ${emptyPromptRegion.index} 的提示词`); return; } } onSubmit({ imageUrl: target.imageUrl, imageSize, mode, title: target.title, regions: validRegions, }); }, [imageSize, isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]); const completeDraftRegion = useCallback( (event: React.PointerEvent) => { const start = dragStartRef.current; if (!start) return; const point = getPoint(event); const nextRect = createRectFromPoints(start, point); const targetRegionId = drawingTargetRegionId; dragStartRef.current = null; setDraftRect(null); setDrawingTargetRegionId(null); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } if ( nextRect.width < DETAIL_LOCAL_EDIT_MIN_RECT_RATIO || nextRect.height < DETAIL_LOCAL_EDIT_MIN_RECT_RATIO ) { toast.info("请拖拽框选更明确的编辑区域"); return; } if (isScaleAdjustMode && targetRegionId) { setRegions((current) => reindexLocalEditRegions( current.map((region) => region.id === targetRegionId ? { ...region, targetRect: nextRect, prompt: getDetailScaleAdjustRegionPrompt(null, true), } : region ) ) ); setActiveRegionId(targetRegionId); return; } const nextRegion: LocalEditRegion = { id: createLocalEditRegionId(), index: regions.length + 1, color: LOCAL_EDIT_COLORS[regions.length % LOCAL_EDIT_COLORS.length], prompt: isScaleAdjustMode ? getDetailScaleAdjustRegionPrompt(null) : "", rect: nextRect, referenceImages: [], createdAt: Date.now(), }; setRegions((current) => { if (current.length >= LOCAL_EDIT_MAX_REGIONS) return current; return reindexLocalEditRegions([...current, nextRegion]); }); setActiveRegionId(nextRegion.id); }, [createRectFromPoints, drawingTargetRegionId, getPoint, isScaleAdjustMode, regions.length] ); const handleRegionInteractionStart = useCallback( ( event: React.PointerEvent, region: LocalEditRegion, fallbackHandle: DetailResultLocalEditRegionHandle = "move", targetKind: DetailResultLocalEditRegionInteractionTarget = "source", targetRect?: DetailResultLocalEditRect | null ) => { if (isBusy) return; const targetElement = event.target as HTMLElement | null; const requestedHandle = targetElement?.dataset ?.detailRegionHandle as DetailResultLocalEditRegionHandle | undefined; const handle = requestedHandle || fallbackHandle; event.preventDefault(); event.stopPropagation(); setActiveRegionId(region.id); dragStartRef.current = null; setDraftRect(null); setDrawingTargetRegionId(null); event.currentTarget.setPointerCapture(event.pointerId); setRegionInteraction({ handle, pointerId: event.pointerId, regionId: region.id, startPoint: getPoint(event), startRect: targetKind === "scaleTarget" ? targetRect || region.rect : region.rect, target: targetKind, }); }, [getPoint, isBusy] ); const handleRegionInteractionMove = useCallback( (event: React.PointerEvent, regionId: string) => { if ( !regionInteraction || regionInteraction.pointerId !== event.pointerId || regionInteraction.regionId !== regionId ) { return; } event.preventDefault(); event.stopPropagation(); updateRegionRect( regionId, calculateInteractionRect(regionInteraction, getPoint(event)), regionInteraction.target ); }, [calculateInteractionRect, getPoint, regionInteraction, updateRegionRect] ); const handleRegionInteractionEnd = useCallback( (event: React.PointerEvent, regionId: string) => { if ( regionInteraction && regionInteraction.pointerId === event.pointerId && regionInteraction.regionId === regionId ) { event.preventDefault(); event.stopPropagation(); updateRegionRect( regionId, calculateInteractionRect(regionInteraction, getPoint(event)), regionInteraction.target ); setRegionInteraction(null); } if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }, [calculateInteractionRect, getPoint, regionInteraction, updateRegionRect] ); const completionCopy = target?.resultModeType === "detail_replacement" ? "处理完成后会更新当前套详情结果图。" : "处理完成后会加入 AI详情结果区。"; const dialogTitle = "编辑详情图"; const dialogDescription = isScaleAdjustMode ? "比例模式:先框主体,再框目标大小和位置。" : "局部模式:框选要修改的位置,填写提示词,并可添加参考图。"; const activeScaleRegion = isScaleAdjustMode ? regions.find((region) => region.id === activeRegionId) || null : null; return ( (!open ? onClose() : undefined)}> {dialogTitle} {dialogDescription} {!activePreviewResult ? (
) : null}
{activePreviewResult?.resultImageUrl ? (
处理完成
) : target?.imageUrl ? (
{ updateDraftRect(event); dragStartRef.current = null; setDraftRect(null); setDrawingTargetRegionId(null); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }} onPointerDown={(event) => { if (event.button !== 0 || isBusy) return; const targetRegion = isScaleAdjustMode ? regions.find((region) => !region.targetRect) : undefined; if (!targetRegion && regions.length >= LOCAL_EDIT_MAX_REGIONS) { toast.info( `${isScaleAdjustMode ? "比例调整" : "局部编辑"}最多支持 ${LOCAL_EDIT_MAX_REGIONS} 个区域` ); return; } event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); const point = getPoint(event); dragStartRef.current = point; setDrawingTargetRegionId(targetRegion?.id || null); if (targetRegion) setActiveRegionId(targetRegion.id); setDraftRect({ x: point.x, y: point.y, width: 0.01, height: 0.01 }); }} onPointerMove={(event) => { if (!dragStartRef.current) return; event.preventDefault(); updateDraftRect(event); }} onPointerUp={(event) => { completeDraftRegion(event); }} > {target.title} {regions.map((region) => { const isActive = region.id === activeRegionId; const isAwaitingScaleTarget = isScaleAdjustMode && !region.targetRect; return ( ); })} {activeScaleRegion ? ( ) : null} {isScaleAdjustMode ? regions .filter((region) => region.targetRect) .map((region) => { const sourceRect = region.rect; const targetRect = region.targetRect as DetailResultLocalEditRect; const markerId = `detail-scale-arrow-${region.id}`; const connectorPoints = [ { key: "nw", source: { x: sourceRect.x, y: sourceRect.y }, target: { x: targetRect.x, y: targetRect.y }, }, { key: "ne", source: { x: sourceRect.x + sourceRect.width, y: sourceRect.y, }, target: { x: targetRect.x + targetRect.width, y: targetRect.y, }, }, { key: "sw", source: { x: sourceRect.x, y: sourceRect.y + sourceRect.height, }, target: { x: targetRect.x, y: targetRect.y + targetRect.height, }, }, { key: "se", source: { x: sourceRect.x + sourceRect.width, y: sourceRect.y + sourceRect.height, }, target: { x: targetRect.x + targetRect.width, y: targetRect.y + targetRect.height, }, }, ]; return ( ); }) : null} {isScaleAdjustMode ? regions .filter((region) => region.targetRect) .map((region) => { const targetRect = region.targetRect as DetailResultLocalEditRect; const isActive = region.id === activeRegionId; return ( ); }) : null} {draftRect ? (
) : null} {submitting ? ( ) : null}
) : null}
{isScaleAdjustMode ? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}` : `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`}
{!activePreviewResult ? (
输出尺寸
{( [ { label: "快速 1K", value: "1K" }, { label: "高清 2K", value: "2K" }, ] as const ).map((option) => ( ))}
) : null} {!activePreviewResult && isScaleAdjustMode ? (
目标框
{pendingScaleTargetRegion ? `请为区域 ${pendingScaleTargetRegion.index} 框选目标大小和位置。` : regions.length > 0 ? "目标框已补齐,可拖动或拉伸继续调整。" : "先在左侧框选要调整比例的主体。"}
) : null} {activePreviewResult ? (
处理结果已生成
左侧展示的是本次处理后的图片。可以开启划线对比检查原图与新图差异;不满意可重新处理,满意后再确认应用到结果区。
) : regions.length === 0 ? (
{isScaleAdjustMode ? "先框主体,再框目标大小和位置。" : "在左侧图片上拖拽框选后,这里会显示区域提示词和参考图。"}
) : (
{regions.map((region) => { const isActive = region.id === activeRegionId; const references = region.referenceImages || []; const canUpload = !isBusy && references.length < LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION; return (
setActiveRegionId(region.id)} >
{region.index} 区域 {region.index} {isScaleAdjustMode ? ( {region.targetRect ? "目标框已设置" : "待框目标框"} ) : ( 参考图 {references.length}/ {LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} )}
{isScaleAdjustMode ? (
主体框 已框选
目标框 {region.targetRect ? "已设置" : "待框选"}
点击此卡片可聚焦区域,删除按钮会同时移除主体框和目标框。
) : ( <>