fix: complete detail local edit workflow

This commit is contained in:
134 2026-06-16 21:55:01 +08:00
parent 553e5b5373
commit 2807dcf9e3

View File

@ -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<LocalEditReferenceImage> {
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<DetailResultLocalEditRect | null>(null);
const [regions, setRegions] = useState<LocalEditRegion[]>([]);
const [draftRect, setDraftRect] = useState<DetailResultLocalEditRect | null>(null);
const [activeRegionId, setActiveRegionId] = useState<string | null>(null);
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<Dialog open={Boolean(target)} onOpenChange={(open) => (!open ? onClose() : undefined)}>
<DialogContent className="max-w-4xl border-white/10 bg-[#101214] text-white">
<DialogContent className="max-w-6xl border-white/10 bg-[#101214] text-white">
<DialogHeader>
<DialogTitle>局部编辑详情图</DialogTitle>
<DialogDescription className="text-white/50">
在图片上拖拽框选需要编辑的位置,并填写局部修改要求。
在图片上拖拽框选编辑位置,可添加多个区域,并为每个区域上传专属参考图。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_280px]">
<div className="flex min-h-[420px] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35 p-3">
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex min-h-[520px] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35 p-3">
{target?.imageUrl ? (
<div
className="relative max-h-[62vh] max-w-full touch-none select-none overflow-hidden rounded-xl"
className="relative max-h-[70vh] max-w-full touch-none select-none overflow-hidden rounded-xl"
onPointerCancel={(event) => {
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);
}}
>
<img
alt={target.title}
className="block max-h-[62vh] max-w-full object-contain"
className="block max-h-[70vh] max-w-full object-contain"
draggable={false}
src={buildImageProxyUrl(target.imageUrl, { width: 1200, quality: 88 })}
/>
{rect ? (
{regions.map((region) => {
const isActive = region.id === activeRegionId;
return (
<button
className="absolute rounded-sm border-2 bg-black/0 text-left transition"
key={region.id}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setActiveRegionId(region.id);
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
setActiveRegionId(region.id);
}}
style={{
borderColor: region.color,
boxShadow: isActive
? `0 0 0 9999px rgba(0,0,0,0.20), 0 0 0 3px ${region.color}55`
: "0 0 0 9999px rgba(0,0,0,0.08)",
left: `${region.rect.x * 100}%`,
top: `${region.rect.y * 100}%`,
width: `${region.rect.width * 100}%`,
height: `${region.rect.height * 100}%`,
}}
title={`区域 ${region.index}`}
type="button"
>
<span
className="absolute -left-2 -top-2 flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-[11px] font-bold text-white shadow-lg"
style={{ backgroundColor: region.color }}
>
{region.index}
</span>
</button>
);
})}
{draftRect ? (
<div
className="pointer-events-none absolute border-2 border-sky-300 bg-sky-400/20 shadow-[0_0_0_9999px_rgba(0,0,0,0.28)]"
className="pointer-events-none absolute border-2 border-sky-300 bg-sky-400/20 shadow-[0_0_0_9999px_rgba(0,0,0,0.24)]"
style={{
left: `${rect.x * 100}%`,
top: `${rect.y * 100}%`,
width: `${rect.width * 100}%`,
height: `${rect.height * 100}%`,
left: `${draftRect.x * 100}%`,
top: `${draftRect.y * 100}%`,
width: `${draftRect.width * 100}%`,
height: `${draftRect.height * 100}%`,
}}
/>
) : null}
</div>
) : null}
</div>
<div className="flex flex-col gap-3">
<div className="flex min-h-0 flex-col gap-3">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-3 text-sm text-white/58">
框选区域后,输入你想改的内容。处理完成的新图会自动加入 AI详情结果区。
拖拽图片添加区域。最多 {LOCAL_EDIT_MAX_REGIONS} 个区域,每个区域最多{" "}
{LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图。{completionCopy}
</div>
<Textarea
className="min-h-[160px] border-white/10 bg-black/30 text-white placeholder:text-white/30"
disabled={submitting}
maxLength={600}
onChange={(event) => setPrompt(event.target.value)}
placeholder="例如:把产品区域替换为金属质感,保持背景和文字不变"
value={prompt}
/>
{regions.length === 0 ? (
<div className="flex min-h-[260px] items-center justify-center rounded-2xl border border-dashed border-white/12 bg-white/[0.02] px-5 text-center text-sm text-white/38">
在左侧图片上拖拽框选后,这里会显示区域提示词和参考图。
</div>
) : (
<div className="max-h-[52vh] space-y-3 overflow-y-auto pr-1">
{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 (
<div
className={cn(
"rounded-2xl border bg-white/[0.035] p-3 transition",
isActive
? "border-white/28 shadow-[0_0_0_1px_rgba(255,255,255,0.12)]"
: "border-white/10"
)}
key={region.id}
onClick={() => setActiveRegionId(region.id)}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span
className="flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-[11px] font-bold text-white"
style={{ backgroundColor: region.color }}
>
{region.index}
</span>
<span className="truncate text-sm font-semibold text-white">
区域 {region.index}
</span>
<span className="text-xs text-white/36">
参考图 {references.length}/
{LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION}
</span>
</div>
<button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white/45 hover:bg-white/10 hover:text-white"
disabled={submitting}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeRegion(region.id);
}}
title="删除区域"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
<Textarea
className="min-h-[92px] resize-none border-white/10 bg-black/30 text-sm text-white placeholder:text-white/30"
disabled={submitting}
maxLength={600}
onChange={(event) => updateRegionPrompt(region.id, event.target.value)}
placeholder={`区域 ${region.index} 要怎么改`}
value={region.prompt}
/>
<input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.jpg,.jpeg,.png,.webp,.heic,.heif"
className="hidden"
id={`detail-local-edit-reference-upload-${region.id}`}
multiple
onChange={(event) => {
void handleReferenceUpload(region.id, event.currentTarget.files);
event.currentTarget.value = "";
}}
type="file"
/>
<div className="mt-3 flex items-center gap-2">
<button
className="inline-flex h-8 items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] px-3 text-xs font-medium text-white/72 hover:bg-white/[0.1] hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
disabled={!canUpload}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
document
.getElementById(
`detail-local-edit-reference-upload-${region.id}`
)
?.click();
}}
type="button"
>
{uploadingRegionId === region.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlus className="h-3.5 w-3.5" />
)}
参考图
</button>
{references.length === 0 ? (
<span className="text-xs text-white/32">可选</span>
) : null}
</div>
{references.length ? (
<div className="mt-3 grid grid-cols-3 gap-2">
{references.map((reference) => (
<div
className="group relative aspect-square overflow-hidden rounded-xl border border-white/10 bg-white/[0.04]"
key={reference.id}
title={reference.name || "区域参考图"}
>
<img
alt={reference.name || "区域参考图"}
className="h-full w-full object-cover"
draggable={false}
src={buildImageProxyUrl(reference.url, {
width: 240,
quality: 82,
})}
/>
<button
className="absolute inset-0 hidden items-center justify-center bg-black/55 text-white group-hover:flex"
disabled={submitting}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeReferenceImage(region.id, reference.id);
}}
title="移除参考图"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
) : null}
</div>
);
})}
</div>
)}
<div className="mt-auto flex justify-end gap-2">
<Button
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
@ -871,7 +1283,7 @@ function DetailResultLocalEditDialog({
</Button>
<Button
className="bg-white text-black hover:bg-white/90"
disabled={submitting}
disabled={submitting || Boolean(uploadingRegionId) || regions.length === 0}
onClick={handleSubmit}
type="button"
>
@ -4659,12 +5071,7 @@ export function AgentGenerationWorkspaceDarkV2({
]
);
const handleSubmitDetailLocalEdit = useCallback(
async (params: {
imageUrl: string;
prompt: string;
rect: DetailResultLocalEditRect;
title: string;
}) => {
async (params: DetailResultLocalEditSubmitParams) => {
const target = detailLocalEditTarget;
const cardId = String(target?.cardId || "").trim();
const imageUrl = String(params.imageUrl || "").trim();
@ -4684,16 +5091,19 @@ export function AgentGenerationWorkspaceDarkV2({
imageSize: "2K",
sourceImageUrl: imageUrl,
sourceNodeId: `agent-detail-${cardId}`,
regions: [
{
id: "detail-local-edit-1",
index: 1,
color: "#1677ff",
prompt: params.prompt,
rect: params.rect,
referenceImages: [],
},
],
regions: params.regions.map((region) => ({
id: region.id,
index: region.index,
color: region.color,
prompt: region.prompt,
rect: region.rect,
referenceImages: (region.referenceImages || []).map((reference) => ({
id: reference.id,
url: reference.url,
name: reference.name,
source: reference.source,
})),
})),
});
if (!submitResult.success || !submitResult.data?.taskId) {