feat: automate agent material selection flow
This commit is contained in:
parent
9276e6c35c
commit
5fc5a5e47c
@ -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({
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -188,6 +188,7 @@ export interface AgentWorkflowMaterialSearchResult {
|
||||
otherCount: number;
|
||||
totalCount: number;
|
||||
images: AgentWorkflowMaterialImage[];
|
||||
recommendedImageIds?: string[];
|
||||
skuOptions?: AgentWorkflowMaterialSkuOption[];
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user