feat: connect product detail image references
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-09 14:10:21 +08:00
parent 19970915fd
commit e6d4c31120
8 changed files with 787 additions and 26 deletions

View File

@ -150,6 +150,7 @@ import type {
AgentWorkflowFourthCategoryEntry,
AgentWorkflowGoodsInfoApiConfig,
AgentWorkflowMaterialApiConfig,
AgentWorkflowProductDetailChannel,
AgentWorkflowOptionUiConfig,
AgentPsdTextColorConfig,
AgentWorkflowStoreEntry,
@ -6523,6 +6524,153 @@ export function AgentWorkflowSettingsClient({
placeholder="输入 API Key"
/>
</div>
<Separator />
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">启用商品ID详情图接口</div>
<div className="text-xs text-muted-foreground">
详情模式可按商品ID和渠道拉取参考详情图,密钥仅保存在服务端配置中
</div>
</div>
<Switch
checked={materialApiConfig.productDetailEnabled === true}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailEnabled: checked,
}))
}
/>
</div>
<div className="space-y-2">
<Label className="text-xs">商品ID详情图接口基地址</Label>
<Input
value={materialApiConfig.productDetailBaseUrl || ""}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailBaseUrl: e.target.value,
}))
}
placeholder="http://服务地址:端口"
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">调用方式</Label>
<Select
value={materialApiConfig.productDetailMethod || "GET"}
onValueChange={(value) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailMethod: value === "POST" ? "POST" : "GET",
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择调用方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET /open-api/detail-images</SelectItem>
<SelectItem value="POST">POST /open-api/detail-images/query</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs">默认渠道</Label>
<Select
value={materialApiConfig.productDetailDefaultChannel || "taobao"}
onValueChange={(value) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailDefaultChannel: value as AgentWorkflowProductDetailChannel,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择默认渠道" />
</SelectTrigger>
<SelectContent>
<SelectItem value="taobao">淘宝 taobao</SelectItem>
<SelectItem value="1688">1688</SelectItem>
<SelectItem value="douyin">抖音 douyin</SelectItem>
<SelectItem value="jd">京东 jd</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">接口路径 / 完整地址</Label>
<Input
value={materialApiConfig.productDetailPath || ""}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailPath: e.target.value,
}))
}
placeholder="/open-api/detail-images"
/>
<p className="text-xs text-muted-foreground">
GET 默认使用 /open-api/detail-images,POST 默认使用 /open-api/detail-images/query;也支持 {"{{item_id}}"}、{"{{productId}}"}、{"{{channel}}"} 占位符。
</p>
</div>
<div className="space-y-2">
<Label className="text-xs">商品ID接口 API Key</Label>
<Input
type="password"
value={materialApiConfig.productDetailApiKey || ""}
onChange={(e) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailApiKey: e.target.value,
}))
}
placeholder="留空则复用上方 API Key"
/>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">同步分析补全</div>
<div className="text-xs text-muted-foreground">
默认随查询触发详情分析并回写
</div>
</div>
<Switch
checked={materialApiConfig.productDetailSyncAnalysis !== false}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailSyncAnalysis: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">强制重跑分析</div>
<div className="text-xs text-muted-foreground">
通常保持关闭,只在管理员排查时开启
</div>
</div>
<Switch
checked={materialApiConfig.productDetailForceReanalyze === true}
onCheckedChange={(checked) =>
setMaterialApiConfig((prev) => ({
...prev,
productDetailForceReanalyze: checked,
}))
}
/>
</div>
</div>
</CardContent>
</Card>
</motion.div>

View File

@ -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

View File

@ -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<typeof normalizeAgentWorkflowMaterialApiConfig>;
}) {
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<string, unknown>)
: {};
return raw.data && typeof raw.data === "object" && !Array.isArray(raw.data)
? (raw.data as Record<string, unknown>)
: {};
}
function extractProductDetailImageUrls(value: unknown) {
const matchedUrls = new Set<string>();
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<string, unknown>).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<typeof normalizeAgentWorkflowMaterialApiConfig>;
}) {
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<string, unknown>)
: {};
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<AgentWorkflowMaterialQueryType>([
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,
});

View File

@ -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

View File

@ -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<HTMLInputElement | null>(null);
const [detailReferenceUploadState, setDetailReferenceUploadState] = useState<{
isUploading: boolean;
count: number;
}>({
isUploading: false,
count: 0,
});
const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false);
const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(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<HTMLInputElement>) => {
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({
<div>
<div className="text-sm font-semibold text-white">参考详情搜索</div>
<div className="text-xs text-white/40">
支持按 SPU / SKU 自动识别候选整套,并选择一套{detailReferenceSourceLabel}参考图进入下方结果区。
支持按 SPU / SKU 自动识别候选整套,也可以上传参考详情图或按商品ID拉取详情图。
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
@ -5846,11 +6007,58 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
<div className="space-y-4 p-5">
<div className="inline-flex rounded-full border border-emerald-400/35 bg-emerald-500/[0.10] px-3 py-1.5 text-xs text-emerald-200">
{detailReplacementQueryType === "sku" ? "当前按SKU命中" : "支持SPU/SKU自动识别"}
<div className="flex flex-wrap gap-2">
{([
["spu", "按SPU查询"],
["sku", "按SKU查询"],
["productId", "按商品ID查询"],
] as Array<[AgentWorkflowReferenceQueryType, string]>).map(([queryType, label]) => (
<button
key={queryType}
type="button"
onClick={() => handleDetailReplacementQueryTypeChange(queryType)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs transition-colors",
detailReplacementQueryType === queryType
? "border-emerald-400/45 bg-emerald-500/[0.12] text-emerald-200"
: "border-white/10 bg-white/[0.03] text-white/58 hover:border-white/18 hover:text-white"
)}
>
{label}
</button>
))}
</div>
<div className="flex flex-col gap-2 lg:flex-row">
<input
ref={detailReferenceUploadInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleDetailReferenceUploadChange}
/>
{detailReplacementQueryType === "productId" ? (
<Select
value={detailReplacementProductDetailChannel}
onValueChange={(value) =>
handleDetailReplacementProductDetailChannelChange(
value as AgentWorkflowProductDetailChannel
)
}
>
<SelectTrigger className="h-12 rounded-[22px] border-white/10 bg-black/20 text-sm text-white focus:border-emerald-400/30 lg:w-[150px]">
<SelectValue placeholder="渠道" />
</SelectTrigger>
<SelectContent>
{PRODUCT_DETAIL_CHANNEL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Input
value={detailReplacementQueryInput}
onChange={(e) =>
@ -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"
/>
<Button
@ -5876,6 +6088,25 @@ export function AgentGenerationWorkspaceDarkV2({
`查询${detailReferenceSourceLabel}`
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => detailReferenceUploadInputRef.current?.click()}
disabled={detailReferenceUploadState.isUploading}
className="h-12 rounded-[22px] border-white/10 bg-white/[0.03] px-5 text-sm text-white hover:bg-white/[0.08]"
>
{detailReferenceUploadState.isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
上传 {detailReferenceUploadState.count} 张
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
上传参考图
</>
)}
</Button>
{detailReplacementReferenceCards.length > 0 ? (
<Button
type="button"
@ -5901,14 +6132,31 @@ export function AgentGenerationWorkspaceDarkV2({
{(detailReplacementSearchState.lastQuery || detailReplacementQueryInput.trim()) ? (
<div className="rounded-[18px] border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-white/48">
当前参考图查询:
{detailReplacementQueryType === "sku" ? "SKU" : "SPU"} /
{detailReplacementQueryType === "productId"
? "商品ID"
: detailReplacementQueryType === "sku"
? "SKU"
: "SPU"} /
{detailReplacementSearchState.lastQuery ||
detailReplacementQueryInput.trim().toUpperCase()}
(detailReplacementQueryType === "productId"
? detailReplacementQueryInput.trim()
: detailReplacementQueryInput.trim().toUpperCase())}
{workflowBrandName ? (
<span className="ml-2 text-white/40">{`品牌 / ${workflowBrandName}`}</span>
) : null}
{detailReplacementQueryType === "productId" ? (
<span className="ml-2 text-white/40">
{`渠道 / ${
PRODUCT_DETAIL_CHANNEL_OPTIONS.find(
(option) => option.value === detailReplacementProductDetailChannel
)?.label || detailReplacementProductDetailChannel
}`}
</span>
) : null}
<span className="ml-2 text-emerald-200/70">
{`已接入真实${detailReferenceSourceLabel}参考图接口`}
{detailReplacementQueryType === "productId"
? "商品ID详情图接口已服务端接入"
: `已接入真实${detailReferenceSourceLabel}参考图接口`}
</span>
</div>
) : null}

View File

@ -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),

View File

@ -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: "",
};
}

View File

@ -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[];