fix: preserve secondary sku scene references
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-02 12:06:10 +08:00
parent 5e51cd8ca4
commit f0086e26da
4 changed files with 292 additions and 50 deletions

View File

@ -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("【模特锁定约束】"),

View File

@ -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;

View File

@ -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) {

View File

@ -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,