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