877 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { generateText } from "ai";
import { execFileSync } from "node:child_process";
import { createCipheriv, createHash } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
buildAgentWorkflowMaterialApiUrl,
isAgentWorkflowMaterialResourceEnabled,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import {
AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY,
normalizeAgentWorkflowGoodsInfoApiConfig,
} from "@/lib/agents/workflow-goods-info-config";
export const maxDuration = 180;
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(),
appearanceRecognitionEnabled: z.enum(["true", "false", "1", "0"]).optional(),
});
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
const FUNCTIONAL_IMAGE_HINT_REGEX = /开门|开柜|柜门|抽屉|打开|开合|功能|展示|收纳|储物|细节/i;
const GOODS_INFO_FETCH_TIMEOUT_MS = 1500;
type ExternalImageRecord = {
key?: string;
label?: string;
fileName?: string;
fileSize?: number;
previewUrl?: string;
downloadUrl?: string;
};
type ExternalSearchResponse = {
success?: boolean;
data?: {
sku?: string;
spu?: string;
materialId?: string;
productModel?: string;
skuCount?: number;
skuList?: Array<{
skuName?: string;
skuId?: string;
hasWhiteBackground?: boolean;
whiteBackgroundCount?: number;
previewImage?: {
downloadUrl?: string;
fileName?: string;
} | null;
}>;
whiteBackground?: {
fixed?: ExternalImageRecord[];
other?: ExternalImageRecord[];
};
downloadUrls?: {
zip?: string;
};
};
error?: {
code?: string;
message?: string;
};
};
type ExternalGoodsInfoResponse = {
success?: boolean;
errMsg?: string | null;
data?: Record<string, unknown> | null;
};
type MaterialSearchImage = {
id: string;
key: string;
label: string;
fileName: string;
imageUrl: string;
previewUrl: string | null;
assetId: string | null;
mimeType: string;
group: "fixed" | "other";
sortOrder: number;
};
function normalizeBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "");
}
function sanitizeGoodsInfoText(value: unknown, maxLength = 255) {
return String(value || "").trim().slice(0, maxLength);
}
function deriveSpuFromSku(value: unknown) {
const normalized = String(value || "").trim().toUpperCase();
if (!/[0-9]{2}$/.test(normalized) || normalized.length <= 2) {
return "";
}
return normalized.slice(0, -2);
}
function createGoodsInfoSignature(timespan: string, secret: string) {
const secretBuffer = Buffer.from(String(secret || "").trim(), "utf8");
if (secretBuffer.length !== 8) {
throw new Error("商品信息接口 Secret 长度必须为 8 位");
}
const plaintext = String(timespan || "").trim();
let encryptedHex = "";
try {
const cipher = createCipheriv("des-cbc", secretBuffer, secretBuffer);
cipher.setAutoPadding(true);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
encryptedHex = encrypted.toString("hex").toUpperCase();
} catch (error) {
const opensslOutput = execFileSync(
"openssl",
[
"enc",
"-des-cbc",
"-provider",
"legacy",
"-K",
secretBuffer.toString("hex"),
"-iv",
secretBuffer.toString("hex"),
"-nosalt",
],
{
input: Buffer.from(plaintext, "utf8"),
}
);
encryptedHex = Buffer.from(opensslOutput).toString("hex").toUpperCase();
console.warn(
"[Agent Workflow Material Search] Fallback to openssl legacy DES for goods info signature:",
error instanceof Error ? error.message : error
);
}
return createHash("md5").update(encryptedHex, "utf8").digest("hex").toUpperCase();
}
function buildGoodsInfoSummary(data: Record<string, unknown>) {
const productName =
sanitizeGoodsInfoText(data["商品名称"], 255) ||
sanitizeGoodsInfoText(data["名称"], 255) ||
null;
const sizeInfoParts = [
sanitizeGoodsInfoText(data["商品尺寸"], 128),
[data["长"], data["宽"], data["高"]]
.map((item) => sanitizeGoodsInfoText(item, 32))
.filter(Boolean)
.join(" x "),
].filter(Boolean);
const materialInfoParts = [
sanitizeGoodsInfoText(data["材质"], 128),
sanitizeGoodsInfoText(data["材质类型"], 128),
].filter(Boolean);
return {
productName,
sizeInfo: sizeInfoParts.join(" · ") || null,
materialInfo: materialInfoParts.join(" · ") || null,
designer: sanitizeGoodsInfoText(data["设计师"], 128) || null,
buyer: sanitizeGoodsInfoText(data["采购负责人"], 128) || null,
boardType: sanitizeGoodsInfoText(data["板材类型"], 128) || null,
matchedSpu:
sanitizeGoodsInfoText(data["spu"], 64) ||
sanitizeGoodsInfoText(data["SPU"], 64) ||
null,
};
}
async function fetchGoodsInfoByKeyword(params: {
keyword: string;
config: ReturnType<typeof normalizeAgentWorkflowGoodsInfoApiConfig>;
}) {
const baseUrl = normalizeBaseUrl(params.config.baseUrl);
const clientKey = String(params.config.clientKey || "").trim();
const secret = String(params.config.secret || "").trim();
if (!params.config.enabled || !baseUrl || !clientKey || !secret) {
return null;
}
const timespan = String(Math.floor(Date.now() / 1000));
const sign = createGoodsInfoSignature(timespan, secret);
const commonHeaders = {
Key: clientKey,
Timespan: timespan,
Sign: sign,
Accept: "application/json",
};
const requestPaths = [
`/api/GoodsInfo/SearchByProcode?procode=${encodeURIComponent(params.keyword)}`,
`/api/GoodsInfo/SearchBySkuoid?skuoid=${encodeURIComponent(params.keyword)}`,
];
for (const requestPath of requestPaths) {
try {
const response = await fetch(`${baseUrl}${requestPath}`, {
method: "GET",
headers: commonHeaders,
cache: "no-store",
signal: AbortSignal.timeout(GOODS_INFO_FETCH_TIMEOUT_MS),
});
const result = (await response.json().catch(() => ({}))) as ExternalGoodsInfoResponse;
if (response.ok && result.success === true && result.data && typeof result.data === "object") {
return buildGoodsInfoSummary(result.data);
}
} catch (error) {
console.warn("[Agent Workflow Material Search] Goods info query failed:", {
requestPath,
error,
});
}
}
return null;
}
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: MaterialSearchImage[];
requiredCount: number;
visionAnalysisEnabled?: boolean;
}) {
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 (
params.visionAnalysisEnabled === true &&
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 inferMimeTypeFromFilename(filename: string) {
const normalized = String(filename || "").toLowerCase();
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) return "image/jpeg";
if (normalized.endsWith(".webp")) return "image/webp";
if (normalized.endsWith(".gif")) return "image/gif";
return "image/png";
}
function mapExternalImage(params: {
keyword: string;
matchedSku: string;
group: "fixed" | "other";
sortOrder: number;
image: ExternalImageRecord;
}): MaterialSearchImage | null {
const downloadUrl = String(params.image.downloadUrl || "").trim();
if (!downloadUrl) return null;
const filename =
String(params.image.fileName || "").trim() ||
String(params.image.label || "").trim() ||
`${params.matchedSku || params.keyword}-${params.group}-${params.sortOrder + 1}.png`;
const identityHash = createHash("sha1")
.update(`${params.group}:${params.sortOrder}:${downloadUrl}`, "utf8")
.digest("hex")
.slice(0, 12);
return {
id: `external-${params.group}-${params.sortOrder + 1}-${identityHash}`,
key: String(params.image.key || "").trim() || `${params.group}-${params.sortOrder + 1}`,
label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`,
fileName: filename,
imageUrl: downloadUrl,
previewUrl: String(params.image.previewUrl || "").trim() || null,
assetId: null,
mimeType: inferMimeTypeFromFilename(filename),
group: params.group,
sortOrder: params.sortOrder,
};
}
function mapUpstreamErrorStatus(code: string) {
if (
code === "SKU_NOT_FOUND" ||
code === "SPU_NOT_FOUND" ||
code === "NO_SKUS" ||
code === "WHITE_BACKGROUND_NOT_FOUND"
) {
return 404;
}
if (code === "INVALID_API_KEY") {
return 500;
}
if (code === "SKU_NOT_UNIQUE") {
return 409;
}
return 502;
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const parsed = querySchema.parse({
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,
appearanceRecognitionEnabled:
request.nextUrl.searchParams.get("appearanceRecognitionEnabled") || undefined,
});
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
const goodsInfoSetting = await getSystemSetting({
key: AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY,
});
const goodsInfoConfig = normalizeAgentWorkflowGoodsInfoApiConfig(
goodsInfoSetting?.value
);
if (!config.enabled) {
return NextResponse.json(
{ error: "素材搜索功能未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!isAgentWorkflowMaterialResourceEnabled(config, "white-background")) {
return NextResponse.json(
{ error: "白底图素材接口未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) {
return NextResponse.json(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
{ status: 500 }
);
}
const normalizedKeyword = parsed.keyword.trim().toUpperCase();
const queryType = parsed.queryType === "spu" ? "spu" : "sku";
const goodsInfoPromise =
queryType === "sku"
? fetchGoodsInfoByKeyword({
keyword: normalizedKeyword,
config: goodsInfoConfig,
}).catch((error) => {
console.warn("[Agent Workflow Material Search] Goods info fetch failed:", error);
return null;
})
: Promise.resolve(null);
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, {
resourceType: "white-background",
queryType,
keyword: normalizedKeyword,
});
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
headers: {
"X-API-Key": config.apiKey,
},
cache: "no-store",
signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000),
});
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse;
if (!upstreamResponse.ok || upstreamResult.success !== true || !upstreamResult.data) {
const errorCode = String(upstreamResult.error?.code || "UPSTREAM_ERROR").trim();
const errorMessage =
String(upstreamResult.error?.message || "").trim() ||
`素材搜索失败(${upstreamResponse.status})`;
return NextResponse.json(
{
success: false,
error: {
code: errorCode,
message: errorMessage,
},
},
{ status: mapUpstreamErrorStatus(errorCode) }
);
}
if (queryType === "spu") {
const skuOptions = Array.isArray(upstreamResult.data.skuList)
? upstreamResult.data.skuList
.map((item) => {
const skuName = String(item?.skuName || "").trim();
if (!skuName) return null;
return {
skuName,
skuId: String(item?.skuId || "").trim() || null,
hasWhiteBackground: item?.hasWhiteBackground === true,
whiteBackgroundCount: Number.isFinite(Number(item?.whiteBackgroundCount))
? Math.max(0, Math.min(999, Math.round(Number(item?.whiteBackgroundCount))))
: 0,
previewImageUrl: String(item?.previewImage?.downloadUrl || "").trim() || null,
previewFileName: String(item?.previewImage?.fileName || "").trim() || null,
};
})
.filter(Boolean)
: [];
return NextResponse.json({
success: true,
data: {
query: normalizedKeyword,
queryType: "spu",
matchedSku: "",
matchedSpu: String(upstreamResult.data.spu || normalizedKeyword).trim() || normalizedKeyword,
materialId: String(upstreamResult.data.materialId || "").trim() || null,
productModel: String(upstreamResult.data.spu || normalizedKeyword).trim() || null,
zipUrl: null,
fixedCount: 0,
otherCount: 0,
totalCount: skuOptions.length,
images: [],
skuOptions,
goodsInfo: null,
},
});
}
const matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword;
const productModel = String(upstreamResult.data.productModel || "").trim() || null;
const goodsInfo = await goodsInfoPromise;
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 || []
: [];
const otherImages = Array.isArray(upstreamResult.data.whiteBackground?.other)
? upstreamResult.data.whiteBackground?.other || []
: [];
const externalImages = [
...fixedImages.map((image, index) =>
mapExternalImage({
keyword: normalizedKeyword,
matchedSku,
group: "fixed",
sortOrder: index,
image,
})
),
...otherImages.map((image, index) =>
mapExternalImage({
keyword: normalizedKeyword,
matchedSku,
group: "other",
sortOrder: fixedImages.length + index,
image,
})
),
].filter((item): item is MaterialSearchImage => Boolean(item));
if (externalImages.length === 0) {
return NextResponse.json(
{
success: false,
error: {
code: "IMAGE_URL_NOT_FOUND",
message: "已获取到素材记录,但未返回可用图片地址,请稍后重试",
},
},
{ status: 502 }
);
}
const recommendedImageIds = requiredCount
? await recommendMaterialImageIds({
images: externalImages,
requiredCount,
})
: [];
return NextResponse.json({
success: true,
data: {
query: normalizedKeyword,
queryType: "sku",
matchedSku,
matchedSpu: goodsInfo?.matchedSpu || productModel || deriveSpuFromSku(matchedSku),
materialId: String(upstreamResult.data.materialId || "").trim() || null,
productModel,
zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null,
fixedCount: externalImages.filter((item) => item.group === "fixed").length,
otherCount: externalImages.filter((item) => item.group === "other").length,
totalCount: externalImages.length,
images: externalImages,
recommendedImageIds,
skuOptions: [],
goodsInfo: goodsInfo
? {
productName: goodsInfo.productName,
sizeInfo: goodsInfo.sizeInfo,
materialInfo: goodsInfo.materialInfo,
designer: goodsInfo.designer,
buyer: goodsInfo.buyer,
boardType: goodsInfo.boardType,
}
: null,
},
});
} catch (error) {
console.error("[Agent Workflow Material Search] Error:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "搜索参数无效", details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "素材搜索失败,请稍后重试" },
{ status: 500 }
);
}
}