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

View File

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

View File

@ -44,6 +44,7 @@ import type {
AgentCreatePayload,
AgentPresetModeType,
AgentPresetModeConfig,
AgentTaskDetailReferenceSource,
AgentPresetType,
AgentPreset,
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({
open,
onOpenChange,
@ -1209,6 +1232,69 @@ export function AgentPresetDialog({
</Select>
</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>
<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")}>
{isDetailReplacementModeBuilder
? "详情替换模式不需要上传模板参考图,第二步会通过 SPU 获取详情参考图,再把输入图逐张替换到详情参考图中。"
: "SKU替换模式会把用户选中的白底图与参考图建立一对一关系,后续在第二步专用工作台中执行替换。"}
? `详情替换模式不需要上传模板参考图,第二步会通过 SPU 获取${TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find((option) => option.value === modeConfig.detail.referenceImageSource)?.label || "详情图"},再把输入图逐张替换到对应参考图中。`
: `SKU替换模式会把用户选中的白底图与${TASK_DETAIL_REFERENCE_SOURCE_OPTIONS.find((option) => option.value === modeConfig.sku.referenceImageSource)?.label || "SKU图"}建立一对一关系,后续在第二步专用工作台中执行替换。`}
</div>
</div>
)}

View File

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

View File

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