feat: automate agent material selection flow

This commit is contained in:
Your Name 2026-03-22 19:43:31 +08:00
parent 9276e6c35c
commit 5fc5a5e47c
8 changed files with 785 additions and 165 deletions

View File

@ -2822,6 +2822,19 @@ function buildAgentSlotPrompt(params: {
type AgentReplacementModePayload = NonNullable<PostRequestBody["agentModePayload"]>;
type ReplacementReferenceDecision = {
shouldReplace: boolean;
containsFurnitureProduct: boolean;
reason: string;
detectionModelName?: string | null;
detectionSource:
| "configured_qwen3_vl"
| "fallback_qwen3_vl"
| "safe_passthrough";
};
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
function normalizeAgentModeReferences(
payload?: PostRequestBody["agentModePayload"],
): AgentReplacementModePayload["references"] {
@ -2843,6 +2856,222 @@ function normalizeAgentModeReferences(
.sort((left, right) => left.sortOrder - right.sortOrder);
}
function normalizeBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") {
return value;
}
const normalized = String(value || "")
.trim()
.toLowerCase();
if (!normalized) {
return null;
}
if (
normalized === "true" ||
normalized === "yes" ||
normalized === "y" ||
normalized === "1" ||
normalized === "是"
) {
return true;
}
if (
normalized === "false" ||
normalized === "no" ||
normalized === "n" ||
normalized === "0" ||
normalized === "否"
) {
return false;
}
return null;
}
function extractJsonObjectText(text: string): string | null {
const normalized = String(text || "").trim();
if (!normalized) {
return null;
}
const fencedMatch = normalized.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fencedMatch?.[1]) {
const fencedContent = fencedMatch[1].trim();
if (fencedContent.startsWith("{") && fencedContent.endsWith("}")) {
return fencedContent;
}
}
if (normalized.startsWith("{") && normalized.endsWith("}")) {
return normalized;
}
const firstBraceIndex = normalized.indexOf("{");
const lastBraceIndex = normalized.lastIndexOf("}");
if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
return normalized.slice(firstBraceIndex, lastBraceIndex + 1);
}
return null;
}
function parseReplacementReferenceDecision(
text: string,
): Omit<ReplacementReferenceDecision, "detectionModelName" | "detectionSource"> | null {
const jsonText = extractJsonObjectText(text);
if (jsonText) {
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
const shouldReplace =
normalizeBooleanLike(parsed.shouldReplace) ??
normalizeBooleanLike(parsed.replace) ??
normalizeBooleanLike(parsed.isReplaceable);
const containsFurnitureProduct =
normalizeBooleanLike(parsed.containsFurnitureProduct) ??
normalizeBooleanLike(parsed.hasFurnitureProduct) ??
shouldReplace;
const reason = String(parsed.reason || parsed.summary || parsed.note || "")
.trim()
.slice(0, 80);
if (typeof shouldReplace === "boolean") {
return {
shouldReplace,
containsFurnitureProduct: Boolean(containsFurnitureProduct),
reason:
reason ||
(shouldReplace
? "检测到可替换的家具主体,继续执行替换"
: "未检测到可替换的家具主体,直接沿用参考图"),
};
}
} catch (error) {
console.warn("[Chat API] Failed to parse replacement reference decision JSON:", error);
}
}
const normalized = String(text || "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
return null;
}
const shouldReplace = /shouldreplace["']?\s*[:=]\s*true|可替换|应该替换|存在可替换|检测到.*家具主体|yes/i.test(
normalized,
);
const shouldKeep = /shouldreplace["']?\s*[:=]\s*false|不应替换|不要替换|沿用参考图|未检测到.*家具主体|无可替换|not replace|no/i.test(
normalized,
);
if (!shouldReplace && !shouldKeep) {
return null;
}
return {
shouldReplace,
containsFurnitureProduct: shouldReplace,
reason: normalized.slice(0, 80),
};
}
async function analyzeReplacementReferenceImage(params: {
imageUrl: string;
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}): Promise<ReplacementReferenceDecision> {
const detectionPrompt = [
"你是家具商品参考图替换判定器。",
"请判断这张图片里,是否存在清晰、主要、适合被产品替换流程替换的家具商品主体。",
"家具主体包含:床、床垫、沙发、椅子、餐桌、茶几、柜子、电视柜、斗柜、床头柜、书桌等。",
"只有当家具商品是主要视觉主体、轮廓清晰、不是单纯环境陪衬时,shouldReplace 才能为 true。",
"以下情况必须判定 shouldReplace=false:纯文字页、参数说明页、品牌介绍页、材质/五金局部特写、只有模特没有家具、只有空间环境没有明确家具主体、家具很小只是背景、主体过多无法明确替换对象。",
'只输出 JSON,不要输出额外解释。格式:{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字"}',
].join("\n");
const configuredModels = params.models || (await getEnabledAiModelsWithProvider());
const configuredQwenVisionModel = configuredModels.find(
({ model }) =>
model.supportsVision &&
!model.isSubModel &&
QWEN_VL_MODEL_MATCHER.test(`${model.displayName} ${model.modelId}`),
);
if (configuredQwenVisionModel) {
try {
const languageModel = await getLanguageModelById(configuredQwenVisionModel.model.id);
if (languageModel) {
const { text } = await generateText({
model: languageModel,
messages: [
{
role: "user",
content: [
{
type: "text",
text: detectionPrompt,
},
{
type: "image",
image: params.imageUrl,
},
],
},
],
temperature: 0,
maxTokens: 160,
abortSignal: AbortSignal.timeout(45_000),
} as any);
const parsed = parseReplacementReferenceDecision(text);
if (parsed) {
return {
...parsed,
detectionModelName:
configuredQwenVisionModel.model.displayName ||
configuredQwenVisionModel.model.modelId,
detectionSource: "configured_qwen3_vl",
};
}
}
} catch (error) {
console.warn("[Chat API] Configured Qwen-VL reference analysis failed:", error);
}
}
try {
const { callQwen3Vl } = await import("@/lib/ai/qwen3-vl");
const fallbackResult = await callQwen3Vl({
imageUrl: params.imageUrl,
prompt: detectionPrompt,
temperature: 0,
maxTokens: 160,
timeoutMs: 45_000,
retries: 1,
});
const parsed = parseReplacementReferenceDecision(fallbackResult.text);
if (parsed) {
return {
...parsed,
detectionModelName: fallbackResult.model,
detectionSource: "fallback_qwen3_vl",
};
}
} catch (error) {
console.warn("[Chat API] Fallback Qwen3-VL reference analysis failed:", error);
}
return {
shouldReplace: false,
containsFurnitureProduct: false,
reason: "视觉判断失败,为避免误替换,直接沿用参考图",
detectionModelName: null,
detectionSource: "safe_passthrough",
};
}
function buildAgentReplacementPrompt(params: {
basePrompt: string;
modeType: "detail_replacement" | "sku_replacement";
@ -5776,8 +6005,18 @@ export async function POST(request: Request) {
originalUrl: string;
thumbnailUrl?: string | null;
}> = [];
let generatedReplacementCount = 0;
let resolvedModelDisplayName =
selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.name;
const replacementVisionModels = await getEnabledAiModelsWithProvider().catch(
(error) => {
console.warn(
"[Chat API] Failed to load configured vision models for replacement analysis:",
error,
);
return undefined;
},
);
const stopKeepalive = startStreamKeepalive(
writer,
"agent-preset:replacement",
@ -5799,6 +6038,31 @@ export async function POST(request: Request) {
pairedProductIndex !== undefined
? [productInputImageUrls[pairedProductIndex]!]
: productInputImageUrls;
const referenceDecision = await analyzeReplacementReferenceImage({
imageUrl: reference.imageUrl,
models: replacementVisionModels,
});
if (!referenceDecision.shouldReplace) {
replacementResultImages.push({
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
});
replacementSections.push(
`### ${reference.label}\n\n${referenceDecision.reason}\n\n${buildAgentToolImagesMarkdown(
[
{
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
imageSize: resolvedImageSize,
},
],
)}`,
);
successCount += 1;
continue;
}
const sourceImageUrls = Array.from(
new Set(
[
@ -5852,6 +6116,7 @@ export async function POST(request: Request) {
])}`,
);
successCount += 1;
generatedReplacementCount += 1;
} catch (replacementItemError) {
console.error("[Chat API] Agent replacement item execution failed:", {
referenceIndex,
@ -5878,7 +6143,7 @@ export async function POST(request: Request) {
]
.filter(Boolean)
.join("\n\n"),
resolvedModelDisplayName,
generatedReplacementCount > 0 ? resolvedModelDisplayName : null,
);
await saveMessages({

View File

@ -1,7 +1,9 @@
import { generateText } from "ai";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting, upsertMediaAsset } from "@/lib/db/queries";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getEnabledAiModelsWithProvider, getSystemSetting, upsertMediaAsset } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
@ -14,8 +16,12 @@ const querySchema = z.object({
keyword: z.string().trim().min(1).max(64),
source: z.enum(["primary", "secondary"]).optional(),
queryType: z.enum(["sku", "spu"]).optional(),
requiredCount: z.coerce.number().int().min(1).max(4).optional(),
});
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
const FUNCTIONAL_IMAGE_HINT_REGEX = /开门|开柜|柜门|抽屉|打开|开合|功能|展示|收纳|储物|细节/i;
type ExternalImageRecord = {
key?: string;
label?: string;
@ -60,6 +66,371 @@ function normalizeBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "");
}
function normalizeImageIdentityText(...values: Array<string | null | undefined>) {
return values
.map((value) => String(value || "").trim().toLowerCase().replace(/\s+/g, ""))
.filter(Boolean)
.join(" ");
}
function normalizeBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") {
return value;
}
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) {
return null;
}
if (["true", "yes", "y", "1", "是"].includes(normalized)) {
return true;
}
if (["false", "no", "n", "0", "否"].includes(normalized)) {
return false;
}
return null;
}
function extractJsonObjectText(text: string): string | null {
const normalized = String(text || "").trim();
if (!normalized) {
return null;
}
const fencedMatch = normalized.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fencedMatch?.[1]) {
const fencedText = fencedMatch[1].trim();
if (fencedText.startsWith("{") && fencedText.endsWith("}")) {
return fencedText;
}
}
if (normalized.startsWith("{") && normalized.endsWith("}")) {
return normalized;
}
const firstBraceIndex = normalized.indexOf("{");
const lastBraceIndex = normalized.lastIndexOf("}");
if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
return normalized.slice(firstBraceIndex, lastBraceIndex + 1);
}
return null;
}
function parseFunctionalShowcaseDecision(text: string): {
isFunctionalShowcase: boolean;
score: number;
} | null {
const jsonText = extractJsonObjectText(text);
if (jsonText) {
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
const isFunctionalShowcase =
normalizeBooleanLike(parsed.isFunctionalShowcase) ??
normalizeBooleanLike(parsed.isFunctional) ??
normalizeBooleanLike(parsed.shouldUse);
const score = Number(parsed.score);
if (typeof isFunctionalShowcase === "boolean") {
return {
isFunctionalShowcase,
score: Number.isFinite(score)
? Math.max(0, Math.min(100, Math.round(score)))
: isFunctionalShowcase
? 80
: 0,
};
}
} catch (error) {
console.warn("[Agent Workflow Material Search] Failed to parse functional showcase JSON:", error);
}
}
const normalized = String(text || "").replace(/\s+/g, " ").trim();
if (!normalized) {
return null;
}
const positive = /功能图|功能展示|打开图|开门图|开柜图|抽屉图|展示图|适合作为第4张|可作为d档|yes|true/i.test(
normalized,
);
const negative = /不是功能图|不适合|非功能展示|no|false/i.test(normalized);
if (!positive && !negative) {
return null;
}
return {
isFunctionalShowcase: positive && !negative,
score: positive && !negative ? 75 : 0,
};
}
function getFixedAnglePriority(image: {
key: string;
label: string;
fileName: string;
group: "fixed" | "other";
}) {
if (image.group !== "fixed") {
return null;
}
const normalized = normalizeImageIdentityText(image.key, image.label, image.fileName);
if (normalized.includes("leftfront45") || /左前45/.test(normalized)) {
return "A" as const;
}
if (normalized.includes("rightfront45") || /右前45/.test(normalized)) {
return "B" as const;
}
if (normalized === "front" || normalized.includes(" front") || /正视|正面/.test(normalized)) {
return "C" as const;
}
return null;
}
function getFallbackFixedAngleScore(image: {
key: string;
label: string;
fileName: string;
group: "fixed" | "other";
}) {
if (image.group !== "fixed") {
return -1;
}
const normalized = normalizeImageIdentityText(image.key, image.label, image.fileName);
if (normalized.includes("left") && !normalized.includes("front") && !normalized.includes("back")) {
return 60;
}
if (normalized.includes("right") && !normalized.includes("front") && !normalized.includes("back")) {
return 58;
}
if (normalized.includes("leftback45") || /左后45/.test(normalized)) {
return 52;
}
if (normalized.includes("rightback45") || /右后45/.test(normalized)) {
return 50;
}
if (normalized.includes("back") || /背面/.test(normalized)) {
return 46;
}
return 20;
}
function getFunctionalHintScore(image: {
key: string;
label: string;
fileName: string;
group: "fixed" | "other";
}) {
const normalized = normalizeImageIdentityText(image.key, image.label, image.fileName);
if (FUNCTIONAL_IMAGE_HINT_REGEX.test(normalized)) {
return 85;
}
return image.group === "other" ? 10 : 0;
}
async function analyzeFunctionalShowcaseImage(params: {
imageUrl: string;
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) {
const prompt = [
"你是白底图角度与功能展示判定器。",
"请判断这张白底产品图是否适合作为第4优先级的功能展示图。",
"功能展示图指:柜门打开、抽屉拉开、功能结构展示、收纳展示、机构展示等。",
"普通角度白底图、纯正面图、纯45度图、纯侧面图,都不是功能展示图。",
'只输出 JSON:{"isFunctionalShowcase":true,"score":92,"reason":"不超过20字"}',
].join("\n");
const configuredModels = Array.isArray(params.models)
? params.models
: await getEnabledAiModelsWithProvider().catch((error) => {
console.warn(
"[Agent Workflow Material Search] Failed to load configured Qwen-VL model list:",
error,
);
return [];
});
const configuredQwenVisionModel = configuredModels.find(
({ model }) =>
model.supportsVision &&
!model.isSubModel &&
QWEN_VL_MODEL_MATCHER.test(`${model.displayName} ${model.modelId}`),
);
if (configuredQwenVisionModel) {
try {
const languageModel = await getLanguageModelById(configuredQwenVisionModel.model.id);
if (languageModel) {
const { text } = await generateText({
model: languageModel,
messages: [
{
role: "user",
content: [
{
type: "text",
text: prompt,
},
{
type: "image",
image: params.imageUrl,
},
],
},
],
temperature: 0,
maxTokens: 120,
abortSignal: AbortSignal.timeout(25_000),
} as any);
const parsed = parseFunctionalShowcaseDecision(text);
if (parsed) {
return parsed;
}
}
} catch (error) {
console.warn("[Agent Workflow Material Search] Configured Qwen-VL material analysis failed:", error);
}
}
try {
const { callQwen3Vl } = await import("@/lib/ai/qwen3-vl");
const result = await callQwen3Vl({
imageUrl: params.imageUrl,
prompt,
temperature: 0,
maxTokens: 120,
timeoutMs: 25_000,
retries: 1,
});
return (
parseFunctionalShowcaseDecision(result.text) || {
isFunctionalShowcase: false,
score: 0,
}
);
} catch (error) {
console.warn("[Agent Workflow Material Search] Fallback Qwen-VL material analysis failed:", error);
}
return {
isFunctionalShowcase: false,
score: 0,
};
}
async function recommendMaterialImageIds(params: {
images: Array<{
id: string;
key: string;
label: string;
fileName: string;
imageUrl: string;
assetId: string | null;
mimeType: string;
group: "fixed" | "other";
sortOrder: number;
}>;
requiredCount: number;
}) {
const requiredCount = Math.max(1, Math.min(4, Math.round(params.requiredCount)));
const selectedIds: string[] = [];
const fixedImages = params.images.filter((image) => image.group === "fixed");
const otherImages = params.images.filter((image) => image.group === "other");
const pushImage = (image?: { id: string } | null) => {
if (!image?.id) return;
if (!selectedIds.includes(image.id)) {
selectedIds.push(image.id);
}
};
pushImage(fixedImages.find((image) => getFixedAnglePriority(image) === "A"));
if (selectedIds.length >= requiredCount) return selectedIds.slice(0, requiredCount);
pushImage(fixedImages.find((image) => getFixedAnglePriority(image) === "B"));
if (selectedIds.length >= requiredCount) return selectedIds.slice(0, requiredCount);
pushImage(fixedImages.find((image) => getFixedAnglePriority(image) === "C"));
if (selectedIds.length >= requiredCount) return selectedIds.slice(0, requiredCount);
const remainingNeedCount = requiredCount - selectedIds.length;
if (remainingNeedCount > 0 && otherImages.length > 0) {
const heuristicFunctionalCandidates = otherImages
.map((image) => ({
image,
score: getFunctionalHintScore(image),
}))
.filter((item) => item.score >= 80)
.sort((left, right) => right.score - left.score || left.image.sortOrder - right.image.sortOrder);
heuristicFunctionalCandidates
.slice(0, remainingNeedCount)
.forEach((item) => pushImage(item.image));
}
const functionalRemainingNeedCount = requiredCount - selectedIds.length;
if (functionalRemainingNeedCount > 0 && otherImages.length > 0) {
const analyzableImages = otherImages
.filter((image) => !selectedIds.includes(image.id))
.slice(0, 6);
if (analyzableImages.length > 0) {
const configuredModels = await getEnabledAiModelsWithProvider().catch((error) => {
console.warn("[Agent Workflow Material Search] Failed to load vision models:", error);
return undefined;
});
const analyzedCandidates = await Promise.all(
analyzableImages.map(async (image) => {
const analyzed = await analyzeFunctionalShowcaseImage({
imageUrl: image.imageUrl,
models: configuredModels,
});
return {
image,
score: analyzed.isFunctionalShowcase ? analyzed.score : 0,
};
}),
);
analyzedCandidates
.filter((item) => item.score > 0)
.sort((left, right) => right.score - left.score || left.image.sortOrder - right.image.sortOrder)
.slice(0, functionalRemainingNeedCount)
.forEach((item) => pushImage(item.image));
}
}
const stillNeedCount = requiredCount - selectedIds.length;
if (stillNeedCount > 0) {
const remainingFixedImages = fixedImages
.filter((image) => !selectedIds.includes(image.id))
.sort(
(left, right) =>
getFallbackFixedAngleScore(right) - getFallbackFixedAngleScore(left) ||
left.sortOrder - right.sortOrder,
);
remainingFixedImages.slice(0, stillNeedCount).forEach((image) => pushImage(image));
}
const finalNeedCount = requiredCount - selectedIds.length;
if (finalNeedCount > 0) {
params.images
.filter((image) => !selectedIds.includes(image.id))
.sort((left, right) => left.sortOrder - right.sortOrder)
.slice(0, finalNeedCount)
.forEach((image) => pushImage(image));
}
return selectedIds.slice(0, requiredCount);
}
function sanitizeFilenamePart(value: string) {
return String(value || "")
.trim()
@ -188,6 +559,7 @@ export async function GET(request: NextRequest) {
keyword: request.nextUrl.searchParams.get("keyword"),
source: request.nextUrl.searchParams.get("source"),
queryType: request.nextUrl.searchParams.get("queryType"),
requiredCount: request.nextUrl.searchParams.get("requiredCount") || undefined,
});
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
@ -277,6 +649,9 @@ export async function GET(request: NextRequest) {
const matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword;
const productModel = String(upstreamResult.data.productModel || "").trim() || null;
const requiredCount = Number.isFinite(Number(parsed.requiredCount))
? Math.max(1, Math.min(4, Math.round(Number(parsed.requiredCount))))
: null;
const fixedImages = Array.isArray(upstreamResult.data.whiteBackground?.fixed)
? upstreamResult.data.whiteBackground?.fixed || []
: [];
@ -346,6 +721,13 @@ export async function GET(request: NextRequest) {
);
}
const recommendedImageIds = requiredCount
? await recommendMaterialImageIds({
images: importedImages,
requiredCount,
})
: [];
return NextResponse.json({
success: true,
data: {
@ -360,6 +742,7 @@ export async function GET(request: NextRequest) {
otherCount: importedImages.filter((item) => item.group === "other").length,
totalCount: importedImages.length,
images: importedImages,
recommendedImageIds,
skuOptions: [],
},
});

View File

@ -18,6 +18,7 @@ const querySchema = z.object({
keyword: z.string().trim().min(1).max(64),
queryType: z.enum(["sku", "spu"]).default("spu"),
target: z.enum(["main", "detail", "sku"]),
storeName: z.string().trim().max(64).optional(),
});
type ExternalTaskDetailCandidate = {
@ -58,6 +59,13 @@ function normalizeBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "");
}
function normalizeShopName(value: unknown) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "");
}
function normalizeUrlList(value: unknown) {
if (!Array.isArray(value)) return [] as string[];
return Array.from(
@ -156,6 +164,26 @@ function sortCandidatesByUploadedAtDesc(candidates: AgentModeReferenceCandidate[
});
}
function isCandidateMatchedStore(
candidate: AgentModeReferenceCandidate,
storeName?: string
) {
const normalizedRequestedStore = normalizeShopName(storeName);
if (!normalizedRequestedStore) return true;
const normalizedShopName = normalizeShopName(candidate.shopName);
const normalizedCategoryName = normalizeShopName(candidate.categoryName);
return (
normalizedShopName === normalizedRequestedStore ||
normalizedCategoryName === normalizedRequestedStore ||
(Boolean(normalizedShopName) && normalizedShopName.includes(normalizedRequestedStore)) ||
(Boolean(normalizedShopName) && normalizedRequestedStore.includes(normalizedShopName)) ||
(Boolean(normalizedCategoryName) && normalizedCategoryName.includes(normalizedRequestedStore)) ||
(Boolean(normalizedCategoryName) && normalizedRequestedStore.includes(normalizedCategoryName))
);
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
@ -167,6 +195,7 @@ export async function GET(request: NextRequest) {
keyword: request.nextUrl.searchParams.get("keyword"),
queryType: request.nextUrl.searchParams.get("queryType") || "spu",
target: request.nextUrl.searchParams.get("target"),
storeName: request.nextUrl.searchParams.get("storeName") || undefined,
});
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
@ -218,27 +247,38 @@ export async function GET(request: NextRequest) {
);
}
const requestedStoreName = String(parsed.storeName || "").trim();
const normalizedCandidates = sortCandidatesByUploadedAtDesc(
(upstreamResult.data.candidates || []).map((item, index) => normalizeCandidate(item, index))
).filter((candidate) => getCandidateTargetUrls(candidate, parsed.target).length > 0);
)
.filter((candidate) => isCandidateMatchedStore(candidate, requestedStoreName))
.filter((candidate) => getCandidateTargetUrls(candidate, parsed.target).length > 0);
if (normalizedCandidates.length === 0) {
return NextResponse.json(
{
success: false,
error: {
code:
parsed.target === "main"
code: requestedStoreName
? "STORE_REFERENCE_NOT_FOUND"
: parsed.target === "main"
? "MAIN_REFERENCE_NOT_FOUND"
: parsed.target === "detail"
? "DETAIL_REFERENCE_NOT_FOUND"
: "SKU_REFERENCE_NOT_FOUND",
message:
parsed.target === "main"
? "DETAIL_REFERENCE_NOT_FOUND"
: "SKU_REFERENCE_NOT_FOUND",
message: requestedStoreName
? `当前店铺「${requestedStoreName}」下没有可用的${
parsed.target === "main"
? "主图参考图"
: parsed.target === "detail"
? "详情参考图"
: "SKU参考图"
}`
: parsed.target === "main"
? "当前整套素材中没有可用的主图参考图"
: parsed.target === "detail"
? "当前整套素材中没有可用的详情参考图"
: "当前整套素材中没有可用的 SKU 参考图",
? "当前整套素材中没有可用的详情参考图"
: "当前整套素材中没有可用的 SKU 参考图",
},
},
{ status: 404 }
@ -259,8 +299,8 @@ export async function GET(request: NextRequest) {
target: parsed.target,
matchedSpu: String(upstreamResult.data.spu || "").trim() || null,
matchedSku: String(upstreamResult.data.sku || "").trim() || null,
categoryName: String(upstreamResult.data.categoryName || "").trim() || null,
shopName: String(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

@ -566,6 +566,7 @@ export function AgentGenerationWorkspaceDarkV2({
const skuReferenceTarget = presetModeConfig.sku.referenceImageSource;
const detailReferenceSourceLabel = getTaskDetailReferenceSourceLabel(detailReferenceTarget);
const skuReferenceSourceLabel = getTaskDetailReferenceSourceLabel(skuReferenceTarget);
const workflowStoreName = String(workflowContext?.store || "").trim();
const shouldAllowModelOverride = !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride;
const selectedProductAttachments = attachments.filter((item, index) =>
modelSlotIndex >= 0 ? index !== modelSlotIndex && Boolean(item) : Boolean(item)
@ -1461,6 +1462,10 @@ export function AgentGenerationWorkspaceDarkV2({
);
const handleDetailReplacementSearch = useCallback(async () => {
const normalized = detailReplacementQueryInput.trim().toUpperCase();
if (!workflowStoreName) {
toast.error("请先在第一步完成品牌和店铺选择后再查询参考图");
return;
}
if (!normalized) {
toast.error(`请输入 SPU 后再查询${detailReferenceSourceLabel}`);
return;
@ -1472,10 +1477,16 @@ export function AgentGenerationWorkspaceDarkV2({
});
try {
const searchParams = new URLSearchParams({
keyword: normalized,
queryType: "spu",
target: detailReferenceTarget,
});
if (workflowStoreName) {
searchParams.set("storeName", workflowStoreName);
}
const response = await fetch(
`/api/agent-workflow/reference-search?keyword=${encodeURIComponent(
normalized
)}&queryType=spu&target=${detailReferenceTarget}`,
`/api/agent-workflow/reference-search?${searchParams.toString()}`,
{ cache: "no-store" }
);
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
@ -1504,7 +1515,13 @@ export function AgentGenerationWorkspaceDarkV2({
});
toast.error(error instanceof Error ? error.message : `${detailReferenceSourceLabel}参考图查询失败`);
}
}, [detailReferenceSourceLabel, detailReferenceTarget, detailReplacementQueryInput, updateDetailReplacementState]);
}, [
detailReferenceSourceLabel,
detailReferenceTarget,
detailReplacementQueryInput,
updateDetailReplacementState,
workflowStoreName,
]);
const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => {
updateDetailReplacementState((current) => {
const nextCandidate =
@ -1536,6 +1553,10 @@ export function AgentGenerationWorkspaceDarkV2({
}, [updateDetailReplacementState]);
const handleSkuReplacementSearch = useCallback(async () => {
const normalized = skuReplacementQueryInput.trim().toUpperCase();
if (!workflowStoreName) {
toast.error("请先在第一步完成品牌和店铺选择后再查询参考图");
return;
}
if (!normalized) {
toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询${skuReferenceSourceLabel}参考图`);
return;
@ -1547,10 +1568,16 @@ export function AgentGenerationWorkspaceDarkV2({
});
try {
const searchParams = new URLSearchParams({
keyword: normalized,
queryType: skuReplacementQueryType,
target: skuReferenceTarget,
});
if (workflowStoreName) {
searchParams.set("storeName", workflowStoreName);
}
const response = await fetch(
`/api/agent-workflow/reference-search?keyword=${encodeURIComponent(
normalized
)}&queryType=${skuReplacementQueryType}&target=${skuReferenceTarget}`,
`/api/agent-workflow/reference-search?${searchParams.toString()}`,
{ cache: "no-store" }
);
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
@ -1593,6 +1620,7 @@ export function AgentGenerationWorkspaceDarkV2({
skuReplacementQueryInput,
skuReplacementQueryType,
updateSkuReplacementState,
workflowStoreName,
]);
const handleSelectSkuReplacementCandidate = useCallback((candidateId: string) => {
updateSkuReplacementState((current) => {
@ -2286,6 +2314,9 @@ export function AgentGenerationWorkspaceDarkV2({
SPU /
{detailReplacementSearchState.lastQuery ||
detailReplacementQueryInput.trim().toUpperCase()}
{workflowStoreName ? (
<span className="ml-2 text-white/40">{`店铺 / ${workflowStoreName}`}</span>
) : null}
<span className="ml-2 text-emerald-200/70">
{`已接入真实${detailReferenceSourceLabel}参考图接口`}
</span>
@ -2318,12 +2349,12 @@ export function AgentGenerationWorkspaceDarkV2({
: "border-white/10 bg-white/[0.03] hover:border-white/18"
)}
>
<div className="relative aspect-[16/9] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.18),transparent_58%),linear-gradient(180deg,#0b0d0f_0%,#11161a_100%)]">
<div className="relative min-h-[180px] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.18),transparent_58%),linear-gradient(180deg,#0b0d0f_0%,#11161a_100%)]">
{previewUrl ? (
<img
src={buildPreviewImageUrl(previewUrl, 640)}
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
className="h-full w-full object-cover"
className="block h-auto w-full"
/>
) : null}
<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">
@ -2378,7 +2409,7 @@ export function AgentGenerationWorkspaceDarkV2({
)}
onClick={() => setActiveDetailReferenceCardId(card.id)}
>
<div className="relative aspect-[5/3] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_55%),linear-gradient(180deg,#0b0d0f_0%,#101316_100%)]">
<div className="relative min-h-[220px] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_55%),linear-gradient(180deg,#0b0d0f_0%,#101316_100%)]">
<button
type="button"
onClick={() => handleRemoveDetailReferenceCard(card.id)}
@ -2390,7 +2421,7 @@ export function AgentGenerationWorkspaceDarkV2({
<img
src={buildPreviewImageUrl(card.imageUrl, 720)}
alt={card.label}
className="h-full w-full object-cover"
className="block h-auto w-full"
/>
) : null}
<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">
@ -2457,11 +2488,11 @@ export function AgentGenerationWorkspaceDarkV2({
)}
>
{card.resultUrl ? (
<div className="relative aspect-[5/3] overflow-hidden bg-black/30">
<div className="relative min-h-[220px] overflow-hidden bg-black/30">
<img
src={buildPreviewImageUrl(card.resultUrl, 720)}
alt={`结果图 ${card.index + 1}`}
className="h-full w-full cursor-zoom-in object-cover"
className="block h-auto w-full cursor-zoom-in"
onClick={() => {
setActiveDetailReferenceCardId(card.id);
setFullscreenPreview(card.resultUrl!);
@ -2683,6 +2714,9 @@ export function AgentGenerationWorkspaceDarkV2({
{skuReplacementQueryType === "spu" ? "SPU" : "SKU"} /
{skuReplacementSearchState.lastQuery ||
skuReplacementQueryInput.trim().toUpperCase()}
{workflowStoreName ? (
<span className="ml-2 text-white/40">{`店铺 / ${workflowStoreName}`}</span>
) : null}
<span className="ml-2 text-emerald-200/70">
{`已接入真实${skuReferenceSourceLabel}参考图接口`}
</span>

View File

@ -451,7 +451,7 @@ export function AgentWorkflowMaterialPicker({
<div className={cn("text-[11px]", isDark ? "text-white/60" : "text-muted-foreground")}>
{isPlaceholder
? "已先展示预览卡位,图片会逐步加载出来。"
: `点击${selected ? "取消" : "选择"},可多选带入生图模式`}
: `系统会先按权重自动选图,你也可以点击${selected ? "取消" : "调整"}。`}
</div>
</div>
</button>
@ -498,7 +498,7 @@ export function AgentWorkflowMaterialPicker({
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{result?.skuOptions?.length
? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。"
: "已选中的图片会在下一步优先带入右侧“生图模式”槽位,你也可以进入后继续替换。"}
: "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"}
</div>
) : null}
</section>

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Plus, Sparkles } from "lucide-react";
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
@ -60,6 +60,7 @@ export function AgentWorkflowMaterialSelectionPanel({
otherCount: 0,
totalCount: count,
skuOptions: [],
recommendedImageIds: [],
images: Array.from({ length: count }, (_, index) => ({
id: `loading-${params.keyword}-${index + 1}`,
key: `loading-${params.keyword}-${index + 1}`,
@ -101,52 +102,6 @@ export function AgentWorkflowMaterialSelectionPanel({
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? Math.max(productSelectionLimit - primarySelectedCount, secondarySelectedCount, 0)
: null;
const primarySelectedImages = useMemo(
() =>
(materials.primary.result?.images || []).filter((image) =>
materials.primary.selectedImageIds.includes(image.id)
),
[materials.primary.result?.images, materials.primary.selectedImageIds]
);
const secondarySelectedImages = useMemo(
() =>
(materials.secondary.result?.images || []).filter((image) =>
materials.secondary.selectedImageIds.includes(image.id)
),
[materials.secondary.result?.images, materials.secondary.selectedImageIds]
);
const slotPreviewCount = useMemo(() => {
if (
typeof productSelectionLimit === "number" &&
Number.isFinite(productSelectionLimit) &&
productSelectionLimit > 0
) {
return Math.round(productSelectionLimit);
}
return primarySelectedImages.length + secondarySelectedImages.length;
}, [primarySelectedImages.length, productSelectionLimit, secondarySelectedImages.length]);
const slotPreviewItems = useMemo(() => {
const orderedSources = [
...primarySelectedImages.map((image, index) => ({
source: "主商品" as const,
image,
sourceIndex: index,
})),
...secondarySelectedImages.map((image, index) => ({
source: "搭配商品" as const,
image,
sourceIndex: index,
})),
];
return Array.from({ length: slotPreviewCount }, (_, index) => {
const assigned = orderedSources[index] || null;
return {
slotLabel: `产品图${index + 1}`,
assigned,
};
});
}, [primarySelectedImages, secondarySelectedImages, slotPreviewCount]);
const hasAnyQueryInput =
Boolean(String(inputs.primarySku || "").trim()) ||
Boolean(String(inputs.secondarySku || "").trim());
@ -174,8 +129,8 @@ export function AgentWorkflowMaterialSelectionPanel({
},
{
badge: "03",
label: "勾选图片",
description: "勾选你要带入生图的白底图,系统会自动映射到产品槽位。",
label: "自动选图",
description: "系统会按角度权重自动选图并映射到产品槽位,你也可以手动微调。",
status: hasAnySelectedImages ? "completed" : hasAnyMaterialResult ? "current" : "pending",
},
] as const;
@ -240,8 +195,24 @@ export function AgentWorkflowMaterialSelectionPanel({
try {
updateMaterialSearchState(source, { isLoading: true, errorText: null });
const searchParams = new URLSearchParams({
keyword,
source,
queryType,
});
const currentSelectionLimit =
source === "primary" ? primarySelectionLimit : secondarySelectionLimit;
const requiredCount =
typeof currentSelectionLimit === "number" &&
Number.isFinite(currentSelectionLimit) &&
currentSelectionLimit > 0
? Math.round(currentSelectionLimit)
: null;
if (requiredCount) {
searchParams.set("requiredCount", String(requiredCount));
}
const response = await fetch(
`/api/agent-workflow/material-search?keyword=${encodeURIComponent(keyword)}&source=${source}&queryType=${queryType}`,
`/api/agent-workflow/material-search?${searchParams.toString()}`,
{ cache: "no-store" }
);
const result = await response.json().catch(() => ({}));
@ -254,7 +225,9 @@ export function AgentWorkflowMaterialSelectionPanel({
...materials,
[source]: {
result: result.data,
selectedImageIds: [],
selectedImageIds: Array.isArray(result.data?.recommendedImageIds)
? result.data.recommendedImageIds
: [],
},
});
} catch (error) {
@ -365,92 +338,6 @@ export function AgentWorkflowMaterialSelectionPanel({
</div>
</div>
{slotPreviewItems.length > 0 ? (
<div
className={cn(
"space-y-3 rounded-[24px] border px-4 py-4",
isDark ? "border-white/10 bg-black/25" : "border-[#dbe3ff] bg-white"
)}
>
<div className="flex flex-col gap-1">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "text-[#334155]")}>
产品图槽位映射
</div>
<div className={cn("text-xs leading-5", isDark ? "text-white/50" : "text-[#64748b]")}>
当前已选图片会按顺序自动带入后续生图槽位,先主商品,后搭配商品。
</div>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{slotPreviewItems.map((item, index) => {
const source = item.assigned?.source || null;
const previewUrl = item.assigned?.image.imageUrl || "";
const isAssigned = Boolean(item.assigned && previewUrl);
return (
<div
key={`${item.slotLabel}-${index}`}
className={cn(
"overflow-hidden rounded-[22px] border",
isAssigned
? isDark
? "border-emerald-400/20 bg-white/[0.03]"
: "border-[#c7d2fe] bg-[#f8faff]"
: isDark
? "border-dashed border-white/10 bg-white/[0.02]"
: "border-dashed border-[#dbe3ff] bg-[#f8faff]"
)}
>
<div
className={cn(
"relative aspect-[5/4] overflow-hidden",
isAssigned ? "" : isDark ? "bg-black/30" : "bg-white"
)}
>
{isAssigned ? (
<img
src={previewUrl}
alt={item.assigned?.image.label || item.slotLabel}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<div
className={cn(
"flex h-full w-full items-center justify-center text-[11px]",
isDark ? "text-white/30" : "text-[#94a3b8]"
)}
>
待选择
</div>
)}
<div className="absolute left-2 top-2 rounded-full bg-black/60 px-2 py-1 text-[10px] text-white">
{item.slotLabel}
</div>
<div className="absolute right-2 top-2 rounded-full border border-white/10 bg-black/45 px-2 py-1 text-[10px] text-white/78">
{source || "空槽位"}
</div>
</div>
<div className="space-y-1 border-t border-white/10 px-3 py-2">
<div className={cn("truncate text-xs font-medium", isDark ? "text-white/82" : "text-[#334155]")}>
{isAssigned
? item.assigned?.image.label || `已映射到${item.slotLabel}`
: `${item.slotLabel}待补图`}
</div>
<div className={cn("truncate text-[11px]", isDark ? "text-white/45" : "text-[#64748b]")}>
{isAssigned
? `${source}第 ${(item.assigned?.sourceIndex || 0) + 1} 张`
: "先在下方搜索并选择图片"}
</div>
</div>
</div>
);
})}
</div>
</div>
) : null}
<AgentWorkflowMaterialPicker
value={inputs.primarySku}
queryMode={materialSearchMode.primary}

View File

@ -110,6 +110,16 @@ function normalizeMaterialSearchResult(value: unknown): AgentWorkflowMaterialSea
images: images
.slice()
.sort((left, right) => left.sortOrder - right.sortOrder),
recommendedImageIds: Array.isArray(raw.recommendedImageIds)
? Array.from(
new Set(
raw.recommendedImageIds
.map((item) => sanitizeText(item, 128))
.filter((item) => item && images.some((image) => image.id === item))
.slice(0, 16)
)
)
: [],
skuOptions,
};
}

View File

@ -188,6 +188,7 @@ export interface AgentWorkflowMaterialSearchResult {
otherCount: number;
totalCount: number;
images: AgentWorkflowMaterialImage[];
recommendedImageIds?: string[];
skuOptions?: AgentWorkflowMaterialSkuOption[];
}