From 5fc5a5e47c5df5da583ac1cd57b9fc5cc445eed0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 22 Mar 2026 19:43:31 +0800 Subject: [PATCH] feat: automate agent material selection flow --- app/(chat)/api/chat/route.ts | 267 +++++++++++- .../agent-workflow/material-search/route.ts | 385 +++++++++++++++++- .../agent-workflow/reference-search/route.ts | 62 ++- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 60 ++- .../agents/AgentWorkflowMaterialPicker.tsx | 4 +- .../AgentWorkflowMaterialSelectionPanel.tsx | 161 ++------ lib/agents/workflow-material-config.ts | 10 + types/agent.ts | 1 + 8 files changed, 785 insertions(+), 165 deletions(-) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 6b12b6148..09963665e 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -2822,6 +2822,19 @@ function buildAgentSlotPrompt(params: { type AgentReplacementModePayload = NonNullable; +type ReplacementReferenceDecision = { + shouldReplace: boolean; + containsFurnitureProduct: boolean; + reason: string; + detectionModelName?: string | null; + detectionSource: + | "configured_qwen3_vl" + | "fallback_qwen3_vl" + | "safe_passthrough"; +}; + +const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i; + function normalizeAgentModeReferences( payload?: PostRequestBody["agentModePayload"], ): AgentReplacementModePayload["references"] { @@ -2843,6 +2856,222 @@ function normalizeAgentModeReferences( .sort((left, right) => left.sortOrder - right.sortOrder); } +function normalizeBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") { + return value; + } + + const normalized = String(value || "") + .trim() + .toLowerCase(); + if (!normalized) { + return null; + } + + if ( + normalized === "true" || + normalized === "yes" || + normalized === "y" || + normalized === "1" || + normalized === "是" + ) { + return true; + } + + if ( + normalized === "false" || + normalized === "no" || + normalized === "n" || + normalized === "0" || + normalized === "否" + ) { + return false; + } + + return null; +} + +function extractJsonObjectText(text: string): string | null { + const normalized = String(text || "").trim(); + if (!normalized) { + return null; + } + + const fencedMatch = normalized.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fencedMatch?.[1]) { + const fencedContent = fencedMatch[1].trim(); + if (fencedContent.startsWith("{") && fencedContent.endsWith("}")) { + return fencedContent; + } + } + + if (normalized.startsWith("{") && normalized.endsWith("}")) { + return normalized; + } + + const firstBraceIndex = normalized.indexOf("{"); + const lastBraceIndex = normalized.lastIndexOf("}"); + if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) { + return normalized.slice(firstBraceIndex, lastBraceIndex + 1); + } + + return null; +} + +function parseReplacementReferenceDecision( + text: string, +): Omit | null { + const jsonText = extractJsonObjectText(text); + if (jsonText) { + try { + const parsed = JSON.parse(jsonText) as Record; + const shouldReplace = + normalizeBooleanLike(parsed.shouldReplace) ?? + normalizeBooleanLike(parsed.replace) ?? + normalizeBooleanLike(parsed.isReplaceable); + const containsFurnitureProduct = + normalizeBooleanLike(parsed.containsFurnitureProduct) ?? + normalizeBooleanLike(parsed.hasFurnitureProduct) ?? + shouldReplace; + const reason = String(parsed.reason || parsed.summary || parsed.note || "") + .trim() + .slice(0, 80); + + if (typeof shouldReplace === "boolean") { + return { + shouldReplace, + containsFurnitureProduct: Boolean(containsFurnitureProduct), + reason: + reason || + (shouldReplace + ? "检测到可替换的家具主体,继续执行替换" + : "未检测到可替换的家具主体,直接沿用参考图"), + }; + } + } catch (error) { + console.warn("[Chat API] Failed to parse replacement reference decision JSON:", error); + } + } + + const normalized = String(text || "") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) { + return null; + } + + const shouldReplace = /shouldreplace["']?\s*[:=]\s*true|可替换|应该替换|存在可替换|检测到.*家具主体|yes/i.test( + normalized, + ); + const shouldKeep = /shouldreplace["']?\s*[:=]\s*false|不应替换|不要替换|沿用参考图|未检测到.*家具主体|无可替换|not replace|no/i.test( + normalized, + ); + + if (!shouldReplace && !shouldKeep) { + return null; + } + + return { + shouldReplace, + containsFurnitureProduct: shouldReplace, + reason: normalized.slice(0, 80), + }; +} + +async function analyzeReplacementReferenceImage(params: { + imageUrl: string; + models?: Awaited>; +}): Promise { + const detectionPrompt = [ + "你是家具商品参考图替换判定器。", + "请判断这张图片里,是否存在清晰、主要、适合被产品替换流程替换的家具商品主体。", + "家具主体包含:床、床垫、沙发、椅子、餐桌、茶几、柜子、电视柜、斗柜、床头柜、书桌等。", + "只有当家具商品是主要视觉主体、轮廓清晰、不是单纯环境陪衬时,shouldReplace 才能为 true。", + "以下情况必须判定 shouldReplace=false:纯文字页、参数说明页、品牌介绍页、材质/五金局部特写、只有模特没有家具、只有空间环境没有明确家具主体、家具很小只是背景、主体过多无法明确替换对象。", + '只输出 JSON,不要输出额外解释。格式:{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字"}', + ].join("\n"); + + const configuredModels = params.models || (await getEnabledAiModelsWithProvider()); + const configuredQwenVisionModel = configuredModels.find( + ({ model }) => + model.supportsVision && + !model.isSubModel && + QWEN_VL_MODEL_MATCHER.test(`${model.displayName} ${model.modelId}`), + ); + + if (configuredQwenVisionModel) { + try { + const languageModel = await getLanguageModelById(configuredQwenVisionModel.model.id); + if (languageModel) { + const { text } = await generateText({ + model: languageModel, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: detectionPrompt, + }, + { + type: "image", + image: params.imageUrl, + }, + ], + }, + ], + temperature: 0, + maxTokens: 160, + abortSignal: AbortSignal.timeout(45_000), + } as any); + + const parsed = parseReplacementReferenceDecision(text); + if (parsed) { + return { + ...parsed, + detectionModelName: + configuredQwenVisionModel.model.displayName || + configuredQwenVisionModel.model.modelId, + detectionSource: "configured_qwen3_vl", + }; + } + } + } catch (error) { + console.warn("[Chat API] Configured Qwen-VL reference analysis failed:", error); + } + } + + try { + const { callQwen3Vl } = await import("@/lib/ai/qwen3-vl"); + const fallbackResult = await callQwen3Vl({ + imageUrl: params.imageUrl, + prompt: detectionPrompt, + temperature: 0, + maxTokens: 160, + timeoutMs: 45_000, + retries: 1, + }); + const parsed = parseReplacementReferenceDecision(fallbackResult.text); + if (parsed) { + return { + ...parsed, + detectionModelName: fallbackResult.model, + detectionSource: "fallback_qwen3_vl", + }; + } + } catch (error) { + console.warn("[Chat API] Fallback Qwen3-VL reference analysis failed:", error); + } + + return { + shouldReplace: false, + containsFurnitureProduct: false, + reason: "视觉判断失败,为避免误替换,直接沿用参考图", + detectionModelName: null, + detectionSource: "safe_passthrough", + }; +} + function buildAgentReplacementPrompt(params: { basePrompt: string; modeType: "detail_replacement" | "sku_replacement"; @@ -5776,8 +6005,18 @@ export async function POST(request: Request) { originalUrl: string; thumbnailUrl?: string | null; }> = []; + let generatedReplacementCount = 0; let resolvedModelDisplayName = selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.name; + const replacementVisionModels = await getEnabledAiModelsWithProvider().catch( + (error) => { + console.warn( + "[Chat API] Failed to load configured vision models for replacement analysis:", + error, + ); + return undefined; + }, + ); const stopKeepalive = startStreamKeepalive( writer, "agent-preset:replacement", @@ -5799,6 +6038,31 @@ export async function POST(request: Request) { pairedProductIndex !== undefined ? [productInputImageUrls[pairedProductIndex]!] : productInputImageUrls; + const referenceDecision = await analyzeReplacementReferenceImage({ + imageUrl: reference.imageUrl, + models: replacementVisionModels, + }); + + if (!referenceDecision.shouldReplace) { + replacementResultImages.push({ + originalUrl: reference.imageUrl, + thumbnailUrl: reference.imageUrl, + }); + replacementSections.push( + `### ${reference.label}\n\n${referenceDecision.reason}\n\n${buildAgentToolImagesMarkdown( + [ + { + originalUrl: reference.imageUrl, + thumbnailUrl: reference.imageUrl, + imageSize: resolvedImageSize, + }, + ], + )}`, + ); + successCount += 1; + continue; + } + const sourceImageUrls = Array.from( new Set( [ @@ -5852,6 +6116,7 @@ export async function POST(request: Request) { ])}`, ); successCount += 1; + generatedReplacementCount += 1; } catch (replacementItemError) { console.error("[Chat API] Agent replacement item execution failed:", { referenceIndex, @@ -5878,7 +6143,7 @@ export async function POST(request: Request) { ] .filter(Boolean) .join("\n\n"), - resolvedModelDisplayName, + generatedReplacementCount > 0 ? resolvedModelDisplayName : null, ); await saveMessages({ diff --git a/app/api/agent-workflow/material-search/route.ts b/app/api/agent-workflow/material-search/route.ts index 4832eb2fa..d1163520a 100644 --- a/app/api/agent-workflow/material-search/route.ts +++ b/app/api/agent-workflow/material-search/route.ts @@ -1,7 +1,9 @@ +import { generateText } from "ai"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; -import { getSystemSetting, upsertMediaAsset } from "@/lib/db/queries"; +import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; +import { getEnabledAiModelsWithProvider, getSystemSetting, upsertMediaAsset } from "@/lib/db/queries"; import { uploadFile } from "@/lib/storage/service"; import { AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, @@ -14,8 +16,12 @@ const querySchema = z.object({ keyword: z.string().trim().min(1).max(64), source: z.enum(["primary", "secondary"]).optional(), queryType: z.enum(["sku", "spu"]).optional(), + requiredCount: z.coerce.number().int().min(1).max(4).optional(), }); +const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i; +const FUNCTIONAL_IMAGE_HINT_REGEX = /开门|开柜|柜门|抽屉|打开|开合|功能|展示|收纳|储物|细节/i; + type ExternalImageRecord = { key?: string; label?: string; @@ -60,6 +66,371 @@ function normalizeBaseUrl(value: string) { return String(value || "").trim().replace(/\/+$/, ""); } +function normalizeImageIdentityText(...values: Array) { + return values + .map((value) => String(value || "").trim().toLowerCase().replace(/\s+/g, "")) + .filter(Boolean) + .join(" "); +} + +function normalizeBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") { + return value; + } + + const normalized = String(value || "").trim().toLowerCase(); + if (!normalized) { + return null; + } + + if (["true", "yes", "y", "1", "是"].includes(normalized)) { + return true; + } + + if (["false", "no", "n", "0", "否"].includes(normalized)) { + return false; + } + + return null; +} + +function extractJsonObjectText(text: string): string | null { + const normalized = String(text || "").trim(); + if (!normalized) { + return null; + } + + const fencedMatch = normalized.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fencedMatch?.[1]) { + const fencedText = fencedMatch[1].trim(); + if (fencedText.startsWith("{") && fencedText.endsWith("}")) { + return fencedText; + } + } + + if (normalized.startsWith("{") && normalized.endsWith("}")) { + return normalized; + } + + const firstBraceIndex = normalized.indexOf("{"); + const lastBraceIndex = normalized.lastIndexOf("}"); + if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) { + return normalized.slice(firstBraceIndex, lastBraceIndex + 1); + } + + return null; +} + +function parseFunctionalShowcaseDecision(text: string): { + isFunctionalShowcase: boolean; + score: number; +} | null { + const jsonText = extractJsonObjectText(text); + if (jsonText) { + try { + const parsed = JSON.parse(jsonText) as Record; + const isFunctionalShowcase = + normalizeBooleanLike(parsed.isFunctionalShowcase) ?? + normalizeBooleanLike(parsed.isFunctional) ?? + normalizeBooleanLike(parsed.shouldUse); + const score = Number(parsed.score); + + if (typeof isFunctionalShowcase === "boolean") { + return { + isFunctionalShowcase, + score: Number.isFinite(score) + ? Math.max(0, Math.min(100, Math.round(score))) + : isFunctionalShowcase + ? 80 + : 0, + }; + } + } catch (error) { + console.warn("[Agent Workflow Material Search] Failed to parse functional showcase JSON:", error); + } + } + + const normalized = String(text || "").replace(/\s+/g, " ").trim(); + if (!normalized) { + return null; + } + + const positive = /功能图|功能展示|打开图|开门图|开柜图|抽屉图|展示图|适合作为第4张|可作为d档|yes|true/i.test( + normalized, + ); + const negative = /不是功能图|不适合|非功能展示|no|false/i.test(normalized); + + if (!positive && !negative) { + return null; + } + + return { + isFunctionalShowcase: positive && !negative, + score: positive && !negative ? 75 : 0, + }; +} + +function getFixedAnglePriority(image: { + key: string; + label: string; + fileName: string; + group: "fixed" | "other"; +}) { + if (image.group !== "fixed") { + return null; + } + + const normalized = normalizeImageIdentityText(image.key, image.label, image.fileName); + if (normalized.includes("leftfront45") || /左前45/.test(normalized)) { + return "A" as const; + } + if (normalized.includes("rightfront45") || /右前45/.test(normalized)) { + return "B" as const; + } + if (normalized === "front" || normalized.includes(" front") || /正视|正面/.test(normalized)) { + return "C" as const; + } + return null; +} + +function getFallbackFixedAngleScore(image: { + key: string; + label: string; + fileName: string; + group: "fixed" | "other"; +}) { + if (image.group !== "fixed") { + return -1; + } + + const normalized = normalizeImageIdentityText(image.key, image.label, image.fileName); + if (normalized.includes("left") && !normalized.includes("front") && !normalized.includes("back")) { + return 60; + } + if (normalized.includes("right") && !normalized.includes("front") && !normalized.includes("back")) { + return 58; + } + if (normalized.includes("leftback45") || /左后45/.test(normalized)) { + return 52; + } + if (normalized.includes("rightback45") || /右后45/.test(normalized)) { + return 50; + } + if (normalized.includes("back") || /背面/.test(normalized)) { + return 46; + } + return 20; +} + +function getFunctionalHintScore(image: { + key: string; + label: string; + fileName: string; + group: "fixed" | "other"; +}) { + const normalized = normalizeImageIdentityText(image.key, image.label, image.fileName); + if (FUNCTIONAL_IMAGE_HINT_REGEX.test(normalized)) { + return 85; + } + return image.group === "other" ? 10 : 0; +} + +async function analyzeFunctionalShowcaseImage(params: { + imageUrl: string; + models?: Awaited>; +}) { + const prompt = [ + "你是白底图角度与功能展示判定器。", + "请判断这张白底产品图是否适合作为第4优先级的功能展示图。", + "功能展示图指:柜门打开、抽屉拉开、功能结构展示、收纳展示、机构展示等。", + "普通角度白底图、纯正面图、纯45度图、纯侧面图,都不是功能展示图。", + '只输出 JSON:{"isFunctionalShowcase":true,"score":92,"reason":"不超过20字"}', + ].join("\n"); + + const configuredModels = Array.isArray(params.models) + ? params.models + : await getEnabledAiModelsWithProvider().catch((error) => { + console.warn( + "[Agent Workflow Material Search] Failed to load configured Qwen-VL model list:", + error, + ); + return []; + }); + const configuredQwenVisionModel = configuredModels.find( + ({ model }) => + model.supportsVision && + !model.isSubModel && + QWEN_VL_MODEL_MATCHER.test(`${model.displayName} ${model.modelId}`), + ); + + if (configuredQwenVisionModel) { + try { + const languageModel = await getLanguageModelById(configuredQwenVisionModel.model.id); + if (languageModel) { + const { text } = await generateText({ + model: languageModel, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: prompt, + }, + { + type: "image", + image: params.imageUrl, + }, + ], + }, + ], + temperature: 0, + maxTokens: 120, + abortSignal: AbortSignal.timeout(25_000), + } as any); + + const parsed = parseFunctionalShowcaseDecision(text); + if (parsed) { + return parsed; + } + } + } catch (error) { + console.warn("[Agent Workflow Material Search] Configured Qwen-VL material analysis failed:", error); + } + } + + try { + const { callQwen3Vl } = await import("@/lib/ai/qwen3-vl"); + const result = await callQwen3Vl({ + imageUrl: params.imageUrl, + prompt, + temperature: 0, + maxTokens: 120, + timeoutMs: 25_000, + retries: 1, + }); + return ( + parseFunctionalShowcaseDecision(result.text) || { + isFunctionalShowcase: false, + score: 0, + } + ); + } catch (error) { + console.warn("[Agent Workflow Material Search] Fallback Qwen-VL material analysis failed:", error); + } + + return { + isFunctionalShowcase: false, + score: 0, + }; +} + +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; + }>; + requiredCount: number; +}) { + const requiredCount = Math.max(1, Math.min(4, Math.round(params.requiredCount))); + const selectedIds: string[] = []; + const fixedImages = params.images.filter((image) => image.group === "fixed"); + const otherImages = params.images.filter((image) => image.group === "other"); + + const pushImage = (image?: { id: string } | null) => { + if (!image?.id) return; + if (!selectedIds.includes(image.id)) { + selectedIds.push(image.id); + } + }; + + pushImage(fixedImages.find((image) => getFixedAnglePriority(image) === "A")); + if (selectedIds.length >= requiredCount) return selectedIds.slice(0, requiredCount); + + pushImage(fixedImages.find((image) => getFixedAnglePriority(image) === "B")); + if (selectedIds.length >= requiredCount) return selectedIds.slice(0, requiredCount); + + pushImage(fixedImages.find((image) => getFixedAnglePriority(image) === "C")); + if (selectedIds.length >= requiredCount) return selectedIds.slice(0, requiredCount); + + const remainingNeedCount = requiredCount - selectedIds.length; + if (remainingNeedCount > 0 && otherImages.length > 0) { + const heuristicFunctionalCandidates = otherImages + .map((image) => ({ + image, + score: getFunctionalHintScore(image), + })) + .filter((item) => item.score >= 80) + .sort((left, right) => right.score - left.score || left.image.sortOrder - right.image.sortOrder); + + heuristicFunctionalCandidates + .slice(0, remainingNeedCount) + .forEach((item) => pushImage(item.image)); + } + + const functionalRemainingNeedCount = requiredCount - selectedIds.length; + if (functionalRemainingNeedCount > 0 && otherImages.length > 0) { + const analyzableImages = otherImages + .filter((image) => !selectedIds.includes(image.id)) + .slice(0, 6); + + if (analyzableImages.length > 0) { + const configuredModels = await getEnabledAiModelsWithProvider().catch((error) => { + console.warn("[Agent Workflow Material Search] Failed to load vision models:", error); + return undefined; + }); + const analyzedCandidates = await Promise.all( + analyzableImages.map(async (image) => { + const analyzed = await analyzeFunctionalShowcaseImage({ + imageUrl: image.imageUrl, + models: configuredModels, + }); + return { + image, + score: analyzed.isFunctionalShowcase ? analyzed.score : 0, + }; + }), + ); + + analyzedCandidates + .filter((item) => item.score > 0) + .sort((left, right) => right.score - left.score || left.image.sortOrder - right.image.sortOrder) + .slice(0, functionalRemainingNeedCount) + .forEach((item) => pushImage(item.image)); + } + } + + const stillNeedCount = requiredCount - selectedIds.length; + if (stillNeedCount > 0) { + const remainingFixedImages = fixedImages + .filter((image) => !selectedIds.includes(image.id)) + .sort( + (left, right) => + getFallbackFixedAngleScore(right) - getFallbackFixedAngleScore(left) || + left.sortOrder - right.sortOrder, + ); + remainingFixedImages.slice(0, stillNeedCount).forEach((image) => pushImage(image)); + } + + const finalNeedCount = requiredCount - selectedIds.length; + if (finalNeedCount > 0) { + params.images + .filter((image) => !selectedIds.includes(image.id)) + .sort((left, right) => left.sortOrder - right.sortOrder) + .slice(0, finalNeedCount) + .forEach((image) => pushImage(image)); + } + + return selectedIds.slice(0, requiredCount); +} + function sanitizeFilenamePart(value: string) { return String(value || "") .trim() @@ -188,6 +559,7 @@ export async function GET(request: NextRequest) { keyword: request.nextUrl.searchParams.get("keyword"), source: request.nextUrl.searchParams.get("source"), queryType: request.nextUrl.searchParams.get("queryType"), + requiredCount: request.nextUrl.searchParams.get("requiredCount") || undefined, }); const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY }); @@ -277,6 +649,9 @@ export async function GET(request: NextRequest) { const matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword; const productModel = String(upstreamResult.data.productModel || "").trim() || null; + const requiredCount = Number.isFinite(Number(parsed.requiredCount)) + ? Math.max(1, Math.min(4, Math.round(Number(parsed.requiredCount)))) + : null; const fixedImages = Array.isArray(upstreamResult.data.whiteBackground?.fixed) ? upstreamResult.data.whiteBackground?.fixed || [] : []; @@ -346,6 +721,13 @@ export async function GET(request: NextRequest) { ); } + const recommendedImageIds = requiredCount + ? await recommendMaterialImageIds({ + images: importedImages, + requiredCount, + }) + : []; + return NextResponse.json({ success: true, data: { @@ -360,6 +742,7 @@ export async function GET(request: NextRequest) { otherCount: importedImages.filter((item) => item.group === "other").length, totalCount: importedImages.length, images: importedImages, + recommendedImageIds, skuOptions: [], }, }); diff --git a/app/api/agent-workflow/reference-search/route.ts b/app/api/agent-workflow/reference-search/route.ts index 96e280320..5dfd10070 100644 --- a/app/api/agent-workflow/reference-search/route.ts +++ b/app/api/agent-workflow/reference-search/route.ts @@ -18,6 +18,7 @@ const querySchema = z.object({ keyword: z.string().trim().min(1).max(64), queryType: z.enum(["sku", "spu"]).default("spu"), target: z.enum(["main", "detail", "sku"]), + storeName: z.string().trim().max(64).optional(), }); type ExternalTaskDetailCandidate = { @@ -58,6 +59,13 @@ function normalizeBaseUrl(value: string) { return String(value || "").trim().replace(/\/+$/, ""); } +function normalizeShopName(value: unknown) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/\s+/g, ""); +} + function normalizeUrlList(value: unknown) { if (!Array.isArray(value)) return [] as string[]; return Array.from( @@ -156,6 +164,26 @@ function sortCandidatesByUploadedAtDesc(candidates: AgentModeReferenceCandidate[ }); } +function isCandidateMatchedStore( + candidate: AgentModeReferenceCandidate, + storeName?: string +) { + const normalizedRequestedStore = normalizeShopName(storeName); + if (!normalizedRequestedStore) return true; + + const normalizedShopName = normalizeShopName(candidate.shopName); + const normalizedCategoryName = normalizeShopName(candidate.categoryName); + + return ( + normalizedShopName === normalizedRequestedStore || + normalizedCategoryName === normalizedRequestedStore || + (Boolean(normalizedShopName) && normalizedShopName.includes(normalizedRequestedStore)) || + (Boolean(normalizedShopName) && normalizedRequestedStore.includes(normalizedShopName)) || + (Boolean(normalizedCategoryName) && normalizedCategoryName.includes(normalizedRequestedStore)) || + (Boolean(normalizedCategoryName) && normalizedRequestedStore.includes(normalizedCategoryName)) + ); +} + export async function GET(request: NextRequest) { try { const session = await auth(); @@ -167,6 +195,7 @@ export async function GET(request: NextRequest) { keyword: request.nextUrl.searchParams.get("keyword"), queryType: request.nextUrl.searchParams.get("queryType") || "spu", target: request.nextUrl.searchParams.get("target"), + storeName: request.nextUrl.searchParams.get("storeName") || undefined, }); const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY }); @@ -218,27 +247,38 @@ export async function GET(request: NextRequest) { ); } + const requestedStoreName = String(parsed.storeName || "").trim(); const normalizedCandidates = sortCandidatesByUploadedAtDesc( (upstreamResult.data.candidates || []).map((item, index) => normalizeCandidate(item, index)) - ).filter((candidate) => getCandidateTargetUrls(candidate, parsed.target).length > 0); + ) + .filter((candidate) => isCandidateMatchedStore(candidate, requestedStoreName)) + .filter((candidate) => getCandidateTargetUrls(candidate, parsed.target).length > 0); if (normalizedCandidates.length === 0) { return NextResponse.json( { success: false, error: { - code: - parsed.target === "main" + code: requestedStoreName + ? "STORE_REFERENCE_NOT_FOUND" + : parsed.target === "main" ? "MAIN_REFERENCE_NOT_FOUND" : parsed.target === "detail" - ? "DETAIL_REFERENCE_NOT_FOUND" - : "SKU_REFERENCE_NOT_FOUND", - message: - parsed.target === "main" + ? "DETAIL_REFERENCE_NOT_FOUND" + : "SKU_REFERENCE_NOT_FOUND", + message: requestedStoreName + ? `当前店铺「${requestedStoreName}」下没有可用的${ + parsed.target === "main" + ? "主图参考图" + : parsed.target === "detail" + ? "详情参考图" + : "SKU参考图" + }` + : parsed.target === "main" ? "当前整套素材中没有可用的主图参考图" : parsed.target === "detail" - ? "当前整套素材中没有可用的详情参考图" - : "当前整套素材中没有可用的 SKU 参考图", + ? "当前整套素材中没有可用的详情参考图" + : "当前整套素材中没有可用的 SKU 参考图", }, }, { status: 404 } @@ -259,8 +299,8 @@ export async function GET(request: NextRequest) { target: parsed.target, matchedSpu: String(upstreamResult.data.spu || "").trim() || null, matchedSku: String(upstreamResult.data.sku || "").trim() || null, - categoryName: String(upstreamResult.data.categoryName || "").trim() || null, - shopName: String(upstreamResult.data.shopName || "").trim() || null, + categoryName: selectedCandidate.categoryName || String(upstreamResult.data.categoryName || "").trim() || null, + shopName: selectedCandidate.shopName || String(upstreamResult.data.shopName || "").trim() || null, candidateCount: normalizedCandidates.length, candidates: normalizedCandidates, selectedCandidateId: selectedCandidate.id, diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 6822919be..a3f448eed 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -566,6 +566,7 @@ export function AgentGenerationWorkspaceDarkV2({ const skuReferenceTarget = presetModeConfig.sku.referenceImageSource; const detailReferenceSourceLabel = getTaskDetailReferenceSourceLabel(detailReferenceTarget); const skuReferenceSourceLabel = getTaskDetailReferenceSourceLabel(skuReferenceTarget); + const workflowStoreName = String(workflowContext?.store || "").trim(); const shouldAllowModelOverride = !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride; const selectedProductAttachments = attachments.filter((item, index) => modelSlotIndex >= 0 ? index !== modelSlotIndex && Boolean(item) : Boolean(item) @@ -1461,6 +1462,10 @@ export function AgentGenerationWorkspaceDarkV2({ ); const handleDetailReplacementSearch = useCallback(async () => { const normalized = detailReplacementQueryInput.trim().toUpperCase(); + if (!workflowStoreName) { + toast.error("请先在第一步完成品牌和店铺选择后再查询参考图"); + return; + } if (!normalized) { toast.error(`请输入 SPU 后再查询${detailReferenceSourceLabel}`); return; @@ -1472,10 +1477,16 @@ export function AgentGenerationWorkspaceDarkV2({ }); try { + const searchParams = new URLSearchParams({ + keyword: normalized, + queryType: "spu", + target: detailReferenceTarget, + }); + if (workflowStoreName) { + searchParams.set("storeName", workflowStoreName); + } const response = await fetch( - `/api/agent-workflow/reference-search?keyword=${encodeURIComponent( - normalized - )}&queryType=spu&target=${detailReferenceTarget}`, + `/api/agent-workflow/reference-search?${searchParams.toString()}`, { cache: "no-store" } ); const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse; @@ -1504,7 +1515,13 @@ export function AgentGenerationWorkspaceDarkV2({ }); toast.error(error instanceof Error ? error.message : `${detailReferenceSourceLabel}参考图查询失败`); } - }, [detailReferenceSourceLabel, detailReferenceTarget, detailReplacementQueryInput, updateDetailReplacementState]); + }, [ + detailReferenceSourceLabel, + detailReferenceTarget, + detailReplacementQueryInput, + updateDetailReplacementState, + workflowStoreName, + ]); const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => { updateDetailReplacementState((current) => { const nextCandidate = @@ -1536,6 +1553,10 @@ export function AgentGenerationWorkspaceDarkV2({ }, [updateDetailReplacementState]); const handleSkuReplacementSearch = useCallback(async () => { const normalized = skuReplacementQueryInput.trim().toUpperCase(); + if (!workflowStoreName) { + toast.error("请先在第一步完成品牌和店铺选择后再查询参考图"); + return; + } if (!normalized) { toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询${skuReferenceSourceLabel}参考图`); return; @@ -1547,10 +1568,16 @@ export function AgentGenerationWorkspaceDarkV2({ }); try { + const searchParams = new URLSearchParams({ + keyword: normalized, + queryType: skuReplacementQueryType, + target: skuReferenceTarget, + }); + if (workflowStoreName) { + searchParams.set("storeName", workflowStoreName); + } const response = await fetch( - `/api/agent-workflow/reference-search?keyword=${encodeURIComponent( - normalized - )}&queryType=${skuReplacementQueryType}&target=${skuReferenceTarget}`, + `/api/agent-workflow/reference-search?${searchParams.toString()}`, { cache: "no-store" } ); const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse; @@ -1593,6 +1620,7 @@ export function AgentGenerationWorkspaceDarkV2({ skuReplacementQueryInput, skuReplacementQueryType, updateSkuReplacementState, + workflowStoreName, ]); const handleSelectSkuReplacementCandidate = useCallback((candidateId: string) => { updateSkuReplacementState((current) => { @@ -2286,6 +2314,9 @@ export function AgentGenerationWorkspaceDarkV2({ SPU / {detailReplacementSearchState.lastQuery || detailReplacementQueryInput.trim().toUpperCase()} + {workflowStoreName ? ( + {`店铺 / ${workflowStoreName}`} + ) : null} {`已接入真实${detailReferenceSourceLabel}参考图接口`} @@ -2318,12 +2349,12 @@ export function AgentGenerationWorkspaceDarkV2({ : "border-white/10 bg-white/[0.03] hover:border-white/18" )} > -
+
{previewUrl ? ( {candidate.sceneName ) : null}
@@ -2378,7 +2409,7 @@ export function AgentGenerationWorkspaceDarkV2({ )} onClick={() => setActiveDetailReferenceCardId(card.id)} > -
+
@@ -498,7 +498,7 @@ export function AgentWorkflowMaterialPicker({ {result?.skuOptions?.length ? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。" - : "已选中的图片会在下一步优先带入右侧“生图模式”槽位,你也可以进入后继续替换。"} + : "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"}
) : null} diff --git a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx index ab87a927f..70c9b6b74 100644 --- a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx +++ b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { Plus, Sparkles } from "lucide-react"; import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker"; import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config"; @@ -60,6 +60,7 @@ export function AgentWorkflowMaterialSelectionPanel({ otherCount: 0, totalCount: count, skuOptions: [], + recommendedImageIds: [], images: Array.from({ length: count }, (_, index) => ({ id: `loading-${params.keyword}-${index + 1}`, key: `loading-${params.keyword}-${index + 1}`, @@ -101,52 +102,6 @@ export function AgentWorkflowMaterialSelectionPanel({ typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0 ? Math.max(productSelectionLimit - primarySelectedCount, secondarySelectedCount, 0) : null; - const primarySelectedImages = useMemo( - () => - (materials.primary.result?.images || []).filter((image) => - materials.primary.selectedImageIds.includes(image.id) - ), - [materials.primary.result?.images, materials.primary.selectedImageIds] - ); - const secondarySelectedImages = useMemo( - () => - (materials.secondary.result?.images || []).filter((image) => - materials.secondary.selectedImageIds.includes(image.id) - ), - [materials.secondary.result?.images, materials.secondary.selectedImageIds] - ); - const slotPreviewCount = useMemo(() => { - if ( - typeof productSelectionLimit === "number" && - Number.isFinite(productSelectionLimit) && - productSelectionLimit > 0 - ) { - return Math.round(productSelectionLimit); - } - return primarySelectedImages.length + secondarySelectedImages.length; - }, [primarySelectedImages.length, productSelectionLimit, secondarySelectedImages.length]); - const slotPreviewItems = useMemo(() => { - const orderedSources = [ - ...primarySelectedImages.map((image, index) => ({ - source: "主商品" as const, - image, - sourceIndex: index, - })), - ...secondarySelectedImages.map((image, index) => ({ - source: "搭配商品" as const, - image, - sourceIndex: index, - })), - ]; - - return Array.from({ length: slotPreviewCount }, (_, index) => { - const assigned = orderedSources[index] || null; - return { - slotLabel: `产品图${index + 1}`, - assigned, - }; - }); - }, [primarySelectedImages, secondarySelectedImages, slotPreviewCount]); const hasAnyQueryInput = Boolean(String(inputs.primarySku || "").trim()) || Boolean(String(inputs.secondarySku || "").trim()); @@ -174,8 +129,8 @@ export function AgentWorkflowMaterialSelectionPanel({ }, { badge: "03", - label: "勾选图片", - description: "勾选你要带入生图的白底图,系统会自动映射到产品槽位。", + label: "自动选图", + description: "系统会按角度权重自动选图并映射到产品槽位,你也可以手动微调。", status: hasAnySelectedImages ? "completed" : hasAnyMaterialResult ? "current" : "pending", }, ] as const; @@ -240,8 +195,24 @@ export function AgentWorkflowMaterialSelectionPanel({ try { updateMaterialSearchState(source, { isLoading: true, errorText: null }); + const searchParams = new URLSearchParams({ + keyword, + source, + queryType, + }); + const currentSelectionLimit = + source === "primary" ? primarySelectionLimit : secondarySelectionLimit; + const requiredCount = + typeof currentSelectionLimit === "number" && + Number.isFinite(currentSelectionLimit) && + currentSelectionLimit > 0 + ? Math.round(currentSelectionLimit) + : null; + if (requiredCount) { + searchParams.set("requiredCount", String(requiredCount)); + } const response = await fetch( - `/api/agent-workflow/material-search?keyword=${encodeURIComponent(keyword)}&source=${source}&queryType=${queryType}`, + `/api/agent-workflow/material-search?${searchParams.toString()}`, { cache: "no-store" } ); const result = await response.json().catch(() => ({})); @@ -254,7 +225,9 @@ export function AgentWorkflowMaterialSelectionPanel({ ...materials, [source]: { result: result.data, - selectedImageIds: [], + selectedImageIds: Array.isArray(result.data?.recommendedImageIds) + ? result.data.recommendedImageIds + : [], }, }); } catch (error) { @@ -365,92 +338,6 @@ export function AgentWorkflowMaterialSelectionPanel({
- {slotPreviewItems.length > 0 ? ( -
-
-
- 产品图槽位映射 -
-
- 当前已选图片会按顺序自动带入后续生图槽位,先主商品,后搭配商品。 -
-
- -
- {slotPreviewItems.map((item, index) => { - const source = item.assigned?.source || null; - const previewUrl = item.assigned?.image.imageUrl || ""; - const isAssigned = Boolean(item.assigned && previewUrl); - - return ( -
-
- {isAssigned ? ( - {item.assigned?.image.label - ) : ( -
- 待选择 -
- )} -
- {item.slotLabel} -
-
- {source || "空槽位"} -
-
-
-
- {isAssigned - ? item.assigned?.image.label || `已映射到${item.slotLabel}` - : `${item.slotLabel}待补图`} -
-
- {isAssigned - ? `${source}第 ${(item.assigned?.sourceIndex || 0) + 1} 张` - : "先在下方搜索并选择图片"} -
-
-
- ); - })} -
-
- ) : null} - left.sortOrder - right.sortOrder), + recommendedImageIds: Array.isArray(raw.recommendedImageIds) + ? Array.from( + new Set( + raw.recommendedImageIds + .map((item) => sanitizeText(item, 128)) + .filter((item) => item && images.some((image) => image.id === item)) + .slice(0, 16) + ) + ) + : [], skuOptions, }; } diff --git a/types/agent.ts b/types/agent.ts index 6cbb2710f..7b284cc3e 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -188,6 +188,7 @@ export interface AgentWorkflowMaterialSearchResult { otherCount: number; totalCount: number; images: AgentWorkflowMaterialImage[]; + recommendedImageIds?: string[]; skuOptions?: AgentWorkflowMaterialSkuOption[]; }