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 { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getEnabledAiModelsWithProvider, getSystemSetting, upsertMediaAsset } from "@/lib/db/queries"; import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import { import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
normalizeAgentWorkflowMaterialApiConfig, normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config"; } from "@/lib/agents/workflow-material-config";
import { analyzeWorkflowMaterialAppearanceFromImage } from "@/lib/agents/workflow-material-appearance";
import { import {
AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY, AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY,
normalizeAgentWorkflowGoodsInfoApiConfig, normalizeAgentWorkflowGoodsInfoApiConfig,
@ -29,6 +27,7 @@ const querySchema = z.object({
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i; const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
const FUNCTIONAL_IMAGE_HINT_REGEX = /开门|开柜|柜门|抽屉|打开|开合|功能|展示|收纳|储物|细节/i; const FUNCTIONAL_IMAGE_HINT_REGEX = /开门|开柜|柜门|抽屉|打开|开合|功能|展示|收纳|储物|细节/i;
const GOODS_INFO_FETCH_TIMEOUT_MS = 1500;
type ExternalImageRecord = { type ExternalImageRecord = {
key?: string; key?: string;
@ -76,6 +75,18 @@ type ExternalGoodsInfoResponse = {
data?: Record<string, unknown> | null; 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) { function normalizeBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, ""); return String(value || "").trim().replace(/\/+$/, "");
} }
@ -191,6 +202,7 @@ async function fetchGoodsInfoByKeyword(params: {
method: "GET", method: "GET",
headers: commonHeaders, headers: commonHeaders,
cache: "no-store", cache: "no-store",
signal: AbortSignal.timeout(GOODS_INFO_FETCH_TIMEOUT_MS),
}); });
const result = (await response.json().catch(() => ({}))) as ExternalGoodsInfoResponse; const result = (await response.json().catch(() => ({}))) as ExternalGoodsInfoResponse;
if (response.ok && result.success === true && result.data && typeof result.data === "object") { if (response.ok && result.success === true && result.data && typeof result.data === "object") {
@ -467,18 +479,9 @@ async function analyzeFunctionalShowcaseImage(params: {
} }
async function recommendMaterialImageIds(params: { async function recommendMaterialImageIds(params: {
images: Array<{ images: MaterialSearchImage[];
id: string;
key: string;
label: string;
fileName: string;
imageUrl: string;
assetId: string | null;
mimeType: string;
group: "fixed" | "other";
sortOrder: number;
}>;
requiredCount: number; requiredCount: number;
visionAnalysisEnabled?: boolean;
}) { }) {
const requiredCount = Math.max(1, Math.min(4, Math.round(params.requiredCount))); const requiredCount = Math.max(1, Math.min(4, Math.round(params.requiredCount)));
const selectedIds: string[] = []; const selectedIds: string[] = [];
@ -517,7 +520,11 @@ async function recommendMaterialImageIds(params: {
} }
const functionalRemainingNeedCount = requiredCount - selectedIds.length; const functionalRemainingNeedCount = requiredCount - selectedIds.length;
if (functionalRemainingNeedCount > 0 && otherImages.length > 0) { if (
params.visionAnalysisEnabled === true &&
functionalRemainingNeedCount > 0 &&
otherImages.length > 0
) {
const analyzableImages = otherImages const analyzableImages = otherImages
.filter((image) => !selectedIds.includes(image.id)) .filter((image) => !selectedIds.includes(image.id))
.slice(0, 6); .slice(0, 6);
@ -572,100 +579,40 @@ async function recommendMaterialImageIds(params: {
return selectedIds.slice(0, requiredCount); return selectedIds.slice(0, requiredCount);
} }
function sanitizeFilenamePart(value: string) { function inferMimeTypeFromFilename(filename: string) {
return String(value || "") const normalized = String(filename || "").toLowerCase();
.trim() if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) return "image/jpeg";
.replace(/[\\/:*?"<>|]+/g, "-") if (normalized.endsWith(".webp")) return "image/webp";
.replace(/\s+/g, "-") if (normalized.endsWith(".gif")) return "image/gif";
.slice(0, 80); return "image/png";
} }
function getExtensionFromMimeType(mimeType: string) { function mapExternalImage(params: {
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;
keyword: string; keyword: string;
source: "primary" | "secondary";
matchedSku: string; matchedSku: string;
productModel?: string | null;
group: "fixed" | "other"; group: "fixed" | "other";
sortOrder: number; sortOrder: number;
image: ExternalImageRecord; image: ExternalImageRecord;
}) { }): MaterialSearchImage | null {
const downloadUrl = String(params.image.downloadUrl || "").trim(); const downloadUrl = String(params.image.downloadUrl || "").trim();
if (!downloadUrl) return null; if (!downloadUrl) return null;
const filename =
const imageResponse = await fetch(downloadUrl, { String(params.image.fileName || "").trim() ||
cache: "no-store", String(params.image.label || "").trim() ||
redirect: "follow", `${params.matchedSku || params.keyword}-${params.group}-${params.sortOrder + 1}.png`;
}); const identityHash = createHash("sha1")
if (!imageResponse.ok) { .update(`${params.group}:${params.sortOrder}:${downloadUrl}`, "utf8")
throw new Error(`下载图片失败(${imageResponse.status})`); .digest("hex")
} .slice(0, 12);
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);
}
return { 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}`, key: String(params.image.key || "").trim() || `${params.group}-${params.sortOrder + 1}`,
label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`, label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`,
fileName: filename, fileName: filename,
imageUrl: uploaded.url, imageUrl: downloadUrl,
assetId, assetId: null,
mimeType: uploaded.contentType || mimeType, mimeType: inferMimeTypeFromFilename(filename),
group: params.group, group: params.group,
sortOrder: params.sortOrder, sortOrder: params.sortOrder,
}; };
@ -704,9 +651,6 @@ export async function GET(request: NextRequest) {
appearanceRecognitionEnabled: appearanceRecognitionEnabled:
request.nextUrl.searchParams.get("appearanceRecognitionEnabled") || undefined, 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 setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value); const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
@ -731,6 +675,16 @@ export async function GET(request: NextRequest) {
const normalizedKeyword = parsed.keyword.trim().toUpperCase(); const normalizedKeyword = parsed.keyword.trim().toUpperCase();
const queryType = parsed.queryType === "spu" ? "spu" : "sku"; 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( const upstreamUrl = `${normalizeBaseUrl(
config.baseUrl config.baseUrl
)}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`; )}/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 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 goodsInfo = const goodsInfo = await goodsInfoPromise;
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 requiredCount = Number.isFinite(Number(parsed.requiredCount)) const requiredCount = Number.isFinite(Number(parsed.requiredCount))
? Math.max(1, Math.min(4, Math.round(Number(parsed.requiredCount)))) ? Math.max(1, Math.min(4, Math.round(Number(parsed.requiredCount))))
: null; : null;
@ -822,62 +767,34 @@ export async function GET(request: NextRequest) {
? upstreamResult.data.whiteBackground?.other || [] ? upstreamResult.data.whiteBackground?.other || []
: []; : [];
const importedImages = ( const externalImages = [
await Promise.all([ ...fixedImages.map((image, index) =>
...fixedImages.map((image, index) => mapExternalImage({
importExternalImage({ keyword: normalizedKeyword,
userId: session.user.id, matchedSku,
keyword: normalizedKeyword, group: "fixed",
source: parsed.source || "primary", sortOrder: index,
matchedSku, image,
productModel, })
group: "fixed", ),
sortOrder: index, ...otherImages.map((image, index) =>
image, mapExternalImage({
}).catch((error) => { keyword: normalizedKeyword,
console.warn("[Agent Workflow Material Search] Fixed image import failed:", error); matchedSku,
return null; group: "other",
}) sortOrder: fixedImages.length + index,
), image,
...otherImages.map((image, index) => })
importExternalImage({ ),
userId: session.user.id, ].filter((item): item is MaterialSearchImage => Boolean(item));
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)
);
if (importedImages.length === 0) { if (externalImages.length === 0) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: { error: {
code: "IMAGE_IMPORT_FAILED", code: "IMAGE_URL_NOT_FOUND",
message: "已获取到素材记录,但图片导入失败,请稍后重试", message: "已获取到素材记录,但未返回可用图片地址,请稍后重试",
}, },
}, },
{ status: 502 } { status: 502 }
@ -886,26 +803,10 @@ export async function GET(request: NextRequest) {
const recommendedImageIds = requiredCount const recommendedImageIds = requiredCount
? await recommendMaterialImageIds({ ? await recommendMaterialImageIds({
images: importedImages, images: externalImages,
requiredCount, 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({ return NextResponse.json({
success: true, success: true,
@ -917,10 +818,10 @@ export async function GET(request: NextRequest) {
materialId: String(upstreamResult.data.materialId || "").trim() || null, materialId: String(upstreamResult.data.materialId || "").trim() || null,
productModel, productModel,
zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null, zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null,
fixedCount: annotatedImages.filter((item) => item.group === "fixed").length, fixedCount: externalImages.filter((item) => item.group === "fixed").length,
otherCount: annotatedImages.filter((item) => item.group === "other").length, otherCount: externalImages.filter((item) => item.group === "other").length,
totalCount: annotatedImages.length, totalCount: externalImages.length,
images: annotatedImages, images: externalImages,
recommendedImageIds, recommendedImageIds,
skuOptions: [], skuOptions: [],
goodsInfo: goodsInfo goodsInfo: goodsInfo
@ -931,13 +832,8 @@ export async function GET(request: NextRequest) {
designer: goodsInfo.designer, designer: goodsInfo.designer,
buyer: goodsInfo.buyer, buyer: goodsInfo.buyer,
boardType: goodsInfo.boardType, boardType: goodsInfo.boardType,
appearanceSummary: firstImageAppearanceSummary,
} }
: firstImageAppearanceSummary : null,
? {
appearanceSummary: firstImageAppearanceSummary,
}
: null,
}, },
}); });
} catch (error) { } catch (error) {