From 57261dad719e5848eda89866efcd03d71a368221 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 11:46:25 +0800 Subject: [PATCH] perf: use external material image urls --- .../agent-workflow/material-search/route.ts | 268 ++++++------------ 1 file changed, 82 insertions(+), 186 deletions(-) diff --git a/app/api/agent-workflow/material-search/route.ts b/app/api/agent-workflow/material-search/route.ts index 1da626478..476dbd22c 100644 --- a/app/api/agent-workflow/material-search/route.ts +++ b/app/api/agent-workflow/material-search/route.ts @@ -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 | 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) {