perf: use external material image urls
This commit is contained in:
parent
f31db01b8e
commit
57261dad71
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user