feat: support configurable agent reference sources
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-03-21 23:04:59 +08:00
parent 8d602bf543
commit da3f4fad64
5 changed files with 217 additions and 65 deletions

View File

@ -17,7 +17,7 @@ export const maxDuration = 180;
const querySchema = z.object({ const querySchema = z.object({
keyword: z.string().trim().min(1).max(64), keyword: z.string().trim().min(1).max(64),
queryType: z.enum(["sku", "spu"]).default("spu"), queryType: z.enum(["sku", "spu"]).default("spu"),
target: z.enum(["detail", "sku"]), target: z.enum(["main", "detail", "sku"]),
}); });
type ExternalTaskDetailCandidate = { type ExternalTaskDetailCandidate = {
@ -116,17 +116,23 @@ function normalizeCandidate(
function getCandidateTargetUrls( function getCandidateTargetUrls(
candidate: AgentModeReferenceCandidate, candidate: AgentModeReferenceCandidate,
target: "detail" | "sku" target: "main" | "detail" | "sku"
) { ) {
if (target === "main") return candidate.mainImageUrls;
return target === "detail" ? candidate.detailImageUrls : candidate.skuImageUrls; return target === "detail" ? candidate.detailImageUrls : candidate.skuImageUrls;
} }
function buildReferenceItems(params: { function buildReferenceItems(params: {
candidate: AgentModeReferenceCandidate; candidate: AgentModeReferenceCandidate;
target: "detail" | "sku"; target: "main" | "detail" | "sku";
}): AgentDetailReplacementReferenceItem[] { }): AgentDetailReplacementReferenceItem[] {
const urls = getCandidateTargetUrls(params.candidate, params.target); const urls = getCandidateTargetUrls(params.candidate, params.target);
const targetLabel = params.target === "detail" ? "详情参考图" : "SKU参考图"; const targetLabel =
params.target === "main"
? "主图参考图"
: params.target === "detail"
? "详情参考图"
: "SKU参考图";
const prefix = const prefix =
params.candidate.sceneName || params.candidate.sceneName ||
params.candidate.taskNo || params.candidate.taskNo ||
@ -222,11 +228,15 @@ export async function GET(request: NextRequest) {
success: false, success: false,
error: { error: {
code: code:
parsed.target === "detail" parsed.target === "main"
? "MAIN_REFERENCE_NOT_FOUND"
: parsed.target === "detail"
? "DETAIL_REFERENCE_NOT_FOUND" ? "DETAIL_REFERENCE_NOT_FOUND"
: "SKU_REFERENCE_NOT_FOUND", : "SKU_REFERENCE_NOT_FOUND",
message: message:
parsed.target === "detail" parsed.target === "main"
? "当前整套素材中没有可用的主图参考图"
: parsed.target === "detail"
? "当前整套素材中没有可用的详情参考图" ? "当前整套素材中没有可用的详情参考图"
: "当前整套素材中没有可用的 SKU 参考图", : "当前整套素材中没有可用的 SKU 参考图",
}, },

View File

@ -54,6 +54,7 @@ import type {
AgentModeReferenceCandidate, AgentModeReferenceCandidate,
AgentModeWorkspaceState, AgentModeWorkspaceState,
AgentPreset, AgentPreset,
AgentTaskDetailReferenceSource,
AgentWorkflowShowcaseConfig, AgentWorkflowShowcaseConfig,
AgentWorkflowComposerContext, AgentWorkflowComposerContext,
AgentWorkflowMaterialQueryType, AgentWorkflowMaterialQueryType,
@ -107,7 +108,7 @@ interface AgentModeReferenceSearchResponse {
data?: { data?: {
query: string; query: string;
queryType: AgentWorkflowMaterialQueryType; queryType: AgentWorkflowMaterialQueryType;
target: "detail" | "sku"; target: AgentTaskDetailReferenceSource;
matchedSpu?: string | null; matchedSpu?: string | null;
matchedSku?: string | null; matchedSku?: string | null;
categoryName?: string | null; categoryName?: string | null;
@ -123,6 +124,10 @@ interface AgentModeReferenceSearchResponse {
}; };
} }
function getTaskDetailReferenceSourceLabel(source: AgentTaskDetailReferenceSource) {
return source === "main" ? "主图" : source === "sku" ? "SKU图" : "详情图";
}
function areImageGenerationParamsEqual( function areImageGenerationParamsEqual(
left: ImageGenerationParams, left: ImageGenerationParams,
right: ImageGenerationParams right: ImageGenerationParams
@ -237,15 +242,20 @@ function getImageDimensions(url: string): Promise<{
function buildReferenceCardsFromCandidate(params: { function buildReferenceCardsFromCandidate(params: {
candidate: AgentModeReferenceCandidate | null | undefined; candidate: AgentModeReferenceCandidate | null | undefined;
target: "detail" | "sku"; target: AgentTaskDetailReferenceSource;
productCount?: number; productCount?: number;
assignProductIndex?: boolean;
}) { }) {
const candidate = params.candidate; const candidate = params.candidate;
if (!candidate) return [] as AgentModeWorkspaceState["detailReplacement"]["references"]; if (!candidate) return [] as AgentModeWorkspaceState["detailReplacement"]["references"];
const urls = const urls =
params.target === "detail" ? candidate.detailImageUrls || [] : candidate.skuImageUrls || []; params.target === "main"
const targetLabel = params.target === "detail" ? "详情参考图" : "SKU参考图"; ? candidate.mainImageUrls || []
: params.target === "detail"
? candidate.detailImageUrls || []
: candidate.skuImageUrls || [];
const targetLabel = `${getTaskDetailReferenceSourceLabel(params.target)}参考图`;
const prefix = const prefix =
candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"; candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套";
@ -256,17 +266,19 @@ function buildReferenceCardsFromCandidate(params: {
candidateId: candidate.id, candidateId: candidate.id,
sortOrder: index, sortOrder: index,
assignedProductIndex: assignedProductIndex:
params.target === "sku" && (params.productCount || 0) > 0 params.assignProductIndex === true && (params.productCount || 0) > 0
? Math.min(index, Math.max((params.productCount || 1) - 1, 0)) ? Math.min(index, Math.max((params.productCount || 1) - 1, 0))
: null, : null,
})); }));
} }
function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: "detail" | "sku") { function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: AgentTaskDetailReferenceSource) {
const imageCount = const imageCount =
target === "detail" target === "main"
? candidate.detailImageUrls.length ? candidate.mainImageUrls.length
: candidate.skuImageUrls.length; : target === "detail"
? candidate.detailImageUrls.length
: candidate.skuImageUrls.length;
const uploadedAt = candidate.uploadedAt ? new Date(candidate.uploadedAt) : null; const uploadedAt = candidate.uploadedAt ? new Date(candidate.uploadedAt) : null;
const timeText = const timeText =
uploadedAt && !Number.isNaN(uploadedAt.getTime()) uploadedAt && !Number.isNaN(uploadedAt.getTime())
@ -289,10 +301,14 @@ function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: "de
function getCandidatePreviewUrl( function getCandidatePreviewUrl(
candidate: AgentModeReferenceCandidate, candidate: AgentModeReferenceCandidate,
target: "detail" | "sku" target: AgentTaskDetailReferenceSource
) { ) {
const targetUrls = const targetUrls =
target === "detail" ? candidate.detailImageUrls || [] : candidate.skuImageUrls || []; target === "main"
? candidate.mainImageUrls || []
: target === "detail"
? candidate.detailImageUrls || []
: candidate.skuImageUrls || [];
return ( return (
targetUrls[0] || targetUrls[0] ||
candidate.mainImageUrls[0] || candidate.mainImageUrls[0] ||
@ -301,6 +317,17 @@ function getCandidatePreviewUrl(
); );
} }
function getCandidateTargetCount(
candidate: AgentModeReferenceCandidate,
target: AgentTaskDetailReferenceSource
) {
return target === "main"
? candidate.mainImageUrls.length
: target === "detail"
? candidate.detailImageUrls.length
: candidate.skuImageUrls.length;
}
type ReplacementCardStatus = "waiting" | "queued" | "generating" | "completed" | "missing"; type ReplacementCardStatus = "waiting" | "queued" | "generating" | "completed" | "missing";
function getReplacementCardStatus(params: { function getReplacementCardStatus(params: {
@ -535,6 +562,10 @@ export function AgentGenerationWorkspaceDarkV2({
fallback: modelSlotEnabled ? 3 : productSlotCount, fallback: modelSlotEnabled ? 3 : productSlotCount,
}) })
: 0; : 0;
const detailReferenceTarget = presetModeConfig.detail.referenceImageSource;
const skuReferenceTarget = presetModeConfig.sku.referenceImageSource;
const detailReferenceSourceLabel = getTaskDetailReferenceSourceLabel(detailReferenceTarget);
const skuReferenceSourceLabel = getTaskDetailReferenceSourceLabel(skuReferenceTarget);
const shouldAllowModelOverride = !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride; const shouldAllowModelOverride = !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride;
const selectedProductAttachments = attachments.filter((item, index) => const selectedProductAttachments = attachments.filter((item, index) =>
modelSlotIndex >= 0 ? index !== modelSlotIndex && Boolean(item) : Boolean(item) modelSlotIndex >= 0 ? index !== modelSlotIndex && Boolean(item) : Boolean(item)
@ -1274,11 +1305,11 @@ export function AgentGenerationWorkspaceDarkV2({
return; return;
} }
if (isDetailReplacementPreset && detailReplacementReferenceCards.length === 0) { if (isDetailReplacementPreset && detailReplacementReferenceCards.length === 0) {
toast.error("请先查询并选择详情参考图"); toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
return; return;
} }
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) { if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
toast.error("请先查询并选择 SKU 参考图"); toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
return; return;
} }
const uploadedAttachmentCount = attachments.filter(Boolean).length; const uploadedAttachmentCount = attachments.filter(Boolean).length;
@ -1431,7 +1462,7 @@ export function AgentGenerationWorkspaceDarkV2({
const handleDetailReplacementSearch = useCallback(async () => { const handleDetailReplacementSearch = useCallback(async () => {
const normalized = detailReplacementQueryInput.trim().toUpperCase(); const normalized = detailReplacementQueryInput.trim().toUpperCase();
if (!normalized) { if (!normalized) {
toast.error("请输入 SPU 后再查询详情图"); toast.error(`请输入 SPU 后再查询${detailReferenceSourceLabel}`);
return; return;
} }
@ -1444,13 +1475,13 @@ export function AgentGenerationWorkspaceDarkV2({
const response = await fetch( const response = await fetch(
`/api/agent-workflow/reference-search?keyword=${encodeURIComponent( `/api/agent-workflow/reference-search?keyword=${encodeURIComponent(
normalized normalized
)}&queryType=spu&target=detail`, )}&queryType=spu&target=${detailReferenceTarget}`,
{ cache: "no-store" } { cache: "no-store" }
); );
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse; const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
if (!response.ok || !result?.success || !result?.data) { if (!response.ok || !result?.success || !result?.data) {
throw new Error(result?.error?.message || "详情参考图查询失败"); throw new Error(result?.error?.message || `${detailReferenceSourceLabel}参考图查询失败`);
} }
updateDetailReplacementState({ updateDetailReplacementState({
@ -1465,15 +1496,15 @@ export function AgentGenerationWorkspaceDarkV2({
isLoading: false, isLoading: false,
lastQuery: normalized, lastQuery: normalized,
}); });
toast.success(`已载入 ${result.data.references?.length || 0} 张详情参考图`); toast.success(`已载入 ${result.data.references?.length || 0} 张${detailReferenceSourceLabel}参考图`);
} catch (error) { } catch (error) {
setDetailReplacementSearchState({ setDetailReplacementSearchState({
isLoading: false, isLoading: false,
lastQuery: normalized, lastQuery: normalized,
}); });
toast.error(error instanceof Error ? error.message : "详情参考图查询失败"); toast.error(error instanceof Error ? error.message : `${detailReferenceSourceLabel}参考图查询失败`);
} }
}, [detailReplacementQueryInput, updateDetailReplacementState]); }, [detailReferenceSourceLabel, detailReferenceTarget, detailReplacementQueryInput, updateDetailReplacementState]);
const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => { const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => {
updateDetailReplacementState((current) => { updateDetailReplacementState((current) => {
const nextCandidate = const nextCandidate =
@ -1483,12 +1514,12 @@ export function AgentGenerationWorkspaceDarkV2({
selectedCandidateId: candidateId, selectedCandidateId: candidateId,
references: buildReferenceCardsFromCandidate({ references: buildReferenceCardsFromCandidate({
candidate: nextCandidate, candidate: nextCandidate,
target: "detail", target: detailReferenceTarget,
productCount: selectedProductAttachments.length, productCount: selectedProductAttachments.length,
}), }),
}; };
}); });
}, [selectedProductAttachments.length, updateDetailReplacementState]); }, [detailReferenceTarget, selectedProductAttachments.length, updateDetailReplacementState]);
const handleRemoveDetailReferenceCard = useCallback((cardId: string) => { const handleRemoveDetailReferenceCard = useCallback((cardId: string) => {
updateDetailReplacementState((current) => ({ updateDetailReplacementState((current) => ({
...current, ...current,
@ -1506,7 +1537,7 @@ export function AgentGenerationWorkspaceDarkV2({
const handleSkuReplacementSearch = useCallback(async () => { const handleSkuReplacementSearch = useCallback(async () => {
const normalized = skuReplacementQueryInput.trim().toUpperCase(); const normalized = skuReplacementQueryInput.trim().toUpperCase();
if (!normalized) { if (!normalized) {
toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询 SKU 参考图`); toast.error(`请输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"}后再查询${skuReferenceSourceLabel}参考图`);
return; return;
} }
@ -1519,13 +1550,13 @@ export function AgentGenerationWorkspaceDarkV2({
const response = await fetch( const response = await fetch(
`/api/agent-workflow/reference-search?keyword=${encodeURIComponent( `/api/agent-workflow/reference-search?keyword=${encodeURIComponent(
normalized normalized
)}&queryType=${skuReplacementQueryType}&target=sku`, )}&queryType=${skuReplacementQueryType}&target=${skuReferenceTarget}`,
{ cache: "no-store" } { cache: "no-store" }
); );
const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse; const result = (await response.json().catch(() => ({}))) as AgentModeReferenceSearchResponse;
if (!response.ok || !result?.success || !result?.data) { if (!response.ok || !result?.success || !result?.data) {
throw new Error(result?.error?.message || "SKU 参考图查询失败"); throw new Error(result?.error?.message || `${skuReferenceSourceLabel}参考图查询失败`);
} }
updateSkuReplacementState({ updateSkuReplacementState({
@ -1547,16 +1578,18 @@ export function AgentGenerationWorkspaceDarkV2({
isLoading: false, isLoading: false,
lastQuery: normalized, lastQuery: normalized,
}); });
toast.success(`已载入 ${result.data.references?.length || 0} 张 SKU 参考图`); toast.success(`已载入 ${result.data.references?.length || 0} 张${skuReferenceSourceLabel}参考图`);
} catch (error) { } catch (error) {
setSkuReplacementSearchState({ setSkuReplacementSearchState({
isLoading: false, isLoading: false,
lastQuery: normalized, lastQuery: normalized,
}); });
toast.error(error instanceof Error ? error.message : "SKU 参考图查询失败"); toast.error(error instanceof Error ? error.message : `${skuReferenceSourceLabel}参考图查询失败`);
} }
}, [ }, [
selectedProductAttachments.length, selectedProductAttachments.length,
skuReferenceSourceLabel,
skuReferenceTarget,
skuReplacementQueryInput, skuReplacementQueryInput,
skuReplacementQueryType, skuReplacementQueryType,
updateSkuReplacementState, updateSkuReplacementState,
@ -1570,12 +1603,13 @@ export function AgentGenerationWorkspaceDarkV2({
selectedCandidateId: candidateId, selectedCandidateId: candidateId,
references: buildReferenceCardsFromCandidate({ references: buildReferenceCardsFromCandidate({
candidate: nextCandidate, candidate: nextCandidate,
target: "sku", target: skuReferenceTarget,
productCount: selectedProductAttachments.length, productCount: selectedProductAttachments.length,
assignProductIndex: true,
}), }),
}; };
}); });
}, [selectedProductAttachments.length, updateSkuReplacementState]); }, [selectedProductAttachments.length, skuReferenceTarget, updateSkuReplacementState]);
const handleAssignSkuReferenceProduct = useCallback((cardId: string, productIndex: number) => { const handleAssignSkuReferenceProduct = useCallback((cardId: string, productIndex: number) => {
updateSkuReplacementState((current) => ({ updateSkuReplacementState((current) => ({
...current, ...current,
@ -2191,9 +2225,9 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="min-h-0 border-b border-white/10 p-4 xl:border-b-0 xl:border-r"> <div className="min-h-0 border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div> <div>
<div className="text-sm font-semibold text-white">详情参考图</div> <div className="text-sm font-semibold text-white">{detailReferenceSourceLabel}参考图</div>
<div className="text-xs text-white/40"> <div className="text-xs text-white/40">
仅支持按 SPU 查询任务-详情整套候选,并切换某一套的详情参考图。 仅支持按 SPU 查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
</div> </div>
</div> </div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55"> <div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
@ -2216,7 +2250,7 @@ export function AgentGenerationWorkspaceDarkV2({
queryType: "spu", queryType: "spu",
}) })
} }
placeholder="输入 SPU,查询任务-详情整套中的详情参考图" placeholder={`输入 SPU,查询任务整套中的${detailReferenceSourceLabel}参考图`}
className="min-h-[68px] resize-none rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30" className="min-h-[68px] resize-none rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/> />
<Button <Button
@ -2231,7 +2265,7 @@ export function AgentGenerationWorkspaceDarkV2({
查询中 查询中
</> </>
) : ( ) : (
"查询详情图" `查询${detailReferenceSourceLabel}`
)} )}
</Button> </Button>
{detailReplacementReferenceCards.length > 0 ? ( {detailReplacementReferenceCards.length > 0 ? (
@ -2253,7 +2287,7 @@ export function AgentGenerationWorkspaceDarkV2({
{detailReplacementSearchState.lastQuery || {detailReplacementSearchState.lastQuery ||
detailReplacementQueryInput.trim().toUpperCase()} detailReplacementQueryInput.trim().toUpperCase()}
<span className="ml-2 text-emerald-200/70"> <span className="ml-2 text-emerald-200/70">
已接入真实详情参考图接口 {`已接入真实${detailReferenceSourceLabel}参考图接口`}
</span> </span>
</div> </div>
) : null} ) : null}
@ -2271,7 +2305,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="agent-dark-scrollbar flex gap-2 overflow-x-auto pb-1"> <div className="agent-dark-scrollbar flex gap-2 overflow-x-auto pb-1">
{detailReplacementCandidates.map((candidate) => { {detailReplacementCandidates.map((candidate) => {
const isSelected = detailReplacementSelectedCandidateId === candidate.id; const isSelected = detailReplacementSelectedCandidateId === candidate.id;
const previewUrl = getCandidatePreviewUrl(candidate, "detail"); const previewUrl = getCandidatePreviewUrl(candidate, detailReferenceTarget);
return ( return (
<button <button
key={candidate.id} key={candidate.id}
@ -2293,7 +2327,7 @@ export function AgentGenerationWorkspaceDarkV2({
/> />
) : null} ) : null}
<div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78"> <div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78">
{candidate.detailImageUrls.length} 张详情图 {getCandidateTargetCount(candidate, detailReferenceTarget)} 张{detailReferenceSourceLabel}
</div> </div>
{isSelected ? ( {isSelected ? (
<div className="absolute right-3 top-3 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100"> <div className="absolute right-3 top-3 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
@ -2306,7 +2340,7 @@ export function AgentGenerationWorkspaceDarkV2({
{candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"} {candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
</div> </div>
<div className="line-clamp-2 text-[11px] text-white/42"> <div className="line-clamp-2 text-[11px] text-white/42">
{formatCandidateMeta(candidate, "detail") || "点击切换这套详情参考图"} {formatCandidateMeta(candidate, detailReferenceTarget) || `点击切换这套${detailReferenceSourceLabel}参考图`}
</div> </div>
<div className="flex flex-wrap gap-1.5 text-[11px] text-white/48"> <div className="flex flex-wrap gap-1.5 text-[11px] text-white/48">
{candidate.shopName ? ( {candidate.shopName ? (
@ -2370,7 +2404,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent px-4 py-3"> <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent px-4 py-3">
<div className="text-sm font-medium text-white/88">{card.label}</div> <div className="text-sm font-medium text-white/88">{card.label}</div>
<div className="text-xs text-white/45"> <div className="text-xs text-white/45">
第 {index + 1} 张详情参考图 第 {index + 1} 张{detailReferenceSourceLabel}参考图
</div> </div>
</div> </div>
</div> </div>
@ -2383,9 +2417,9 @@ export function AgentGenerationWorkspaceDarkV2({
)) ))
) : ( ) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center"> <div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待详情参考图</div> <div className="text-sm font-medium text-white/72">等待{detailReferenceSourceLabel}参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40"> <div className="mt-2 text-xs leading-6 text-white/40">
输入 SPU 后,这里会展示任务-详情整套里的真实详情参考图。 输入 SPU 后,这里会展示任务整套里的真实{detailReferenceSourceLabel}参考图。
删除不需要的参考图后,列表会自动上移补位。 删除不需要的参考图后,列表会自动上移补位。
</div> </div>
</div> </div>
@ -2398,7 +2432,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div> <div>
<div className="text-sm font-semibold text-white">替换结果</div> <div className="text-sm font-semibold text-white">替换结果</div>
<div className="text-xs text-white/40"> <div className="text-xs text-white/40">
每张详情参考图将对应生成一张替换结果,并支持逐屏文字编辑。 每张{detailReferenceSourceLabel}参考图将对应生成一张替换结果,并支持逐屏文字编辑。
</div> </div>
</div> </div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80"> <div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80">
@ -2476,7 +2510,7 @@ export function AgentGenerationWorkspaceDarkV2({
结果图 {card.index + 1} 结果图 {card.index + 1}
</div> </div>
<div className="text-xs text-white/35"> <div className="text-xs text-white/35">
将按“输入图组 + 第 {card.index + 1} 张详情参考图”生成 将按“输入图组 + 第 {card.index + 1} 张{detailReferenceSourceLabel}参考图”生成
</div> </div>
</div> </div>
</div> </div>
@ -2500,7 +2534,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center"> <div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待替换结果</div> <div className="text-sm font-medium text-white/72">等待替换结果</div>
<div className="mt-2 text-xs leading-6 text-white/40"> <div className="mt-2 text-xs leading-6 text-white/40">
详情参考图加载后,右侧会按相同顺序生成对应结果图,并逐屏支持文字编辑。 {detailReferenceSourceLabel}参考图加载后,右侧会按相同顺序生成对应结果图,并逐屏支持文字编辑。
</div> </div>
</div> </div>
)} )}
@ -2514,7 +2548,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div> <div>
<div className="text-sm font-semibold text-white">输入图</div> <div className="text-sm font-semibold text-white">输入图</div>
<div className="text-xs text-white/40"> <div className="text-xs text-white/40">
选中的白底图会和 SKU 参考图建立一对一替换关系 选中的白底图会和{skuReferenceSourceLabel}参考图建立一对一替换关系
</div> </div>
</div> </div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55"> <div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
@ -2566,9 +2600,9 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="min-h-0 border-b border-white/10 p-4 xl:border-b-0 xl:border-r"> <div className="min-h-0 border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div> <div>
<div className="text-sm font-semibold text-white">SKU 参考图</div> <div className="text-sm font-semibold text-white">{skuReferenceSourceLabel}参考图</div>
<div className="text-xs text-white/40"> <div className="text-xs text-white/40">
支持按 SKU 或 SPU 查询任务-详情整套候选,并切换对应的 SKU 参考图。 支持按 SKU 或 SPU 查询任务整套候选,并切换对应的{skuReferenceSourceLabel}参考图。
</div> </div>
</div> </div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55"> <div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
@ -2613,7 +2647,7 @@ export function AgentGenerationWorkspaceDarkV2({
query: e.target.value, query: e.target.value,
}) })
} }
placeholder={`输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"},查询任务-详情整套中的 SKU 参考图`} placeholder={`输入${skuReplacementQueryType === "spu" ? "SPU" : "SKU"},查询任务整套中的${skuReferenceSourceLabel}参考图`}
className="min-h-[68px] resize-none rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30" className="min-h-[68px] resize-none rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
/> />
<Button <Button
@ -2628,7 +2662,7 @@ export function AgentGenerationWorkspaceDarkV2({
查询中 查询中
</> </>
) : ( ) : (
"查询 SKU 图" `查询${skuReferenceSourceLabel}`
)} )}
</Button> </Button>
{skuReplacementReferenceCards.length > 0 ? ( {skuReplacementReferenceCards.length > 0 ? (
@ -2650,7 +2684,7 @@ export function AgentGenerationWorkspaceDarkV2({
{skuReplacementSearchState.lastQuery || {skuReplacementSearchState.lastQuery ||
skuReplacementQueryInput.trim().toUpperCase()} skuReplacementQueryInput.trim().toUpperCase()}
<span className="ml-2 text-emerald-200/70"> <span className="ml-2 text-emerald-200/70">
已接入真实 SKU 参考图接口 {`已接入真实${skuReferenceSourceLabel}参考图接口`}
</span> </span>
</div> </div>
) : null} ) : null}
@ -2668,7 +2702,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="agent-dark-scrollbar flex gap-2 overflow-x-auto pb-1"> <div className="agent-dark-scrollbar flex gap-2 overflow-x-auto pb-1">
{skuReplacementCandidates.map((candidate) => { {skuReplacementCandidates.map((candidate) => {
const isSelected = skuReplacementSelectedCandidateId === candidate.id; const isSelected = skuReplacementSelectedCandidateId === candidate.id;
const previewUrl = getCandidatePreviewUrl(candidate, "sku"); const previewUrl = getCandidatePreviewUrl(candidate, skuReferenceTarget);
return ( return (
<button <button
key={candidate.id} key={candidate.id}
@ -2690,7 +2724,7 @@ export function AgentGenerationWorkspaceDarkV2({
/> />
) : null} ) : null}
<div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78"> <div className="absolute left-3 top-3 rounded-full border border-white/10 bg-black/45 px-2.5 py-1 text-[11px] text-white/78">
{candidate.skuImageUrls.length} 张SKU图 {getCandidateTargetCount(candidate, skuReferenceTarget)} 张{skuReferenceSourceLabel}
</div> </div>
{isSelected ? ( {isSelected ? (
<div className="absolute right-3 top-3 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100"> <div className="absolute right-3 top-3 rounded-full border border-emerald-300/35 bg-emerald-500/[0.14] px-2.5 py-1 text-[11px] text-emerald-100">
@ -2703,7 +2737,7 @@ export function AgentGenerationWorkspaceDarkV2({
{candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"} {candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"}
</div> </div>
<div className="mt-1 line-clamp-2 text-[11px] text-white/42"> <div className="mt-1 line-clamp-2 text-[11px] text-white/42">
{formatCandidateMeta(candidate, "sku") || "点击切换这套 SKU 参考图"} {formatCandidateMeta(candidate, skuReferenceTarget) || `点击切换这套${skuReferenceSourceLabel}参考图`}
</div> </div>
<div className="flex flex-wrap gap-1.5 text-[11px] text-white/48"> <div className="flex flex-wrap gap-1.5 text-[11px] text-white/48">
{candidate.shopName ? ( {candidate.shopName ? (
@ -2776,7 +2810,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent px-4 py-3"> <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent px-4 py-3">
<div className="text-sm font-medium text-white/88">{card.label}</div> <div className="text-sm font-medium text-white/88">{card.label}</div>
<div className="text-xs text-white/45"> <div className="text-xs text-white/45">
第 {index + 1} 张 SKU 参考图 第 {index + 1} 张{skuReferenceSourceLabel}参考图
</div> </div>
</div> </div>
</div> </div>
@ -2821,9 +2855,9 @@ export function AgentGenerationWorkspaceDarkV2({
)) ))
) : ( ) : (
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center"> <div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待 SKU 参考图</div> <div className="text-sm font-medium text-white/72">等待{skuReferenceSourceLabel}参考图</div>
<div className="mt-2 text-xs leading-6 text-white/40"> <div className="mt-2 text-xs leading-6 text-white/40">
输入 SKU 或 SPU 后,这里会展示任务-详情整套里的真实 SKU 参考图。 输入 SKU 或 SPU 后,这里会展示任务整套里的真实{skuReferenceSourceLabel}参考图。
选择多少张图,就会按相同数量进入替换流程。 选择多少张图,就会按相同数量进入替换流程。
</div> </div>
</div> </div>
@ -2836,7 +2870,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div> <div>
<div className="text-sm font-semibold text-white">替换结果</div> <div className="text-sm font-semibold text-white">替换结果</div>
<div className="text-xs text-white/40"> <div className="text-xs text-white/40">
每张 SKU 参考图会对应一张结果图,形成一对一批量替换结果。 每张{skuReferenceSourceLabel}参考图会对应一张结果图,形成一对一批量替换结果。
</div> </div>
</div> </div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80"> <div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80">
@ -2907,7 +2941,7 @@ export function AgentGenerationWorkspaceDarkV2({
结果图 {card.index + 1} 结果图 {card.index + 1}
</div> </div>
<div className="text-xs text-white/35"> <div className="text-xs text-white/35">
将按“输入图 + 第 {card.index + 1} 张 SKU 参考图”生成 将按“输入图 + 第 {card.index + 1} 张{skuReferenceSourceLabel}参考图”生成
</div> </div>
</div> </div>
</div> </div>
@ -2930,7 +2964,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center"> <div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
<div className="text-sm font-medium text-white/72">等待替换结果</div> <div className="text-sm font-medium text-white/72">等待替换结果</div>
<div className="mt-2 text-xs leading-6 text-white/40"> <div className="mt-2 text-xs leading-6 text-white/40">
SKU 参考图加载后,右侧会按相同顺序生成一对一替换结果,并支持批量下载。 {skuReferenceSourceLabel}参考图加载后,右侧会按相同顺序生成一对一替换结果,并支持批量下载。
</div> </div>
</div> </div>
)} )}

View File

@ -44,6 +44,7 @@ import type {
AgentCreatePayload, AgentCreatePayload,
AgentPresetModeType, AgentPresetModeType,
AgentPresetModeConfig, AgentPresetModeConfig,
AgentTaskDetailReferenceSource,
AgentPresetType, AgentPresetType,
AgentPreset, AgentPreset,
AgentPresetSlot, AgentPresetSlot,
@ -102,6 +103,28 @@ const IMAGE_MODE_OPTIONS: Array<{
}, },
]; ];
const TASK_DETAIL_REFERENCE_SOURCE_OPTIONS: Array<{
value: AgentTaskDetailReferenceSource;
label: string;
description: string;
}> = [
{
value: "main",
label: "主图",
description: "获取任务整套里的主图作为参考图来源。",
},
{
value: "sku",
label: "SKU图",
description: "获取任务整套里的 SKU 图作为参考图来源。",
},
{
value: "detail",
label: "详情图",
description: "获取任务整套里的详情图作为参考图来源。",
},
];
export function AgentPresetDialog({ export function AgentPresetDialog({
open, open,
onOpenChange, onOpenChange,
@ -1209,6 +1232,69 @@ export function AgentPresetDialog({
</Select> </Select>
</div> </div>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
参考图来源
</div>
<Select
value={
isDetailReplacementModeBuilder
? modeConfig.detail.referenceImageSource
: modeConfig.sku.referenceImageSource
}
onValueChange={(value) =>
setModeConfig((prev) => ({
...prev,
modeType: isDetailReplacementModeBuilder
? "detail_replacement"
: "sku_replacement",
detail: {
...prev.detail,
referenceImageSource: isDetailReplacementModeBuilder
? (value as AgentTaskDetailReferenceSource)
: prev.detail.referenceImageSource,
},
sku: {
...prev.sku,
referenceImageSource: isSkuReplacementModeBuilder
? (value as AgentTaskDetailReferenceSource)
: prev.sku.referenceImageSource,
},
}))
}
>
<SelectTrigger
className={cn(
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={darkSelectContentClass}>
{TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className={cn("text-xs leading-5", isDark ? "text-white/42" : "text-muted-foreground")}>
{TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find(
(option) =>
option.value ===
(isDetailReplacementModeBuilder
? modeConfig.detail.referenceImageSource
: modeConfig.sku.referenceImageSource)
)?.description || "根据模式配置自动获取对应参考图"}
</div>
</div>
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2.5"> <div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2.5">
<div> <div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>模特图槽位</div> <div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>模特图槽位</div>
@ -1284,8 +1370,8 @@ export function AgentPresetDialog({
<div className={cn("rounded-xl border border-dashed px-3 py-3 text-xs", isDark ? "border-white/10 text-white/45" : "text-muted-foreground")}> <div className={cn("rounded-xl border border-dashed px-3 py-3 text-xs", isDark ? "border-white/10 text-white/45" : "text-muted-foreground")}>
{isDetailReplacementModeBuilder {isDetailReplacementModeBuilder
? "详情替换模式不需要上传模板参考图,第二步会通过 SPU 获取详情参考图,再把输入图逐张替换到详情参考图中。" ? `详情替换模式不需要上传模板参考图,第二步会通过 SPU 获取${TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find((option) => option.value === modeConfig.detail.referenceImageSource)?.label || "详情图"},再把输入图逐张替换到对应参考图中。`
: "SKU替换模式会把用户选中的白底图与参考图建立一对一关系,后续在第二步专用工作台中执行替换。"} : `SKU替换模式会把用户选中的白底图与${TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find((option) => option.value === modeConfig.sku.referenceImageSource)?.label || "SKU图"}建立一对一关系,后续在第二步专用工作台中执行替换。`}
</div> </div>
</div> </div>
)} )}

View File

@ -1,6 +1,7 @@
import type { import type {
AgentPresetModeConfig, AgentPresetModeConfig,
AgentPresetModeType, AgentPresetModeType,
AgentTaskDetailReferenceSource,
} from "@/types/agent"; } from "@/types/agent";
export const defaultAgentSceneGenerationModeConfig = { export const defaultAgentSceneGenerationModeConfig = {
@ -14,12 +15,14 @@ export const defaultAgentDetailReplacementModeConfig = {
enableTextEditing: true, enableTextEditing: true,
productImageSlotCount: 2, productImageSlotCount: 2,
includeModelSlot: true, includeModelSlot: true,
referenceImageSource: "detail",
} as const; } as const;
export const defaultAgentSkuReplacementModeConfig = { export const defaultAgentSkuReplacementModeConfig = {
enableTextEditing: true, enableTextEditing: true,
productImageSlotCount: 2, productImageSlotCount: 2,
includeModelSlot: true, includeModelSlot: true,
referenceImageSource: "sku",
} as const; } as const;
export const defaultAgentPresetModeConfig: AgentPresetModeConfig = { export const defaultAgentPresetModeConfig: AgentPresetModeConfig = {
@ -39,6 +42,13 @@ function normalizeInteger(value: unknown, fallback: number, min: number, max: nu
return Math.min(max, Math.max(min, Math.round(normalized))); return Math.min(max, Math.max(min, Math.round(normalized)));
} }
function normalizeReferenceSource(
value: unknown,
fallback: AgentTaskDetailReferenceSource
): AgentTaskDetailReferenceSource {
return value === "main" || value === "sku" || value === "detail" ? value : fallback;
}
export function normalizeAgentPresetModeConfig(value: unknown): AgentPresetModeConfig { export function normalizeAgentPresetModeConfig(value: unknown): AgentPresetModeConfig {
const raw = const raw =
value && typeof value === "object" && !Array.isArray(value) value && typeof value === "object" && !Array.isArray(value)
@ -108,6 +118,10 @@ export function normalizeAgentPresetModeConfig(value: unknown): AgentPresetModeC
detailRaw.includeModelSlot, detailRaw.includeModelSlot,
defaultAgentDetailReplacementModeConfig.includeModelSlot defaultAgentDetailReplacementModeConfig.includeModelSlot
), ),
referenceImageSource: normalizeReferenceSource(
detailRaw.referenceImageSource,
defaultAgentDetailReplacementModeConfig.referenceImageSource
),
}, },
sku: { sku: {
enableTextEditing: normalizeBoolean( enableTextEditing: normalizeBoolean(
@ -124,6 +138,10 @@ export function normalizeAgentPresetModeConfig(value: unknown): AgentPresetModeC
skuRaw.includeModelSlot, skuRaw.includeModelSlot,
defaultAgentSkuReplacementModeConfig.includeModelSlot defaultAgentSkuReplacementModeConfig.includeModelSlot
), ),
referenceImageSource: normalizeReferenceSource(
skuRaw.referenceImageSource,
defaultAgentSkuReplacementModeConfig.referenceImageSource
),
}, },
}; };
} }

View File

@ -8,6 +8,8 @@ export type AgentPresetModeType =
| "detail_replacement" | "detail_replacement"
| "sku_replacement"; | "sku_replacement";
export type AgentTaskDetailReferenceSource = "main" | "sku" | "detail";
export interface AgentSceneGenerationModeConfig { export interface AgentSceneGenerationModeConfig {
productImageSlotCount: number; productImageSlotCount: number;
includeModelSlot: boolean; includeModelSlot: boolean;
@ -19,12 +21,14 @@ export interface AgentDetailReplacementModeConfig {
enableTextEditing: boolean; enableTextEditing: boolean;
productImageSlotCount: number; productImageSlotCount: number;
includeModelSlot: boolean; includeModelSlot: boolean;
referenceImageSource: AgentTaskDetailReferenceSource;
} }
export interface AgentSkuReplacementModeConfig { export interface AgentSkuReplacementModeConfig {
enableTextEditing: boolean; enableTextEditing: boolean;
productImageSlotCount: number; productImageSlotCount: number;
includeModelSlot: boolean; includeModelSlot: boolean;
referenceImageSource: AgentTaskDetailReferenceSource;
} }
export interface AgentPresetModeConfig { export interface AgentPresetModeConfig {