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 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(
|
function normalizeAgentModeReferences(
|
||||||
payload?: PostRequestBody["agentModePayload"],
|
payload?: PostRequestBody["agentModePayload"],
|
||||||
): AgentReplacementModePayload["references"] {
|
): AgentReplacementModePayload["references"] {
|
||||||
@ -2843,6 +2856,222 @@ function normalizeAgentModeReferences(
|
|||||||
.sort((left, right) => left.sortOrder - right.sortOrder);
|
.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: {
|
function buildAgentReplacementPrompt(params: {
|
||||||
basePrompt: string;
|
basePrompt: string;
|
||||||
modeType: "detail_replacement" | "sku_replacement";
|
modeType: "detail_replacement" | "sku_replacement";
|
||||||
@ -5776,8 +6005,18 @@ export async function POST(request: Request) {
|
|||||||
originalUrl: string;
|
originalUrl: string;
|
||||||
thumbnailUrl?: string | null;
|
thumbnailUrl?: string | null;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
let generatedReplacementCount = 0;
|
||||||
let resolvedModelDisplayName =
|
let resolvedModelDisplayName =
|
||||||
selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.name;
|
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(
|
const stopKeepalive = startStreamKeepalive(
|
||||||
writer,
|
writer,
|
||||||
"agent-preset:replacement",
|
"agent-preset:replacement",
|
||||||
@ -5799,6 +6038,31 @@ export async function POST(request: Request) {
|
|||||||
pairedProductIndex !== undefined
|
pairedProductIndex !== undefined
|
||||||
? [productInputImageUrls[pairedProductIndex]!]
|
? [productInputImageUrls[pairedProductIndex]!]
|
||||||
: productInputImageUrls;
|
: 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(
|
const sourceImageUrls = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
[
|
[
|
||||||
@ -5852,6 +6116,7 @@ export async function POST(request: Request) {
|
|||||||
])}`,
|
])}`,
|
||||||
);
|
);
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
|
generatedReplacementCount += 1;
|
||||||
} catch (replacementItemError) {
|
} catch (replacementItemError) {
|
||||||
console.error("[Chat API] Agent replacement item execution failed:", {
|
console.error("[Chat API] Agent replacement item execution failed:", {
|
||||||
referenceIndex,
|
referenceIndex,
|
||||||
@ -5878,7 +6143,7 @@ export async function POST(request: Request) {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n"),
|
.join("\n\n"),
|
||||||
resolvedModelDisplayName,
|
generatedReplacementCount > 0 ? resolvedModelDisplayName : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
await saveMessages({
|
await saveMessages({
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
|
import { generateText } from "ai";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
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 { uploadFile } from "@/lib/storage/service";
|
||||||
import {
|
import {
|
||||||
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
||||||
@ -14,8 +16,12 @@ const querySchema = z.object({
|
|||||||
keyword: z.string().trim().min(1).max(64),
|
keyword: z.string().trim().min(1).max(64),
|
||||||
source: z.enum(["primary", "secondary"]).optional(),
|
source: z.enum(["primary", "secondary"]).optional(),
|
||||||
queryType: z.enum(["sku", "spu"]).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 = {
|
type ExternalImageRecord = {
|
||||||
key?: string;
|
key?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
@ -60,6 +66,371 @@ function normalizeBaseUrl(value: string) {
|
|||||||
return String(value || "").trim().replace(/\/+$/, "");
|
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) {
|
function sanitizeFilenamePart(value: string) {
|
||||||
return String(value || "")
|
return String(value || "")
|
||||||
.trim()
|
.trim()
|
||||||
@ -188,6 +559,7 @@ export async function GET(request: NextRequest) {
|
|||||||
keyword: request.nextUrl.searchParams.get("keyword"),
|
keyword: request.nextUrl.searchParams.get("keyword"),
|
||||||
source: request.nextUrl.searchParams.get("source"),
|
source: request.nextUrl.searchParams.get("source"),
|
||||||
queryType: request.nextUrl.searchParams.get("queryType"),
|
queryType: request.nextUrl.searchParams.get("queryType"),
|
||||||
|
requiredCount: request.nextUrl.searchParams.get("requiredCount") || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
|
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 matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword;
|
||||||
const productModel = String(upstreamResult.data.productModel || "").trim() || null;
|
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)
|
const fixedImages = Array.isArray(upstreamResult.data.whiteBackground?.fixed)
|
||||||
? 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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@ -360,6 +742,7 @@ export async function GET(request: NextRequest) {
|
|||||||
otherCount: importedImages.filter((item) => item.group === "other").length,
|
otherCount: importedImages.filter((item) => item.group === "other").length,
|
||||||
totalCount: importedImages.length,
|
totalCount: importedImages.length,
|
||||||
images: importedImages,
|
images: importedImages,
|
||||||
|
recommendedImageIds,
|
||||||
skuOptions: [],
|
skuOptions: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const querySchema = z.object({
|
|||||||
keyword: z.string().trim().min(1).max(64),
|
keyword: z.string().trim().min(1).max(64),
|
||||||
queryType: z.enum(["sku", "spu"]).default("spu"),
|
queryType: z.enum(["sku", "spu"]).default("spu"),
|
||||||
target: z.enum(["main", "detail", "sku"]),
|
target: z.enum(["main", "detail", "sku"]),
|
||||||
|
storeName: z.string().trim().max(64).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ExternalTaskDetailCandidate = {
|
type ExternalTaskDetailCandidate = {
|
||||||
@ -58,6 +59,13 @@ function normalizeBaseUrl(value: string) {
|
|||||||
return String(value || "").trim().replace(/\/+$/, "");
|
return String(value || "").trim().replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeShopName(value: unknown) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUrlList(value: unknown) {
|
function normalizeUrlList(value: unknown) {
|
||||||
if (!Array.isArray(value)) return [] as string[];
|
if (!Array.isArray(value)) return [] as string[];
|
||||||
return Array.from(
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -167,6 +195,7 @@ export async function GET(request: NextRequest) {
|
|||||||
keyword: request.nextUrl.searchParams.get("keyword"),
|
keyword: request.nextUrl.searchParams.get("keyword"),
|
||||||
queryType: request.nextUrl.searchParams.get("queryType") || "spu",
|
queryType: request.nextUrl.searchParams.get("queryType") || "spu",
|
||||||
target: request.nextUrl.searchParams.get("target"),
|
target: request.nextUrl.searchParams.get("target"),
|
||||||
|
storeName: request.nextUrl.searchParams.get("storeName") || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
|
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(
|
const normalizedCandidates = sortCandidatesByUploadedAtDesc(
|
||||||
(upstreamResult.data.candidates || []).map((item, index) => normalizeCandidate(item, index))
|
(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) {
|
if (normalizedCandidates.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code:
|
code: requestedStoreName
|
||||||
parsed.target === "main"
|
? "STORE_REFERENCE_NOT_FOUND"
|
||||||
|
: parsed.target === "main"
|
||||||
? "MAIN_REFERENCE_NOT_FOUND"
|
? "MAIN_REFERENCE_NOT_FOUND"
|
||||||
: parsed.target === "detail"
|
: parsed.target === "detail"
|
||||||
? "DETAIL_REFERENCE_NOT_FOUND"
|
? "DETAIL_REFERENCE_NOT_FOUND"
|
||||||
: "SKU_REFERENCE_NOT_FOUND",
|
: "SKU_REFERENCE_NOT_FOUND",
|
||||||
message:
|
message: requestedStoreName
|
||||||
parsed.target === "main"
|
? `当前店铺「${requestedStoreName}」下没有可用的${
|
||||||
|
parsed.target === "main"
|
||||||
|
? "主图参考图"
|
||||||
|
: parsed.target === "detail"
|
||||||
|
? "详情参考图"
|
||||||
|
: "SKU参考图"
|
||||||
|
}`
|
||||||
|
: parsed.target === "main"
|
||||||
? "当前整套素材中没有可用的主图参考图"
|
? "当前整套素材中没有可用的主图参考图"
|
||||||
: parsed.target === "detail"
|
: parsed.target === "detail"
|
||||||
? "当前整套素材中没有可用的详情参考图"
|
? "当前整套素材中没有可用的详情参考图"
|
||||||
: "当前整套素材中没有可用的 SKU 参考图",
|
: "当前整套素材中没有可用的 SKU 参考图",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
@ -259,8 +299,8 @@ export async function GET(request: NextRequest) {
|
|||||||
target: parsed.target,
|
target: parsed.target,
|
||||||
matchedSpu: String(upstreamResult.data.spu || "").trim() || null,
|
matchedSpu: String(upstreamResult.data.spu || "").trim() || null,
|
||||||
matchedSku: String(upstreamResult.data.sku || "").trim() || null,
|
matchedSku: String(upstreamResult.data.sku || "").trim() || null,
|
||||||
categoryName: String(upstreamResult.data.categoryName || "").trim() || null,
|
categoryName: selectedCandidate.categoryName || String(upstreamResult.data.categoryName || "").trim() || null,
|
||||||
shopName: String(upstreamResult.data.shopName || "").trim() || null,
|
shopName: selectedCandidate.shopName || String(upstreamResult.data.shopName || "").trim() || null,
|
||||||
candidateCount: normalizedCandidates.length,
|
candidateCount: normalizedCandidates.length,
|
||||||
candidates: normalizedCandidates,
|
candidates: normalizedCandidates,
|
||||||
selectedCandidateId: selectedCandidate.id,
|
selectedCandidateId: selectedCandidate.id,
|
||||||
|
|||||||
@ -566,6 +566,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const skuReferenceTarget = presetModeConfig.sku.referenceImageSource;
|
const skuReferenceTarget = presetModeConfig.sku.referenceImageSource;
|
||||||
const detailReferenceSourceLabel = getTaskDetailReferenceSourceLabel(detailReferenceTarget);
|
const detailReferenceSourceLabel = getTaskDetailReferenceSourceLabel(detailReferenceTarget);
|
||||||
const skuReferenceSourceLabel = getTaskDetailReferenceSourceLabel(skuReferenceTarget);
|
const skuReferenceSourceLabel = getTaskDetailReferenceSourceLabel(skuReferenceTarget);
|
||||||
|
const workflowStoreName = String(workflowContext?.store || "").trim();
|
||||||
const shouldAllowModelOverride = !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride;
|
const shouldAllowModelOverride = !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride;
|
||||||
const selectedProductAttachments = attachments.filter((item, index) =>
|
const selectedProductAttachments = attachments.filter((item, index) =>
|
||||||
modelSlotIndex >= 0 ? index !== modelSlotIndex && Boolean(item) : Boolean(item)
|
modelSlotIndex >= 0 ? index !== modelSlotIndex && Boolean(item) : Boolean(item)
|
||||||
@ -1461,6 +1462,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
);
|
);
|
||||||
const handleDetailReplacementSearch = useCallback(async () => {
|
const handleDetailReplacementSearch = useCallback(async () => {
|
||||||
const normalized = detailReplacementQueryInput.trim().toUpperCase();
|
const normalized = detailReplacementQueryInput.trim().toUpperCase();
|
||||||
|
if (!workflowStoreName) {
|
||||||
|
toast.error("请先在第一步完成品牌和店铺选择后再查询参考图");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
toast.error(`请输入 SPU 后再查询${detailReferenceSourceLabel}`);
|
toast.error(`请输入 SPU 后再查询${detailReferenceSourceLabel}`);
|
||||||
return;
|
return;
|
||||||
@ -1472,10 +1477,16 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
keyword: normalized,
|
||||||
|
queryType: "spu",
|
||||||
|
target: detailReferenceTarget,
|
||||||
|
});
|
||||||
|
if (workflowStoreName) {
|
||||||
|
searchParams.set("storeName", workflowStoreName);
|
||||||
|
}
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/agent-workflow/reference-search?keyword=${encodeURIComponent(
|
`/api/agent-workflow/reference-search?${searchParams.toString()}`,
|
||||||
normalized
|
|
||||||
)}&queryType=spu&target=${detailReferenceTarget}`,
|
|
||||||
{ cache: "no-store" }
|
{ cache: "no-store" }
|
||||||
);
|
);
|
||||||
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
|
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
|
||||||
@ -1504,7 +1515,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
});
|
});
|
||||||
toast.error(error instanceof Error ? error.message : `${detailReferenceSourceLabel}参考图查询失败`);
|
toast.error(error instanceof Error ? error.message : `${detailReferenceSourceLabel}参考图查询失败`);
|
||||||
}
|
}
|
||||||
}, [detailReferenceSourceLabel, detailReferenceTarget, detailReplacementQueryInput, updateDetailReplacementState]);
|
}, [
|
||||||
|
detailReferenceSourceLabel,
|
||||||
|
detailReferenceTarget,
|
||||||
|
detailReplacementQueryInput,
|
||||||
|
updateDetailReplacementState,
|
||||||
|
workflowStoreName,
|
||||||
|
]);
|
||||||
const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => {
|
const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => {
|
||||||
updateDetailReplacementState((current) => {
|
updateDetailReplacementState((current) => {
|
||||||
const nextCandidate =
|
const nextCandidate =
|
||||||
@ -1536,6 +1553,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}, [updateDetailReplacementState]);
|
}, [updateDetailReplacementState]);
|
||||||
const handleSkuReplacementSearch = useCallback(async () => {
|
const handleSkuReplacementSearch = useCallback(async () => {
|
||||||
const normalized = skuReplacementQueryInput.trim().toUpperCase();
|
const normalized = skuReplacementQueryInput.trim().toUpperCase();
|
||||||
|
if (!workflowStoreName) {
|
||||||
|
toast.error("请先在第一步完成品牌和店铺选择后再查询参考图");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询${skuReferenceSourceLabel}参考图`);
|
toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询${skuReferenceSourceLabel}参考图`);
|
||||||
return;
|
return;
|
||||||
@ -1547,10 +1568,16 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
keyword: normalized,
|
||||||
|
queryType: skuReplacementQueryType,
|
||||||
|
target: skuReferenceTarget,
|
||||||
|
});
|
||||||
|
if (workflowStoreName) {
|
||||||
|
searchParams.set("storeName", workflowStoreName);
|
||||||
|
}
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/agent-workflow/reference-search?keyword=${encodeURIComponent(
|
`/api/agent-workflow/reference-search?${searchParams.toString()}`,
|
||||||
normalized
|
|
||||||
)}&queryType=${skuReplacementQueryType}&target=${skuReferenceTarget}`,
|
|
||||||
{ cache: "no-store" }
|
{ cache: "no-store" }
|
||||||
);
|
);
|
||||||
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
|
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
|
||||||
@ -1593,6 +1620,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
skuReplacementQueryInput,
|
skuReplacementQueryInput,
|
||||||
skuReplacementQueryType,
|
skuReplacementQueryType,
|
||||||
updateSkuReplacementState,
|
updateSkuReplacementState,
|
||||||
|
workflowStoreName,
|
||||||
]);
|
]);
|
||||||
const handleSelectSkuReplacementCandidate = useCallback((candidateId: string) => {
|
const handleSelectSkuReplacementCandidate = useCallback((candidateId: string) => {
|
||||||
updateSkuReplacementState((current) => {
|
updateSkuReplacementState((current) => {
|
||||||
@ -2286,6 +2314,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
SPU /
|
SPU /
|
||||||
{detailReplacementSearchState.lastQuery ||
|
{detailReplacementSearchState.lastQuery ||
|
||||||
detailReplacementQueryInput.trim().toUpperCase()}
|
detailReplacementQueryInput.trim().toUpperCase()}
|
||||||
|
{workflowStoreName ? (
|
||||||
|
<span className="ml-2 text-white/40">{`店铺 / ${workflowStoreName}`}</span>
|
||||||
|
) : null}
|
||||||
<span className="ml-2 text-emerald-200/70">
|
<span className="ml-2 text-emerald-200/70">
|
||||||
{`已接入真实${detailReferenceSourceLabel}参考图接口`}
|
{`已接入真实${detailReferenceSourceLabel}参考图接口`}
|
||||||
</span>
|
</span>
|
||||||
@ -2318,12 +2349,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
: "border-white/10 bg-white/[0.03] hover:border-white/18"
|
: "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 ? (
|
{previewUrl ? (
|
||||||
<img
|
<img
|
||||||
src={buildPreviewImageUrl(previewUrl, 640)}
|
src={buildPreviewImageUrl(previewUrl, 640)}
|
||||||
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
|
alt={candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
|
||||||
className="h-full w-full object-cover"
|
className="block h-auto w-full"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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">
|
<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)}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveDetailReferenceCard(card.id)}
|
onClick={() => handleRemoveDetailReferenceCard(card.id)}
|
||||||
@ -2390,7 +2421,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
<img
|
<img
|
||||||
src={buildPreviewImageUrl(card.imageUrl, 720)}
|
src={buildPreviewImageUrl(card.imageUrl, 720)}
|
||||||
alt={card.label}
|
alt={card.label}
|
||||||
className="h-full w-full object-cover"
|
className="block h-auto w-full"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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">
|
<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 ? (
|
{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
|
<img
|
||||||
src={buildPreviewImageUrl(card.resultUrl, 720)}
|
src={buildPreviewImageUrl(card.resultUrl, 720)}
|
||||||
alt={`结果图 ${card.index + 1}`}
|
alt={`结果图 ${card.index + 1}`}
|
||||||
className="h-full w-full cursor-zoom-in object-cover"
|
className="block h-auto w-full cursor-zoom-in"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveDetailReferenceCardId(card.id);
|
setActiveDetailReferenceCardId(card.id);
|
||||||
setFullscreenPreview(card.resultUrl!);
|
setFullscreenPreview(card.resultUrl!);
|
||||||
@ -2683,6 +2714,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
{skuReplacementQueryType === "spu" ? "SPU" : "SKU"} /
|
{skuReplacementQueryType === "spu" ? "SPU" : "SKU"} /
|
||||||
{skuReplacementSearchState.lastQuery ||
|
{skuReplacementSearchState.lastQuery ||
|
||||||
skuReplacementQueryInput.trim().toUpperCase()}
|
skuReplacementQueryInput.trim().toUpperCase()}
|
||||||
|
{workflowStoreName ? (
|
||||||
|
<span className="ml-2 text-white/40">{`店铺 / ${workflowStoreName}`}</span>
|
||||||
|
) : null}
|
||||||
<span className="ml-2 text-emerald-200/70">
|
<span className="ml-2 text-emerald-200/70">
|
||||||
{`已接入真实${skuReferenceSourceLabel}参考图接口`}
|
{`已接入真实${skuReferenceSourceLabel}参考图接口`}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -451,7 +451,7 @@ export function AgentWorkflowMaterialPicker({
|
|||||||
<div className={cn("text-[11px]", isDark ? "text-white/60" : "text-muted-foreground")}>
|
<div className={cn("text-[11px]", isDark ? "text-white/60" : "text-muted-foreground")}>
|
||||||
{isPlaceholder
|
{isPlaceholder
|
||||||
? "已先展示预览卡位,图片会逐步加载出来。"
|
? "已先展示预览卡位,图片会逐步加载出来。"
|
||||||
: `点击${selected ? "取消" : "选择"},可多选带入生图模式`}
|
: `系统会先按权重自动选图,你也可以点击${selected ? "取消" : "调整"}。`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -498,7 +498,7 @@ export function AgentWorkflowMaterialPicker({
|
|||||||
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
{result?.skuOptions?.length
|
{result?.skuOptions?.length
|
||||||
? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。"
|
? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。"
|
||||||
: "已选中的图片会在下一步优先带入右侧“生图模式”槽位,你也可以进入后继续替换。"}
|
: "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Plus, Sparkles } from "lucide-react";
|
import { Plus, Sparkles } from "lucide-react";
|
||||||
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
|
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
|
||||||
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
|
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
|
||||||
@ -60,6 +60,7 @@ export function AgentWorkflowMaterialSelectionPanel({
|
|||||||
otherCount: 0,
|
otherCount: 0,
|
||||||
totalCount: count,
|
totalCount: count,
|
||||||
skuOptions: [],
|
skuOptions: [],
|
||||||
|
recommendedImageIds: [],
|
||||||
images: Array.from({ length: count }, (_, index) => ({
|
images: Array.from({ length: count }, (_, index) => ({
|
||||||
id: `loading-${params.keyword}-${index + 1}`,
|
id: `loading-${params.keyword}-${index + 1}`,
|
||||||
key: `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
|
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
|
||||||
? Math.max(productSelectionLimit - primarySelectedCount, secondarySelectedCount, 0)
|
? Math.max(productSelectionLimit - primarySelectedCount, secondarySelectedCount, 0)
|
||||||
: null;
|
: 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 =
|
const hasAnyQueryInput =
|
||||||
Boolean(String(inputs.primarySku || "").trim()) ||
|
Boolean(String(inputs.primarySku || "").trim()) ||
|
||||||
Boolean(String(inputs.secondarySku || "").trim());
|
Boolean(String(inputs.secondarySku || "").trim());
|
||||||
@ -174,8 +129,8 @@ export function AgentWorkflowMaterialSelectionPanel({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
badge: "03",
|
badge: "03",
|
||||||
label: "勾选图片",
|
label: "自动选图",
|
||||||
description: "勾选你要带入生图的白底图,系统会自动映射到产品槽位。",
|
description: "系统会按角度权重自动选图并映射到产品槽位,你也可以手动微调。",
|
||||||
status: hasAnySelectedImages ? "completed" : hasAnyMaterialResult ? "current" : "pending",
|
status: hasAnySelectedImages ? "completed" : hasAnyMaterialResult ? "current" : "pending",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
@ -240,8 +195,24 @@ export function AgentWorkflowMaterialSelectionPanel({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
updateMaterialSearchState(source, { isLoading: true, errorText: null });
|
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(
|
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" }
|
{ cache: "no-store" }
|
||||||
);
|
);
|
||||||
const result = await response.json().catch(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
@ -254,7 +225,9 @@ export function AgentWorkflowMaterialSelectionPanel({
|
|||||||
...materials,
|
...materials,
|
||||||
[source]: {
|
[source]: {
|
||||||
result: result.data,
|
result: result.data,
|
||||||
selectedImageIds: [],
|
selectedImageIds: Array.isArray(result.data?.recommendedImageIds)
|
||||||
|
? result.data.recommendedImageIds
|
||||||
|
: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -365,92 +338,6 @@ export function AgentWorkflowMaterialSelectionPanel({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<AgentWorkflowMaterialPicker
|
||||||
value={inputs.primarySku}
|
value={inputs.primarySku}
|
||||||
queryMode={materialSearchMode.primary}
|
queryMode={materialSearchMode.primary}
|
||||||
|
|||||||
@ -110,6 +110,16 @@ function normalizeMaterialSearchResult(value: unknown): AgentWorkflowMaterialSea
|
|||||||
images: images
|
images: images
|
||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => left.sortOrder - right.sortOrder),
|
.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,
|
skuOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -188,6 +188,7 @@ export interface AgentWorkflowMaterialSearchResult {
|
|||||||
otherCount: number;
|
otherCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
images: AgentWorkflowMaterialImage[];
|
images: AgentWorkflowMaterialImage[];
|
||||||
|
recommendedImageIds?: string[];
|
||||||
skuOptions?: AgentWorkflowMaterialSkuOption[];
|
skuOptions?: AgentWorkflowMaterialSkuOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user