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

View File

@ -6,10 +6,11 @@ import {
useMemo, useMemo,
useRef, useRef,
useState, useState,
type ChangeEvent,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
type SyntheticEvent, type SyntheticEvent,
} from "react"; } 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 { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -22,6 +23,10 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { createClientLogger } from "@/lib/observability/client-logger"; import { createClientLogger } from "@/lib/observability/client-logger";
import {
createUploadedDetailReferenceCard,
uploadDetailReferenceImageFile,
} from "@/components/agents/detail-reference-upload";
import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
const MIN_SLICE_HEIGHT = 120; const MIN_SLICE_HEIGHT = 120;
@ -211,6 +216,10 @@ export function AgentDetailReferenceStitchDialog({
const [warningText, setWarningText] = useState<string | null>(null); const [warningText, setWarningText] = useState<string | null>(null);
const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false); const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false);
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]); const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
const [segmentReplacementCards, setSegmentReplacementCards] = useState<
Record<number, AgentDetailReplacementReferenceItem>
>({});
const [uploadingSegmentIndex, setUploadingSegmentIndex] = useState<number | null>(null);
const hasManualCutLineChangesRef = useRef(false); const hasManualCutLineChangesRef = useRef(false);
const onPreparedRef = useRef(onPrepared); const onPreparedRef = useRef(onPrepared);
@ -265,6 +274,10 @@ export function AgentDetailReferenceStitchDialog({
() => new Set(passthroughSegmentIndexes), () => new Set(passthroughSegmentIndexes),
[passthroughSegmentIndexes] [passthroughSegmentIndexes]
); );
const segmentReplacementCount = useMemo(
() => Object.keys(segmentReplacementCards).length,
[segmentReplacementCards]
);
const mergedPreviewDisplayUrl = useMemo( const mergedPreviewDisplayUrl = useMemo(
() => () =>
snapshot snapshot
@ -285,6 +298,8 @@ export function AgentDetailReferenceStitchDialog({
setWarningText(null); setWarningText(null);
setPreviewImageLoadFailed(false); setPreviewImageLoadFailed(false);
setPassthroughSegmentIndexes([]); setPassthroughSegmentIndexes([]);
setSegmentReplacementCards({});
setUploadingSegmentIndex(null);
hasManualCutLineChangesRef.current = false; hasManualCutLineChangesRef.current = false;
return; return;
} }
@ -311,6 +326,8 @@ export function AgentDetailReferenceStitchDialog({
setPreviewImageLoadFailed(true); setPreviewImageLoadFailed(true);
setIsLoading(false); setIsLoading(false);
setIsPreparing(true); setIsPreparing(true);
setSegmentReplacementCards({});
setUploadingSegmentIndex(null);
setLoadingProgressText(`正在后台校准 ${setupCards.length} 张参考图尺寸`); setLoadingProgressText(`正在后台校准 ${setupCards.length} 张参考图尺寸`);
const setup = async () => { const setup = async () => {
@ -431,6 +448,15 @@ export function AgentDetailReferenceStitchDialog({
setPassthroughSegmentIndexes((current) => setPassthroughSegmentIndexes((current) =>
current.filter((index) => validIndexes.has(index)) 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]); }, [sliceSegments]);
useEffect(() => { 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 () => { const handleApply = useCallback(async () => {
if (!snapshot) { if (!snapshot) {
toast.error("当前还没有可用的拼合图"); toast.error("当前还没有可用的拼合图");
@ -668,6 +727,10 @@ export function AgentDetailReferenceStitchDialog({
toast.error("当前没有可切割的参考图"); toast.error("当前没有可切割的参考图");
return; return;
} }
if (uploadingSegmentIndex !== null) {
toast.error("请等待切片图片上传完成");
return;
}
setIsApplying(true); setIsApplying(true);
setApplyProgressText(`正在服务端切割 ${sliceSegments.length} 张`); setApplyProgressText(`正在服务端切割 ${sliceSegments.length} 张`);
@ -691,11 +754,14 @@ export function AgentDetailReferenceStitchDialog({
throw new Error(result.error || "切割参考图失败"); throw new Error(result.error || "切割参考图失败");
} }
const orderedCards = result.data.cards.map((card, index) => ({ const orderedCards = result.data.cards.map((card, index) => {
...card, const replacementCard = segmentReplacementCards[index];
sortOrder: index, return {
passthrough: passthroughSegmentIndexSet.has(index), ...(replacementCard || card),
})); sortOrder: index,
passthrough: passthroughSegmentIndexSet.has(index),
};
});
if (orderedCards.length !== sliceSegments.length) { if (orderedCards.length !== sliceSegments.length) {
throw new Error("部分切片处理失败,请稍后重试"); throw new Error("部分切片处理失败,请稍后重试");
} }
@ -728,8 +794,10 @@ export function AgentDetailReferenceStitchDialog({
onOpenChange, onOpenChange,
passthroughSegmentIndexes, passthroughSegmentIndexes,
passthroughSegmentIndexSet, passthroughSegmentIndexSet,
segmentReplacementCards,
sliceSegments.length, sliceSegments.length,
snapshot, snapshot,
uploadingSegmentIndex,
]); ]);
return ( return (
@ -770,6 +838,11 @@ export function AgentDetailReferenceStitchDialog({
直出 {passthroughSegmentIndexes.length} 张 直出 {passthroughSegmentIndexes.length} 张
</div> </div>
) : null} ) : 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>
</div> </div>
</DialogHeader> </DialogHeader>
@ -856,28 +929,28 @@ export function AgentDetailReferenceStitchDialog({
<div <div
key={`${segment.card.id || "segment"}-${index}-${segment.top}`} key={`${segment.card.id || "segment"}-${index}-${segment.top}`}
className="relative overflow-hidden bg-white" className="relative overflow-hidden bg-white"
style={{ style={{
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`, aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
contentVisibility: index > 4 ? "auto" : undefined, contentVisibility: index > 4 ? "auto" : undefined,
containIntrinsicSize: containIntrinsicSize:
index > 4 ? `${Math.max(segment.height, 1)}px` : undefined, 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"> <div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-neutral-400">
参考图 {index + 1} 加载失败 参考图 {index + 1} 加载失败
</div> </div>
{segmentImageUrl ? ( {segmentImageUrl ? (
<img <img
src={buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width)} src={buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width)}
alt={`拼合分段预览 ${index + 1}`} alt={`拼合分段预览 ${index + 1}`}
className="relative z-10 block h-full w-full select-none object-fill" className="relative z-10 block h-full w-full select-none object-fill"
draggable={false} draggable={false}
decoding="async" decoding="async"
loading={index > 2 ? "lazy" : "eager"} loading={index > 2 ? "lazy" : "eager"}
fetchPriority={index < 2 ? "high" : "low"} fetchPriority={index < 2 ? "high" : "low"}
onError={handleSegmentPreviewImageError(originalImageUrl)} onError={handleSegmentPreviewImageError(originalImageUrl)}
/> />
) : null} ) : null}
</div> </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"> <div className="agent-dark-scrollbar flex-1 space-y-3 overflow-y-auto px-5 py-4">
{sliceSegments.length > 0 ? ( {sliceSegments.length > 0 ? (
sliceSegments.map((segment) => ( sliceSegments.map((segment) => {
<div const replacementCard = segmentReplacementCards[segment.index];
key={`slice-segment-${segment.index}`} const replacementUrl = String(
className="rounded-[20px] border border-white/10 bg-white/[0.03] p-3" replacementCard?.previewUrl || replacementCard?.imageUrl || ""
> ).trim();
<div className="flex items-center justify-between gap-3"> const isUploadingThisSegment = uploadingSegmentIndex === segment.index;
<div className="text-sm font-medium text-white"> return (
切片 {segment.index + 1} <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>
<div className="rounded-full border border-white/10 bg-black/30 px-2.5 py-1 text-[11px] text-white/52"> <div className="mt-2 text-xs leading-6 text-white/42">
高度 {segment.height}px 从 {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> </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"> <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>
<div className="border-t border-white/10 px-5 py-4"> <div className="border-t border-white/10 px-5 py-4">
<Button <Button
type="button" type="button"
onClick={handleApply} onClick={handleApply}
disabled={isLoading || isPreparing || isApplying || !snapshot || sliceSegments.length === 0} disabled={
className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50" 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 ? ( {isApplying ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <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 { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog";
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog"; import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; 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 { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-style-performance-monitor";
import { ImageAnnotator } from "@/components/image-annotator"; import { ImageAnnotator } from "@/components/image-annotator";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
@ -1174,6 +1180,11 @@ function DetailResultLocalEditDialog({
}} }}
/> />
) : null} ) : null}
{submitting ? (
<DetailResultProcessingScanOverlay
label={isScaleAdjustMode ? "比例调整处理中" : "局部编辑处理中"}
/>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@ -1920,31 +1931,36 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
function DetailResultProcessingScanOverlay({ label }: { label: string }) { function DetailResultProcessingScanOverlay({ label }: { label: string }) {
return ( 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 <motion.div
aria-hidden aria-hidden
className="absolute inset-0 opacity-45" className="absolute inset-0 opacity-50"
animate={{ 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={{ style={{
backgroundImage: backgroundImage:
"linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px)", "linear-gradient(rgba(255,255,255,0.11) 0px, rgba(255,255,255,0.11) 1px, transparent 1px, transparent 20px)",
backgroundSize: "100% 28px", backgroundSize: "100% 20px",
}} }}
/> />
<motion.div <motion.div
aria-hidden 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)]" className="absolute inset-x-[-8%] h-[26%] min-h-[110px]"
animate={{ y: ["-45%", "115%"], opacity: [0.58, 1, 0.7] }} animate={{ top: ["-30%", "104%"], opacity: [0.38, 0.95, 0.48] }}
transition={{ duration: 1.85, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }} 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 <motion.div
aria-hidden aria-hidden
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(16,185,129,0.20),transparent_58%)]" className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(16,185,129,0.12),transparent_62%)]"
animate={{ opacity: [0.18, 0.44, 0.18] }} animate={{ opacity: [0.16, 0.28, 0.16] }}
transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }} 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"> <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" /> <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: { function AnimatedGenerationPlaceholderCard(params: {
title: string; title: string;
description: string; description: string;
@ -2176,6 +2209,8 @@ export function AgentGenerationWorkspaceDarkV2({
const [detailProductIdQueryInput, setDetailProductIdQueryInput] = useState(""); const [detailProductIdQueryInput, setDetailProductIdQueryInput] = useState("");
const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false); const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false);
const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(null); const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(null);
const [detailReferenceUploadTarget, setDetailReferenceUploadTarget] =
useState<null | { type: "append" } | { type: "replace"; cardId: string }>(null);
const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{ const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{
isLoading: boolean; isLoading: boolean;
lastQuery: string | null; lastQuery: string | null;
@ -2194,6 +2229,9 @@ export function AgentGenerationWorkspaceDarkV2({
const [detailGenerationRunResults, setDetailGenerationRunResults] = useState< const [detailGenerationRunResults, setDetailGenerationRunResults] = useState<
DirectDetailGenerationImage[] DirectDetailGenerationImage[]
>([]); >([]);
const [processedResultHintByUrl, setProcessedResultHintByUrl] = useState<
Record<string, string>
>({});
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>( const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
() => createEmptyReplacementResultState() () => createEmptyReplacementResultState()
); );
@ -2206,6 +2244,7 @@ export function AgentGenerationWorkspaceDarkV2({
const [replacementRerunPrompt, setReplacementRerunPrompt] = useState(""); const [replacementRerunPrompt, setReplacementRerunPrompt] = useState("");
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] = const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
useState<Set<string>>(() => new Set()); useState<Set<string>>(() => new Set());
const processedResultHintTimerRef = useRef<Record<string, number>>({});
const closePsdTemplateDialog = useCallback(() => { const closePsdTemplateDialog = useCallback(() => {
setIsPsdTemplateDialogOpen(false); setIsPsdTemplateDialogOpen(false);
setPsdTemplateDialogSourceUrl(null); setPsdTemplateDialogSourceUrl(null);
@ -3139,6 +3178,15 @@ export function AgentGenerationWorkspaceDarkV2({
); );
}, [historyMessages, setMessages]); }, [historyMessages, setMessages]);
useEffect(() => {
return () => {
Object.values(processedResultHintTimerRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
processedResultHintTimerRef.current = {};
};
}, []);
useEffect(() => { useEffect(() => {
if (isWorkflowModelLocked) { if (isWorkflowModelLocked) {
setSelectedModelId((current) => (current === "auto" ? current : "auto")); setSelectedModelId((current) => (current === "auto" ? current : "auto"));
@ -5121,11 +5169,11 @@ export function AgentGenerationWorkspaceDarkV2({
}, },
[mergedResultCollections.scene_generation, onResultPreviewUrlsChange, uploadBase64ResultToStorage] [mergedResultCollections.scene_generation, onResultPreviewUrlsChange, uploadBase64ResultToStorage]
); );
const appendResultUrlToCollection = useCallback( const appendResultUrlToCollection = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => { (modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim(); const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onResultPreviewUrlsChange) { if (!normalizedUrl || !onResultPreviewUrlsChange) {
return false; return false;
} }
const nextUrls = Array.from( const nextUrls = Array.from(
@ -5139,13 +5187,38 @@ export function AgentGenerationWorkspaceDarkV2({
setSelectedResultUrl(normalizedUrl); setSelectedResultUrl(normalizedUrl);
} }
return true; return true;
}, },
[activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange] [activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange]
); );
const handleEnhanceDetailGenerationResult = useCallback( const showProcessedResultHint = useCallback((imageUrl: string, label: string) => {
async (params: { const normalizedUrl = String(imageUrl || "").trim();
cardId: string; const normalizedLabel = String(label || "").trim();
imageUrl: string; 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"; resultModeType?: "detail_generation" | "detail_replacement";
}) => { }) => {
const cardId = String(params.cardId || "").trim(); const cardId = String(params.cardId || "").trim();
@ -5296,6 +5369,10 @@ export function AgentGenerationWorkspaceDarkV2({
setActiveResultModeType("detail_generation"); setActiveResultModeType("detail_generation");
setSelectedResultUrl(nextUrl); setSelectedResultUrl(nextUrl);
} }
showProcessedResultHint(
nextUrl,
isScaleAdjust ? "比例调整完成" : "局部编辑完成"
);
setDetailLocalEditTarget(null); setDetailLocalEditTarget(null);
toast.success( toast.success(
resultModeType === "detail_replacement" resultModeType === "detail_replacement"
@ -5333,6 +5410,7 @@ export function AgentGenerationWorkspaceDarkV2({
detailLocalEditSubmittingId, detailLocalEditSubmittingId,
detailLocalEditTarget, detailLocalEditTarget,
handleUpdateReplacementResultRecord, handleUpdateReplacementResultRecord,
showProcessedResultHint,
] ]
); );
const handlePsdDerivedImageCreated = useCallback( const handlePsdDerivedImageCreated = useCallback(
@ -5341,13 +5419,13 @@ export function AgentGenerationWorkspaceDarkV2({
}, },
[appendResultUrlToCollection, psdTemplateDialogResultModeType] [appendResultUrlToCollection, psdTemplateDialogResultModeType]
); );
const handlePsdRenderedResultCreated = useCallback( const handlePsdRenderedResultCreated = useCallback(
(imageUrl: string) => { (imageUrl: string) => {
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl); appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
}, },
[appendResultUrlToCollection, psdTemplateDialogResultModeType] [appendResultUrlToCollection, psdTemplateDialogResultModeType]
); );
const handleDeleteResultImage = useCallback( const handleDeleteResultImage = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => { (modeType: AgentGenerationResultModeType, imageUrl: string) => {
const normalizedUrl = String(imageUrl || "").trim(); const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onDeleteResultImage) { if (!normalizedUrl || !onDeleteResultImage) {
@ -5363,6 +5441,17 @@ export function AgentGenerationWorkspaceDarkV2({
if (sceneResizeEditorTargetUrl === normalizedUrl) { if (sceneResizeEditorTargetUrl === normalizedUrl) {
setSceneResizeEditorTargetUrl(null); 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); onDeleteResultImage(modeType, normalizedUrl);
toast.success("结果图已删除"); toast.success("结果图已删除");
@ -6258,6 +6347,101 @@ export function AgentGenerationWorkspaceDarkV2({
references: [], references: [],
})); }));
}, [updateDetailReplacementState]); }, [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( const handlePreparedDetailReferenceCards = useCallback(
(preparedCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => { (preparedCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
const preparedById = new Map(preparedCards.map((card) => [card.id, card])); const preparedById = new Map(preparedCards.map((card) => [card.id, card]));
@ -7425,11 +7609,34 @@ export function AgentGenerationWorkspaceDarkV2({
目标 / {detailReferenceSourceLabel} 目标 / {detailReferenceSourceLabel}
</span> </span>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Input <label
value={detailProductIdQueryInput} className={cn(
onChange={(event) => setDetailProductIdQueryInput(event.target.value)} "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) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
@ -7924,10 +8131,33 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="text-xs text-white/40"> <div className="text-xs text-white/40">
支持按 SPU / SKU 自动兜底查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。 支持按 SPU / SKU 自动兜底查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{detailReplacementReferenceCards.length > 0 ? ( <label
<button 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" type="button"
onClick={() => setDetailReferenceStitchDialogOpen(true)} 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]" 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"> <div className="grid grid-cols-1 gap-3">
{detailReplacementReferenceCards.length > 0 ? ( {detailReplacementReferenceCards.length > 0 ? (
detailReplacementReferenceCards.map((card, index) => ( detailReplacementReferenceCards.map((card, index) => (
(() => { (() => {
const isActive = activeDetailReferenceCardId === card.id; const isActive = activeDetailReferenceCardId === card.id;
return ( const isReplacingReference =
<div detailReferenceUploadTarget?.type === "replace" &&
detailReferenceUploadTarget.cardId === card.id;
return (
<div
key={card.id} key={card.id}
style={AGENT_DEFERRED_LIST_CARD_STYLE} style={AGENT_DEFERRED_LIST_CARD_STYLE}
className={cn( className={cn(
@ -7958,10 +8191,34 @@ export function AgentGenerationWorkspaceDarkV2({
)} )}
onClick={() => setActiveDetailReferenceCardId(card.id)} onClick={() => setActiveDetailReferenceCardId(card.id)}
> >
<div className="relative min-h-[220px] overflow-hidden bg-white/95"> <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"> <div className="absolute right-3 top-3 z-10 flex flex-col items-end gap-2">
<button <label
type="button" 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) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
handleRemoveDetailReferenceCard(card.id); 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="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="text-sm font-medium text-white/72">等待{detailReferenceSourceLabel}参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40"> <div className="mt-2 text-xs leading-6 text-white/40">
点击“检索可用模版”后,这里会展示当前品牌下主 SPU 对应的真实{detailReferenceSourceLabel}参考图。 点击“检索可用模版”后,这里会展示当前品牌下主 SPU 对应的真实{detailReferenceSourceLabel}参考图;也可以直接上传目标详情图。
删除不需要的参考图后,列表会自动上移补位。 删除不需要的参考图后,列表会自动上移补位。
</div> </div>
</div> </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)}> <div className={cn("absolute left-3 top-3", IMAGE_OVERLAY_STATUS_BASE_CLASS, statusMeta.className)}>
{statusMeta.label} {statusMeta.label}
</div> </div>
<AnimatePresence>
{card.resultUrl && processedResultHintByUrl[card.resultUrl] ? (
<ResultProcessedHintBubble
label={processedResultHintByUrl[card.resultUrl]}
/>
) : null}
</AnimatePresence>
{isActive ? ( {isActive ? (
<div className={cn("absolute left-3 top-12", IMAGE_OVERLAY_ACTIVE_BADGE_CLASS)}> <div className={cn("absolute left-3 top-12", IMAGE_OVERLAY_ACTIVE_BADGE_CLASS)}>
当前联动 当前联动
@ -8967,6 +9231,13 @@ export function AgentGenerationWorkspaceDarkV2({
<span>套PSD</span> <span>套PSD</span>
</button> </button>
) : null} ) : null}
<AnimatePresence>
{card.url && processedResultHintByUrl[card.url] ? (
<ResultProcessedHintBubble
label={processedResultHintByUrl[card.url]}
/>
) : null}
</AnimatePresence>
<button <button
type="button" type="button"
onClick={(event) => { 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,
};
}