feat: support target detail uploads and improve scale adjust

This commit is contained in:
134 2026-06-17 17:37:51 +08:00
parent 25af7e5526
commit af63ebba9e
4 changed files with 698 additions and 139 deletions

View File

@ -451,13 +451,13 @@ function buildScaleAdjustPrompt(
const top = Math.round(region.rect.y * 1000) / 10;
const width = Math.round(region.rect.width * 1000) / 10;
const height = Math.round(region.rect.height * 1000) / 10;
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):识别该框选范围内的完整主体物体,以主体视觉中心为锚点执行整体等比${actionLabel},目标尺寸为原物体的 ${Math.round(scaleFactor * 100)}%。`;
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):该矩形只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁。请识别此范围内最主要的商品/物体主体,以主体自身轮廓和视觉中心为准,执行整体等比${actionLabel},目标视觉尺寸为原主体的 ${Math.round(scaleFactor * 100)}%。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
})
.join("\n");
return [
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;彩色框只存在于产品界面里,不会作为最终画面内容。",
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片或蒙版边界。",
"最终结果必须保持输入图片1的原始画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。",
"请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
"",
@ -465,11 +465,13 @@ function buildScaleAdjustPrompt(
regionLines,
"",
"关键要求:",
"1. 只调整框选主体物体的整体视觉比例,必须等比缩放,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。",
"2. 缩小时,主体应围绕原位置中心自然变小,露出的背景需要与原图环境、纹理、阴影和透视自然补全。",
"3. 放大时,主体应围绕原位置中心自然变大,边缘与遮挡关系要合理,不能变形,也不能超出画面造成突兀裁切。",
"4. 未被框选的其它画面区域必须保持原图不变,不允许重绘、改色、补光、移动物体或改变背景细节。",
"5. 最终输出必须是一张干净照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。",
"1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。",
"2. 严禁把整个矩形框选区域当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
"3. 缩小时,目标主体应围绕原位置视觉中心自然变小;主体缩小后露出的区域必须根据周围真实背景、阴影、遮挡、纹理和透视进行无缝补全。",
"4. 放大时,目标主体应围绕原位置视觉中心自然变大;边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。",
"5. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
"6. 未受比例调整影响的其它画面区域必须尽量保持原图不变,不允许整体重绘、改色、补光、移动物体或改变背景细节。",
"7. 最终输出必须是一张完整、干净、无缝的照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。",
].join("\n");
}
@ -572,6 +574,8 @@ export async function POST(request: NextRequest) {
operation === SCALE_ADJUST_OPERATION && scalePercent !== null
? buildScaleAdjustPrompt(regions, scalePercent)
: buildLocalEditPrompt(regions);
const isScaleAdjustOperation =
operation === SCALE_ADJUST_OPERATION && scalePercent !== null;
const regionReferenceImageUrls = regions.flatMap((region) =>
region.referenceImages.map((reference) => reference.url)
);
@ -594,6 +598,7 @@ export async function POST(request: NextRequest) {
regionReferenceImageCount: regionReferenceImageUrls.length,
regionCount: regions.length,
scalePercent,
scaleAdjustUsesFullFrameOutput: isScaleAdjustOperation,
sourceHeight,
sourceNodeId: body.sourceNodeId || null,
sourceWidth,
@ -608,17 +613,21 @@ export async function POST(request: NextRequest) {
? body.model.trim()
: undefined,
negativePrompt:
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
isScaleAdjustOperation
? "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、主体未缩放、整块框选区域缩放"
: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
prompt,
promptExtend: false,
referenceImages: [
sourceImageUrl,
...regionReferenceImageUrls,
],
localEditComposite: {
sourceImageUrl,
regions: regions.map((region) => region.rect),
},
localEditComposite: isScaleAdjustOperation
? undefined
: {
sourceImageUrl,
regions: regions.map((region) => region.rect),
},
ownerKey: session.user.id,
};

View File

@ -6,10 +6,11 @@ import {
useMemo,
useRef,
useState,
type ChangeEvent,
type PointerEvent as ReactPointerEvent,
type SyntheticEvent,
} from "react";
import { Loader2, Plus, RotateCcw, Scissors, Trash2 } from "lucide-react";
import { Loader2, Plus, RotateCcw, Scissors, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@ -22,6 +23,10 @@ import {
import { cn } from "@/lib/utils";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { createClientLogger } from "@/lib/observability/client-logger";
import {
createUploadedDetailReferenceCard,
uploadDetailReferenceImageFile,
} from "@/components/agents/detail-reference-upload";
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
const MIN_SLICE_HEIGHT = 120;
@ -211,6 +216,10 @@ export function AgentDetailReferenceStitchDialog({
const [warningText, setWarningText] = useState<string | null>(null);
const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false);
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
const [segmentReplacementCards, setSegmentReplacementCards] = useState<
Record<number, AgentDetailReplacementReferenceItem>
>({});
const [uploadingSegmentIndex, setUploadingSegmentIndex] = useState<number | null>(null);
const hasManualCutLineChangesRef = useRef(false);
const onPreparedRef = useRef(onPrepared);
@ -265,6 +274,10 @@ export function AgentDetailReferenceStitchDialog({
() => new Set(passthroughSegmentIndexes),
[passthroughSegmentIndexes]
);
const segmentReplacementCount = useMemo(
() => Object.keys(segmentReplacementCards).length,
[segmentReplacementCards]
);
const mergedPreviewDisplayUrl = useMemo(
() =>
snapshot
@ -285,6 +298,8 @@ export function AgentDetailReferenceStitchDialog({
setWarningText(null);
setPreviewImageLoadFailed(false);
setPassthroughSegmentIndexes([]);
setSegmentReplacementCards({});
setUploadingSegmentIndex(null);
hasManualCutLineChangesRef.current = false;
return;
}
@ -311,6 +326,8 @@ export function AgentDetailReferenceStitchDialog({
setPreviewImageLoadFailed(true);
setIsLoading(false);
setIsPreparing(true);
setSegmentReplacementCards({});
setUploadingSegmentIndex(null);
setLoadingProgressText(`正在后台校准 ${setupCards.length} 张参考图尺寸`);
const setup = async () => {
@ -431,6 +448,15 @@ export function AgentDetailReferenceStitchDialog({
setPassthroughSegmentIndexes((current) =>
current.filter((index) => validIndexes.has(index))
);
setSegmentReplacementCards((current) => {
const nextEntries = Object.entries(current).filter(([index]) =>
validIndexes.has(Number(index))
);
if (nextEntries.length === Object.keys(current).length) {
return current;
}
return Object.fromEntries(nextEntries);
});
}, [sliceSegments]);
useEffect(() => {
@ -658,6 +684,39 @@ export function AgentDetailReferenceStitchDialog({
[]
);
const handleReplaceSegmentImage = useCallback(
async (segmentIndex: number, event: ChangeEvent<HTMLInputElement>) => {
const input = event.currentTarget;
const file = input.files?.[0];
if (!file) return;
setUploadingSegmentIndex(segmentIndex);
try {
const upload = await uploadDetailReferenceImageFile(file);
const replacementCard = createUploadedDetailReferenceCard({
upload,
index: segmentIndex,
labelPrefix: "更换切片",
});
setSegmentReplacementCards((current) => ({
...current,
[segmentIndex]: {
...replacementCard,
sortOrder: segmentIndex,
passthrough: false,
},
}));
toast.success(`已更换切片 ${segmentIndex + 1}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "切片图片更换失败");
} finally {
setUploadingSegmentIndex(null);
input.value = "";
}
},
[]
);
const handleApply = useCallback(async () => {
if (!snapshot) {
toast.error("当前还没有可用的拼合图");
@ -668,6 +727,10 @@ export function AgentDetailReferenceStitchDialog({
toast.error("当前没有可切割的参考图");
return;
}
if (uploadingSegmentIndex !== null) {
toast.error("请等待切片图片上传完成");
return;
}
setIsApplying(true);
setApplyProgressText(`正在服务端切割 ${sliceSegments.length} 张`);
@ -691,11 +754,14 @@ export function AgentDetailReferenceStitchDialog({
throw new Error(result.error || "切割参考图失败");
}
const orderedCards = result.data.cards.map((card, index) => ({
...card,
sortOrder: index,
passthrough: passthroughSegmentIndexSet.has(index),
}));
const orderedCards = result.data.cards.map((card, index) => {
const replacementCard = segmentReplacementCards[index];
return {
...(replacementCard || card),
sortOrder: index,
passthrough: passthroughSegmentIndexSet.has(index),
};
});
if (orderedCards.length !== sliceSegments.length) {
throw new Error("部分切片处理失败,请稍后重试");
}
@ -728,8 +794,10 @@ export function AgentDetailReferenceStitchDialog({
onOpenChange,
passthroughSegmentIndexes,
passthroughSegmentIndexSet,
segmentReplacementCards,
sliceSegments.length,
snapshot,
uploadingSegmentIndex,
]);
return (
@ -770,6 +838,11 @@ export function AgentDetailReferenceStitchDialog({
直出 {passthroughSegmentIndexes.length} 张
</div>
) : null}
{segmentReplacementCount > 0 ? (
<div className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100/85">
已更换 {segmentReplacementCount} 张
</div>
) : null}
</div>
</div>
</DialogHeader>
@ -856,28 +929,28 @@ export function AgentDetailReferenceStitchDialog({
<div
key={`${segment.card.id || "segment"}-${index}-${segment.top}`}
className="relative overflow-hidden bg-white"
style={{
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
contentVisibility: index > 4 ? "auto" : undefined,
containIntrinsicSize:
index > 4 ? `${Math.max(segment.height, 1)}px` : undefined,
}}
>
style={{
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
contentVisibility: index > 4 ? "auto" : undefined,
containIntrinsicSize:
index > 4 ? `${Math.max(segment.height, 1)}px` : undefined,
}}
>
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-neutral-400">
参考图 {index + 1} 加载失败
</div>
{segmentImageUrl ? (
<img
src={buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width)}
alt={`拼合分段预览 ${index + 1}`}
className="relative z-10 block h-full w-full select-none object-fill"
draggable={false}
decoding="async"
loading={index > 2 ? "lazy" : "eager"}
fetchPriority={index < 2 ? "high" : "low"}
onError={handleSegmentPreviewImageError(originalImageUrl)}
/>
) : null}
{segmentImageUrl ? (
<img
src={buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width)}
alt={`拼合分段预览 ${index + 1}`}
className="relative z-10 block h-full w-full select-none object-fill"
draggable={false}
decoding="async"
loading={index > 2 ? "lazy" : "eager"}
fetchPriority={index < 2 ? "high" : "low"}
onError={handleSegmentPreviewImageError(originalImageUrl)}
/>
) : null}
</div>
);
})}
@ -976,51 +1049,121 @@ export function AgentDetailReferenceStitchDialog({
<div className="agent-dark-scrollbar flex-1 space-y-3 overflow-y-auto px-5 py-4">
{sliceSegments.length > 0 ? (
sliceSegments.map((segment) => (
<div
key={`slice-segment-${segment.index}`}
className="rounded-[20px] border border-white/10 bg-white/[0.03] p-3"
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-white">
切片 {segment.index + 1}
sliceSegments.map((segment) => {
const replacementCard = segmentReplacementCards[segment.index];
const replacementUrl = String(
replacementCard?.previewUrl || replacementCard?.imageUrl || ""
).trim();
const isUploadingThisSegment = uploadingSegmentIndex === segment.index;
return (
<div
key={`slice-segment-${segment.index}`}
className={cn(
"rounded-[20px] border bg-white/[0.03] p-3",
replacementCard
? "border-emerald-400/25"
: "border-white/10"
)}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-white">
切片 {segment.index + 1}
</div>
<div className="rounded-full border border-white/10 bg-black/30 px-2.5 py-1 text-[11px] text-white/52">
高度 {segment.height}px
</div>
</div>
<div className="rounded-full border border-white/10 bg-black/30 px-2.5 py-1 text-[11px] text-white/52">
高度 {segment.height}px
<div className="mt-2 text-xs leading-6 text-white/42">
从 {Math.round(segment.top)}px 到 {Math.round(segment.top + segment.height)}px
</div>
{replacementUrl ? (
<div className="mt-3 overflow-hidden rounded-[16px] border border-emerald-400/20 bg-white">
<img
src={buildStitchSegmentDisplayImageUrl(replacementUrl, 360)}
alt={`已更换切片 ${segment.index + 1}`}
className="block aspect-[4/3] w-full object-contain"
loading="lazy"
decoding="async"
onError={handleSegmentPreviewImageError(replacementCard?.imageUrl || replacementUrl)}
/>
</div>
) : null}
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<div className="min-w-[120px] flex-1 text-[11px] text-white/45">
{replacementCard
? "已使用手动更换图片,应用后会替代当前切片。"
: passthroughSegmentIndexSet.has(segment.index)
? "当前切片不会参与生图,应用后会直接进入结果区。"
: "当前切片会继续参与详情替换生成。"}
</div>
<div className="flex flex-wrap justify-end gap-2">
<label
className={cn(
"inline-flex cursor-pointer items-center rounded-full border px-3 py-1 text-[11px] transition-colors",
isUploadingThisSegment
? "border-emerald-400/35 bg-emerald-500/[0.12] text-emerald-100"
: "border-white/10 bg-white/[0.03] text-white/68 hover:border-white/20 hover:text-white",
(isApplying || uploadingSegmentIndex !== null) &&
!isUploadingThisSegment &&
"pointer-events-none opacity-50"
)}
>
<input
type="file"
accept="image/*,.heic,.heif"
className="hidden"
disabled={isApplying || uploadingSegmentIndex !== null}
onChange={(event) => {
void handleReplaceSegmentImage(segment.index, event);
}}
/>
{isUploadingThisSegment ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="mr-1.5 h-3.5 w-3.5" />
)}
{replacementCard ? "重新更换" : "更换图片"}
</label>
{replacementCard ? (
<button
type="button"
onClick={() =>
setSegmentReplacementCards((current) => {
const next = { ...current };
delete next[segment.index];
return next;
})
}
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-[11px] text-white/62 transition-colors hover:border-white/20 hover:text-white"
>
恢复切片
</button>
) : null}
<button
type="button"
onClick={() =>
setPassthroughSegmentIndexes((current) =>
current.includes(segment.index)
? current.filter((item) => item !== segment.index)
: [...current, segment.index].sort((left, right) => left - right)
)
}
className={cn(
"rounded-full border px-3 py-1 text-[11px] transition-colors",
passthroughSegmentIndexSet.has(segment.index)
? "border-cyan-400/35 bg-cyan-500/[0.12] text-cyan-100"
: "border-white/10 bg-white/[0.03] text-white/68 hover:border-white/20 hover:text-white"
)}
>
{passthroughSegmentIndexSet.has(segment.index)
? "取消不处理"
: "标记不处理"}
</button>
</div>
</div>
</div>
<div className="mt-2 text-xs leading-6 text-white/42">
从 {Math.round(segment.top)}px 到 {Math.round(segment.top + segment.height)}px
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<div className="text-[11px] text-white/45">
{passthroughSegmentIndexSet.has(segment.index)
? "当前切片不会参与生图,应用后会直接进入结果区。"
: "当前切片会继续参与详情替换生成。"}
</div>
<button
type="button"
onClick={() =>
setPassthroughSegmentIndexes((current) =>
current.includes(segment.index)
? current.filter((item) => item !== segment.index)
: [...current, segment.index].sort((left, right) => left - right)
)
}
className={cn(
"rounded-full border px-3 py-1 text-[11px] transition-colors",
passthroughSegmentIndexSet.has(segment.index)
? "border-cyan-400/35 bg-cyan-500/[0.12] text-cyan-100"
: "border-white/10 bg-white/[0.03] text-white/68 hover:border-white/20 hover:text-white"
)}
>
{passthroughSegmentIndexSet.has(segment.index)
? "取消不处理"
: "标记不处理"}
</button>
</div>
</div>
))
);
})
) : (
<div className="flex min-h-[220px] items-center justify-center rounded-[20px] border border-dashed border-white/12 bg-black/20 px-5 text-center text-sm text-white/42">
当前还没有可切割的长图。
@ -1029,12 +1172,19 @@ export function AgentDetailReferenceStitchDialog({
</div>
<div className="border-t border-white/10 px-5 py-4">
<Button
type="button"
onClick={handleApply}
disabled={isLoading || isPreparing || isApplying || !snapshot || sliceSegments.length === 0}
className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50"
>
<Button
type="button"
onClick={handleApply}
disabled={
isLoading ||
isPreparing ||
isApplying ||
uploadingSegmentIndex !== null ||
!snapshot ||
sliceSegments.length === 0
}
className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50"
>
{isApplying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />

View File

@ -65,6 +65,12 @@ import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetai
import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog";
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";
@ -1174,6 +1180,11 @@ function DetailResultLocalEditDialog({
}}
/>
) : null}
{submitting ? (
<DetailResultProcessingScanOverlay
label={isScaleAdjustMode ? "比例调整处理中" : "局部编辑处理中"}
/>
) : null}
</div>
) : null}
</div>
@ -1920,31 +1931,36 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
function DetailResultProcessingScanOverlay({ label }: { label: string }) {
return (
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-zinc-950/38 backdrop-blur-[1.5px]">
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-zinc-950/34">
<motion.div
aria-hidden
className="absolute inset-0 opacity-45"
className="absolute inset-0 opacity-50"
animate={{
backgroundPosition: ["0px 0px", "0px 28px"],
backgroundPosition: ["0px 0px", "0px 20px"],
}}
transition={{ duration: 1.8, repeat: Infinity, ease: "linear" }}
transition={{ duration: 1.45, repeat: Infinity, ease: "linear" }}
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px)",
backgroundSize: "100% 28px",
"linear-gradient(rgba(255,255,255,0.11) 0px, rgba(255,255,255,0.11) 1px, transparent 1px, transparent 20px)",
backgroundSize: "100% 20px",
}}
/>
<motion.div
aria-hidden
className="absolute inset-x-[-18%] h-24 bg-[linear-gradient(180deg,transparent_0%,rgba(34,211,238,0.10)_17%,rgba(16,185,129,0.64)_43%,rgba(255,255,255,0.92)_50%,rgba(16,185,129,0.58)_58%,rgba(34,211,238,0.10)_83%,transparent_100%)] shadow-[0_0_42px_rgba(16,185,129,0.46)]"
animate={{ y: ["-45%", "115%"], opacity: [0.58, 1, 0.7] }}
transition={{ duration: 1.85, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
/>
className="absolute inset-x-[-8%] h-[26%] min-h-[110px]"
animate={{ top: ["-30%", "104%"], opacity: [0.38, 0.95, 0.48] }}
transition={{ duration: 3.2, repeat: Infinity, ease: "linear" }}
>
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent_0%,rgba(34,211,238,0.06)_18%,rgba(52,211,153,0.22)_34%,rgba(255,255,255,0.92)_50%,rgba(52,211,153,0.26)_58%,rgba(34,211,238,0.08)_74%,transparent_100%)]" />
<div className="absolute inset-x-[3%] top-1/2 h-px -translate-y-1/2 bg-white/95 shadow-[0_0_18px_rgba(255,255,255,0.92),0_0_34px_rgba(52,211,153,0.55)]" />
<div className="absolute inset-x-[6%] top-[calc(50%-10px)] h-px bg-emerald-200/48" />
<div className="absolute inset-x-[6%] top-[calc(50%+10px)] h-px bg-cyan-200/38" />
</motion.div>
<motion.div
aria-hidden
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(16,185,129,0.20),transparent_58%)]"
animate={{ opacity: [0.18, 0.44, 0.18] }}
transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(16,185,129,0.12),transparent_62%)]"
animate={{ opacity: [0.16, 0.28, 0.16] }}
transition={{ duration: 2.6, repeat: Infinity, ease: "easeInOut" }}
/>
<div className="absolute left-3 top-3 inline-flex items-center gap-2 rounded-full border border-emerald-100/65 bg-zinc-950/86 px-3 py-1.5 text-[11px] font-semibold text-emerald-50 shadow-[0_14px_36px_rgba(0,0,0,0.48),0_0_22px_rgba(16,185,129,0.24)] backdrop-blur-md">
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-200" />
@ -1954,6 +1970,23 @@ function DetailResultProcessingScanOverlay({ label }: { label: string }) {
);
}
function ResultProcessedHintBubble({ label }: { label: string }) {
return (
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.96 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="pointer-events-none absolute left-1/2 top-3 z-40 -translate-x-1/2"
>
<div className="inline-flex items-center gap-1.5 rounded-full border border-emerald-200/70 bg-zinc-950/88 px-3 py-1.5 text-[11px] font-semibold text-emerald-50 shadow-[0_14px_34px_rgba(0,0,0,0.42),0_0_20px_rgba(16,185,129,0.22)] backdrop-blur-md">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
<span>{label}</span>
</div>
</motion.div>
);
}
function AnimatedGenerationPlaceholderCard(params: {
title: string;
description: string;
@ -2176,6 +2209,8 @@ export function AgentGenerationWorkspaceDarkV2({
const [detailProductIdQueryInput, setDetailProductIdQueryInput] = useState("");
const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false);
const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(null);
const [detailReferenceUploadTarget, setDetailReferenceUploadTarget] =
useState<null | { type: "append" } | { type: "replace"; cardId: string }>(null);
const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{
isLoading: boolean;
lastQuery: string | null;
@ -2194,6 +2229,9 @@ export function AgentGenerationWorkspaceDarkV2({
const [detailGenerationRunResults, setDetailGenerationRunResults] = useState<
DirectDetailGenerationImage[]
>([]);
const [processedResultHintByUrl, setProcessedResultHintByUrl] = useState<
Record<string, string>
>({});
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
() => createEmptyReplacementResultState()
);
@ -2206,6 +2244,7 @@ export function AgentGenerationWorkspaceDarkV2({
const [replacementRerunPrompt, setReplacementRerunPrompt] = useState("");
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
useState<Set<string>>(() => new Set());
const processedResultHintTimerRef = useRef<Record<string, number>>({});
const closePsdTemplateDialog = useCallback(() => {
setIsPsdTemplateDialogOpen(false);
setPsdTemplateDialogSourceUrl(null);
@ -3139,6 +3178,15 @@ export function AgentGenerationWorkspaceDarkV2({
);
}, [historyMessages, setMessages]);
useEffect(() => {
return () => {
Object.values(processedResultHintTimerRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
processedResultHintTimerRef.current = {};
};
}, []);
useEffect(() => {
if (isWorkflowModelLocked) {
setSelectedModelId((current) => (current === "auto" ? current : "auto"));
@ -5121,11 +5169,11 @@ export function AgentGenerationWorkspaceDarkV2({
},
[mergedResultCollections.scene_generation, onResultPreviewUrlsChange, uploadBase64ResultToStorage]
);
const appendResultUrlToCollection = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onResultPreviewUrlsChange) {
return false;
const appendResultUrlToCollection = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onResultPreviewUrlsChange) {
return false;
}
const nextUrls = Array.from(
@ -5139,13 +5187,38 @@ export function AgentGenerationWorkspaceDarkV2({
setSelectedResultUrl(normalizedUrl);
}
return true;
},
[activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange]
);
const handleEnhanceDetailGenerationResult = useCallback(
async (params: {
cardId: string;
imageUrl: string;
},
[activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange]
);
const showProcessedResultHint = useCallback((imageUrl: string, label: string) => {
const normalizedUrl = String(imageUrl || "").trim();
const normalizedLabel = String(label || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl) || !normalizedLabel) return;
const existingTimer = processedResultHintTimerRef.current[normalizedUrl];
if (existingTimer) {
window.clearTimeout(existingTimer);
}
setProcessedResultHintByUrl((current) => ({
...current,
[normalizedUrl]: normalizedLabel,
}));
processedResultHintTimerRef.current[normalizedUrl] = window.setTimeout(() => {
setProcessedResultHintByUrl((current) => {
if (!(normalizedUrl in current)) return current;
const next = { ...current };
delete next[normalizedUrl];
return next;
});
delete processedResultHintTimerRef.current[normalizedUrl];
}, 2800);
}, []);
const handleEnhanceDetailGenerationResult = useCallback(
async (params: {
cardId: string;
imageUrl: string;
resultModeType?: "detail_generation" | "detail_replacement";
}) => {
const cardId = String(params.cardId || "").trim();
@ -5296,6 +5369,10 @@ export function AgentGenerationWorkspaceDarkV2({
setActiveResultModeType("detail_generation");
setSelectedResultUrl(nextUrl);
}
showProcessedResultHint(
nextUrl,
isScaleAdjust ? "比例调整完成" : "局部编辑完成"
);
setDetailLocalEditTarget(null);
toast.success(
resultModeType === "detail_replacement"
@ -5333,6 +5410,7 @@ export function AgentGenerationWorkspaceDarkV2({
detailLocalEditSubmittingId,
detailLocalEditTarget,
handleUpdateReplacementResultRecord,
showProcessedResultHint,
]
);
const handlePsdDerivedImageCreated = useCallback(
@ -5341,13 +5419,13 @@ export function AgentGenerationWorkspaceDarkV2({
},
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
);
const handlePsdRenderedResultCreated = useCallback(
(imageUrl: string) => {
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
},
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
);
const handleDeleteResultImage = useCallback(
const handlePsdRenderedResultCreated = useCallback(
(imageUrl: string) => {
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
},
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
);
const handleDeleteResultImage = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onDeleteResultImage) {
@ -5363,6 +5441,17 @@ export function AgentGenerationWorkspaceDarkV2({
if (sceneResizeEditorTargetUrl === normalizedUrl) {
setSceneResizeEditorTargetUrl(null);
}
const existingTimer = processedResultHintTimerRef.current[normalizedUrl];
if (existingTimer) {
window.clearTimeout(existingTimer);
delete processedResultHintTimerRef.current[normalizedUrl];
}
setProcessedResultHintByUrl((current) => {
if (!(normalizedUrl in current)) return current;
const next = { ...current };
delete next[normalizedUrl];
return next;
});
onDeleteResultImage(modeType, normalizedUrl);
toast.success("结果图已删除");
@ -6258,6 +6347,101 @@ export function AgentGenerationWorkspaceDarkV2({
references: [],
}));
}, [updateDetailReplacementState]);
const clearDetailReplacementResultRecords = useCallback((cardIds: string[]) => {
const idSet = new Set(cardIds.map((item) => String(item || "").trim()).filter(Boolean));
if (idSet.size === 0) return;
setReplacementResultState((current) => {
const nextDetail = { ...current.detail_replacement };
let changed = false;
idSet.forEach((cardId) => {
if (cardId in nextDetail) {
delete nextDetail[cardId];
changed = true;
}
});
return changed
? {
...current,
detail_replacement: nextDetail,
}
: current;
});
}, []);
const handleAppendDetailReferenceFiles = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const input = event.currentTarget;
const files = Array.from(input.files || []);
if (files.length === 0) return;
setDetailReferenceUploadTarget({ type: "append" });
try {
const uploads: UploadedDetailReferenceImage[] = [];
for (const file of files) {
uploads.push(await uploadDetailReferenceImageFile(file));
}
updateDetailReplacementState((current) => {
const startIndex = current.references.length;
const uploadedCards = uploads.map((upload, uploadIndex) =>
createUploadedDetailReferenceCard({
upload,
index: startIndex + uploadIndex,
labelPrefix: "上传目标详情",
})
);
return {
...current,
references: [...current.references, ...uploadedCards].map((card, index) => ({
...card,
sortOrder: index,
})),
};
});
toast.success(`已上传 ${uploads.length} 张目标详情`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "目标详情上传失败");
} finally {
setDetailReferenceUploadTarget(null);
input.value = "";
}
},
[updateDetailReplacementState]
);
const handleReplaceDetailReferenceCardFile = useCallback(
async (cardId: string, event: React.ChangeEvent<HTMLInputElement>) => {
const input = event.currentTarget;
const file = input.files?.[0];
if (!file) return;
setDetailReferenceUploadTarget({ type: "replace", cardId });
try {
const upload = await uploadDetailReferenceImageFile(file);
let didReplace = false;
updateDetailReplacementState((current) => ({
...current,
references: current.references.map((card, index) => {
if (card.id !== cardId) return card;
didReplace = true;
return {
...applyUploadedDetailReferenceToCard(card, upload),
sortOrder: card.sortOrder ?? index,
};
}),
}));
if (didReplace) {
clearDetailReplacementResultRecords([cardId]);
toast.success("已更换当前目标详情图");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "目标详情更换失败");
} finally {
setDetailReferenceUploadTarget(null);
input.value = "";
}
},
[clearDetailReplacementResultRecords, updateDetailReplacementState]
);
const handlePreparedDetailReferenceCards = useCallback(
(preparedCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
const preparedById = new Map(preparedCards.map((card) => [card.id, card]));
@ -7425,11 +7609,34 @@ export function AgentGenerationWorkspaceDarkV2({
目标 / {detailReferenceSourceLabel}
</span>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={detailProductIdQueryInput}
onChange={(event) => setDetailProductIdQueryInput(event.target.value)}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<label
className={cn(
"inline-flex h-12 cursor-pointer items-center justify-center rounded-[22px] border border-white/10 bg-white/[0.03] px-5 text-sm text-white transition-colors hover:bg-white/[0.08]",
detailReferenceUploadTarget !== null && "pointer-events-none opacity-60"
)}
>
<input
type="file"
accept="image/*,.heic,.heif"
multiple
className="hidden"
disabled={detailReferenceUploadTarget !== null}
onChange={(event) => {
void handleAppendDetailReferenceFiles(event);
}}
/>
{detailReferenceUploadTarget?.type === "append" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传目标详情
</label>
<Input
value={detailProductIdQueryInput}
onChange={(event) => setDetailProductIdQueryInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
@ -7924,10 +8131,33 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="text-xs text-white/40">
支持按 SPU / SKU 自动兜底查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
</div>
</div>
<div className="flex items-center gap-2">
{detailReplacementReferenceCards.length > 0 ? (
<button
</div>
<div className="flex items-center gap-2">
<label
className={cn(
"inline-flex cursor-pointer items-center rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white",
detailReferenceUploadTarget !== null && "pointer-events-none opacity-60"
)}
>
<input
type="file"
accept="image/*,.heic,.heif"
multiple
className="hidden"
disabled={detailReferenceUploadTarget !== null}
onChange={(event) => {
void handleAppendDetailReferenceFiles(event);
}}
/>
{detailReferenceUploadTarget?.type === "append" ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="mr-1.5 h-3.5 w-3.5" />
)}
上传
</label>
{detailReplacementReferenceCards.length > 0 ? (
<button
type="button"
onClick={() => setDetailReferenceStitchDialogOpen(true)}
className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100 transition-colors hover:bg-emerald-500/[0.14]"
@ -7943,11 +8173,14 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="grid grid-cols-1 gap-3">
{detailReplacementReferenceCards.length > 0 ? (
detailReplacementReferenceCards.map((card, index) => (
(() => {
const isActive = activeDetailReferenceCardId === card.id;
return (
<div
detailReplacementReferenceCards.map((card, index) => (
(() => {
const isActive = activeDetailReferenceCardId === card.id;
const isReplacingReference =
detailReferenceUploadTarget?.type === "replace" &&
detailReferenceUploadTarget.cardId === card.id;
return (
<div
key={card.id}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
className={cn(
@ -7958,10 +8191,34 @@ export function AgentGenerationWorkspaceDarkV2({
)}
onClick={() => setActiveDetailReferenceCardId(card.id)}
>
<div className="relative min-h-[220px] overflow-hidden bg-white/95">
<div className="absolute right-3 top-3 z-10 flex flex-col items-end gap-2">
<button
type="button"
<div className="relative min-h-[220px] overflow-hidden bg-white/95">
<div className="absolute right-3 top-3 z-10 flex flex-col items-end gap-2">
<label
className={cn(
IMAGE_OVERLAY_ACTION_CLASS,
"inline-flex cursor-pointer items-center gap-1.5",
detailReferenceUploadTarget !== null && "pointer-events-none opacity-60"
)}
onClick={(event) => event.stopPropagation()}
>
<input
type="file"
accept="image/*,.heic,.heif"
className="hidden"
disabled={detailReferenceUploadTarget !== null}
onChange={(event) => {
void handleReplaceDetailReferenceCardFile(card.id, event);
}}
/>
{isReplacingReference ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
<span>{isReplacingReference ? "更换中" : "更换"}</span>
</label>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRemoveDetailReferenceCard(card.id);
@ -8037,10 +8294,10 @@ export function AgentGenerationWorkspaceDarkV2({
) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待{detailReferenceSourceLabel}参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40">
点击“检索可用模版”后,这里会展示当前品牌下主 SPU 对应的真实{detailReferenceSourceLabel}参考图。
删除不需要的参考图后,列表会自动上移补位。
</div>
<div className="mt-2 text-xs leading-6 text-white/40">
点击“检索可用模版”后,这里会展示当前品牌下主 SPU 对应的真实{detailReferenceSourceLabel}参考图;也可以直接上传目标详情图。
删除不需要的参考图后,列表会自动上移补位。
</div>
</div>
)}
</div>
@ -8119,6 +8376,13 @@ export function AgentGenerationWorkspaceDarkV2({
<div className={cn("absolute left-3 top-3", IMAGE_OVERLAY_STATUS_BASE_CLASS, statusMeta.className)}>
{statusMeta.label}
</div>
<AnimatePresence>
{card.resultUrl && processedResultHintByUrl[card.resultUrl] ? (
<ResultProcessedHintBubble
label={processedResultHintByUrl[card.resultUrl]}
/>
) : null}
</AnimatePresence>
{isActive ? (
<div className={cn("absolute left-3 top-12", IMAGE_OVERLAY_ACTIVE_BADGE_CLASS)}>
当前联动
@ -8967,6 +9231,13 @@ export function AgentGenerationWorkspaceDarkV2({
<span>套PSD</span>
</button>
) : null}
<AnimatePresence>
{card.url && processedResultHintByUrl[card.url] ? (
<ResultProcessedHintBubble
label={processedResultHintByUrl[card.url]}
/>
) : null}
</AnimatePresence>
<button
type="button"
onClick={(event) => {

View File

@ -0,0 +1,129 @@
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
export interface UploadedDetailReferenceImage {
url: string;
name: string;
contentType: string | null;
assetId: string | null;
width: number | null;
height: number | null;
}
function isSupportedDetailReferenceImage(file: File) {
return (
file.type.startsWith("image/") ||
/\.(jpe?g|png|webp|heic|heif)$/i.test(file.name)
);
}
function buildDetailReferenceUploadFilename(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) || "target-detail";
return `${baseName}-${Date.now()}.${extension}`;
}
function normalizeDimension(value: unknown) {
const numberValue = Math.round(Number(value));
return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : null;
}
async function readLocalImageDimensions(file: File) {
if (typeof window === "undefined") {
return { width: null, height: null };
}
return new Promise<{ width: number | null; height: number | null }>((resolve) => {
const objectUrl = window.URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
const width = normalizeDimension(image.naturalWidth);
const height = normalizeDimension(image.naturalHeight);
window.URL.revokeObjectURL(objectUrl);
resolve({ width, height });
};
image.onerror = () => {
window.URL.revokeObjectURL(objectUrl);
resolve({ width: null, height: null });
};
image.src = objectUrl;
});
}
export async function uploadDetailReferenceImageFile(
file: File
): Promise<UploadedDetailReferenceImage> {
if (!isSupportedDetailReferenceImage(file)) {
throw new Error("请上传图片文件");
}
if (file.size > DETAIL_REFERENCE_UPLOAD_MAX_BYTES) {
throw new Error("目标详情图过大,请压缩到 50MB 以内");
}
const dimensionsPromise = readLocalImageDimensions(file);
const formData = new FormData();
formData.append("file", file, buildDetailReferenceUploadFilename(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 url = String(payload.url || "").trim();
if (!/^https?:\/\//i.test(url)) {
throw new Error("目标详情图上传后未返回可用地址");
}
const localDimensions = await dimensionsPromise;
return {
url,
name: file.name || "目标详情图",
contentType: file.type || null,
assetId: typeof payload.assetId === "string" ? payload.assetId : null,
width: normalizeDimension(payload.width) || localDimensions.width,
height: normalizeDimension(payload.height) || localDimensions.height,
};
}
export function createUploadedDetailReferenceCard(params: {
upload: UploadedDetailReferenceImage;
index: number;
labelPrefix?: string;
}): AgentDetailReplacementReferenceItem {
const labelPrefix = params.labelPrefix || "上传目标详情";
return {
id: `manual-detail-reference-${Date.now()}-${params.index + 1}-${Math.random()
.toString(36)
.slice(2, 8)}`,
label: `${labelPrefix} ${String(params.index + 1).padStart(2, "0")}`,
imageUrl: params.upload.url,
previewUrl: params.upload.url,
width: params.upload.width,
height: params.upload.height,
candidateId: null,
sortOrder: params.index,
passthrough: false,
};
}
export function applyUploadedDetailReferenceToCard(
card: AgentDetailReplacementReferenceItem,
upload: UploadedDetailReferenceImage
): AgentDetailReplacementReferenceItem {
return {
...card,
imageUrl: upload.url,
previewUrl: upload.url,
width: upload.width,
height: upload.height,
};
}