diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index 1057d495e..ee3f433d5 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -150,6 +150,7 @@ import type { AgentWorkflowFourthCategoryEntry, AgentWorkflowGoodsInfoApiConfig, AgentWorkflowMaterialApiConfig, + AgentWorkflowProductDetailChannel, AgentWorkflowOptionUiConfig, AgentPsdTextColorConfig, AgentWorkflowStoreEntry, @@ -6523,6 +6524,153 @@ export function AgentWorkflowSettingsClient({ placeholder="输入 API Key" /> + + + +
+
+
启用商品ID详情图接口
+
+ 详情模式可按商品ID和渠道拉取参考详情图,密钥仅保存在服务端配置中 +
+
+ + setMaterialApiConfig((prev) => ({ + ...prev, + productDetailEnabled: checked, + })) + } + /> +
+ +
+ + + setMaterialApiConfig((prev) => ({ + ...prev, + productDetailBaseUrl: e.target.value, + })) + } + placeholder="http://服务地址:端口" + /> +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + setMaterialApiConfig((prev) => ({ + ...prev, + productDetailPath: e.target.value, + })) + } + placeholder="/open-api/detail-images" + /> +

+ GET 默认使用 /open-api/detail-images,POST 默认使用 /open-api/detail-images/query;也支持 {"{{item_id}}"}、{"{{productId}}"}、{"{{channel}}"} 占位符。 +

+
+
+ + + setMaterialApiConfig((prev) => ({ + ...prev, + productDetailApiKey: e.target.value, + })) + } + placeholder="留空则复用上方 API Key" + /> +
+ +
+
+
+
同步分析补全
+
+ 默认随查询触发详情分析并回写 +
+
+ + setMaterialApiConfig((prev) => ({ + ...prev, + productDetailSyncAnalysis: checked, + })) + } + /> +
+
+
+
强制重跑分析
+
+ 通常保持关闭,只在管理员排查时开启 +
+
+ + setMaterialApiConfig((prev) => ({ + ...prev, + productDetailForceReanalyze: checked, + })) + } + /> +
+
diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts index a84651140..2007850a8 100644 --- a/app/(chat)/api/chat/schema.ts +++ b/app/(chat)/api/chat/schema.ts @@ -92,7 +92,7 @@ const agentModePayloadSchema = z .object({ modeType: z.enum(["detail_replacement", "sku_replacement"]), query: z.string().max(64).optional(), - queryType: z.enum(["sku", "spu"]).optional(), + queryType: z.enum(["sku", "spu", "productId"]).optional(), selectedCandidateId: z.string().min(1).max(128).nullable().optional(), references: z.array(agentModeReferenceSchema).max(40), sourceImages: z diff --git a/app/api/agent-workflow/reference-search/route.ts b/app/api/agent-workflow/reference-search/route.ts index 4a9d9f488..7b93055d5 100644 --- a/app/api/agent-workflow/reference-search/route.ts +++ b/app/api/agent-workflow/reference-search/route.ts @@ -16,15 +16,18 @@ import type { AgentDetailReplacementReferenceItem, AgentModeReferenceCandidate, AgentWorkflowMaterialQueryType, + AgentWorkflowProductDetailChannel, + AgentWorkflowReferenceQueryType, } from "@/types/agent"; export const maxDuration = 180; const querySchema = z.object({ keyword: z.string().trim().min(1).max(64), - queryType: z.enum(["sku", "spu"]).default("spu"), + queryType: z.enum(["sku", "spu", "productId"]).default("spu"), target: z.enum(["main", "detail", "sku"]), brandName: z.string().trim().max(64).optional(), + channel: z.enum(["taobao", "1688", "douyin", "jd"]).optional(), }); function jsonNoStore( @@ -472,6 +475,285 @@ async function requestTaskDetailAttempt(params: { }; } +function buildProductDetailRequestUrl(params: { + productId: string; + channel: AgentWorkflowProductDetailChannel; + method: "GET" | "POST"; + config: ReturnType; +}) { + const baseUrl = normalizeBaseUrl(params.config.productDetailBaseUrl || ""); + const rawPath = + String(params.config.productDetailPath || "").trim() || + (params.method === "POST" + ? "/open-api/detail-images/query" + : "/open-api/detail-images"); + if (!baseUrl && !/^https?:\/\//i.test(rawPath)) { + throw new Error("商品ID详情图接口基地址未配置"); + } + + let requestUrl = /^https?:\/\//i.test(rawPath) + ? rawPath + : `${baseUrl}/${rawPath.replace(/^\/+/, "")}`; + const encodedProductId = encodeURIComponent(params.productId); + const encodedChannel = encodeURIComponent(params.channel); + const beforeReplace = requestUrl; + requestUrl = requestUrl + .replace(/\{\{\s*item_id\s*\}\}/gi, encodedProductId) + .replace(/\{\s*item_id\s*\}/gi, encodedProductId) + .replace(/\{\{\s*productId\s*\}\}/gi, encodedProductId) + .replace(/\{\s*productId\s*\}/gi, encodedProductId) + .replace(/\{\{\s*channel\s*\}\}/gi, encodedChannel) + .replace(/\{\s*channel\s*\}/gi, encodedChannel); + + if (params.method === "GET") { + const url = new URL(requestUrl); + if (!url.searchParams.has("item_id")) { + url.searchParams.set("item_id", params.productId); + } + if (!url.searchParams.has("channel")) { + url.searchParams.set("channel", params.channel); + } + if (!url.searchParams.has("refresh")) { + url.searchParams.set("refresh", "false"); + } + if (!url.searchParams.has("sync_analysis")) { + url.searchParams.set( + "sync_analysis", + params.config.productDetailSyncAnalysis === false ? "false" : "true" + ); + } + if (!url.searchParams.has("force_reanalyze")) { + url.searchParams.set( + "force_reanalyze", + params.config.productDetailForceReanalyze === true ? "true" : "false" + ); + } + requestUrl = url.toString(); + } else if (requestUrl === beforeReplace) { + return requestUrl; + } + + return requestUrl; +} + +function getProductDetailResponseData(value: unknown) { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + return raw.data && typeof raw.data === "object" && !Array.isArray(raw.data) + ? (raw.data as Record) + : {}; +} + +function extractProductDetailImageUrls(value: unknown) { + const matchedUrls = new Set(); + const keyPattern = /(detail|desc|description|product).*?(image|img|pic|url|urls)|(image|img|pic|url|urls).*?(detail|desc|description|product)|^(images?|imageUrls?|detailImages?|detailImageUrls?|pics?|urls?)$/i; + + const visit = (input: unknown, keyHint = "", forced = false, depth = 0) => { + if (depth > 8 || matchedUrls.size >= 64) return; + + if (typeof input === "string") { + const normalized = input.trim(); + if ((forced || keyPattern.test(keyHint)) && /^https?:\/\//i.test(normalized)) { + matchedUrls.add(normalized); + } + return; + } + + if (Array.isArray(input)) { + const nextForced = forced || keyPattern.test(keyHint); + input.forEach((item) => visit(item, keyHint, nextForced, depth + 1)); + return; + } + + if (!input || typeof input !== "object") { + return; + } + + Object.entries(input as Record).forEach(([key, child]) => { + visit(child, key, forced || keyPattern.test(key), depth + 1); + }); + }; + + visit(value, Array.isArray(value) ? "images" : "", Array.isArray(value), 0); + return Array.from(matchedUrls); +} + +async function requestProductDetailReferences(params: { + productId: string; + channel: AgentWorkflowProductDetailChannel; + config: ReturnType; +}) { + if (!params.config.productDetailEnabled) { + return jsonNoStore( + { + success: false, + error: { + code: "PRODUCT_DETAIL_API_DISABLED", + message: "商品ID详情图接口未启用,请先在系统设置中配置", + }, + }, + { status: 400 } + ); + } + if (!params.config.productDetailBaseUrl && !params.config.productDetailPath) { + return jsonNoStore( + { + success: false, + error: { + code: "PRODUCT_DETAIL_API_NOT_CONFIGURED", + message: "商品ID详情图接口地址未配置,请先在系统设置中填写", + }, + }, + { status: 400 } + ); + } + const apiKey = String(params.config.productDetailApiKey || params.config.apiKey || "").trim(); + if (!apiKey) { + return jsonNoStore( + { + success: false, + error: { + code: "PRODUCT_DETAIL_API_KEY_MISSING", + message: "商品ID详情图接口 API Key 未配置,请先在系统设置中填写", + }, + }, + { status: 500 } + ); + } + + const method = params.config.productDetailMethod === "POST" ? "POST" : "GET"; + const requestUrl = buildProductDetailRequestUrl({ + ...params, + method, + }); + const requestBody = { + item_id: params.productId, + channel: params.channel, + refresh: false, + sync_analysis: params.config.productDetailSyncAnalysis !== false, + force_reanalyze: params.config.productDetailForceReanalyze === true, + }; + const response = await fetch(requestUrl, { + method, + headers: { + Accept: "application/json", + "X-API-KEY": apiKey, + ...(method === "POST" ? { "Content-Type": "application/json" } : {}), + }, + body: method === "POST" ? JSON.stringify(requestBody) : undefined, + cache: "no-store", + }); + const result = await response.json().catch(() => ({})); + const responseRecord = + result && typeof result === "object" && !Array.isArray(result) + ? (result as Record) + : {}; + if (responseRecord.success === false) { + const errorCode = sanitizeText(responseRecord.error, 128) || "PRODUCT_DETAIL_API_ERROR"; + const errorMessage = + sanitizeText(responseRecord.message, 255) || + sanitizeText(responseRecord.error, 255) || + "商品ID详情图查询失败"; + return jsonNoStore( + { + success: false, + error: { + code: errorCode, + message: errorMessage, + }, + }, + { status: response.ok ? 502 : response.status } + ); + } + if (!response.ok) { + return jsonNoStore( + { + success: false, + error: { + code: "PRODUCT_DETAIL_API_ERROR", + message: `商品ID详情图查询失败(${response.status})`, + }, + }, + { status: 502 } + ); + } + + const productData = getProductDetailResponseData(result); + const detailImageUrls = normalizeUrlList(productData.detailImages); + const fallbackDetailImageUrls = + detailImageUrls.length > 0 ? detailImageUrls : extractProductDetailImageUrls(result); + if (fallbackDetailImageUrls.length === 0) { + return jsonNoStore( + { + success: false, + error: { + code: "PRODUCT_DETAIL_IMAGES_NOT_FOUND", + message: "商品ID接口未返回可用详情图片", + }, + }, + { status: 404 } + ); + } + const mainImageUrls = normalizeUrlList(productData.mainImages); + const resolvedItemId = + sanitizeText(productData.itemId, 128) || + sanitizeText(productData.sourceItemId, 128) || + params.productId; + const title = sanitizeText(productData.title, 255); + const shopName = sanitizeText(productData.shopName, 128) || null; + const channelLabel = + sanitizeText(productData.channelLabel, 32) || + (params.channel === "taobao" + ? "淘宝" + : params.channel === "douyin" + ? "抖音" + : params.channel === "jd" + ? "京东" + : "1688"); + + const candidate: AgentModeReferenceCandidate = { + id: `product-id-${params.channel}-${resolvedItemId}`, + materialId: resolvedItemId, + taskNo: sanitizeText(responseRecord.request_id, 128) || null, + productModel: title || resolvedItemId, + brandName: null, + categoryName: null, + shopName, + sceneName: title || `${channelLabel}商品 ${resolvedItemId}`, + uploadedAt: sanitizeText(productData.fetchedAt, 64) || new Date().toISOString(), + isFullSet: true, + mainImageUrls, + skuImageUrls: [], + detailImageUrls: fallbackDetailImageUrls, + allImageUrls: Array.from(new Set([...mainImageUrls, ...fallbackDetailImageUrls])), + }; + const references = buildReferenceItems({ + candidate, + target: "detail", + }); + + return jsonNoStore({ + success: true, + data: { + query: resolvedItemId, + queryType: "productId" as const, + target: "detail" as const, + matchedSpu: null, + matchedSku: resolvedItemId, + brandName: null, + categoryName: null, + shopName, + channel: params.channel, + candidateCount: 1, + candidates: [candidate], + selectedCandidateId: candidate.id, + references, + }, + }); +} + export async function GET(request: NextRequest) { try { const session = await auth(); @@ -484,6 +766,7 @@ export async function GET(request: NextRequest) { queryType: request.nextUrl.searchParams.get("queryType") || "spu", target: request.nextUrl.searchParams.get("target"), brandName: request.nextUrl.searchParams.get("brandName") || undefined, + channel: request.nextUrl.searchParams.get("channel") || undefined, }); const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY }); @@ -494,6 +777,31 @@ export async function GET(request: NextRequest) { const goodsInfoConfig = normalizeAgentWorkflowGoodsInfoApiConfig( goodsInfoSetting?.value ); + const queryType = parsed.queryType as AgentWorkflowReferenceQueryType; + const normalizedKeyword = + queryType === "productId" ? parsed.keyword.trim() : parsed.keyword.trim().toUpperCase(); + if (queryType === "productId") { + if (parsed.target !== "detail") { + return jsonNoStore( + { + success: false, + error: { + code: "UNSUPPORTED_PRODUCT_ID_TARGET", + message: "商品ID查询当前仅支持详情图", + }, + }, + { status: 400 } + ); + } + const productDetailChannel = + parsed.channel || config.productDetailDefaultChannel || "taobao"; + return requestProductDetailReferences({ + productId: normalizedKeyword, + channel: productDetailChannel, + config, + }); + } + if (!config.enabled) { return jsonNoStore( { error: "素材搜索功能未开启,请先在系统设置中启用" }, @@ -507,8 +815,7 @@ export async function GET(request: NextRequest) { ); } - const normalizedKeyword = parsed.keyword.trim().toUpperCase(); - const queryType = parsed.queryType as AgentWorkflowMaterialQueryType; + const materialQueryType = queryType as AgentWorkflowMaterialQueryType; const requestedBrandName = String(parsed.brandName || "").trim(); const verifiedGoodsBrandName = requestedBrandName ? await fetchGoodsBrandByKeyword({ @@ -533,8 +840,8 @@ export async function GET(request: NextRequest) { const queryTypesToTry = Array.from( new Set([ - queryType, - queryType === "spu" ? "sku" : "spu", + materialQueryType, + materialQueryType === "spu" ? "sku" : "spu", ]) ); @@ -642,10 +949,10 @@ export async function GET(request: NextRequest) { ); } - if (selectedAttempt.queryType !== queryType) { + if (selectedAttempt.queryType !== materialQueryType) { console.info("[Agent Workflow Reference Search] Query type fallback succeeded:", { keyword: normalizedKeyword, - requestedQueryType: queryType, + requestedQueryType: materialQueryType, resolvedQueryType: selectedAttempt.queryType, target: parsed.target, }); diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 55d79e71d..051adb5fa 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -1635,6 +1635,14 @@ const updateSchema = z.object({ enabled: z.boolean().optional(), baseUrl: z.string().trim().max(255).optional(), apiKey: z.string().trim().max(500).optional(), + productDetailEnabled: z.boolean().optional(), + productDetailBaseUrl: z.string().trim().max(500).optional(), + productDetailApiKey: z.string().trim().max(500).optional(), + productDetailPath: z.string().trim().max(500).optional(), + productDetailMethod: z.enum(["GET", "POST"]).optional(), + productDetailDefaultChannel: z.enum(["taobao", "1688", "douyin", "jd"]).optional(), + productDetailSyncAnalysis: z.boolean().optional(), + productDetailForceReanalyze: z.boolean().optional(), }) .optional(), agentWorkflowGoodsInfoApiConfig: z diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 0c0d3af96..068bab7a8 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -111,6 +111,8 @@ import type { AgentWorkflowBusinessModeConfig, AgentWorkflowBusinessModeSettings, AgentWorkflowMaterialQueryType, + AgentWorkflowProductDetailChannel, + AgentWorkflowReferenceQueryType, AgentWorkflowPageFeatureSettings, AgentWorkflowSelectedBrandModel, } from "@/types/agent"; @@ -177,6 +179,15 @@ const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; const MAX_AGENT_MODE_QUERY_LENGTH = 64; const MAX_AGENT_MODE_REFERENCE_COUNT = 40; const DIRECT_MODEL_UPLOAD_SLOT_COUNT = 4; +const PRODUCT_DETAIL_CHANNEL_OPTIONS: Array<{ + value: AgentWorkflowProductDetailChannel; + label: string; +}> = [ + { value: "taobao", label: "淘宝" }, + { value: "1688", label: "1688" }, + { value: "douyin", label: "抖音" }, + { value: "jd", label: "京东" }, +]; const CHAT_UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const ALLOWED_CHAT_MEDIA_TYPES = new Set([ @@ -253,13 +264,14 @@ interface AgentModeReferenceSearchResponse { success?: boolean; data?: { query: string; - queryType: AgentWorkflowMaterialQueryType; + queryType: AgentWorkflowReferenceQueryType; target: AgentTaskDetailReferenceSource; matchedSpu?: string | null; matchedSku?: string | null; brandName?: string | null; categoryName?: string | null; shopName?: string | null; + channel?: AgentWorkflowProductDetailChannel | null; candidateCount: number; candidates: AgentModeReferenceCandidate[]; selectedCandidateId?: string | null; @@ -1265,6 +1277,14 @@ export function AgentGenerationWorkspaceDarkV2({ isLoading: false, lastQuery: null, }); + const detailReferenceUploadInputRef = useRef(null); + const [detailReferenceUploadState, setDetailReferenceUploadState] = useState<{ + isUploading: boolean; + count: number; + }>({ + isUploading: false, + count: 0, + }); const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false); const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState(null); const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{ @@ -1412,6 +1432,8 @@ export function AgentGenerationWorkspaceDarkV2({ : "reference_replace"; const detailReplacementReferenceCards = detailReplacementState.references; const detailReplacementQueryType = detailReplacementState.queryType || "spu"; + const detailReplacementProductDetailChannel = + detailReplacementState.productDetailChannel || "taobao"; const skuReplacementState = resolvedAgentModeState.skuReplacement; const skuReplacementReferenceCards = skuReplacementState.references; const presetModeConfig = useMemo( @@ -1939,7 +1961,7 @@ export function AgentGenerationWorkspaceDarkV2({ isDetailReplacementPreset: boolean; isSkuReplacementPreset: boolean; detailReplacementQueryInput: string; - detailReplacementQueryType: AgentWorkflowMaterialQueryType; + detailReplacementQueryType: AgentWorkflowReferenceQueryType; detailReplacementSelectedCandidateId: string | null; detailReplacementReferenceCards: typeof detailReplacementReferenceCards; detailReplacementProductSourceImages: typeof detailReplacementProductSourceImages; @@ -2696,20 +2718,20 @@ export function AgentGenerationWorkspaceDarkV2({ ), detail_replacement: Array.from( new Set([ + ...detailReplacementResultUrls, ...excludeDeletedResultUrls( resultCollections?.detail_replacement, normalizedDeletedResultCollections.detail_replacement ), - ...detailReplacementResultUrls, ]) ), sku_replacement: Array.from( new Set([ + ...skuReplacementResultUrls, ...excludeDeletedResultUrls( resultCollections?.sku_replacement, normalizedDeletedResultCollections.sku_replacement ), - ...skuReplacementResultUrls, ]) ), }; @@ -4701,15 +4723,143 @@ export function AgentGenerationWorkspaceDarkV2({ setPendingGeneration(null); } } + const handleDetailReplacementQueryTypeChange = useCallback( + (queryType: AgentWorkflowReferenceQueryType) => { + updateDetailReplacementState((current) => ({ + ...current, + queryType, + candidates: current.queryType === queryType ? current.candidates : [], + selectedCandidateId: + current.queryType === queryType ? current.selectedCandidateId : null, + references: current.queryType === queryType ? current.references : [], + })); + }, + [updateDetailReplacementState] + ); + const handleDetailReplacementProductDetailChannelChange = useCallback( + (channel: AgentWorkflowProductDetailChannel) => { + updateDetailReplacementState((current) => ({ + ...current, + productDetailChannel: channel, + })); + }, + [updateDetailReplacementState] + ); + const handleDetailReferenceUploadChange = useCallback( + async (event: React.ChangeEvent) => { + const inputElement = event.currentTarget; + const rawFiles = Array.from(inputElement.files || []); + const imageFiles = rawFiles.filter((file) => file.type.startsWith("image/")); + if (rawFiles.length > 0 && imageFiles.length === 0) { + toast.error("请上传图片文件"); + inputElement.value = ""; + return; + } + + const availableSlotCount = Math.max( + MAX_AGENT_MODE_REFERENCE_COUNT - detailReplacementReferenceCards.length, + 0 + ); + if (availableSlotCount <= 0) { + toast.error(`最多支持 ${MAX_AGENT_MODE_REFERENCE_COUNT} 张参考图,请先删除部分图片`); + inputElement.value = ""; + return; + } + + const uploadFiles = imageFiles.slice(0, availableSlotCount); + if (imageFiles.length > uploadFiles.length) { + toast.warning(`已达到参考图上限,本次只上传前 ${uploadFiles.length} 张`); + } + if (uploadFiles.length === 0) { + inputElement.value = ""; + return; + } + + setDetailReferenceUploadState({ + isUploading: true, + count: uploadFiles.length, + }); + + try { + const uploadedItems = await Promise.all( + uploadFiles.map(async (file, index) => { + const formData = new FormData(); + formData.append("file", file); + const response = await fetchUploadWithRetry(formData); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data?.url) { + throw new Error(data?.error || `${file.name || `第 ${index + 1} 张`}上传失败`); + } + return { + index, + file, + url: String(data.url), + }; + }) + ); + const timestamp = Date.now(); + const orderedItems = uploadedItems.slice().sort((left, right) => left.index - right.index); + + updateDetailReplacementState((current) => { + const baseLength = current.references.length; + const nextReferences = [ + ...current.references, + ...orderedItems.map((item, index) => ({ + id: `detail-upload-${timestamp}-${index + 1}-${generateUUID().slice(0, 8)}`, + label: item.file.name + ? `上传详情图 ${baseLength + index + 1} · ${item.file.name}` + : `上传详情图 ${baseLength + index + 1}`, + imageUrl: item.url, + candidateId: null, + sortOrder: baseLength + index, + passthrough: false, + assignedProductIndex: null, + })), + ] + .slice(0, MAX_AGENT_MODE_REFERENCE_COUNT) + .map((card, index) => ({ + ...card, + sortOrder: index, + })); + + return { + ...current, + references: nextReferences, + }; + }); + toast.success(`已按文件选择顺序上传 ${orderedItems.length} 张参考详情图`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "上传参考详情图失败"); + } finally { + setDetailReferenceUploadState({ + isUploading: false, + count: 0, + }); + inputElement.value = ""; + } + }, + [detailReplacementReferenceCards.length, updateDetailReplacementState] + ); const handleDetailReplacementSearch = useCallback(async () => { - const normalized = detailReplacementQueryInput.trim().toUpperCase(); + const normalized = + detailReplacementQueryType === "productId" + ? detailReplacementQueryInput.trim() + : detailReplacementQueryInput.trim().toUpperCase(); const currentWorkflowBrandName = getCurrentWorkflowBrandName(); - if (!currentWorkflowBrandName) { + if (!currentWorkflowBrandName && detailReplacementQueryType !== "productId") { toast.error("请先在第一步完成品牌选择后再查询参考图"); return; } if (!normalized) { - toast.error(`请输入 SPU 或 SKU 后再查询${detailReferenceSourceLabel}`); + toast.error( + `请输入${ + detailReplacementQueryType === "productId" + ? "商品ID" + : detailReplacementQueryType === "sku" + ? "SKU" + : "SPU" + }后再查询${detailReferenceSourceLabel}` + ); return; } @@ -4724,7 +4874,12 @@ export function AgentGenerationWorkspaceDarkV2({ queryType: detailReplacementQueryType, target: detailReferenceTarget, }); - searchParams.set("brandName", currentWorkflowBrandName); + if (currentWorkflowBrandName) { + searchParams.set("brandName", currentWorkflowBrandName); + } + if (detailReplacementQueryType === "productId") { + searchParams.set("channel", detailReplacementProductDetailChannel); + } const response = await fetch( `/api/agent-workflow/reference-search?${searchParams.toString()}`, { cache: "no-store" } @@ -4738,6 +4893,8 @@ export function AgentGenerationWorkspaceDarkV2({ updateDetailReplacementState({ query: normalized, queryType: result.data.queryType || detailReplacementQueryType, + productDetailChannel: + result.data.channel || detailReplacementProductDetailChannel, candidates: result.data.candidates || [], selectedCandidateId: result.data.selectedCandidateId || result.data.candidates?.[0]?.id || null, @@ -4758,6 +4915,7 @@ export function AgentGenerationWorkspaceDarkV2({ }, [ detailReferenceSourceLabel, detailReferenceTarget, + detailReplacementProductDetailChannel, detailReplacementQueryInput, detailReplacementQueryType, getCurrentWorkflowBrandName, @@ -4850,7 +5008,10 @@ export function AgentGenerationWorkspaceDarkV2({ updateSkuReplacementState({ query: normalized, - queryType: result.data.queryType || skuReplacementQueryType, + queryType: + result.data.queryType === "spu" || result.data.queryType === "sku" + ? result.data.queryType + : skuReplacementQueryType, candidates: result.data.candidates || [], selectedCandidateId: result.data.selectedCandidateId || result.data.candidates?.[0]?.id || null, @@ -5836,7 +5997,7 @@ export function AgentGenerationWorkspaceDarkV2({
参考详情搜索
- 支持按 SPU / SKU 自动识别候选整套,并选择一套{detailReferenceSourceLabel}参考图进入下方结果区。 + 支持按 SPU / SKU 自动识别候选整套,也可以上传参考详情图或按商品ID拉取详情图。
@@ -5846,11 +6007,58 @@ export function AgentGenerationWorkspaceDarkV2({
-
- {detailReplacementQueryType === "sku" ? "当前按SKU命中" : "支持SPU/SKU自动识别"} +
+ {([ + ["spu", "按SPU查询"], + ["sku", "按SKU查询"], + ["productId", "按商品ID查询"], + ] as Array<[AgentWorkflowReferenceQueryType, string]>).map(([queryType, label]) => ( + + ))}
+ + {detailReplacementQueryType === "productId" ? ( + + ) : null} @@ -5858,7 +6066,11 @@ export function AgentGenerationWorkspaceDarkV2({ query: e.target.value, }) } - placeholder={`输入 SPU 或 SKU,查询任务整套中的${detailReferenceSourceLabel}参考图`} + placeholder={ + detailReplacementQueryType === "productId" + ? "输入商品ID,查询并拉取详情图片" + : `输入 ${detailReplacementQueryType === "sku" ? "SKU" : "SPU"},查询任务整套中的${detailReferenceSourceLabel}参考图` + } className="h-12 rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30" /> {detailReplacementReferenceCards.length > 0 ? (
) : null} diff --git a/lib/agents/mode-workspace-state.ts b/lib/agents/mode-workspace-state.ts index 8c17f56db..5c47ee438 100644 --- a/lib/agents/mode-workspace-state.ts +++ b/lib/agents/mode-workspace-state.ts @@ -3,6 +3,8 @@ import type { AgentModeReferenceCandidate, AgentModeWorkspaceState, AgentWorkflowMaterialQueryType, + AgentWorkflowProductDetailChannel, + AgentWorkflowReferenceQueryType, } from "@/types/agent"; import { createEmptyAgentDetailBriefForm, @@ -17,6 +19,7 @@ export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState { method: "reference_replace", query: "", queryType: "spu", + productDetailChannel: "taobao", candidates: [], selectedCandidateId: null, references: [], @@ -61,6 +64,7 @@ function normalizeReferenceItems(value: unknown): AgentDetailReplacementReferenc sortOrder: Number.isFinite(Number(raw.sortOrder)) ? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder)))) : index, + passthrough: raw.passthrough === true, assignedProductIndex: Number.isFinite(Number(raw.assignedProductIndex)) ? Math.max(0, Math.min(9, Math.round(Number(raw.assignedProductIndex)))) : null, @@ -73,6 +77,17 @@ function normalizeQueryType(value: unknown, fallback: AgentWorkflowMaterialQuery return value === "sku" || value === "spu" ? value : fallback; } +function normalizeReferenceQueryType( + value: unknown, + fallback: AgentWorkflowReferenceQueryType +) { + return value === "sku" || value === "spu" || value === "productId" ? value : fallback; +} + +function normalizeProductDetailChannel(value: unknown): AgentWorkflowProductDetailChannel { + return value === "1688" || value === "douyin" || value === "jd" ? value : "taobao"; +} + function normalizeGenerationSlots(value: unknown) { if (!Array.isArray(value)) return createEmptyAgentDetailSlots(); const base = createEmptyAgentDetailSlots(); @@ -138,6 +153,7 @@ function normalizeCandidate(value: unknown, index: number): AgentModeReferenceCa materialId: String(raw.materialId || "").trim() || null, taskNo: String(raw.taskNo || "").trim() || null, productModel: String(raw.productModel || "").trim() || null, + brandName: String(raw.brandName || "").trim() || null, categoryName: String(raw.categoryName || "").trim() || null, shopName: String(raw.shopName || "").trim() || null, sceneName: String(raw.sceneName || "").trim() || null, @@ -175,7 +191,8 @@ export function normalizeAgentModeWorkspaceState(value: unknown): AgentModeWorks detailReplacement: { method: detailRaw.method === "ai_detail_generate" ? "ai_detail_generate" : "reference_replace", query: String(detailRaw.query || detailRaw.spuQuery || "").trim(), - queryType: normalizeQueryType(detailRaw.queryType, "spu"), + queryType: normalizeReferenceQueryType(detailRaw.queryType, "spu"), + productDetailChannel: normalizeProductDetailChannel(detailRaw.productDetailChannel), candidates: normalizeCandidates(detailRaw.candidates), selectedCandidateId: String(detailRaw.selectedCandidateId || "").trim() || null, references: normalizeReferenceItems(detailRaw.references), diff --git a/lib/agents/workflow-material-config.ts b/lib/agents/workflow-material-config.ts index b231f85d7..43f927eb5 100644 --- a/lib/agents/workflow-material-config.ts +++ b/lib/agents/workflow-material-config.ts @@ -13,6 +13,14 @@ export const defaultAgentWorkflowMaterialApiConfig: AgentWorkflowMaterialApiConf enabled: false, baseUrl: "https://sc.jiansun.vip/api/external", apiKey: "", + productDetailEnabled: false, + productDetailBaseUrl: "", + productDetailApiKey: "", + productDetailPath: "", + productDetailMethod: "GET", + productDetailDefaultChannel: "taobao", + productDetailSyncAnalysis: true, + productDetailForceReanalyze: false, }; function sanitizeText(value: unknown, maxLength: number) { @@ -206,6 +214,19 @@ export function normalizeAgentWorkflowMaterialApiConfig( baseUrl: sanitizeText(raw.baseUrl, 255) || defaultAgentWorkflowMaterialApiConfig.baseUrl, apiKey: sanitizeText(raw.apiKey, 500), + productDetailEnabled: raw.productDetailEnabled === true, + productDetailBaseUrl: sanitizeText(raw.productDetailBaseUrl, 500), + productDetailApiKey: sanitizeText(raw.productDetailApiKey, 500), + productDetailPath: sanitizeText(raw.productDetailPath, 500), + productDetailMethod: raw.productDetailMethod === "POST" ? "POST" : "GET", + productDetailDefaultChannel: + raw.productDetailDefaultChannel === "1688" || + raw.productDetailDefaultChannel === "douyin" || + raw.productDetailDefaultChannel === "jd" + ? raw.productDetailDefaultChannel + : "taobao", + productDetailSyncAnalysis: raw.productDetailSyncAnalysis !== false, + productDetailForceReanalyze: raw.productDetailForceReanalyze === true, }; } @@ -216,5 +237,6 @@ export function normalizeAgentWorkflowMaterialApiConfigForPublic( return { ...config, apiKey: "", + productDetailApiKey: "", }; } diff --git a/types/agent.ts b/types/agent.ts index 08907d91d..8472df26b 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -209,6 +209,14 @@ export interface AgentWorkflowMaterialApiConfig { enabled: boolean; baseUrl: string; apiKey: string; + productDetailEnabled?: boolean; + productDetailBaseUrl?: string; + productDetailApiKey?: string; + productDetailPath?: string; + productDetailMethod?: "GET" | "POST"; + productDetailDefaultChannel?: AgentWorkflowProductDetailChannel; + productDetailSyncAnalysis?: boolean; + productDetailForceReanalyze?: boolean; } export interface AgentWorkflowGoodsInfoApiConfig { @@ -219,6 +227,8 @@ export interface AgentWorkflowGoodsInfoApiConfig { } export type AgentWorkflowMaterialQueryType = "sku" | "spu"; +export type AgentWorkflowReferenceQueryType = AgentWorkflowMaterialQueryType | "productId"; +export type AgentWorkflowProductDetailChannel = "taobao" | "1688" | "douyin" | "jd"; export interface AgentWorkflowBrandModelImage { id: string; @@ -417,7 +427,8 @@ export interface AgentModeReferenceCandidate { export interface AgentDetailReplacementState { method: AgentDetailMethod; query: string; - queryType: AgentWorkflowMaterialQueryType; + queryType: AgentWorkflowReferenceQueryType; + productDetailChannel?: AgentWorkflowProductDetailChannel; candidates: AgentModeReferenceCandidate[]; selectedCandidateId?: string | null; references: AgentDetailReplacementReferenceItem[];