fix: improve detail replacement result sync
This commit is contained in:
parent
ccfaef9df9
commit
583145c97c
@ -3079,6 +3079,7 @@ function buildAgentReplacementPrompt(params: {
|
|||||||
referenceIndex: number;
|
referenceIndex: number;
|
||||||
totalReferences: number;
|
totalReferences: number;
|
||||||
hasModelImage: boolean;
|
hasModelImage: boolean;
|
||||||
|
productImageCount: number;
|
||||||
pairedProductIndex?: number;
|
pairedProductIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const instructionLines =
|
const instructionLines =
|
||||||
@ -3086,8 +3087,13 @@ function buildAgentReplacementPrompt(params: {
|
|||||||
? [
|
? [
|
||||||
"【执行模式】详情替换",
|
"【执行模式】详情替换",
|
||||||
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张详情参考图:${params.referenceLabel}`,
|
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张详情参考图:${params.referenceLabel}`,
|
||||||
|
"图像输入顺序规则必须严格遵守:前面的图片是待替换进去的商品输入图,如有模特图会出现在更前面的输入图里;最后一张图片才是当前详情参考图。",
|
||||||
|
params.productImageCount > 1
|
||||||
|
? `前面的 ${params.productImageCount} 张商品输入图都属于同一产品的多角度/多细节白底图,请综合这些图片理解产品结构、材质、颜色、比例、边角、开孔、五金与细节,不可只参考第一张。`
|
||||||
|
: "前面的商品输入图就是当前要替换进去的产品白底图,请准确保持该产品的结构、材质、颜色、比例和细节。",
|
||||||
"请严格保留最后一张参考图的版式结构、镜头关系、构图、光影氛围、留白位置和信息层级,只替换其中的商品主体。",
|
"请严格保留最后一张参考图的版式结构、镜头关系、构图、光影氛围、留白位置和信息层级,只替换其中的商品主体。",
|
||||||
"前面的输入商品图用于提供商品主体、材质、颜色和细节特征;如有模特图,请在需要人物展示时优先参考模特图。",
|
"前面的输入商品图用于提供商品主体、材质、颜色和细节特征;如有模特图,请只在参考图本身包含人物展示时再参考模特图。",
|
||||||
|
"绝对不要把最后一张参考图原样返回,也不要忽略前面的商品输入图重新生成无关场景。",
|
||||||
"不要改动参考图的整体布局,不要新增无关主体,不要删除关键版式元素。",
|
"不要改动参考图的整体布局,不要新增无关主体,不要删除关键版式元素。",
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@ -3096,6 +3102,10 @@ function buildAgentReplacementPrompt(params: {
|
|||||||
params.pairedProductIndex !== undefined
|
params.pairedProductIndex !== undefined
|
||||||
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
|
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
|
||||||
: "请使用输入商品图作为本张参考图的一对一替换主体。",
|
: "请使用输入商品图作为本张参考图的一对一替换主体。",
|
||||||
|
params.productImageCount > 1
|
||||||
|
? "其余商品输入图同样是这个产品的补充角度/细节参考,请一并综合理解产品信息,不可忽略。"
|
||||||
|
: "输入商品图就是当前产品主体,请保持产品信息准确一致。",
|
||||||
|
"图像输入顺序规则必须严格遵守:前面的图片是待替换商品图,最后一张图片是当前 SKU 参考图。",
|
||||||
"请严格保留最后一张参考图的场景、机位、透视、光影和构图关系,只替换商品主体本身。",
|
"请严格保留最后一张参考图的场景、机位、透视、光影和构图关系,只替换商品主体本身。",
|
||||||
"输出要自然融合,不要改变参考图原有风格和景别。",
|
"输出要自然融合,不要改变参考图原有风格和景别。",
|
||||||
];
|
];
|
||||||
@ -6124,9 +6134,14 @@ export async function POST(request: Request) {
|
|||||||
productInputImageUrls.length - 1
|
productInputImageUrls.length - 1
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const currentProductImageUrls =
|
const orderedProductContextImageUrls =
|
||||||
pairedProductIndex !== undefined
|
pairedProductIndex !== undefined
|
||||||
? [productInputImageUrls[pairedProductIndex]!]
|
? [
|
||||||
|
productInputImageUrls[pairedProductIndex]!,
|
||||||
|
...productInputImageUrls.filter(
|
||||||
|
(_, productIndex) => productIndex !== pairedProductIndex
|
||||||
|
),
|
||||||
|
]
|
||||||
: productInputImageUrls;
|
: productInputImageUrls;
|
||||||
const referenceDecision = await analyzeReplacementReferenceImage({
|
const referenceDecision = await analyzeReplacementReferenceImage({
|
||||||
imageUrl: reference.imageUrl,
|
imageUrl: reference.imageUrl,
|
||||||
@ -6185,7 +6200,7 @@ export async function POST(request: Request) {
|
|||||||
const sourceImageUrls = Array.from(
|
const sourceImageUrls = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
[
|
[
|
||||||
...currentProductImageUrls,
|
...orderedProductContextImageUrls,
|
||||||
...(modelInputImageUrl ? [modelInputImageUrl] : []),
|
...(modelInputImageUrl ? [modelInputImageUrl] : []),
|
||||||
...replacementTemplateReferenceImageUrls,
|
...replacementTemplateReferenceImageUrls,
|
||||||
reference.imageUrl,
|
reference.imageUrl,
|
||||||
@ -6203,6 +6218,7 @@ export async function POST(request: Request) {
|
|||||||
referenceIndex,
|
referenceIndex,
|
||||||
totalReferences: replacementReferences.length,
|
totalReferences: replacementReferences.length,
|
||||||
hasModelImage: Boolean(modelInputImageUrl),
|
hasModelImage: Boolean(modelInputImageUrl),
|
||||||
|
productImageCount: productInputImageUrls.length,
|
||||||
pairedProductIndex,
|
pairedProductIndex,
|
||||||
});
|
});
|
||||||
console.info("[Chat API] Replacement image generation started:", {
|
console.info("[Chat API] Replacement image generation started:", {
|
||||||
|
|||||||
@ -150,6 +150,8 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
const [snapshot, setSnapshot] = useState<MergedSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<MergedSnapshot | null>(null);
|
||||||
const [cutLines, setCutLines] = useState<number[]>([]);
|
const [cutLines, setCutLines] = useState<number[]>([]);
|
||||||
const [draggingLineIndex, setDraggingLineIndex] = useState<number | null>(null);
|
const [draggingLineIndex, setDraggingLineIndex] = useState<number | null>(null);
|
||||||
|
const [isAddingCutLine, setIsAddingCutLine] = useState(false);
|
||||||
|
const [previewCutLineY, setPreviewCutLineY] = useState<number | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isApplying, setIsApplying] = useState(false);
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
const [applyProgressText, setApplyProgressText] = useState("");
|
const [applyProgressText, setApplyProgressText] = useState("");
|
||||||
@ -206,6 +208,8 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDraggingLineIndex(null);
|
setDraggingLineIndex(null);
|
||||||
|
setIsAddingCutLine(false);
|
||||||
|
setPreviewCutLineY(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,6 +266,37 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
};
|
};
|
||||||
}, [open, sourceLabel, validCards]);
|
}, [open, sourceLabel, validCards]);
|
||||||
|
|
||||||
|
const resolveInsertCutLineY = useCallback(
|
||||||
|
(rawY: number) => {
|
||||||
|
if (!snapshot) return null;
|
||||||
|
const sortedLines = [...normalizedCutLines].sort((left, right) => left - right);
|
||||||
|
const insertIndex = sortedLines.findIndex((value) => value > rawY);
|
||||||
|
const previousBoundary =
|
||||||
|
insertIndex <= 0 ? 0 : sortedLines[(insertIndex === -1 ? sortedLines.length : insertIndex) - 1];
|
||||||
|
const nextBoundary =
|
||||||
|
insertIndex === -1 ? snapshot.height : sortedLines[insertIndex];
|
||||||
|
const minY = previousBoundary + MIN_SLICE_HEIGHT;
|
||||||
|
const maxY = nextBoundary - MIN_SLICE_HEIGHT;
|
||||||
|
if (maxY < minY) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return clamp(rawY, minY, maxY);
|
||||||
|
},
|
||||||
|
[normalizedCutLines, snapshot]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolveCanvasYFromClient = useCallback(
|
||||||
|
(clientY: number) => {
|
||||||
|
const container = previewContainerRef.current;
|
||||||
|
if (!container || !snapshot) return null;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
if (rect.height <= 0) return null;
|
||||||
|
const rawY = ((clientY - rect.top) / rect.height) * snapshot.height;
|
||||||
|
return clamp(rawY, 0, snapshot.height);
|
||||||
|
},
|
||||||
|
[snapshot]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draggingLineIndex === null || !snapshot) {
|
if (draggingLineIndex === null || !snapshot) {
|
||||||
return;
|
return;
|
||||||
@ -305,26 +340,17 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
const handleAddCutLine = useCallback(() => {
|
const handleAddCutLine = useCallback(() => {
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
const points = [0, ...normalizedCutLines, snapshot.height];
|
const points = [0, ...normalizedCutLines, snapshot.height];
|
||||||
let bestIndex = -1;
|
const hasSpace = points.some(
|
||||||
let bestHeight = 0;
|
(value, index) =>
|
||||||
|
index < points.length - 1 && points[index + 1] - value >= MIN_SLICE_HEIGHT * 2
|
||||||
for (let index = 0; index < points.length - 1; index += 1) {
|
);
|
||||||
const currentHeight = points[index + 1] - points[index];
|
if (!hasSpace) {
|
||||||
if (currentHeight > bestHeight) {
|
|
||||||
bestHeight = currentHeight;
|
|
||||||
bestIndex = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestIndex < 0 || bestHeight < MIN_SLICE_HEIGHT * 2) {
|
|
||||||
toast.error("当前没有足够空间继续添加切线");
|
toast.error("当前没有足够空间继续添加切线");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextY = Math.round((points[bestIndex] + points[bestIndex + 1]) / 2);
|
setIsAddingCutLine((current) => !current);
|
||||||
setCutLines((current) =>
|
setPreviewCutLineY(null);
|
||||||
[...current, nextY].sort((left, right) => left - right)
|
|
||||||
);
|
|
||||||
}, [normalizedCutLines, snapshot]);
|
}, [normalizedCutLines, snapshot]);
|
||||||
|
|
||||||
const handleResetCutLines = useCallback(() => {
|
const handleResetCutLines = useCallback(() => {
|
||||||
@ -336,6 +362,40 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
setCutLines((current) => current.filter((_, currentIndex) => currentIndex !== index));
|
setCutLines((current) => current.filter((_, currentIndex) => currentIndex !== index));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handlePreviewPointerMove = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isAddingCutLine) return;
|
||||||
|
const rawY = resolveCanvasYFromClient(event.clientY);
|
||||||
|
if (rawY === null) {
|
||||||
|
setPreviewCutLineY(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewCutLineY(resolveInsertCutLineY(rawY));
|
||||||
|
},
|
||||||
|
[isAddingCutLine, resolveCanvasYFromClient, resolveInsertCutLineY]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePreviewPointerLeave = useCallback(() => {
|
||||||
|
if (!isAddingCutLine) return;
|
||||||
|
setPreviewCutLineY(null);
|
||||||
|
}, [isAddingCutLine]);
|
||||||
|
|
||||||
|
const handlePreviewClick = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!isAddingCutLine) return;
|
||||||
|
const rawY = resolveCanvasYFromClient(event.clientY);
|
||||||
|
const nextY = rawY === null ? null : resolveInsertCutLineY(rawY);
|
||||||
|
if (nextY === null) {
|
||||||
|
toast.error("当前位置无法添加切线,请换一个位置");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCutLines((current) => [...current, Math.round(nextY)].sort((left, right) => left - right));
|
||||||
|
setIsAddingCutLine(false);
|
||||||
|
setPreviewCutLineY(null);
|
||||||
|
},
|
||||||
|
[isAddingCutLine, resolveCanvasYFromClient, resolveInsertCutLineY]
|
||||||
|
);
|
||||||
|
|
||||||
const handleLinePointerDown = useCallback(
|
const handleLinePointerDown = useCallback(
|
||||||
(index: number) => (event: ReactPointerEvent<HTMLDivElement>) => {
|
(index: number) => (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -477,7 +537,7 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
className="border-white/12 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
|
className="border-white/12 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
添加切线
|
{isAddingCutLine ? "点击长图落线" : "添加切线"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -491,6 +551,7 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<div className="text-xs text-white/42">
|
<div className="text-xs text-white/42">
|
||||||
默认切线已按原始详情页边界生成,可直接拖动横线微调。
|
默认切线已按原始详情页边界生成,可直接拖动横线微调。
|
||||||
|
{isAddingCutLine ? " 当前已进入加线模式,移动鼠标预览位置后点击长图即可落线。" : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -513,6 +574,9 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
<div
|
<div
|
||||||
ref={previewContainerRef}
|
ref={previewContainerRef}
|
||||||
className="relative overflow-hidden rounded-[24px] border border-white/10 bg-white"
|
className="relative overflow-hidden rounded-[24px] border border-white/10 bg-white"
|
||||||
|
onPointerMove={handlePreviewPointerMove}
|
||||||
|
onPointerLeave={handlePreviewPointerLeave}
|
||||||
|
onClick={handlePreviewClick}
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={previewCanvasRef}
|
ref={previewCanvasRef}
|
||||||
@ -520,6 +584,21 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="pointer-events-none absolute inset-0">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
{isAddingCutLine && previewCutLineY !== null ? (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0"
|
||||||
|
style={{ top: `${(previewCutLineY / snapshot.height) * 100}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 flex -translate-y-1/2 items-center justify-center">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div className="h-[2px] w-full border-t border-dashed border-emerald-400 bg-transparent" />
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full border border-emerald-300/35 bg-black/72 px-2 py-1 text-[11px] text-emerald-100">
|
||||||
|
点击落线
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{normalizedCutLines.map((lineY, index) => {
|
{normalizedCutLines.map((lineY, index) => {
|
||||||
const topPercent = (lineY / snapshot.height) * 100;
|
const topPercent = (lineY / snapshot.height) * 100;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -171,6 +171,34 @@ function arePickedAttachmentsEqual(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergePersistedHistoryMessages(
|
||||||
|
currentMessages: ChatMessage[],
|
||||||
|
persistedMessages: ChatMessage[]
|
||||||
|
) {
|
||||||
|
if (persistedMessages.length === 0) {
|
||||||
|
return currentMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentMessages.length === 0) {
|
||||||
|
return persistedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedIds = new Set(persistedMessages.map((message) => message.id));
|
||||||
|
const extraLocalMessages = currentMessages.filter(
|
||||||
|
(message) => !persistedIds.has(message.id)
|
||||||
|
);
|
||||||
|
const mergedMessages = [...persistedMessages, ...extraLocalMessages];
|
||||||
|
|
||||||
|
if (
|
||||||
|
mergedMessages.length === currentMessages.length &&
|
||||||
|
mergedMessages.every((message, index) => currentMessages[index]?.id === message.id)
|
||||||
|
) {
|
||||||
|
return currentMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
function getAttachmentIdentityKey(
|
function getAttachmentIdentityKey(
|
||||||
attachment:
|
attachment:
|
||||||
| PickedAttachment
|
| PickedAttachment
|
||||||
@ -570,7 +598,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
|
|
||||||
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
|
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
|
||||||
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
|
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
|
||||||
fetcher
|
fetcher,
|
||||||
|
{
|
||||||
|
refreshInterval: selectedAgentPresetId ? 3000 : 0,
|
||||||
|
revalidateOnFocus: true,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: modelsData } = useSWR<{
|
const { data: modelsData } = useSWR<{
|
||||||
@ -817,10 +849,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!messages.length && historyMessages.length) {
|
if (!historyMessages.length) {
|
||||||
setMessages(historyMessages);
|
return;
|
||||||
}
|
}
|
||||||
}, [historyMessages, messages.length, setMessages]);
|
|
||||||
|
setMessages((currentMessages) =>
|
||||||
|
mergePersistedHistoryMessages(currentMessages, historyMessages)
|
||||||
|
);
|
||||||
|
}, [historyMessages, setMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (normalizedIncomingWorkspaceState.selectedModelId) {
|
if (normalizedIncomingWorkspaceState.selectedModelId) {
|
||||||
@ -2478,13 +2514,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-stretch gap-3">
|
<div className="flex items-stretch gap-3">
|
||||||
<div className="relative w-[72px] min-h-[96px] shrink-0 overflow-hidden rounded-[18px] border border-white/10 bg-black/30">
|
<div className="relative w-[72px] min-h-[96px] shrink-0 overflow-hidden rounded-[18px] border border-white/10 bg-white">
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
{primaryPreviewUrl ? (
|
{primaryPreviewUrl ? (
|
||||||
<img
|
<img
|
||||||
src={buildPreviewImageUrl(primaryPreviewUrl, 360)}
|
src={buildPreviewImageUrl(primaryPreviewUrl, 360)}
|
||||||
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
|
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
|
||||||
className="h-full w-full object-cover object-top transition-transform duration-500 group-hover:scale-105"
|
className="h-full w-full object-contain object-top transition-transform duration-500 group-hover:scale-[1.02]"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full w-full bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.01))]" />
|
<div className="h-full w-full bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.01))]" />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user