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