fix: complete detail local edit workflow
This commit is contained in:
parent
553e5b5373
commit
2807dcf9e3
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user