fix: preserve secondary sku scene references
This commit is contained in:
parent
5e51cd8ca4
commit
f0086e26da
@ -2850,6 +2850,48 @@ function extractCurrentMessageImageParts(
|
||||
}));
|
||||
}
|
||||
|
||||
function getSceneUploadedImageBreakdown(params: {
|
||||
uploadedImageParts: AgentPresetMessageImagePart[];
|
||||
fallbackPrimaryCount: number;
|
||||
fallbackIncludeModelImage: boolean;
|
||||
}) {
|
||||
const normalizedNames = params.uploadedImageParts.map((part) => String(part.name || "").trim());
|
||||
const primaryCountFromNames = normalizedNames.filter((name) => name.startsWith("主SKU参考图")).length;
|
||||
const secondaryCountFromNames = normalizedNames.filter((name) => name.startsWith("搭配SKU参考图")).length;
|
||||
const hasModelFromNames = normalizedNames.some((name) => name.startsWith("品牌模特图"));
|
||||
const hasNamedBreakdown =
|
||||
primaryCountFromNames > 0 || secondaryCountFromNames > 0 || hasModelFromNames;
|
||||
|
||||
if (hasNamedBreakdown) {
|
||||
return {
|
||||
primaryProductImageCount: primaryCountFromNames,
|
||||
bundleProductImageCount: secondaryCountFromNames,
|
||||
includeModelImage: hasModelFromNames,
|
||||
};
|
||||
}
|
||||
|
||||
const uploadedImageCount = params.uploadedImageParts.length;
|
||||
const includeModelImage =
|
||||
params.fallbackIncludeModelImage && uploadedImageCount > params.fallbackPrimaryCount;
|
||||
const bundleProductImageCount = Math.max(
|
||||
0,
|
||||
uploadedImageCount - params.fallbackPrimaryCount - (includeModelImage ? 1 : 0)
|
||||
);
|
||||
const primaryProductImageCount = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
params.fallbackPrimaryCount,
|
||||
uploadedImageCount - bundleProductImageCount - (includeModelImage ? 1 : 0)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
primaryProductImageCount,
|
||||
bundleProductImageCount,
|
||||
includeModelImage,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgentSupplementPrompt(text: string, presetName?: string | null) {
|
||||
const normalized = String(text || "").trim();
|
||||
if (!normalized) return "";
|
||||
@ -7257,10 +7299,15 @@ export async function POST(request: Request) {
|
||||
[...sharedUploadedAssetIds, ...(sceneSlot?.referenceAssetIds || [])].filter(Boolean),
|
||||
),
|
||||
);
|
||||
const sceneUploadedImageBreakdown = getSceneUploadedImageBreakdown({
|
||||
uploadedImageParts,
|
||||
fallbackPrimaryCount: sceneProductSlotCount,
|
||||
fallbackIncludeModelImage: sceneModelSlotEnabled,
|
||||
});
|
||||
const sceneImageRolePrompt = buildAgentSceneImageRolePrompt({
|
||||
uploadedImageCount: sharedUploadedImageUrls.length,
|
||||
productImageCount: sceneProductSlotCount,
|
||||
includeModelImage: sceneModelSlotEnabled,
|
||||
primaryProductImageCount: sceneUploadedImageBreakdown.primaryProductImageCount,
|
||||
bundleProductImageCount: sceneUploadedImageBreakdown.bundleProductImageCount,
|
||||
includeModelImage: sceneUploadedImageBreakdown.includeModelImage,
|
||||
});
|
||||
const shouldAppendSceneSlotPrompt = !hiddenPromptText;
|
||||
const rawSceneSlotPrompt = shouldAppendSceneSlotPrompt
|
||||
@ -7295,10 +7342,11 @@ export async function POST(request: Request) {
|
||||
agentExecutionSnapshotId: presetSnapshot.id,
|
||||
includesWorkflowPrompt: Boolean(hiddenPromptText),
|
||||
includesUserSupplementPrompt: Boolean(sanitizedUserSupplementPrompt),
|
||||
includesModelImage: sceneModelSlotEnabled,
|
||||
includesModelImage: sceneUploadedImageBreakdown.includeModelImage,
|
||||
shouldAppendSceneSlotPrompt,
|
||||
includesSceneSlotPrompt: Boolean(dedupedSceneSlotPrompt),
|
||||
productImageCount: sceneProductSlotCount,
|
||||
productImageCount: sceneUploadedImageBreakdown.primaryProductImageCount,
|
||||
bundleImageCount: sceneUploadedImageBreakdown.bundleProductImageCount,
|
||||
uploadedImageCount: sharedUploadedImageUrls.length,
|
||||
hasProductIdentityLock: sceneImageRolePrompt.includes("【产品锁定约束】"),
|
||||
hasModelIdentityLock: sceneImageRolePrompt.includes("【模特锁定约束】"),
|
||||
|
||||
@ -392,6 +392,26 @@ function getAttachmentIdentityKey(
|
||||
return url ? `url:${url}` : null;
|
||||
}
|
||||
|
||||
function toWorkflowReferenceAttachment(params: {
|
||||
url?: string | null;
|
||||
name?: string | null;
|
||||
label?: string | null;
|
||||
mimeType?: string | null;
|
||||
assetId?: string | null;
|
||||
}): PickedAttachment | null {
|
||||
const url = String(params.url || "").trim();
|
||||
if (!isValidChatHttpUrl(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
name: normalizeChatAttachmentName(params.name || params.label || "参考图"),
|
||||
contentType: normalizeChatMediaType(params.mimeType || "image/png"),
|
||||
assetId: isValidChatUuid(params.assetId) ? params.assetId || undefined : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
type ToolStepKey =
|
||||
| "preset"
|
||||
| "brand-model"
|
||||
@ -1246,6 +1266,97 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
);
|
||||
const selectedModelAttachment =
|
||||
modelSlotIndex >= 0 && attachments[modelSlotIndex] ? attachments[modelSlotIndex] : null;
|
||||
const scenePrimaryReferenceCount = useMemo(
|
||||
() => referenceImages.filter((image) => image.source === "primary").length,
|
||||
[referenceImages]
|
||||
);
|
||||
const sceneSecondaryReferenceAttachments = useMemo(() => {
|
||||
const seenKeys = new Set<string>();
|
||||
return referenceImages
|
||||
.filter((image) => image.source === "secondary")
|
||||
.map((image) =>
|
||||
toWorkflowReferenceAttachment({
|
||||
url: image.url,
|
||||
name: image.name,
|
||||
label: image.label,
|
||||
mimeType: image.mimeType,
|
||||
assetId: image.assetId || null,
|
||||
})
|
||||
)
|
||||
.filter((attachment): attachment is PickedAttachment => {
|
||||
if (!attachment) return false;
|
||||
const key = getAttachmentIdentityKey(attachment);
|
||||
if (key && seenKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
if (key) {
|
||||
seenKeys.add(key);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [referenceImages]);
|
||||
const sceneSecondaryReferenceCount = sceneSecondaryReferenceAttachments.length;
|
||||
const sceneMessageAttachments = useMemo(() => {
|
||||
if (!isSceneGenerationPreset) {
|
||||
return attachments
|
||||
.map((attachment) => sanitizeChatAttachment(attachment))
|
||||
.filter((attachment): attachment is PickedAttachment => Boolean(attachment));
|
||||
}
|
||||
|
||||
const ordered: PickedAttachment[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
const pushUniqueAttachment = (
|
||||
attachment:
|
||||
| PickedAttachment
|
||||
| {
|
||||
url?: string | null;
|
||||
name?: string | null;
|
||||
contentType?: string | null;
|
||||
assetId?: string | null;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
) => {
|
||||
const sanitized = sanitizeChatAttachment(attachment as PickedAttachment | null | undefined);
|
||||
if (!sanitized) return;
|
||||
const key = getAttachmentIdentityKey(sanitized);
|
||||
if (key && seenKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
if (key) {
|
||||
seenKeys.add(key);
|
||||
}
|
||||
ordered.push(sanitized);
|
||||
};
|
||||
|
||||
const primaryVisibleCount = Math.min(
|
||||
Math.max(scenePrimaryReferenceCount, selectedProductAttachments.length > 0 ? 1 : 0),
|
||||
selectedProductAttachments.length
|
||||
);
|
||||
selectedProductAttachments.slice(0, primaryVisibleCount).forEach((attachment) => {
|
||||
pushUniqueAttachment(attachment);
|
||||
});
|
||||
selectedProductAttachments.slice(primaryVisibleCount).forEach((attachment) => {
|
||||
pushUniqueAttachment(attachment);
|
||||
});
|
||||
sceneSecondaryReferenceAttachments.forEach((attachment) => {
|
||||
pushUniqueAttachment(attachment);
|
||||
});
|
||||
pushUniqueAttachment(selectedModelAttachment);
|
||||
|
||||
attachments.forEach((attachment) => {
|
||||
pushUniqueAttachment(attachment);
|
||||
});
|
||||
|
||||
return ordered;
|
||||
}, [
|
||||
attachments,
|
||||
isSceneGenerationPreset,
|
||||
scenePrimaryReferenceCount,
|
||||
sceneSecondaryReferenceAttachments,
|
||||
selectedModelAttachment,
|
||||
selectedProductAttachments,
|
||||
]);
|
||||
const filledAttachmentCount = attachments.filter(Boolean).length;
|
||||
const missingAttachmentCount = Math.max(requiredUploadCount - filledAttachmentCount, 0);
|
||||
const productAttachmentSummary = `${selectedProductAttachments.length}/${productSlotCount} 张产品图`;
|
||||
@ -1257,20 +1368,25 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const attachmentSummaryReady =
|
||||
hasAgentUploadSlots && (requiredUploadCount === 0 || filledAttachmentCount >= requiredUploadCount);
|
||||
const sceneImageRolePromptPreview = useMemo(() => {
|
||||
if (!isSceneGenerationPreset || filledAttachmentCount <= 0) {
|
||||
const sceneHasModelReference = Boolean(selectedModelAttachment);
|
||||
if (
|
||||
!isSceneGenerationPreset ||
|
||||
scenePrimaryReferenceCount + sceneSecondaryReferenceCount + (sceneHasModelReference ? 1 : 0) <= 0
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return buildAgentSceneImageRolePrompt({
|
||||
uploadedImageCount: filledAttachmentCount,
|
||||
productImageCount: productSlotCount,
|
||||
includeModelImage: modelSlotEnabled,
|
||||
primaryProductImageCount: Math.max(scenePrimaryReferenceCount, selectedProductAttachments.length > 0 ? 1 : 0),
|
||||
bundleProductImageCount: sceneSecondaryReferenceCount,
|
||||
includeModelImage: sceneHasModelReference,
|
||||
});
|
||||
}, [
|
||||
filledAttachmentCount,
|
||||
isSceneGenerationPreset,
|
||||
modelSlotEnabled,
|
||||
productSlotCount,
|
||||
scenePrimaryReferenceCount,
|
||||
sceneSecondaryReferenceCount,
|
||||
selectedModelAttachment,
|
||||
selectedProductAttachments.length,
|
||||
]);
|
||||
const detailReplacementQueryInput = detailReplacementState.query;
|
||||
const detailReplacementCandidates = detailReplacementState.candidates;
|
||||
@ -2589,19 +2705,43 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
return;
|
||||
}
|
||||
const parts: Array<{ type: "text"; text: string } | { type: "file"; mediaType: string; name: string; url: string; assetId?: string }> = [];
|
||||
const sanitizedAttachments = attachments
|
||||
.map((attachment) => sanitizeChatAttachment(attachment))
|
||||
.filter((attachment): attachment is PickedAttachment => Boolean(attachment));
|
||||
const sanitizedAttachments = sceneMessageAttachments;
|
||||
if (hasAgentUploadSlots && sanitizedAttachments.length < requiredUploadCount) {
|
||||
toast.error(`当前模式需要上传 ${requiredUploadCount} 张有效图片`);
|
||||
return;
|
||||
}
|
||||
sanitizedAttachments.forEach((attachment) => {
|
||||
const primaryAttachmentCountForMessage = Math.min(
|
||||
scenePrimaryReferenceCount,
|
||||
sanitizedAttachments.length
|
||||
);
|
||||
const secondaryAttachmentCountForMessage = Math.min(
|
||||
sceneSecondaryReferenceCount,
|
||||
Math.max(
|
||||
sanitizedAttachments.length -
|
||||
primaryAttachmentCountForMessage -
|
||||
(selectedModelAttachment ? 1 : 0),
|
||||
0
|
||||
)
|
||||
);
|
||||
sanitizedAttachments.forEach((attachment, index) => {
|
||||
if (!attachment) return;
|
||||
let attachmentName = normalizeChatAttachmentName(attachment.name);
|
||||
if (isSceneGenerationPreset) {
|
||||
if (index < primaryAttachmentCountForMessage) {
|
||||
attachmentName = `主SKU参考图 ${index + 1}`;
|
||||
} else if (index < primaryAttachmentCountForMessage + secondaryAttachmentCountForMessage) {
|
||||
attachmentName = `搭配SKU参考图 ${index - primaryAttachmentCountForMessage + 1}`;
|
||||
} else if (
|
||||
selectedModelAttachment &&
|
||||
index === primaryAttachmentCountForMessage + secondaryAttachmentCountForMessage
|
||||
) {
|
||||
attachmentName = "品牌模特图";
|
||||
}
|
||||
}
|
||||
parts.push({
|
||||
type: "file",
|
||||
mediaType: normalizeChatMediaType(attachment.contentType),
|
||||
name: normalizeChatAttachmentName(attachment.name),
|
||||
name: attachmentName,
|
||||
url: attachment.url,
|
||||
assetId: isValidChatUuid(attachment.assetId) ? attachment.assetId : undefined,
|
||||
});
|
||||
@ -2679,7 +2819,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
skuReplacementReferenceCards.length,
|
||||
userMessageCount,
|
||||
getLatestWorkflowContext,
|
||||
isSceneGenerationPreset,
|
||||
workflowContext,
|
||||
sceneMessageAttachments,
|
||||
scenePrimaryReferenceCount,
|
||||
sceneSecondaryReferenceCount,
|
||||
selectedModelAttachment,
|
||||
]);
|
||||
const handleStopGeneration = useCallback(() => {
|
||||
detailGenerationCancelRef.current = true;
|
||||
|
||||
@ -1,29 +1,47 @@
|
||||
export function buildAgentSceneImageRolePrompt(params: {
|
||||
uploadedImageCount: number;
|
||||
productImageCount: number;
|
||||
primaryProductImageCount: number;
|
||||
bundleProductImageCount: number;
|
||||
includeModelImage: boolean;
|
||||
}) {
|
||||
if (params.uploadedImageCount <= 0) {
|
||||
const primaryProductImageCount = Math.max(
|
||||
0,
|
||||
Math.floor(Number(params.primaryProductImageCount) || 0)
|
||||
);
|
||||
const bundleProductImageCount = Math.max(
|
||||
0,
|
||||
Math.floor(Number(params.bundleProductImageCount) || 0)
|
||||
);
|
||||
const uploadedImageCount =
|
||||
primaryProductImageCount +
|
||||
bundleProductImageCount +
|
||||
(params.includeModelImage ? 1 : 0);
|
||||
if (uploadedImageCount <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines = ["【输入图片角色说明】"];
|
||||
const resolvedProductCount = Math.max(
|
||||
1,
|
||||
Math.min(params.productImageCount, params.uploadedImageCount)
|
||||
);
|
||||
const hasModelImage =
|
||||
params.includeModelImage && params.uploadedImageCount > resolvedProductCount;
|
||||
const hasPrimaryProductImage = primaryProductImageCount > 0;
|
||||
const hasBundleProductImage = bundleProductImageCount > 0;
|
||||
const hasModelImage = params.includeModelImage;
|
||||
const bundleStartIndex = primaryProductImageCount;
|
||||
const modelImageIndex = primaryProductImageCount + bundleProductImageCount;
|
||||
|
||||
for (let index = 0; index < params.uploadedImageCount; index += 1) {
|
||||
if (index < resolvedProductCount) {
|
||||
for (let index = 0; index < uploadedImageCount; index += 1) {
|
||||
if (index < primaryProductImageCount) {
|
||||
lines.push(
|
||||
`图片${index + 1}:产品白底参考图,用于锁定产品的外观、比例、材质、颜色、结构和细节。`
|
||||
`图片${index + 1}:主SKU白底参考图,用于锁定主产品的外观、比例、材质、颜色、结构和细节。`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasModelImage && index === resolvedProductCount) {
|
||||
if (index >= bundleStartIndex && index < bundleStartIndex + bundleProductImageCount) {
|
||||
lines.push(
|
||||
`图片${index + 1}:搭配SKU白底参考图,用于锁定搭配产品本身的外观与结构。它是独立的第二产品,只能用于组合展示、场景搭配或补充关联销售,不能替代主SKU,也不能和主SKU融合成一件产品。`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasModelImage && index === modelImageIndex) {
|
||||
lines.push(
|
||||
`图片${index + 1}:固定模特图。若画面需要人物,必须复用这张图中的同一模特(脸型、发型、体型、肤色、穿搭风格保持一致),不得替换成其他人物。`
|
||||
);
|
||||
@ -35,28 +53,42 @@ export function buildAgentSceneImageRolePrompt(params: {
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`必须以图片1${resolvedProductCount > 1 ? `到图片${resolvedProductCount}` : ""}中的产品信息为最高优先级,严格保持同一产品。`
|
||||
);
|
||||
lines.push(
|
||||
"【产品锁定约束】生成结果中的产品必须与产品白底参考图保持同一产品身份,禁止改成另一款产品。"
|
||||
);
|
||||
lines.push(
|
||||
"严禁改变产品的主体结构、外轮廓、长宽高比例、层板数量与位置、抽屉数量与位置、柜门/开孔/隔断布局、弧度造型、柜腿样式、把手/五金位置。"
|
||||
);
|
||||
lines.push(
|
||||
"严禁改变产品主色、木纹方向、材质类型、表面纹理、明暗层次和细节做工;不得把深色木纹改成浅色、白色、金属色或其他颜色体系。"
|
||||
);
|
||||
lines.push(
|
||||
"如果场景风格与产品外观产生冲突,必须优先保留产品本体不变,只允许调整周边场景、软装、灯光、背景和道具,不允许为了适配风格而修改产品。"
|
||||
);
|
||||
lines.push(
|
||||
"产品白底图是产品身份锁定图,不是灵感参考图。输出时必须让人一眼看出是同一个产品,只能换场景,不能换产品本体。"
|
||||
);
|
||||
lines.push("不要把多张输入图拼贴成多个主体,也不要把所有输入图元素强行同时融入结果。");
|
||||
if (hasPrimaryProductImage) {
|
||||
lines.push(
|
||||
`必须以图片1${
|
||||
primaryProductImageCount > 1 ? `到图片${primaryProductImageCount}` : ""
|
||||
}中的主SKU信息为最高优先级,严格保持主产品身份不变。`
|
||||
);
|
||||
lines.push(
|
||||
"【产品锁定约束】生成结果中的主SKU必须与主SKU白底参考图保持同一产品身份,禁止改成另一款产品。"
|
||||
);
|
||||
lines.push(
|
||||
"严禁改变主SKU的主体结构、外轮廓、长宽高比例、层板数量与位置、抽屉数量与位置、柜门/开孔/隔断布局、弧度造型、柜腿样式、把手/五金位置。"
|
||||
);
|
||||
lines.push(
|
||||
"严禁改变主SKU主色、木纹方向、材质类型、表面纹理、明暗层次和细节做工;不得把深色木纹改成浅色、白色、金属色或其他颜色体系。"
|
||||
);
|
||||
lines.push(
|
||||
"如果场景风格与主SKU外观产生冲突,必须优先保留主产品本体不变,只允许调整周边场景、软装、灯光、背景和道具,不允许为了适配风格而修改主产品。"
|
||||
);
|
||||
lines.push(
|
||||
"主SKU白底图是主产品身份锁定图,不是灵感参考图。输出时必须让人一眼看出是同一个主产品,只能换场景,不能换主产品本体。"
|
||||
);
|
||||
}
|
||||
if (hasBundleProductImage) {
|
||||
lines.push(
|
||||
"【搭配产品约束】搭配SKU是独立的第二产品。需要搭配展示时,结果中必须同时出现主SKU与搭配SKU,清楚表现组合关系与使用场景。"
|
||||
);
|
||||
lines.push(
|
||||
"严禁把搭配SKU当成主SKU的另一视角、局部细节或替代品;也严禁把主SKU与搭配SKU融合、拼接、嫁接成单一产品。"
|
||||
);
|
||||
lines.push(
|
||||
"如果当前场景不适合展示搭配SKU,可以弱化其占比,但不得因此篡改、替换或覆盖主SKU。"
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
hasModelImage
|
||||
? `如果当前场景有人物展示需求,只允许使用图片${resolvedProductCount + 1}里的模特;如果不需要人物,则不要强行加入人物。`
|
||||
? `如果当前场景有人物展示需求,只允许使用图片${modelImageIndex + 1}里的模特;如果不需要人物,则不要强行加入人物。`
|
||||
: "当前没有固定模特图,不要凭空生成强人物主体。"
|
||||
);
|
||||
if (hasModelImage) {
|
||||
|
||||
@ -83,6 +83,19 @@ function buildGoodsInfoSummaryLine(params: {
|
||||
return `${params.label}:${parts.join(" / ")}`;
|
||||
}
|
||||
|
||||
function buildBundleUsagePromptLine(params: {
|
||||
referenceImages: AgentWorkflowReferenceImage[];
|
||||
}) {
|
||||
const primaryCount = params.referenceImages.filter((item) => item.source === "primary").length;
|
||||
const secondaryCount = params.referenceImages.filter((item) => item.source === "secondary").length;
|
||||
|
||||
if (primaryCount <= 0 || secondaryCount <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "搭配SKU使用规则:搭配SKU是独立的第二产品,不是主SKU的另一角度或替代图。需要做搭配展示时,必须同场景同时展示主SKU与搭配SKU(例如桌子+椅子),保持两者各自的外观、比例和结构,不得把两件产品融合成一件,也不得用搭配SKU替换主SKU。";
|
||||
}
|
||||
|
||||
function normalizeWorkflowText(value: string) {
|
||||
return String(value || "")
|
||||
.replace(/\s+/g, " ")
|
||||
@ -323,6 +336,10 @@ export function buildAgentWorkflowComposerContext(params: {
|
||||
}`
|
||||
);
|
||||
}
|
||||
const bundleUsagePrompt = buildBundleUsagePromptLine({ referenceImages });
|
||||
if (bundleUsagePrompt) {
|
||||
promptLines.push(bundleUsagePrompt);
|
||||
}
|
||||
promptLines.push(
|
||||
...buildGroupedSelectionLines({
|
||||
selections,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user