diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index cd7077efb..4843e6748 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -68,6 +68,16 @@ import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-s 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"; @@ -233,11 +243,18 @@ type DetailResultLocalEditRect = { 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; @@ -715,6 +732,58 @@ 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, @@ -724,24 +793,31 @@ function DetailResultLocalEditDialog({ submitting: boolean; target: DetailResultLocalEditTarget | null; onClose: () => void; - onSubmit: (params: { - imageUrl: string; - prompt: string; - rect: DetailResultLocalEditRect; - title: string; - }) => void; + onSubmit: (params: DetailResultLocalEditSubmitParams) => void; }) { - const [prompt, setPrompt] = useState(""); - const [rect, setRect] = useState(null); + 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(() => { - if (!target) { - setPrompt(""); - setRect(null); - dragStartRef.current = null; + setRegions([]); + setDraftRect(null); + setActiveRegionId(null); + setUploadingRegionId(null); + dragStartRef.current = null; + }, [target?.cardId, target?.imageUrl]); + + useEffect(() => { + if (regions.length === 0) { + if (activeRegionId) setActiveRegionId(null); + return; } - }, [target]); + 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(); @@ -753,112 +829,448 @@ function DetailResultLocalEditDialog({ }; }, []); - const updateRect = useCallback( + 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); - 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)), - }); + setDraftRect(createRectFromPoints(start, point)); }, - [getPoint] + [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 || !rect) return; - const normalizedPrompt = prompt.trim(); - if (!normalizedPrompt) { - toast.error("请填写局部编辑提示词"); + if (!target) return; + if (uploadingRegionId) { + toast.info("参考图上传中,请稍候"); return; } - if (rect.width < 0.025 || rect.height < 0.025) { - toast.error("请框选更明确的编辑区域"); + + 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, - prompt: normalizedPrompt, - rect, title: target.title, + regions: validRegions, }); - }, [onSubmit, prompt, rect, target]); + }, [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 ? (
{ - 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); + 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} - {rect ? ( + {regions.map((region) => { + const isActive = region.id === activeRegionId; + return ( + + ); + })} + {draftRect ? (
) : null}
) : null}
-
+
- 框选区域后,输入你想改的内容。处理完成的新图会自动加入 AI详情结果区。 + 拖拽图片添加区域。最多 {LOCAL_EDIT_MAX_REGIONS} 个区域,每个区域最多{" "} + {LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图。{completionCopy}
-