fix: improve detail replacement passthrough and search
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-28 17:50:01 +08:00
parent 7d51b980b9
commit bfb492babd
6 changed files with 438 additions and 134 deletions

View File

@ -1157,6 +1157,83 @@ async function createAgentImageThumbnailBuffer(buffer: Buffer): Promise<Buffer>
.toBuffer();
}
const REPLACEMENT_OUTPUT_ASPECT_RATIOS = [
"1:1",
"1:4",
"1:8",
"2:3",
"3:2",
"3:4",
"4:1",
"4:3",
"4:5",
"5:4",
"8:1",
"9:16",
"16:9",
"21:9",
] as const;
type ReplacementReferenceGeometry = {
width: number;
height: number;
aspectRatio: string;
};
function parseReplacementAspectRatioValue(ratio: string) {
const [width, height] = ratio.split(":").map((item) => Number(item));
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return 1;
}
return width / height;
}
function inferClosestReplacementAspectRatio(width: number, height: number) {
const aspectValue = width / Math.max(1, height);
let closest = "1:1";
let minDiff = Number.POSITIVE_INFINITY;
for (const candidate of REPLACEMENT_OUTPUT_ASPECT_RATIOS) {
const diff = Math.abs(parseReplacementAspectRatioValue(candidate) - aspectValue);
if (diff < minDiff) {
minDiff = diff;
closest = candidate;
}
}
return closest;
}
async function resolveReplacementReferenceGeometry(
referenceImageUrl: string,
): Promise<ReplacementReferenceGeometry | null> {
try {
const sharp = (await import("sharp")).default;
const referenceBuffer = await fetchImageBufferFromUrl(referenceImageUrl);
const metadata = await sharp(referenceBuffer, { failOn: "none" })
.rotate()
.metadata();
const width = metadata.width;
const height = metadata.height;
if (!width || !height) {
return null;
}
return {
width,
height,
aspectRatio: inferClosestReplacementAspectRatio(width, height),
};
} catch (error) {
console.warn("[Chat API] Failed to resolve replacement reference geometry:", {
referenceImageUrl,
error: error instanceof Error ? error.message : "unknown",
});
return null;
}
}
async function normalizeReplacementResultImage(params: {
generatedImageUrl: string;
referenceImageUrl: string;
@ -1185,7 +1262,10 @@ async function normalizeReplacementResultImage(params: {
const normalizedBuffer = await sharp(generatedBuffer, { failOn: "none" })
.rotate()
.resize(targetWidth, targetHeight, {
fit: "fill",
// Keep the model output proportional when adapting it back to the reference page.
// `fill` stretches the picture and can squash products; `cover` behaves like a screenshot crop.
fit: "cover",
position: "centre",
withoutEnlargement: false,
})
.png()
@ -3641,6 +3721,7 @@ function normalizeAgentModeReferences(
sortOrder: Number.isFinite(Number(item.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(item.sortOrder))))
: 0,
passthrough: item.passthrough === true,
assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex))
? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex))))
: null,
@ -4062,6 +4143,9 @@ async function analyzeReplacementExecutionPlan(params: {
params.modelImageUrl
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
: "本轮没有模特图,useModelImage 必须为 false。",
params.modelImageUrl
? "如果当前参考图存在人物展示需求,后续生图必须尽量复用提供模特图中的同一人物身份,不要明显换脸、换发型、换体型。"
: "",
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级。",
isDetailReplacement
? "这张参考图不是要求原样保留的场景照片。后续生图只允许替换/重建背景图、产品场景图、商品主体图或人物展示图区域;文字、标题、卖点标签、UI图标、按钮、分割线、品牌标识、留白和排版必须保留。"
@ -4173,6 +4257,7 @@ function buildAgentReplacementPrompt(params: {
templateReferenceCount: number;
pairedProductIndex?: number;
scenePlan?: ReplacementScenePlan | null;
referenceGeometry?: ReplacementReferenceGeometry | null;
}) {
const imageOrderLines: string[] = [];
let nextImageIndex = 1;
@ -4263,10 +4348,15 @@ function buildAgentReplacementPrompt(params: {
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
"要把最后一张参考图理解为“页面版式底板 + 背景图/场景图区参考”,不是“旧场景照片必须原样保留”。",
"前面“工作流设定”里的场景组合、空间风格、硬装方案、软装方案、灯光氛围、人群设定,必须作为当前这一屏的强制场景改造要求生效。",
"场景风格必须优先执行前面用户提示词与工作流设定中给出的空间风格、材质、灯光和氛围描述,不要被参考图原有旧场景绑死。",
"执行时要基于这些场景组合要求,重建参考图中的背景图/产品场景图区域,使当前这一屏呈现出与第一步已选风格一致的新背景画面。",
"允许替换/重建的范围是参考图里的背景图、产品场景图、商品主体图和必要的人物展示图区;不允许改动版式层。",
"如果前面商品图、模特图与最后一张参考图底板存在冲突,始终以最后一张参考图底板的版式、机位、构图和信息层级为最高优先级。",
"最后一张参考图是唯一底板,必须完整保留这张图的文字排版、品牌字样、标题区、卖点区、UI图标、按钮、标签、装饰元素、分割线、留白、画布比例和信息层级。",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。",
"前面的商品图和模特图用于生成新的背景图/产品场景图内容,并按工作流设定替换参考图中的旧背景画面,但不能改变参考图的版式设计。",
"可被替换重建的内容包括但不限于:背景照片、产品展示照片、墙面、地面材质、沙发、床、背景柜、窗帘、灯具、地毯、挂画、摆件、桌面道具、空间色调、环境光氛围。",
params.productImageCount > 1
@ -4276,6 +4366,7 @@ function buildAgentReplacementPrompt(params: {
"请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置和信息层级;但旧背景图里的家具、背景物件、材质、灯光氛围、软装小物、空间质感需要按第一步工作流设定进行明显重建。",
"如果第一步工作流风格与参考图原场景冲突,必须保留版式骨架和UI层,但放弃原背景图物件本身,以第一步工作流风格为准。",
"前面的输入商品图用于提供商品主体、材质、颜色和细节特征;如有模特图,请只在参考图本身包含人物展示时再参考模特图。",
"如果提供了模特图且这一屏确实有人物展示需求,必须尽量复用同一人物身份与体态关系,不要明显换脸、换发型、换体型,服装风格也尽量保持一致。",
"绝对不要忽略最后一张参考图去生成一张无关的新场景图。",
"如果生成结果仍然保留了参考图原有的大部分背景图物件,只是把产品换掉,则视为执行失败;你必须让背景图/场景图发生清晰可见的变化。",
"绝对不要移动、删除、改写、重排参考图里的任何文字、UI图标、按钮、标签、装饰元素与排版元素。",
@ -4297,6 +4388,9 @@ function buildAgentReplacementPrompt(params: {
: "输入商品图就是当前产品主体,请保持产品信息准确一致。",
"如果前面商品图与最后一张参考图存在冲突,始终以最后一张参考图的机位、场景、光影和构图为准。",
"请严格保留最后一张参考图的场景、机位、透视、光影和构图关系,只替换商品主体本身。",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个画布比例。替换商品时只能等比缩放、透视匹配和自然融合,禁止把商品横向或纵向拉伸变形。`
: "输出必须保持当前参考图画布比例。替换商品时只能等比缩放、透视匹配和自然融合,禁止把商品横向或纵向拉伸变形。",
"输出结果必须保持最后一张参考图的原始纵横比与画布尺寸。",
"输出要自然融合,不要改变参考图原有风格和景别。",
];
@ -7311,6 +7405,29 @@ export async function POST(request: Request) {
try {
for (const [referenceIndex, reference] of replacementReferences.entries()) {
if (reference.passthrough === true) {
const referenceSourceImageUrls = Array.from(
new Set([
...productInputImageUrls,
...(modelInputImageUrl ? [modelInputImageUrl] : []),
reference.imageUrl,
]),
);
await emitReplacementResultMessage({
reference,
bodyText: "已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。",
image: {
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
},
modelDisplayName: null,
sourceImageUrls: referenceSourceImageUrls,
streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
});
successCount += 1;
continue;
}
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
modeType: "detail_replacement",
referenceImageUrl: reference.imageUrl,
@ -7345,6 +7462,8 @@ export async function POST(request: Request) {
continue;
}
const referenceGeometry = await resolveReplacementReferenceGeometry(reference.imageUrl);
const generationAspectRatio = referenceGeometry?.aspectRatio || resolvedAspectRatio;
const selectedProductIndexes = replacementExecutionPlan.scenePlan
.selectedProductImageIndexes.map((index) => index - 1)
.filter((index) => index >= 0 && index < productInputImageUrls.length);
@ -7374,6 +7493,7 @@ export async function POST(request: Request) {
originalProductImageCount: productInputImageUrls.length,
templateReferenceCount: 0,
scenePlan: replacementExecutionPlan.scenePlan,
referenceGeometry,
});
try {
@ -7381,10 +7501,17 @@ export async function POST(request: Request) {
prompt: replacementPrompt,
negativePrompt: effectiveImageParams?.negativePrompt,
model: resolvedModelRef,
aspectRatio: resolvedAspectRatio,
aspectRatio: generationAspectRatio,
imageSize: resolvedImageSize,
imageCount: 1,
referenceImages: sourceImageUrls,
debugLabel: "agent-detail-replacement",
debugMeta: {
referenceLabel: reference.label,
referenceWidth: referenceGeometry?.width,
referenceHeight: referenceGeometry?.height,
referenceAspectRatio: referenceGeometry?.aspectRatio,
},
});
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
const firstImage = result.images[0];
@ -7913,6 +8040,35 @@ export async function POST(request: Request) {
try {
for (const [referenceIndex, reference] of replacementReferences.entries()) {
if (reference.passthrough === true) {
const replacementSourceType = isDetailReplacementPreset
? "detail_replacement"
: "sku_replacement";
const referenceSourceImageUrls = Array.from(
new Set([
...sharedUploadedImageUrls,
...replacementTemplateReferenceImageUrls,
reference.imageUrl,
]),
);
await emitReplacementResultMessage({
reference,
bodyText: "已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。",
image: {
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
},
modelDisplayName: null,
sourceImageUrls: referenceSourceImageUrls,
sourceType: replacementSourceType,
streamLabel: isDetailReplacementPreset
? "agent-preset:image:detail-replacement:item-passthrough"
: "agent-preset:image:sku-replacement:item-passthrough",
});
successCount += 1;
continue;
}
const pairedProductIndex =
isSkuReplacementPreset && productInputImageUrls.length > 0
? Math.min(
@ -8008,6 +8164,8 @@ export async function POST(request: Request) {
continue;
}
const referenceGeometry = await resolveReplacementReferenceGeometry(reference.imageUrl);
const generationAspectRatio = referenceGeometry?.aspectRatio || resolvedAspectRatio;
const selectedProductIndexes = replacementExecutionPlan.scenePlan
.selectedProductImageIndexes.map((index) => index - 1)
.filter(
@ -8054,6 +8212,7 @@ export async function POST(request: Request) {
templateReferenceCount: replacementTemplateReferenceImageUrls.length,
pairedProductIndex,
scenePlan: replacementExecutionPlan.scenePlan,
referenceGeometry,
});
console.info("[Chat API] Replacement image generation started:", {
mode: replacementSourceType,
@ -8063,6 +8222,7 @@ export async function POST(request: Request) {
pairedProductIndex:
typeof pairedProductIndex === "number" ? pairedProductIndex + 1 : null,
sourceImageCount: sourceImageUrls.length,
generationAspectRatio,
imageOrder: {
productImages: selectedProductContextImageUrls.length,
originalProductImages: productInputImageUrls.length,
@ -8082,10 +8242,17 @@ export async function POST(request: Request) {
? mergedImageParams.negativePrompt
: undefined,
model: resolvedModelRef,
aspectRatio: resolvedAspectRatio,
aspectRatio: generationAspectRatio,
imageSize: resolvedImageSize,
imageCount: 1,
referenceImages: sourceImageUrls,
debugLabel: `agent-${replacementSourceType}`,
debugMeta: {
referenceLabel: reference.label,
referenceWidth: referenceGeometry?.width,
referenceHeight: referenceGeometry?.height,
referenceAspectRatio: referenceGeometry?.aspectRatio,
},
});
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
const firstImage = result.images[0];

View File

@ -78,6 +78,7 @@ const agentModeReferenceSchema = z.object({
candidateId: z.string().min(1).max(128).nullable().optional(),
sortOrder: z.number().min(0).max(999).optional(),
assignedProductIndex: z.number().min(0).max(9).nullable().optional(),
passthrough: z.boolean().nullable().optional(),
});
const agentModeSourceImageSchema = z.object({

View File

@ -338,6 +338,7 @@ function buildReferenceItems(params: {
imageUrl: url,
candidateId: params.candidate.id,
sortOrder: index,
passthrough: false,
}));
}
@ -353,7 +354,10 @@ function isCandidateMatchedBrand(
candidate: AgentModeReferenceCandidate,
brandName?: string
) {
return isBrandNameMatched(candidate.brandName, brandName);
return (
isBrandNameMatched(candidate.brandName, brandName) ||
isBrandNameMatched(candidate.shopName, brandName)
);
}
function isBrandNameMatched(
@ -387,6 +391,87 @@ function filterCandidatesByBrand(
);
}
async function requestTaskDetailAttempt(params: {
keyword: string;
queryType: AgentWorkflowMaterialQueryType;
target: "main" | "detail" | "sku";
requestedBrandName: string;
verifiedGoodsBrandName?: string | null;
config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>;
}) {
const upstreamParams = new URLSearchParams({
resourceType: "task-detail",
[params.queryType]: params.keyword,
});
const upstreamUrl = `${normalizeBaseUrl(
params.config.baseUrl
)}/standard-model-white-background?${upstreamParams.toString()}`;
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
headers: {
"X-API-Key": params.config.apiKey,
},
cache: "no-store",
});
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
const upstreamBrandName = getExternalBrandName(upstreamResult.data || {});
const rawCandidates = upstreamResult.data?.candidates || [];
const candidateBrandSamples = Array.from(
new Set(
rawCandidates
.flatMap((candidate) => [
getExternalBrandName(candidate),
String(candidate.shopName || "").trim() || null,
])
.filter((brandName): brandName is string => Boolean(brandName))
)
).slice(0, 8);
const hasVerifiedRequestedBrand =
Boolean(params.verifiedGoodsBrandName) &&
isBrandNameMatched(params.verifiedGoodsBrandName, params.requestedBrandName);
const hasUpstreamRequestedBrand =
Boolean(upstreamBrandName) &&
isBrandNameMatched(upstreamBrandName, params.requestedBrandName);
let fallbackBrandName: string | null = null;
if (params.requestedBrandName) {
if (hasVerifiedRequestedBrand) {
fallbackBrandName = params.verifiedGoodsBrandName || null;
} else if (hasUpstreamRequestedBrand) {
fallbackBrandName = upstreamBrandName || null;
}
} else {
fallbackBrandName = upstreamBrandName;
}
const normalizedCandidates =
upstreamResponse.ok && upstreamResult.success === true && upstreamResult.data
? filterCandidatesByBrand(
sortCandidatesByUploadedAtDesc(
rawCandidates.map((item, index) =>
normalizeCandidate(item, index, fallbackBrandName)
)
),
params.requestedBrandName,
).filter(
(candidate) => getRenderableCandidateTargetUrls(candidate, params.target).length > 0
)
: [];
return {
queryType: params.queryType,
upstreamResponse,
upstreamResult,
upstreamBrandName,
rawCandidates,
candidateBrandSamples,
fallbackBrandName,
normalizedCandidates,
};
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
@ -439,60 +524,112 @@ export async function GET(request: NextRequest) {
verifiedGoodsBrandName &&
!isBrandNameMatched(verifiedGoodsBrandName, requestedBrandName)
) {
return jsonNoStore(
{
success: false,
error: {
code: "BRAND_REFERENCE_NOT_FOUND",
message: `当前搜索商品属于品牌「${verifiedGoodsBrandName}」,与当前选择品牌「${requestedBrandName}」不一致`,
},
},
{ status: 404 }
);
console.warn("[Agent Workflow Reference Search] Goods brand hint mismatched selected brand, keep searching with upstream data:", {
keyword: normalizedKeyword,
requestedBrandName,
verifiedGoodsBrandName,
});
}
const upstreamParams = new URLSearchParams({
resourceType: "task-detail",
[queryType]: normalizedKeyword,
});
const upstreamUrl = `${normalizeBaseUrl(
config.baseUrl
)}/standard-model-white-background?${upstreamParams.toString()}`;
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
headers: {
"X-API-Key": config.apiKey,
},
cache: "no-store",
});
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
const queryTypesToTry = Array.from(
new Set<AgentWorkflowMaterialQueryType>([
queryType,
queryType === "spu" ? "sku" : "spu",
])
);
let selectedAttempt:
| Awaited<ReturnType<typeof requestTaskDetailAttempt>>
| null = null;
let firstUpstreamFailure:
| Awaited<ReturnType<typeof requestTaskDetailAttempt>>
| null = null;
let firstEmptyAttempt:
| Awaited<ReturnType<typeof requestTaskDetailAttempt>>
| null = null;
for (const currentQueryType of queryTypesToTry) {
const attempt = await requestTaskDetailAttempt({
keyword: normalizedKeyword,
queryType: currentQueryType,
target: parsed.target,
requestedBrandName,
verifiedGoodsBrandName,
config,
});
if (requestedBrandName) {
console.info("[Agent Workflow Reference Search] Brand gate:", {
keyword: normalizedKeyword,
queryType: currentQueryType,
target: parsed.target,
requestedBrandName,
verifiedGoodsBrandName,
upstreamBrandName: attempt.upstreamBrandName,
candidateCount: attempt.rawCandidates.length,
candidateBrandSamples: attempt.candidateBrandSamples,
});
}
if (!upstreamResponse.ok || upstreamResult.success !== true || !upstreamResult.data) {
const errorCode = String(upstreamResult.error?.code || "UPSTREAM_ERROR").trim();
const errorMessage =
String(upstreamResult.error?.message || "").trim() ||
`参考图搜索失败(${upstreamResponse.status})`;
if (
requestedBrandName &&
(errorCode === "TASK_DETAIL_NOT_FOUND" || errorCode === "FULL_SET_NOT_FOUND")
attempt.upstreamResponse.ok &&
attempt.upstreamResult.success === true &&
attempt.upstreamResult.data
) {
if (!firstEmptyAttempt) {
firstEmptyAttempt = attempt;
}
if (attempt.normalizedCandidates.length > 0) {
selectedAttempt = attempt;
break;
}
continue;
}
if (!firstUpstreamFailure) {
firstUpstreamFailure = attempt;
}
}
if (!selectedAttempt) {
if (firstEmptyAttempt) {
return jsonNoStore(
{
success: false,
error: {
code: "BRAND_REFERENCE_NOT_FOUND",
message: `当前品牌「${requestedBrandName}」下没有可用的${
parsed.target === "main"
? "主图参考图"
code: requestedBrandName
? "BRAND_REFERENCE_NOT_FOUND"
: parsed.target === "main"
? "MAIN_REFERENCE_NOT_FOUND"
: parsed.target === "detail"
? "详情参考图"
: "SKU参考图"
}`,
? "DETAIL_REFERENCE_NOT_FOUND"
: "SKU_REFERENCE_NOT_FOUND",
message: requestedBrandName
? `当前品牌「${requestedBrandName}」下没有可用的${
parsed.target === "main"
? "主图参考图"
: parsed.target === "detail"
? "详情参考图"
: "SKU参考图"
}`
: parsed.target === "main"
? "当前整套素材中没有可用的主图参考图"
: parsed.target === "detail"
? "当前整套素材中没有可用的详情参考图"
: "当前整套素材中没有可用的 SKU 参考图",
},
},
{ status: 404 }
);
}
const errorCode = String(
firstUpstreamFailure?.upstreamResult.error?.code || "UPSTREAM_ERROR"
).trim();
const errorMessage =
String(firstUpstreamFailure?.upstreamResult.error?.message || "").trim() ||
`参考图搜索失败(${firstUpstreamFailure?.upstreamResponse.status || 500})`;
return jsonNoStore(
{
success: false,
@ -505,82 +642,16 @@ export async function GET(request: NextRequest) {
);
}
const upstreamBrandName = getExternalBrandName(upstreamResult.data);
const rawCandidates = upstreamResult.data.candidates || [];
const candidateBrandSamples = Array.from(
new Set(
rawCandidates
.map((candidate) => getExternalBrandName(candidate))
.filter((brandName): brandName is string => Boolean(brandName))
)
).slice(0, 8);
if (requestedBrandName) {
console.info("[Agent Workflow Reference Search] Brand gate:", {
if (selectedAttempt.queryType !== queryType) {
console.info("[Agent Workflow Reference Search] Query type fallback succeeded:", {
keyword: normalizedKeyword,
queryType,
requestedQueryType: queryType,
resolvedQueryType: selectedAttempt.queryType,
target: parsed.target,
requestedBrandName,
verifiedGoodsBrandName,
upstreamBrandName,
candidateCount: rawCandidates.length,
candidateBrandSamples,
});
}
const hasVerifiedRequestedBrand =
Boolean(verifiedGoodsBrandName) &&
isBrandNameMatched(verifiedGoodsBrandName, requestedBrandName);
const hasUpstreamRequestedBrand =
Boolean(upstreamBrandName) &&
isBrandNameMatched(upstreamBrandName, requestedBrandName);
let fallbackBrandName: string | null = null;
if (requestedBrandName) {
if (hasVerifiedRequestedBrand) {
fallbackBrandName = verifiedGoodsBrandName || null;
} else if (hasUpstreamRequestedBrand) {
fallbackBrandName = upstreamBrandName || null;
}
} else {
fallbackBrandName = upstreamBrandName;
}
const sortedCandidates = sortCandidatesByUploadedAtDesc(
rawCandidates.map((item, index) =>
normalizeCandidate(item, index, fallbackBrandName)
)
);
const normalizedCandidates = filterCandidatesByBrand(sortedCandidates, requestedBrandName)
.filter((candidate) => getRenderableCandidateTargetUrls(candidate, parsed.target).length > 0);
if (normalizedCandidates.length === 0) {
return jsonNoStore(
{
success: false,
error: {
code: requestedBrandName
? "BRAND_REFERENCE_NOT_FOUND"
: parsed.target === "main"
? "MAIN_REFERENCE_NOT_FOUND"
: parsed.target === "detail"
? "DETAIL_REFERENCE_NOT_FOUND"
: "SKU_REFERENCE_NOT_FOUND",
message: requestedBrandName
? `当前品牌「${requestedBrandName}」下没有可用的${
parsed.target === "main"
? "主图参考图"
: parsed.target === "detail"
? "详情参考图"
: "SKU参考图"
}`
: parsed.target === "main"
? "当前整套素材中没有可用的主图参考图"
: parsed.target === "detail"
? "当前整套素材中没有可用的详情参考图"
: "当前整套素材中没有可用的 SKU 参考图",
},
},
{ status: 404 }
);
}
const normalizedCandidates = selectedAttempt.normalizedCandidates;
const selectedCandidate = normalizedCandidates[0];
const references = buildReferenceItems({
candidate: selectedCandidate,
@ -591,16 +662,23 @@ export async function GET(request: NextRequest) {
success: true,
data: {
query: normalizedKeyword,
queryType,
queryType: selectedAttempt.queryType,
target: parsed.target,
matchedSpu: String(upstreamResult.data.spu || "").trim() || null,
matchedSku: String(upstreamResult.data.sku || "").trim() || null,
matchedSpu: String(selectedAttempt.upstreamResult.data?.spu || "").trim() || null,
matchedSku: String(selectedAttempt.upstreamResult.data?.sku || "").trim() || null,
brandName:
selectedCandidate.brandName ||
fallbackBrandName ||
selectedAttempt.fallbackBrandName ||
selectedCandidate.shopName ||
null,
categoryName:
selectedCandidate.categoryName ||
String(selectedAttempt.upstreamResult.data?.categoryName || "").trim() ||
null,
shopName:
selectedCandidate.shopName ||
String(selectedAttempt.upstreamResult.data?.shopName || "").trim() ||
null,
categoryName: selectedCandidate.categoryName || String(upstreamResult.data.categoryName || "").trim() || null,
shopName: selectedCandidate.shopName || String(upstreamResult.data.shopName || "").trim() || null,
candidateCount: normalizedCandidates.length,
candidates: normalizedCandidates,
selectedCandidateId: selectedCandidate.id,

View File

@ -236,6 +236,7 @@ export function AgentDetailReferenceStitchDialog({
const [loadingProgressText, setLoadingProgressText] = useState("");
const [errorText, setErrorText] = useState<string | null>(null);
const [warningText, setWarningText] = useState<string | null>(null);
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
const validCards = useMemo(
() =>
@ -263,6 +264,10 @@ export function AgentDetailReferenceStitchDialog({
height: Math.max(points[index + 1] - points[index], 1),
}));
}, [normalizedCutLines, snapshot]);
const passthroughSegmentIndexSet = useMemo(
() => new Set(passthroughSegmentIndexes),
[passthroughSegmentIndexes]
);
const redrawPreviewCanvas = useCallback(() => {
const previewCanvas = previewCanvasRef.current;
@ -292,6 +297,7 @@ export function AgentDetailReferenceStitchDialog({
setPreviewCutLineY(null);
setLoadingProgressText("");
setWarningText(null);
setPassthroughSegmentIndexes([]);
return;
}
@ -377,6 +383,13 @@ export function AgentDetailReferenceStitchDialog({
};
}, [open, sourceLabel, validCards]);
useEffect(() => {
const validIndexes = new Set(sliceSegments.map((segment) => segment.index));
setPassthroughSegmentIndexes((current) =>
current.filter((index) => validIndexes.has(index))
);
}, [sliceSegments]);
useEffect(() => {
return () => {
snapshot?.loadedImages.forEach((item) => item.revokeObjectUrl?.());
@ -607,6 +620,7 @@ export function AgentDetailReferenceStitchDialog({
imageUrl: String(result.url),
candidateId: sourceCandidateId,
sortOrder: segment.index,
passthrough: passthroughSegmentIndexSet.has(segment.index),
});
}
@ -620,7 +634,7 @@ export function AgentDetailReferenceStitchDialog({
setIsApplying(false);
setApplyProgressText("");
}
}, [onApply, onOpenChange, sliceSegments, snapshot, validCards]);
}, [onApply, onOpenChange, passthroughSegmentIndexSet, sliceSegments, snapshot, validCards]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -646,6 +660,11 @@ export function AgentDetailReferenceStitchDialog({
<div className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100/85">
当前将切成 {sliceSegments.length || 0} 张
</div>
{passthroughSegmentIndexes.length > 0 ? (
<div className="rounded-full border border-cyan-400/20 bg-cyan-500/[0.08] px-3 py-1 text-xs text-cyan-100/85">
直出 {passthroughSegmentIndexes.length} 张
</div>
) : null}
</div>
</div>
</DialogHeader>
@ -780,7 +799,7 @@ export function AgentDetailReferenceStitchDialog({
</div>
<div className="rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-white/48">
向下拖动可浏览整张拼合长图。每条横线代表一次切割边界,应用后会替换当前详情参考图列表。
向下拖动可浏览整张拼合长图。每条横线代表一次切割边界;右侧可把某些切片标记为“直接应用”,这些模块会跳过继续生成,直接进入结果区。
</div>
</div>
) : null}
@ -791,7 +810,7 @@ export function AgentDetailReferenceStitchDialog({
<div className="border-b border-white/10 px-5 py-4">
<div className="text-sm font-semibold text-white">切割结果预估</div>
<div className="mt-1 text-xs text-white/42">
应用切割后,下方每一段都会回到详情替换参考图区,继续逐屏生成。
应用切割后,下方每一段都会回到详情替换参考图区;标记为“直接应用”的切片会跳过继续生成,未标记的切片再逐屏生成。
</div>
</div>
@ -813,6 +832,33 @@ export function AgentDetailReferenceStitchDialog({
<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>
))
) : (

View File

@ -677,6 +677,7 @@ function buildReferenceCardsFromCandidate(params: {
imageUrl: url,
candidateId: candidate.id,
sortOrder: index,
passthrough: false,
assignedProductIndex:
params.assignProductIndex === true && (params.productCount || 0) > 0
? Math.min(index, Math.max((params.productCount || 1) - 1, 0))
@ -1247,6 +1248,7 @@ export function AgentGenerationWorkspaceDarkV2({
? "ai_detail_generate"
: "reference_replace";
const detailReplacementReferenceCards = detailReplacementState.references;
const detailReplacementQueryType = detailReplacementState.queryType || "spu";
const skuReplacementState = resolvedAgentModeState.skuReplacement;
const skuReplacementReferenceCards = skuReplacementState.references;
const presetModeConfig = useMemo(
@ -1752,6 +1754,7 @@ export function AgentGenerationWorkspaceDarkV2({
isDetailReplacementPreset: boolean;
isSkuReplacementPreset: boolean;
detailReplacementQueryInput: string;
detailReplacementQueryType: AgentWorkflowMaterialQueryType;
detailReplacementSelectedCandidateId: string | null;
detailReplacementReferenceCards: typeof detailReplacementReferenceCards;
detailReplacementProductSourceImages: typeof detailReplacementProductSourceImages;
@ -1774,6 +1777,7 @@ export function AgentGenerationWorkspaceDarkV2({
isDetailReplacementPreset: false,
isSkuReplacementPreset: false,
detailReplacementQueryInput: "",
detailReplacementQueryType: "spu",
detailReplacementSelectedCandidateId: null,
detailReplacementReferenceCards: [],
detailReplacementProductSourceImages: [],
@ -1793,6 +1797,7 @@ export function AgentGenerationWorkspaceDarkV2({
isDetailReplacementPreset,
isSkuReplacementPreset,
detailReplacementQueryInput,
detailReplacementQueryType,
detailReplacementSelectedCandidateId: detailReplacementSelectedCandidateId || null,
detailReplacementReferenceCards,
detailReplacementProductSourceImages,
@ -1922,7 +1927,7 @@ export function AgentGenerationWorkspaceDarkV2({
MAX_AGENT_MODE_QUERY_LENGTH
),
queryType: transportState.isDetailReplacementPreset
? ("spu" as const)
? transportState.detailReplacementQueryType
: transportState.skuReplacementQueryType,
selectedCandidateId: transportState.isDetailReplacementPreset
? transportState.detailReplacementSelectedCandidateId || null
@ -1942,6 +1947,7 @@ export function AgentGenerationWorkspaceDarkV2({
? String(item.candidateId).slice(0, 128)
: null,
sortOrder: item.sortOrder ?? 0,
passthrough: item.passthrough === true,
assignedProductIndex:
transportState.selectedProductAttachmentCount > 0
? Math.min(
@ -4041,7 +4047,7 @@ export function AgentGenerationWorkspaceDarkV2({
return;
}
if (!normalized) {
toast.error(`请输入 SPU 后再查询${detailReferenceSourceLabel}`);
toast.error(`请输入 SPU 或 SKU 后再查询${detailReferenceSourceLabel}`);
return;
}
@ -4053,7 +4059,7 @@ export function AgentGenerationWorkspaceDarkV2({
try {
const searchParams = new URLSearchParams({
keyword: normalized,
queryType: "spu",
queryType: detailReplacementQueryType,
target: detailReferenceTarget,
});
searchParams.set("brandName", currentWorkflowBrandName);
@ -4069,7 +4075,7 @@ export function AgentGenerationWorkspaceDarkV2({
updateDetailReplacementState({
query: normalized,
queryType: "spu",
queryType: result.data.queryType || detailReplacementQueryType,
candidates: result.data.candidates || [],
selectedCandidateId:
result.data.selectedCandidateId || result.data.candidates?.[0]?.id || null,
@ -4091,6 +4097,7 @@ export function AgentGenerationWorkspaceDarkV2({
detailReferenceSourceLabel,
detailReferenceTarget,
detailReplacementQueryInput,
detailReplacementQueryType,
getCurrentWorkflowBrandName,
updateDetailReplacementState,
]);
@ -5119,7 +5126,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div>
<div className="text-sm font-semibold text-white">参考详情搜索</div>
<div className="text-xs text-white/40">
按 SPU 查询候选整套,并选择一套{detailReferenceSourceLabel}参考图进入下方结果区。
支持按 SPU / SKU 自动识别候选整套,并选择一套{detailReferenceSourceLabel}参考图进入下方结果区。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
@ -5130,7 +5137,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="space-y-4 p-5">
<div className="inline-flex rounded-full border border-emerald-400/35 bg-emerald-500/[0.10] px-3 py-1.5 text-xs text-emerald-200">
按SPU查询
{detailReplacementQueryType === "sku" ? "当前按SKU命中" : "支持SPU/SKU自动识别"}
</div>
<div className="flex flex-col gap-2 lg:flex-row">
@ -5139,10 +5146,9 @@ export function AgentGenerationWorkspaceDarkV2({
onChange={(e) =>
updateDetailReplacementState({
query: e.target.value,
queryType: "spu",
})
}
placeholder={`输入 SPU,查询任务整套中的${detailReferenceSourceLabel}参考图`}
placeholder={`输入 SPU 或 SKU,查询任务整套中的${detailReferenceSourceLabel}参考图`}
className="h-12 rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/>
<Button
@ -5185,7 +5191,7 @@ export function AgentGenerationWorkspaceDarkV2({
{(detailReplacementSearchState.lastQuery || detailReplacementQueryInput.trim()) ? (
<div className="rounded-[18px] border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-white/48">
当前参考图查询:
SPU /
{detailReplacementQueryType === "sku" ? "SKU" : "SPU"} /
{detailReplacementSearchState.lastQuery ||
detailReplacementQueryInput.trim().toUpperCase()}
{workflowBrandName ? (
@ -5571,7 +5577,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div>
<div className="text-sm font-semibold text-white">{detailReferenceSourceLabel}参考图</div>
<div className="text-xs text-white/40">
仅支持按 SPU 查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
支持按 SPU / SKU 自动兜底查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
</div>
</div>
<div className="flex items-center gap-2">
@ -5627,6 +5633,11 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78">
参考图 {index + 1}
</div>
{card.passthrough ? (
<div className="absolute right-3 top-12 rounded-full border border-cyan-300/35 bg-cyan-500/[0.14] px-2.5 py-1 text-[11px] text-cyan-100">
直出结果
</div>
) : null}
{isActive ? (
<div className="absolute left-3 top-12 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
当前联动
@ -5650,7 +5661,7 @@ 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="text-sm font-medium text-white/72">等待{detailReferenceSourceLabel}参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40">
输入 SPU 后,这里会展示任务整套里的真实{detailReferenceSourceLabel}参考图。
输入 SPU 或 SKU 后,这里会展示任务整套里的真实{detailReferenceSourceLabel}参考图。
删除不需要的参考图后,列表会自动上移补位。
</div>
</div>

View File

@ -378,6 +378,7 @@ export interface AgentDetailReplacementReferenceItem {
candidateId?: string | null;
sortOrder?: number;
assignedProductIndex?: number | null;
passthrough?: boolean | null;
}
export interface AgentModeReferenceCandidate {