"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 { LOCAL_EDIT_COLORS, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION, LOCAL_EDIT_MAX_REGIONS, createLocalEditReferenceImageId, createLocalEditRegionId, 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, 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; resultModeType: "detail_generation" | "detail_replacement"; }; type DetailResultLocalEditRect = { x: number; y: number; width: number; height: number; }; type DetailResultLocalEditSubmitParams = { imageUrl: string; title: string; regions: LocalEditRegion[]; }; 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 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 isHttpImageUrl(url?: string) { return /^https?:\/\//i.test(String(url || "").trim()); } 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 DetailResultLocalEditDialog({ submitting, target, onClose, onSubmit, }: { submitting: boolean; target: DetailResultLocalEditTarget | null; onClose: () => void; onSubmit: (params: DetailResultLocalEditSubmitParams) => void; }) { const [regions, setRegions] = useState([]); const [draftRect, setDraftRect] = useState(null); const [activeRegionId, setActiveRegionId] = useState(null); const [uploadingRegionId, setUploadingRegionId] = useState(null); const dragStartRef = useRef<{ x: number; y: number } | null>(null); useEffect(() => { setRegions([]); setDraftRect(null); setActiveRegionId(null); setUploadingRegionId(null); dragStartRef.current = null; }, [target?.cardId, target?.imageUrl]); 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 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 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(0.01, Math.abs(point.x - start.x))), height: Math.min(1 - top, Math.max(0.01, Math.abs(point.y - start.y))), }; }, [] ); 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: 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; } const emptyPromptRegion = validRegions.find((region) => !region.prompt); if (emptyPromptRegion) { setActiveRegionId(emptyPromptRegion.id); toast.error(`请填写区域 ${emptyPromptRegion.index} 的提示词`); return; } onSubmit({ imageUrl: target.imageUrl, title: target.title, regions: validRegions, }); }, [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); dragStartRef.current = null; setDraftRect(null); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } if (nextRect.width < 0.025 || nextRect.height < 0.025) { toast.info("请拖拽框选更明确的编辑区域"); return; } const nextRegion: LocalEditRegion = { id: createLocalEditRegionId(), index: regions.length + 1, color: LOCAL_EDIT_COLORS[regions.length % LOCAL_EDIT_COLORS.length], prompt: "", 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, getPoint, regions.length] ); const completionCopy = target?.resultModeType === "detail_replacement" ? "处理完成后会更新当前套详情结果图。" : "处理完成后会加入 AI详情结果区。"; const isBusy = submitting || Boolean(uploadingRegionId); return ( (!open ? onClose() : undefined)}> 局部编辑详情图 在图片上拖拽框选编辑位置,可添加多个区域,并为每个区域上传专属参考图。
{target?.imageUrl ? (
{ updateDraftRect(event); dragStartRef.current = null; setDraftRect(null); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }} onPointerDown={(event) => { if (event.button !== 0 || isBusy) return; if (regions.length >= LOCAL_EDIT_MAX_REGIONS) { toast.info(`局部编辑最多支持 ${LOCAL_EDIT_MAX_REGIONS} 个区域`); return; } event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); const point = getPoint(event); dragStartRef.current = point; 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; return ( ); })} {draftRect ? (
) : null}
) : null}
拖拽图片添加区域。最多 {LOCAL_EDIT_MAX_REGIONS} 个区域,每个区域最多{" "} {LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图。{completionCopy}
{regions.length === 0 ? (
在左侧图片上拖拽框选后,这里会显示区域提示词和参考图。
) : (
{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} 参考图 {references.length}/ {LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION}