perf: use external material image urls

This commit is contained in:
Your Name 2026-06-01 11:48:29 +08:00
parent b1dc367bcf
commit 903cf5459e

View File

@ -5,13 +5,11 @@ 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, upsertMediaAsset } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import { analyzeWorkflowMaterialAppearanceFromImage } from "@/lib/agents/workflow-material-appearance";
import {
AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY,
normalizeAgentWorkflowGoodsInfoApiConfig,
@ -29,6 +27,7 @@ const querySchema = z.object({
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;
@ -76,6 +75,18 @@ type ExternalGoodsInfoResponse = {
data?: Record<string, unknown> | null;
};
type MaterialSearchImage = {
id: string;
key: string;
label: string;
fileName: string;
imageUrl: string;
assetId: string | null;
mimeType: string;
group: "fixed" | "other";
sortOrder: number;
};
function normalizeBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "");
}
@ -191,6 +202,7 @@ async function fetchGoodsInfoByKeyword(params: {
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") {
@ -467,18 +479,9 @@ async function analyzeFunctionalShowcaseImage(params: {
}
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;
}>;
images: MaterialSearchImage[];
requiredCount: number;
visionAnalysisEnabled?: boolean;
}) {
const requiredCount = Math.max(1, Math.min(4, Math.round(params.requiredCount)));
const selectedIds: string[] = [];
@ -517,7 +520,11 @@ async function recommendMaterialImageIds(params: {
}
const functionalRemainingNeedCount = requiredCount - selectedIds.length;
if (functionalRemainingNeedCount > 0 && otherImages.length > 0) {
if (
params.visionAnalysisEnabled === true &&
functionalRemainingNeedCount > 0 &&
otherImages.length > 0
) {
const analyzableImages = otherImages
.filter((image) => !selectedIds.includes(image.id))
.slice(0, 6);
@ -572,100 +579,40 @@ async function recommendMaterialImageIds(params: {
return selectedIds.slice(0, requiredCount);
}
function sanitizeFilenamePart(value: string) {
return String(value || "")
.trim()
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.slice(0, 80);
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 getExtensionFromMimeType(mimeType: string) {
const normalized = String(mimeType || "").toLowerCase();
if (normalized.includes("png")) return "png";
if (normalized.includes("webp")) return "webp";
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
if (normalized.includes("gif")) return "gif";
return "png";
}
function ensureFilenameWithExtension(filename: string, mimeType: string) {
const trimmed = sanitizeFilenamePart(filename) || `workflow-material-${Date.now()}`;
if (/\.[a-z0-9]+$/i.test(trimmed)) {
return trimmed;
}
return `${trimmed}.${getExtensionFromMimeType(mimeType)}`;
}
async function importExternalImage(params: {
userId: string;
function mapExternalImage(params: {
keyword: string;
source: "primary" | "secondary";
matchedSku: string;
productModel?: string | null;
group: "fixed" | "other";
sortOrder: number;
image: ExternalImageRecord;
}) {
}): MaterialSearchImage | null {
const downloadUrl = String(params.image.downloadUrl || "").trim();
if (!downloadUrl) return null;
const imageResponse = await fetch(downloadUrl, {
cache: "no-store",
redirect: "follow",
});
if (!imageResponse.ok) {
throw new Error(`下载图片失败(${imageResponse.status})`);
}
const imageBuffer = await imageResponse.arrayBuffer();
const mimeType =
String(imageResponse.headers.get("content-type") || "").split(";")[0].trim() || "image/png";
const filename = ensureFilenameWithExtension(
params.image.fileName ||
params.image.label ||
`${params.matchedSku || params.keyword}-${params.group}-${params.sortOrder + 1}`,
mimeType
);
const uploaded = await uploadFile(filename, imageBuffer);
let assetId: string | null = null;
try {
const asset = await upsertMediaAsset({
userId: params.userId,
mediaType: "image",
sourceType: "agent_workflow_material_search",
status: "ready",
originalUrl: uploaded.url,
previewUrl: uploaded.url,
thumbnailUrl: uploaded.url,
mimeType: uploaded.contentType || mimeType,
displayName: filename,
sourceTable: "agent_workflow_material_search",
metadata: {
keyword: params.keyword,
source: params.source,
matchedSku: params.matchedSku,
productModel: params.productModel || null,
group: params.group,
externalKey: String(params.image.key || "").trim() || null,
fileSize: Number(params.image.fileSize || 0) || null,
pathname: uploaded.pathname,
},
});
assetId = asset?.id ?? null;
} catch (error) {
console.warn("[Agent Workflow Material Search] Failed to register media asset:", error);
}
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: assetId || `${params.group}-${params.sortOrder + 1}`,
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: uploaded.url,
assetId,
mimeType: uploaded.contentType || mimeType,
imageUrl: downloadUrl,
assetId: null,
mimeType: inferMimeTypeFromFilename(filename),
group: params.group,
sortOrder: params.sortOrder,
};
@ -704,9 +651,6 @@ export async function GET(request: NextRequest) {
appearanceRecognitionEnabled:
request.nextUrl.searchParams.get("appearanceRecognitionEnabled") || undefined,
});
const appearanceRecognitionEnabled =
parsed.appearanceRecognitionEnabled !== "false" &&
parsed.appearanceRecognitionEnabled !== "0";
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
@ -731,6 +675,16 @@ export async function GET(request: NextRequest) {
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 = `${normalizeBaseUrl(
config.baseUrl
)}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`;
@ -802,16 +756,7 @@ export async function GET(request: NextRequest) {
const matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword;
const productModel = String(upstreamResult.data.productModel || "").trim() || null;
const goodsInfo =
queryType === "sku"
? await fetchGoodsInfoByKeyword({
keyword: normalizedKeyword,
config: goodsInfoConfig,
}).catch((error) => {
console.warn("[Agent Workflow Material Search] Goods info fetch failed:", error);
return null;
})
: null;
const goodsInfo = await goodsInfoPromise;
const requiredCount = Number.isFinite(Number(parsed.requiredCount))
? Math.max(1, Math.min(4, Math.round(Number(parsed.requiredCount))))
: null;
@ -822,62 +767,34 @@ export async function GET(request: NextRequest) {
? upstreamResult.data.whiteBackground?.other || []
: [];
const importedImages = (
await Promise.all([
...fixedImages.map((image, index) =>
importExternalImage({
userId: session.user.id,
keyword: normalizedKeyword,
source: parsed.source || "primary",
matchedSku,
productModel,
group: "fixed",
sortOrder: index,
image,
}).catch((error) => {
console.warn("[Agent Workflow Material Search] Fixed image import failed:", error);
return null;
})
),
...otherImages.map((image, index) =>
importExternalImage({
userId: session.user.id,
keyword: normalizedKeyword,
source: parsed.source || "primary",
matchedSku,
productModel,
group: "other",
sortOrder: fixedImages.length + index,
image,
}).catch((error) => {
console.warn("[Agent Workflow Material Search] Other image import failed:", error);
return null;
})
),
])
).filter(
(
item
): item is {
id: string;
key: string;
label: string;
fileName: string;
imageUrl: string;
assetId: string | null;
mimeType: string;
group: "fixed" | "other";
sortOrder: number;
} => Boolean(item)
);
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 (importedImages.length === 0) {
if (externalImages.length === 0) {
return NextResponse.json(
{
success: false,
error: {
code: "IMAGE_IMPORT_FAILED",
message: "已获取到素材记录,但图片导入失败,请稍后重试",
code: "IMAGE_URL_NOT_FOUND",
message: "已获取到素材记录,但未返回可用图片地址,请稍后重试",
},
},
{ status: 502 }
@ -886,26 +803,10 @@ export async function GET(request: NextRequest) {
const recommendedImageIds = requiredCount
? await recommendMaterialImageIds({
images: importedImages,
images: externalImages,
requiredCount,
})
: [];
const firstImageAppearanceSummary = appearanceRecognitionEnabled
? await analyzeWorkflowMaterialAppearanceFromImage(
importedImages[0]?.imageUrl || ""
).catch((error) => {
console.warn("[Agent Workflow Material Search] Appearance analysis failed:", error);
return null;
})
: null;
const annotatedImages = importedImages.map((image, index) =>
index === 0 && firstImageAppearanceSummary
? {
...image,
appearanceSummary: firstImageAppearanceSummary,
}
: image
);
return NextResponse.json({
success: true,
@ -917,10 +818,10 @@ export async function GET(request: NextRequest) {
materialId: String(upstreamResult.data.materialId || "").trim() || null,
productModel,
zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null,
fixedCount: annotatedImages.filter((item) => item.group === "fixed").length,
otherCount: annotatedImages.filter((item) => item.group === "other").length,
totalCount: annotatedImages.length,
images: annotatedImages,
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
@ -931,13 +832,8 @@ export async function GET(request: NextRequest) {
designer: goodsInfo.designer,
buyer: goodsInfo.buyer,
boardType: goodsInfo.boardType,
appearanceSummary: firstImageAppearanceSummary,
}
: firstImageAppearanceSummary
? {
appearanceSummary: firstImageAppearanceSummary,
}
: null,
: null,
},
});
} catch (error) {