feat: upgrade agent workflow generation modes
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-18 22:17:11 +08:00
parent eb56b7967d
commit b6db6a671c
20 changed files with 3877 additions and 303 deletions

View File

@ -954,7 +954,7 @@ export function AgentWorkflowSettingsClient({
</div>
<div>
<CardTitle className="text-base">素材搜索接口</CardTitle>
<CardDescription>配置 SPU/SKU 搜图接口</CardDescription>
<CardDescription>配置白底图、详情参考图、SKU 参考图共用接口</CardDescription>
</div>
</div>
</CardHeader>
@ -963,7 +963,7 @@ export function AgentWorkflowSettingsClient({
<div>
<div className="text-sm font-medium">启用素材搜索</div>
<div className="text-xs text-muted-foreground">
开启后支持搜索白底素材图
开启后支持搜索白底素材图、详情参考图和 SKU 参考图
</div>
</div>
<Switch

View File

@ -52,6 +52,10 @@ import {
executeDirectVideoGeneration,
generateVideo,
} from "@/lib/ai/tools/generate-video";
import {
getAgentRequiredUploadCount,
normalizeAgentPresetModeConfig,
} from "@/lib/agents/preset-mode-config";
import { getEnabledTools } from "@/lib/ai/tools-registry";
import { isProductionEnvironment } from "@/lib/constants";
import {
@ -2771,6 +2775,67 @@ function buildAgentSlotPrompt(params: {
return lines.join("\n\n").trim();
}
type AgentReplacementModePayload = NonNullable<PostRequestBody["agentModePayload"]>;
function normalizeAgentModeReferences(
payload?: PostRequestBody["agentModePayload"],
): AgentReplacementModePayload["references"] {
if (!payload?.references?.length) return [];
return payload.references
.map((item) => ({
id: String(item.id || "").trim(),
label: String(item.label || "").trim() || "参考图",
imageUrl: String(item.imageUrl || "").trim(),
candidateId: String(item.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(item.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(item.sortOrder))))
: 0,
assignedProductIndex: Number.isFinite(Number(item.assignedProductIndex))
? Math.max(0, Math.min(9, Math.round(Number(item.assignedProductIndex))))
: null,
}))
.filter((item) => item.id && /^https?:\/\//i.test(item.imageUrl))
.sort((left, right) => left.sortOrder - right.sortOrder);
}
function buildAgentReplacementPrompt(params: {
basePrompt: string;
modeType: "detail_replacement" | "sku_replacement";
referenceLabel: string;
referenceIndex: number;
totalReferences: number;
hasModelImage: boolean;
pairedProductIndex?: number;
}) {
const instructionLines =
params.modeType === "detail_replacement"
? [
"【执行模式】详情替换",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张详情参考图:${params.referenceLabel}`,
"请严格保留最后一张参考图的版式结构、镜头关系、构图、光影氛围、留白位置和信息层级,只替换其中的商品主体。",
"前面的输入商品图用于提供商品主体、材质、颜色和细节特征;如有模特图,请在需要人物展示时优先参考模特图。",
"不要改动参考图的整体布局,不要新增无关主体,不要删除关键版式元素。",
]
: [
"【执行模式】SKU替换",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张 SKU 参考图:${params.referenceLabel}`,
params.pairedProductIndex !== undefined
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`
: "请使用输入商品图作为本张参考图的一对一替换主体。",
"请严格保留最后一张参考图的场景、机位、透视、光影和构图关系,只替换商品主体本身。",
"输出要自然融合,不要改变参考图原有风格和景别。",
];
if (params.hasModelImage) {
instructionLines.push("如有模特图,请在需要人物展示时保持模特体态、穿搭和出镜关系一致。");
}
return [params.basePrompt, instructionLines.join("\n")]
.filter(Boolean)
.join("\n\n")
.trim();
}
const MODEL_UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@ -2980,6 +3045,7 @@ export async function POST(request: Request) {
hiddenPrompt,
autoContext,
contextPayload,
agentModePayload,
deepThinkingEnabled,
debugProtocol,
}: {
@ -2991,6 +3057,7 @@ export async function POST(request: Request) {
hiddenPrompt?: string;
autoContext?: string;
contextPayload?: PostRequestBody["contextPayload"];
agentModePayload?: PostRequestBody["agentModePayload"];
deepThinkingEnabled?: boolean;
debugProtocol?: boolean;
} = requestBody;
@ -5376,6 +5443,30 @@ export async function POST(request: Request) {
const orderedSlots = [...(selectedAgentPreset.slots || [])].sort(
(left, right) => left.slotIndex - right.slotIndex,
);
const presetModeConfig = normalizeAgentPresetModeConfig(selectedAgentPreset.defaultParams);
const isSceneGenerationPreset =
selectedAgentPreset.type === "image" &&
presetModeConfig.modeType === "scene_generation";
const isDetailReplacementPreset =
selectedAgentPreset.type === "image" &&
presetModeConfig.modeType === "detail_replacement";
const isSkuReplacementPreset =
selectedAgentPreset.type === "image" &&
presetModeConfig.modeType === "sku_replacement";
const replacementProductSlotCount = isDetailReplacementPreset
? presetModeConfig.detail.productImageSlotCount
: isSkuReplacementPreset
? presetModeConfig.sku.productImageSlotCount
: 0;
const replacementModelSlotEnabled = isDetailReplacementPreset
? presetModeConfig.detail.includeModelSlot
: isSkuReplacementPreset
? presetModeConfig.sku.includeModelSlot
: false;
const replacementReferences = normalizeAgentModeReferences(agentModePayload);
const isMatchingReplacementPayload =
(isDetailReplacementPreset && agentModePayload?.modeType === "detail_replacement") ||
(isSkuReplacementPreset && agentModePayload?.modeType === "sku_replacement");
if (orderedSlots.length === 0) {
return NextResponse.json(
{
@ -5385,9 +5476,33 @@ export async function POST(request: Request) {
{ status: 400 },
);
}
if ((isDetailReplacementPreset || isSkuReplacementPreset) && !isMatchingReplacementPayload) {
return NextResponse.json(
{
code: "bad_request:api",
message: "当前模式缺少执行上下文,请返回第二步重新选择参考图后再试",
},
{ status: 400 },
);
}
if ((isDetailReplacementPreset || isSkuReplacementPreset) && replacementReferences.length === 0) {
return NextResponse.json(
{
code: "bad_request:api",
message: isDetailReplacementPreset
? "详情替换模式至少需要 1 张详情参考图"
: "SKU替换模式至少需要 1 张 SKU 参考图",
},
{ status: 400 },
);
}
const uploadedImageParts = extractCurrentMessageImageParts(message.parts as any[]);
const requiredUploadCount = 3;
const requiredUploadCount = getAgentRequiredUploadCount({
presetType: selectedAgentPreset.type,
defaultParams: selectedAgentPreset.defaultParams || null,
fallback: 3,
});
if (uploadedImageParts.length < requiredUploadCount) {
return NextResponse.json(
@ -5456,6 +5571,16 @@ export async function POST(request: Request) {
agentPresetId,
requiredUploadCount,
uploadedImageUrls: uploadedImageParts.map((part) => part.url),
agentModePayload:
isDetailReplacementPreset || isSkuReplacementPreset
? {
modeType: agentModePayload?.modeType || null,
query: agentModePayload?.query || null,
queryType: agentModePayload?.queryType || null,
selectedCandidateId: agentModePayload?.selectedCandidateId || null,
referenceCount: replacementReferences.length,
}
: null,
},
});
@ -5488,7 +5613,11 @@ export async function POST(request: Request) {
modelName:
selectedAgentPreset.defaultModel?.displayName ||
selectedAgentPreset.name,
imageCount: orderedSlots.length,
imageCount: isSceneGenerationPreset
? Math.max(1, effectiveImageParams?.imageCount || 1)
: isDetailReplacementPreset || isSkuReplacementPreset
? replacementReferences.length
: orderedSlots.length,
},
"agent-preset:image:status-processing",
);
@ -5503,6 +5632,425 @@ export async function POST(request: Request) {
.map((part) => part.assetId)
.filter((assetId): assetId is string => Boolean(assetId));
if (
selectedAgentPreset.type === "image" &&
(isDetailReplacementPreset || isSkuReplacementPreset)
) {
const replacementSlot = orderedSlots[0];
const replacementAssistantMessageId = generateUUID();
const replacementTemplateReferenceImageUrls = Array.from(
new Set(
(replacementSlot?.referenceAssets || [])
.map((asset) => String(asset.originalUrl || "").trim())
.filter(Boolean),
),
);
const productInputImageUrls = sharedUploadedImageUrls.slice(
0,
Math.max(1, replacementProductSlotCount)
);
const modelInputImageUrl = replacementModelSlotEnabled
? sharedUploadedImageUrls[Math.max(0, replacementProductSlotCount)] || null
: null;
const sourceAssetIds = Array.from(
new Set(
[
...sharedUploadedAssetIds,
...(replacementSlot?.referenceAssetIds || []),
].filter(Boolean),
),
);
const baseReplacementPrompt = buildAgentSlotPrompt({
workflowPrompt: hiddenPromptText || null,
slotPrompt:
replacementSlot?.promptTemplate || selectedAgentPreset.promptTemplate,
slotTitle: selectedAgentPreset.name,
userSupplementPrompt: sanitizedUserSupplementPrompt || null,
});
if (productInputImageUrls.length === 0) {
throw new Error("替换模式至少需要 1 张输入商品图");
}
const mergedImageParams = {
...(selectedAgentPreset.defaultParams || {}),
...(replacementSlot?.defaultParams || {}),
} as Record<string, any>;
const resolvedModelRef =
String(selectedChatModel || "").trim() &&
String(selectedChatModel || "").trim() !== "auto"
? String(selectedChatModel || "").trim()
: presetModelId;
const resolvedAspectRatio =
typeof mergedImageParams.aspectRatio === "string"
? mergedImageParams.aspectRatio
: effectiveImageParams?.aspectRatio || defaultImageQualityParams.aspectRatio;
const resolvedImageSize =
typeof mergedImageParams.imageSize === "string"
? mergedImageParams.imageSize
: effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize;
const replacementSections: string[] = [];
const replacementResultImages: Array<{
originalUrl: string;
thumbnailUrl?: string | null;
}> = [];
let resolvedModelDisplayName =
selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.name;
const stopKeepalive = startStreamKeepalive(
writer,
"agent-preset:replacement",
12_000,
);
try {
for (const [referenceIndex, reference] of replacementReferences.entries()) {
const pairedProductIndex =
isSkuReplacementPreset && productInputImageUrls.length > 0
? Math.min(
typeof reference.assignedProductIndex === "number"
? reference.assignedProductIndex
: referenceIndex,
productInputImageUrls.length - 1
)
: undefined;
const currentProductImageUrls =
pairedProductIndex !== undefined
? [productInputImageUrls[pairedProductIndex]!]
: productInputImageUrls;
const sourceImageUrls = Array.from(
new Set(
[
...currentProductImageUrls,
...(modelInputImageUrl ? [modelInputImageUrl] : []),
...replacementTemplateReferenceImageUrls,
reference.imageUrl,
].filter(Boolean),
),
);
const replacementPrompt = buildAgentReplacementPrompt({
basePrompt:
baseReplacementPrompt ||
(isDetailReplacementPreset ? "执行详情替换" : "执行 SKU 替换"),
modeType: isDetailReplacementPreset
? "detail_replacement"
: "sku_replacement",
referenceLabel: reference.label,
referenceIndex,
totalReferences: replacementReferences.length,
hasModelImage: Boolean(modelInputImageUrl),
pairedProductIndex,
});
try {
const result = await generateCanvasImage({
prompt: replacementPrompt,
negativePrompt:
typeof mergedImageParams.negativePrompt === "string"
? mergedImageParams.negativePrompt
: undefined,
model: resolvedModelRef,
aspectRatio: resolvedAspectRatio,
imageSize: resolvedImageSize,
imageCount: 1,
referenceImages: sourceImageUrls,
});
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
const firstImage = result.images[0];
if (!firstImage) {
throw new Error("未返回结果图片");
}
replacementResultImages.push(firstImage);
replacementSections.push(
`### ${reference.label}\n\n${buildAgentToolImagesMarkdown([
{
originalUrl: firstImage.originalUrl,
thumbnailUrl: firstImage.thumbnailUrl,
imageSize: resolvedImageSize,
},
])}`,
);
successCount += 1;
} catch (replacementItemError) {
console.error("[Chat API] Agent replacement item execution failed:", {
referenceIndex,
referenceLabel: reference.label,
error: replacementItemError,
});
replacementSections.push(
`### ${reference.label}\n\n执行失败:${
replacementItemError instanceof Error
? replacementItemError.message
: "请稍后重试"
}`,
);
}
}
} finally {
stopKeepalive();
}
const assistantText = prependImageModelBadge(
[
`### ${selectedAgentPreset.name}`,
...replacementSections,
]
.filter(Boolean)
.join("\n\n"),
resolvedModelDisplayName,
);
await saveMessages({
messages: [
{
id: replacementAssistantMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: assistantText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
if (replacementResultImages.length > 0) {
await saveGeneratedImages(
assistantText,
session.user.id,
id,
replacementAssistantMessageId,
baseReplacementPrompt || selectedAgentPreset.name,
resolvedModelRef,
{
sourceAssetIds,
sourceImageUrls: Array.from(
new Set([
...sharedUploadedImageUrls,
...replacementTemplateReferenceImageUrls,
...replacementReferences.map((item) => item.imageUrl),
]),
),
relationType: "edited_from",
sourceType: isDetailReplacementPreset
? "detail_replacement"
: "sku_replacement",
projectId: contextPayload?.projectId || null,
userPrompt: requestTextContent,
optimizedPrompt: baseReplacementPrompt || undefined,
requestedImageCount: replacementResultImages.length,
aspectRatio: resolvedAspectRatio,
imageSize: resolvedImageSize,
},
);
}
writeAssistantTextChunk(
writer,
assistantText,
isDetailReplacementPreset
? "agent-preset:image:detail-replacement"
: "agent-preset:image:sku-replacement",
replacementAssistantMessageId,
);
if (contextPayload?.source === "canvas" && replacementResultImages.length > 0) {
writeCanvasMediaChunk(
writer,
{
messageId: replacementAssistantMessageId,
imageUrls: replacementResultImages.map((image) => image.originalUrl),
prompt: baseReplacementPrompt || selectedAgentPreset.name,
sourceImageUrls: Array.from(
new Set([
...sharedUploadedImageUrls,
...replacementTemplateReferenceImageUrls,
...replacementReferences.map((item) => item.imageUrl),
]),
),
agentPresetId: selectedAgentPreset.id,
agentPresetName: selectedAgentPreset.name,
agentCategoryId:
selectedAgentPreset.category?.id ||
selectedAgentPreset.categoryId ||
null,
agentCategoryName: selectedAgentPreset.category?.name || null,
agentExecutionSnapshotId: presetSnapshot.id,
},
isDetailReplacementPreset
? "agent-preset:image:detail-replacement:canvas-media"
: "agent-preset:image:sku-replacement:canvas-media",
);
}
} else if (selectedAgentPreset.type === "image" && isSceneGenerationPreset) {
const sceneSlot = orderedSlots[0];
const sceneAssistantMessageId = generateUUID();
const sceneReferenceImageUrls = Array.from(
new Set(
(sceneSlot?.referenceAssets || [])
.map((asset) => String(asset.originalUrl || "").trim())
.filter(Boolean),
),
);
const sourceImageUrls = Array.from(
new Set([...sharedUploadedImageUrls, ...sceneReferenceImageUrls].filter(Boolean)),
);
const sourceAssetIds = Array.from(
new Set(
[...sharedUploadedAssetIds, ...(sceneSlot?.referenceAssetIds || [])].filter(Boolean),
),
);
const scenePrompt = buildAgentSlotPrompt({
workflowPrompt: hiddenPromptText || null,
slotPrompt: sceneSlot?.promptTemplate || selectedAgentPreset.promptTemplate,
slotTitle: selectedAgentPreset.name,
userSupplementPrompt: sanitizedUserSupplementPrompt || null,
});
try {
const mergedImageParams = {
...(selectedAgentPreset.defaultParams || {}),
...(sceneSlot?.defaultParams || {}),
} as Record<string, any>;
const resolvedModelRef =
presetModeConfig.scene.allowModelOverride &&
String(selectedChatModel || "").trim() &&
String(selectedChatModel || "").trim() !== "auto"
? String(selectedChatModel || "").trim()
: presetModelId;
const resolvedImageCount = Math.max(1, effectiveImageParams?.imageCount || 1);
const result = await generateCanvasImage({
prompt: scenePrompt || `生成${selectedAgentPreset.name}`,
negativePrompt:
typeof mergedImageParams.negativePrompt === "string"
? mergedImageParams.negativePrompt
: undefined,
model: resolvedModelRef,
aspectRatio:
typeof mergedImageParams.aspectRatio === "string"
? mergedImageParams.aspectRatio
: effectiveImageParams?.aspectRatio || defaultImageQualityParams.aspectRatio,
imageSize:
typeof mergedImageParams.imageSize === "string"
? mergedImageParams.imageSize
: effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize,
imageCount: resolvedImageCount,
referenceImages: sourceImageUrls,
});
const imageMarkdown = buildAgentToolImagesMarkdown(
result.images.map((image) => ({
originalUrl: image.originalUrl,
thumbnailUrl: image.thumbnailUrl,
imageSize:
typeof mergedImageParams.imageSize === "string"
? mergedImageParams.imageSize
: effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize,
})),
);
const assistantText = prependImageModelBadge(
[`### ${selectedAgentPreset.name}`, imageMarkdown].filter(Boolean).join("\n\n"),
result.model,
);
await saveMessages({
messages: [
{
id: sceneAssistantMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: assistantText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
await saveGeneratedImages(
assistantText,
session.user.id,
id,
sceneAssistantMessageId,
scenePrompt || selectedAgentPreset.name,
result.modelRef || resolvedModelRef,
{
sourceAssetIds,
sourceImageUrls,
relationType: "generated_from",
sourceType:
sourceImageUrls.length > 0 ? "reference_generate" : "generated",
projectId: contextPayload?.projectId || null,
userPrompt: requestTextContent,
optimizedPrompt: scenePrompt || undefined,
requestedImageCount: resolvedImageCount,
},
);
writeAssistantTextChunk(
writer,
assistantText,
"agent-preset:image:scene-generation",
sceneAssistantMessageId,
);
if (contextPayload?.source === "canvas") {
writeCanvasMediaChunk(
writer,
{
messageId: sceneAssistantMessageId,
imageUrls: result.images.map((image) => image.originalUrl),
prompt: scenePrompt || selectedAgentPreset.name,
sourceImageUrls,
agentPresetId: selectedAgentPreset.id,
agentPresetName: selectedAgentPreset.name,
agentCategoryId:
selectedAgentPreset.category?.id ||
selectedAgentPreset.categoryId ||
null,
agentCategoryName: selectedAgentPreset.category?.name || null,
agentExecutionSnapshotId: presetSnapshot.id,
},
"agent-preset:image:scene-generation:canvas-media",
);
}
successCount = result.images.length;
} catch (sceneError) {
console.error("[Chat API] Agent preset scene generation failed:", sceneError);
const errorMessage = `### ${selectedAgentPreset.name}\n\n执行失败:${
sceneError instanceof Error ? sceneError.message : "请稍后重试"
}`;
await saveMessages({
messages: [
{
id: sceneAssistantMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: errorMessage }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
writeAssistantTextChunk(
writer,
errorMessage,
"agent-preset:error:scene-generation",
sceneAssistantMessageId,
);
}
} else {
for (const [slotOrder, slot] of orderedSlots.entries()) {
const slotLabel =
String(slot.title || slot.uploadLabel || "").trim() || `第${slotOrder + 1}张`;
@ -5841,6 +6389,7 @@ export async function POST(request: Request) {
);
}
}
}
try {
await persistAgentPresetTurn({

View File

@ -71,6 +71,25 @@ const contextPayloadSchema = z
})
.optional();
const agentModeReferenceSchema = z.object({
id: z.string().min(1).max(128),
label: z.string().min(1).max(255),
imageUrl: z.string().url(),
candidateId: z.string().min(1).max(128).nullable().optional(),
sortOrder: z.number().min(0).max(999).optional(),
assignedProductIndex: z.number().min(0).max(9).nullable().optional(),
});
const agentModePayloadSchema = z
.object({
modeType: z.enum(["detail_replacement", "sku_replacement"]),
query: z.string().max(64).optional(),
queryType: z.enum(["sku", "spu"]).optional(),
selectedCandidateId: z.string().min(1).max(128).nullable().optional(),
references: z.array(agentModeReferenceSchema).max(40),
})
.optional();
export const postRequestBodySchema = z.object({
id: z.string().uuid(),
message: z.object({
@ -89,6 +108,7 @@ export const postRequestBodySchema = z.object({
videoParams: videoParamsSchema, // 视频参数
imageParams: imageParamsSchema, // 🆕 图片生成参数
contextPayload: contextPayloadSchema,
agentModePayload: agentModePayloadSchema,
});
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;

View File

@ -13,6 +13,7 @@ export const maxDuration = 180;
const querySchema = z.object({
keyword: z.string().trim().min(1).max(64),
source: z.enum(["primary", "secondary"]).optional(),
queryType: z.enum(["sku", "spu"]).optional(),
});
type ExternalImageRecord = {
@ -27,8 +28,20 @@ type ExternalSearchResponse = {
success?: boolean;
data?: {
sku?: string;
spu?: string;
materialId?: string;
productModel?: string;
skuCount?: number;
skuList?: Array<{
skuName?: string;
skuId?: string;
hasWhiteBackground?: boolean;
whiteBackgroundCount?: number;
previewImage?: {
downloadUrl?: string;
fileName?: string;
} | null;
}>;
whiteBackground?: {
fixed?: ExternalImageRecord[];
other?: ExternalImageRecord[];
@ -147,7 +160,12 @@ async function importExternalImage(params: {
}
function mapUpstreamErrorStatus(code: string) {
if (code === "SKU_NOT_FOUND" || code === "WHITE_BACKGROUND_NOT_FOUND") {
if (
code === "SKU_NOT_FOUND" ||
code === "SPU_NOT_FOUND" ||
code === "NO_SKUS" ||
code === "WHITE_BACKGROUND_NOT_FOUND"
) {
return 404;
}
if (code === "INVALID_API_KEY") {
@ -169,6 +187,7 @@ export async function GET(request: NextRequest) {
const parsed = querySchema.parse({
keyword: request.nextUrl.searchParams.get("keyword"),
source: request.nextUrl.searchParams.get("source"),
queryType: request.nextUrl.searchParams.get("queryType"),
});
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
@ -187,9 +206,10 @@ export async function GET(request: NextRequest) {
}
const normalizedKeyword = parsed.keyword.trim().toUpperCase();
const queryType = parsed.queryType === "spu" ? "spu" : "sku";
const upstreamUrl = `${normalizeBaseUrl(
config.baseUrl
)}/standard-model-white-background?sku=${encodeURIComponent(normalizedKeyword)}`;
)}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`;
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
headers: {
@ -216,6 +236,45 @@ export async function GET(request: NextRequest) {
);
}
if (queryType === "spu") {
const skuOptions = Array.isArray(upstreamResult.data.skuList)
? upstreamResult.data.skuList
.map((item) => {
const skuName = String(item?.skuName || "").trim();
if (!skuName) return null;
return {
skuName,
skuId: String(item?.skuId || "").trim() || null,
hasWhiteBackground: item?.hasWhiteBackground === true,
whiteBackgroundCount: Number.isFinite(Number(item?.whiteBackgroundCount))
? Math.max(0, Math.min(999, Math.round(Number(item?.whiteBackgroundCount))))
: 0,
previewImageUrl: String(item?.previewImage?.downloadUrl || "").trim() || null,
previewFileName: String(item?.previewImage?.fileName || "").trim() || null,
};
})
.filter(Boolean)
: [];
return NextResponse.json({
success: true,
data: {
query: normalizedKeyword,
queryType: "spu",
matchedSku: "",
matchedSpu: String(upstreamResult.data.spu || normalizedKeyword).trim() || normalizedKeyword,
materialId: String(upstreamResult.data.materialId || "").trim() || null,
productModel: String(upstreamResult.data.spu || normalizedKeyword).trim() || null,
zipUrl: null,
fixedCount: 0,
otherCount: 0,
totalCount: skuOptions.length,
images: [],
skuOptions,
},
});
}
const matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword;
const productModel = String(upstreamResult.data.productModel || "").trim() || null;
const fixedImages = Array.isArray(upstreamResult.data.whiteBackground?.fixed)
@ -291,7 +350,9 @@ export async function GET(request: NextRequest) {
success: true,
data: {
query: normalizedKeyword,
queryType: "sku",
matchedSku,
matchedSpu: productModel,
materialId: String(upstreamResult.data.materialId || "").trim() || null,
productModel,
zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null,
@ -299,6 +360,7 @@ export async function GET(request: NextRequest) {
otherCount: importedImages.filter((item) => item.group === "other").length,
totalCount: importedImages.length,
images: importedImages,
skuOptions: [],
},
});
} catch (error) {

View File

@ -0,0 +1,275 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
normalizeAgentWorkflowMaterialApiConfig,
} from "@/lib/agents/workflow-material-config";
import type {
AgentDetailReplacementReferenceItem,
AgentModeReferenceCandidate,
AgentWorkflowMaterialQueryType,
} 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"),
target: z.enum(["detail", "sku"]),
});
type ExternalTaskDetailCandidate = {
materialId?: string;
taskNo?: string;
productModel?: string;
categoryName?: string;
shopName?: string;
sceneName?: string;
uploadedAt?: string;
isFullSet?: boolean;
downloadUrls?: {
main?: string[];
sku?: string[];
detail?: string[];
all?: string[];
};
};
type ExternalTaskDetailResponse = {
success?: boolean;
data?: {
resourceType?: string;
spu?: string | null;
sku?: string | null;
categoryName?: string | null;
shopName?: string | null;
candidateCount?: number;
candidates?: ExternalTaskDetailCandidate[];
};
error?: {
code?: string;
message?: string;
};
};
function normalizeBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "");
}
function normalizeUrlList(value: unknown) {
if (!Array.isArray(value)) return [] as string[];
return Array.from(
new Set(
value
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
).slice(0, 64);
}
function mapUpstreamErrorStatus(code: string) {
if (
code === "SKU_NOT_FOUND" ||
code === "SPU_NOT_FOUND" ||
code === "TASK_DETAIL_NOT_FOUND" ||
code === "FULL_SET_NOT_FOUND"
) {
return 404;
}
if (code === "INVALID_API_KEY") {
return 500;
}
if (code === "SKU_NOT_UNIQUE") {
return 409;
}
return 502;
}
function normalizeCandidate(
value: ExternalTaskDetailCandidate,
index: number
): AgentModeReferenceCandidate {
const uploadedAt = String(value.uploadedAt || "").trim() || null;
const taskNo = String(value.taskNo || "").trim() || null;
const materialId = String(value.materialId || "").trim() || null;
const sceneName = String(value.sceneName || "").trim() || null;
const id = taskNo || materialId || `candidate-${index + 1}`;
return {
id,
materialId,
taskNo,
productModel: String(value.productModel || "").trim() || null,
categoryName: String(value.categoryName || "").trim() || null,
shopName: String(value.shopName || "").trim() || null,
sceneName,
uploadedAt,
isFullSet: value.isFullSet !== false,
mainImageUrls: normalizeUrlList(value.downloadUrls?.main),
skuImageUrls: normalizeUrlList(value.downloadUrls?.sku),
detailImageUrls: normalizeUrlList(value.downloadUrls?.detail),
allImageUrls: normalizeUrlList(value.downloadUrls?.all),
};
}
function getCandidateTargetUrls(
candidate: AgentModeReferenceCandidate,
target: "detail" | "sku"
) {
return target === "detail" ? candidate.detailImageUrls : candidate.skuImageUrls;
}
function buildReferenceItems(params: {
candidate: AgentModeReferenceCandidate;
target: "detail" | "sku";
}): AgentDetailReplacementReferenceItem[] {
const urls = getCandidateTargetUrls(params.candidate, params.target);
const targetLabel = params.target === "detail" ? "详情参考图" : "SKU参考图";
const prefix =
params.candidate.sceneName ||
params.candidate.taskNo ||
params.candidate.productModel ||
"候选整套";
return urls.map((url, index) => ({
id: `${params.candidate.id}-${params.target}-${index + 1}`,
label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`,
imageUrl: url,
candidateId: params.candidate.id,
sortOrder: index,
}));
}
function sortCandidatesByUploadedAtDesc(candidates: AgentModeReferenceCandidate[]) {
return candidates.slice().sort((left, right) => {
const leftTime = left.uploadedAt ? new Date(left.uploadedAt).getTime() : 0;
const rightTime = right.uploadedAt ? new Date(right.uploadedAt).getTime() : 0;
return rightTime - leftTime;
});
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const parsed = querySchema.parse({
keyword: request.nextUrl.searchParams.get("keyword"),
queryType: request.nextUrl.searchParams.get("queryType") || "spu",
target: request.nextUrl.searchParams.get("target"),
});
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
if (!config.enabled) {
return NextResponse.json(
{ error: "素材搜索功能未开启,请先在系统设置中启用" },
{ status: 400 }
);
}
if (!config.apiKey) {
return NextResponse.json(
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
{ status: 500 }
);
}
const normalizedKeyword = parsed.keyword.trim().toUpperCase();
const queryType = parsed.queryType as AgentWorkflowMaterialQueryType;
const upstreamUrl = `${normalizeBaseUrl(
config.baseUrl
)}/standard-model-white-background?resourceType=task-detail&${queryType}=${encodeURIComponent(
normalizedKeyword
)}`;
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
headers: {
"X-API-Key": config.apiKey,
},
cache: "no-store",
});
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
if (!upstreamResponse.ok || upstreamResult.success !== true || !upstreamResult.data) {
const errorCode = String(upstreamResult.error?.code || "UPSTREAM_ERROR").trim();
const errorMessage =
String(upstreamResult.error?.message || "").trim() ||
`参考图搜索失败(${upstreamResponse.status})`;
return NextResponse.json(
{
success: false,
error: {
code: errorCode,
message: errorMessage,
},
},
{ status: mapUpstreamErrorStatus(errorCode) }
);
}
const normalizedCandidates = sortCandidatesByUploadedAtDesc(
(upstreamResult.data.candidates || []).map((item, index) => normalizeCandidate(item, index))
).filter((candidate) => getCandidateTargetUrls(candidate, parsed.target).length > 0);
if (normalizedCandidates.length === 0) {
return NextResponse.json(
{
success: false,
error: {
code:
parsed.target === "detail"
? "DETAIL_REFERENCE_NOT_FOUND"
: "SKU_REFERENCE_NOT_FOUND",
message:
parsed.target === "detail"
? "当前整套素材中没有可用的详情参考图"
: "当前整套素材中没有可用的 SKU 参考图",
},
},
{ status: 404 }
);
}
const selectedCandidate = normalizedCandidates[0];
const references = buildReferenceItems({
candidate: selectedCandidate,
target: parsed.target,
});
return NextResponse.json({
success: true,
data: {
query: normalizedKeyword,
queryType,
target: parsed.target,
matchedSpu: String(upstreamResult.data.spu || "").trim() || null,
matchedSku: String(upstreamResult.data.sku || "").trim() || null,
categoryName: String(upstreamResult.data.categoryName || "").trim() || null,
shopName: String(upstreamResult.data.shopName || "").trim() || null,
candidateCount: normalizedCandidates.length,
candidates: normalizedCandidates,
selectedCandidateId: selectedCandidate.id,
references,
},
});
} catch (error) {
console.error("[Agent Workflow Reference Search] Error:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "搜索参数无效", details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "参考图搜索失败,请稍后重试" },
{ status: 500 }
);
}
}

View File

@ -90,7 +90,12 @@ import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
} from "@/lib/agents/workflow-composer";
import {
createEmptyAgentModeWorkspaceState,
normalizeAgentModeWorkspaceState,
} from "@/lib/agents/mode-workspace-state";
import type {
AgentModeWorkspaceState,
AgentWorkflowComposerContext,
AgentWorkflowMaterialSelections,
AgentWorkflowPersistedState,
@ -120,6 +125,23 @@ const DOCKED_PANEL_INSET = 0;
const FLOATING_UI_GAP = 16;
const FLOATING_BOTTOM_INSET = 20;
function buildScopedCanvasChatId(projectId: string, scope: "agent-mode") {
const normalized = String(projectId || "").trim().toLowerCase().replace(/-/g, "");
if (!/^[0-9a-f]{32}$/.test(normalized)) {
return projectId;
}
const scopeSuffix = scope === "agent-mode" ? "a93e17a60001" : "a93e17a60000";
const scoped = `${normalized.slice(0, 20)}${scopeSuffix}`;
return [
scoped.slice(0, 8),
scoped.slice(8, 12),
scoped.slice(12, 16),
scoped.slice(16, 20),
scoped.slice(20, 32),
].join("-");
}
export default function CanvasEditorPage({ params }: PageProps) {
// 使用 useParams 确保客户端导航时能正确获取更新的 id
const routeParams = useParams();
@ -187,9 +209,13 @@ export default function CanvasEditorPage({ params }: PageProps) {
useState<AgentWorkflowSelectedBrandModel | null>(null);
const [selectedAgentPresetId, setSelectedAgentPresetId] = useState<string | null>(null);
const [currentAgentStage, setCurrentAgentStage] = useState<"workflow" | "mode">("workflow");
const [agentModeState, setAgentModeState] = useState<AgentModeWorkspaceState>(
() => createEmptyAgentModeWorkspaceState()
);
const [workflowHydrationSource, setWorkflowHydrationSource] = useState<
"pending" | "project" | "local" | "empty"
>("pending");
const agentModeChatId = useMemo(() => buildScopedCanvasChatId(id, "agent-mode"), [id]);
const { data: siteSettingsData } = useSWR<{
siteName?: string;
@ -293,6 +319,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
setWorkflowSelections({});
setSelectedBrandModel(null);
setSelectedAgentPresetId(null);
setAgentModeState(createEmptyAgentModeWorkspaceState());
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
useCanvasStore.getState().clearCanvas();
@ -348,6 +375,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
setCurrentAgentStage(
persistedWorkflow.currentStage === "mode" ? "mode" : "workflow"
);
setAgentModeState(
normalizeAgentModeWorkspaceState(persistedWorkflow.agentModeState)
);
setWorkflowHydrationSource("project");
} else {
setWorkflowHydrationSource("empty");
@ -407,6 +437,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedBrandModel,
selectedAgentPresetId: selectedAgentPresetId || null,
currentStage: currentAgentStage,
agentModeState,
};
return {
@ -418,6 +449,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
getCanvasState,
selectedAgentPresetId,
selectedBrandModel,
agentModeState,
workflowInputs,
workflowMaterials,
workflowSelections,
@ -461,6 +493,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
);
setSelectedAgentPresetId(String(parsed.selectedAgentPresetId || "").trim() || null);
setCurrentAgentStage(parsed.currentStage === "mode" ? "mode" : "workflow");
setAgentModeState(normalizeAgentModeWorkspaceState(parsed.agentModeState));
} catch (error) {
console.warn("[Canvas] Failed to hydrate workflow state from localStorage:", error);
} finally {
@ -481,6 +514,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedBrandModel,
selectedAgentPresetId,
currentAgentStage,
agentModeState,
})
);
} catch (error) {
@ -495,6 +529,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedBrandModel,
selectedAgentPresetId,
currentAgentStage,
agentModeState,
]);
useEffect(() => {
@ -1030,6 +1065,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
setWorkflowSelections({});
setSelectedBrandModel(null);
setSelectedAgentPresetId(null);
setAgentModeState(createEmptyAgentModeWorkspaceState());
setCurrentAgentStage("workflow");
}}
/>
@ -1039,15 +1075,24 @@ export default function CanvasEditorPage({ params }: PageProps) {
// 第二步:智能体选择 + 生图工作台
return (
<AgentGenerationWorkspaceDarkV2
chatId={`canvas-${id}`}
chatId={agentModeChatId}
projectId={id}
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
selectedBrandModel={selectedBrandModel}
workflowContext={workflowContext}
agentModeState={agentModeState}
onAgentModeStateChange={setAgentModeState}
siteName={siteSettingsData?.siteName}
siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark}
syncGeneratedResultsToCanvas={false}
onBack={() => setCurrentAgentStage("workflow")}
onOpenCanvasEditor={() => {
setCurrentAgentStage("mode");
updateCanvasSearchParams({
mode: null,
});
}}
onClearPreset={() => setSelectedAgentPresetId(null)}
// 嵌入的智能体选择面板
presetPanel={

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,8 @@
import { motion } from "framer-motion";
import { Play, Copy, Pencil, Sparkles, Trash2, Video, Wand2, FileText, Clock, User } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { getAgentPresetModeTypeFromDefaultParams } from "@/lib/agents/preset-mode-config";
import type { AgentPreset } from "@/types/agent";
interface AgentPresetCardDarkProps {
@ -71,6 +71,15 @@ export function AgentPresetCardDark({
const typeInfo = typeConfig[preset.type] || typeConfig.image;
const TypeIcon = typeInfo.icon;
const imageModeType = getAgentPresetModeTypeFromDefaultParams(preset.defaultParams);
const imageModeLabel =
imageModeType === "detail_replacement"
? "详情替换"
: imageModeType === "sku_replacement"
? "SKU替换"
: imageModeType === "scene_generation"
? "场景图"
: null;
return (
<motion.div
@ -133,7 +142,7 @@ export function AgentPresetCardDark({
typeInfo.text
)}>
<TypeIcon className="h-3.5 w-3.5" />
{typeInfo.label}
{imageModeLabel || typeInfo.label}
</div>
{/* 可见性标签 */}

View File

@ -15,6 +15,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
@ -24,10 +25,17 @@ import {
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn, fetcher } from "@/lib/utils";
import {
defaultAgentPresetModeConfig,
normalizeAgentPresetModeConfig,
patchAgentDefaultParamsWithModeConfig,
} from "@/lib/agents/preset-mode-config";
import { imageGenerationService } from "@/services/canvas";
import { agentService } from "@/services/agents";
import type {
AgentCreatePayload,
AgentPresetModeType,
AgentPresetModeConfig,
AgentPresetType,
AgentPreset,
AgentPresetSlot,
@ -51,6 +59,41 @@ interface AgentPresetDialogProps {
theme?: "light" | "dark";
}
function createDefaultDialogSlot(): DialogSlotState {
return {
slotIndex: 0,
title: "第1张",
promptTemplate: "",
requireUpload: true,
uploadLabel: "第1张",
defaultParams: null,
referenceAssetIds: [],
localReferenceAssets: [],
};
}
const IMAGE_MODE_OPTIONS: Array<{
value: AgentPresetModeType;
label: string;
description: string;
}> = [
{
value: "scene_generation",
label: "场景图生成",
description: "输入白底图和模特图,生成多张场景图结果。",
},
{
value: "detail_replacement",
label: "详情替换",
description: "用输入图逐张替换 SPU 详情参考图,生成详情结果。",
},
{
value: "sku_replacement",
label: "SKU替换",
description: "让输入图与参考图一对一替换,批量完成 SKU 场景替换。",
},
];
export function AgentPresetDialog({
open,
onOpenChange,
@ -76,21 +119,14 @@ export function AgentPresetDialog({
const [description, setDescription] = useState("");
const [avatarAssetId, setAvatarAssetId] = useState<string>("");
const [avatarUrl, setAvatarUrl] = useState<string>("");
const [modeConfig, setModeConfig] = useState<AgentPresetModeConfig>({
...defaultAgentPresetModeConfig,
modeType: "scene_generation",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [uploadingSlotIndex, setUploadingSlotIndex] = useState<number | null>(null);
const [avatarUploading, setAvatarUploading] = useState(false);
const [slots, setSlots] = useState<DialogSlotState[]>([
{
slotIndex: 0,
title: "第1张",
promptTemplate: "",
requireUpload: true,
uploadLabel: "第1张",
defaultParams: null,
referenceAssetIds: [],
localReferenceAssets: [],
},
]);
const [slots, setSlots] = useState<DialogSlotState[]>([createDefaultDialogSlot()]);
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const isDark = theme === "dark";
@ -98,6 +134,25 @@ export function AgentPresetDialog({
const dialogTitle = initialPreset ? `编辑${entityLabel}` : `新建${entityLabel}`;
const dialogDescription = `配置${entityLabel}名称、预览图、模型和槽位提示词。`;
const submitLabel = initialPreset ? `保存${entityLabel}` : `创建${entityLabel}`;
const isStructuredImageModeBuilder = type === "image" && modeConfig.modeType !== "legacy";
const isSceneModeBuilder = type === "image" && modeConfig.modeType === "scene_generation";
const isDetailReplacementModeBuilder =
type === "image" && modeConfig.modeType === "detail_replacement";
const isSkuReplacementModeBuilder =
type === "image" && modeConfig.modeType === "sku_replacement";
const selectedImageModeMeta =
IMAGE_MODE_OPTIONS.find((item) => item.value === modeConfig.modeType) || IMAGE_MODE_OPTIONS[0];
const imageModeSelectOptions =
modeConfig.modeType === "legacy"
? [
{
value: "legacy" as AgentPresetModeType,
label: "旧版兼容",
description: "保留历史模板的多槽位执行方式。",
},
...IMAGE_MODE_OPTIONS,
]
: IMAGE_MODE_OPTIONS;
const videoProviderTypes = useMemo(
() => new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video", "ruxa-video"]),
@ -152,18 +207,11 @@ export function AgentPresetDialog({
setDescription("");
setAvatarAssetId("");
setAvatarUrl("");
setSlots([
{
slotIndex: 0,
title: "第1张",
promptTemplate: "",
requireUpload: true,
uploadLabel: "第1张",
defaultParams: null,
referenceAssetIds: [],
localReferenceAssets: [],
},
]);
setModeConfig({
...defaultAgentPresetModeConfig,
modeType: "scene_generation",
});
setSlots([createDefaultDialogSlot()]);
setUploadingSlotIndex(null);
};
@ -181,6 +229,7 @@ export function AgentPresetDialog({
setDescription(initialPreset.description || "");
setAvatarAssetId(initialPreset.avatarAssetId || "");
setAvatarUrl(initialPreset.avatarUrl || "");
setModeConfig(normalizeAgentPresetModeConfig(initialPreset.defaultParams));
setSlots(
(initialPreset.slots || []).map((slot, index) => ({
slotIndex: index,
@ -199,6 +248,39 @@ export function AgentPresetDialog({
);
}, [initialPreset, open]);
useEffect(() => {
if (type !== "image" || modeConfig.modeType === "legacy") {
return;
}
setSlots((current) => {
const primary = current[0] || createDefaultDialogSlot();
return [
{
...primary,
slotIndex: 0,
title:
primary.title ||
(modeConfig.modeType === "detail_replacement"
? "详情替换"
: modeConfig.modeType === "sku_replacement"
? "SKU替换"
: "场景图生成"),
requireUpload: true,
uploadLabel:
primary.uploadLabel ||
(modeConfig.modeType === "detail_replacement"
? "详情替换输入图"
: modeConfig.modeType === "sku_replacement"
? "SKU替换输入图"
: "输入图片"),
localReferenceAssets: primary.localReferenceAssets || [],
referenceAssetIds: primary.referenceAssetIds || [],
},
];
});
}, [modeConfig.modeType, type]);
const handleSlotChange = (slotIndex: number, updates: Partial<DialogSlotState>) => {
setSlots((current) =>
current.map((slot, index) =>
@ -314,20 +396,50 @@ export function AgentPresetDialog({
toast.error("请选择对应模型");
return;
}
if (slots.some((slot) => !slot.promptTemplate.trim())) {
toast.error("每个槽位都需要填写提示词");
const effectiveSlots = isStructuredImageModeBuilder
? [
{
...(slots[0] || createDefaultDialogSlot()),
slotIndex: 0,
title:
modeConfig.modeType === "detail_replacement"
? "详情替换"
: modeConfig.modeType === "sku_replacement"
? "SKU替换"
: "场景图生成",
requireUpload: true,
uploadLabel:
modeConfig.modeType === "detail_replacement"
? "详情替换输入图"
: modeConfig.modeType === "sku_replacement"
? "SKU替换输入图"
: "输入图片",
},
]
: slots;
if (effectiveSlots.some((slot) => !slot.promptTemplate.trim())) {
toast.error(
isStructuredImageModeBuilder
? `请输入${selectedImageModeMeta.label}提示词`
: "每个槽位都需要填写提示词"
);
return;
}
setIsSubmitting(true);
try {
const nextDefaultParams = isStructuredImageModeBuilder
? patchAgentDefaultParamsWithModeConfig(initialPreset?.defaultParams || null, modeConfig)
: undefined;
const basePayload: AgentCreatePayload = {
name: name.trim(),
type,
visibility,
description: description.trim() || undefined,
defaultModelId,
slots: slots.map((slot, index) => ({
defaultParams: nextDefaultParams || undefined,
slots: effectiveSlots.map((slot, index) => ({
slotIndex: index,
title: slot.title || null,
promptTemplate: slot.promptTemplate.trim(),
@ -391,7 +503,8 @@ export function AgentPresetDialog({
<ScrollArea className="max-h-[72vh] pr-3">
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className={cn("grid grid-cols-1 gap-3", isDetailReplacementModeBuilder ? "md:grid-cols-1" : "md:grid-cols-2")}>
{!isDetailReplacementModeBuilder ? (
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>预览图</div>
<div className="flex items-start gap-4">
@ -449,6 +562,7 @@ export function AgentPresetDialog({
</label>
</div>
</div>
) : null}
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
{entityNameLabel}
@ -465,7 +579,7 @@ export function AgentPresetDialog({
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className={cn("grid grid-cols-1 gap-3", type === "image" ? "md:grid-cols-4" : "md:grid-cols-3")}>
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>可见性</div>
<Select
@ -487,6 +601,41 @@ export function AgentPresetDialog({
</SelectContent>
</Select>
</div>
{type === "image" ? (
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>模式类型</div>
<Select
value={modeConfig.modeType}
onValueChange={(value: AgentPresetModeType) =>
setModeConfig((prev) => ({
...prev,
modeType: value,
}))
}
>
<SelectTrigger
className={cn(
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={cn(isDark ? "border-white/10 bg-zinc-950 text-white" : "")}>
{imageModeSelectOptions.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>
) : null}
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>类型</div>
<Select
@ -539,6 +688,292 @@ export function AgentPresetDialog({
</div>
</div>
{type === "image" ? (
<div
className={cn(
"rounded-2xl border p-3",
isDark ? "border-white/10 bg-black/20" : "border-slate-200 bg-muted/20"
)}
>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
当前模式:{modeConfig.modeType === "legacy" ? "旧版兼容" : selectedImageModeMeta.label}
</div>
<div className={cn("mt-1 text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
{modeConfig.modeType === "legacy"
? "当前模板沿用旧版多槽位执行逻辑。切换到新模式后,将按新的业务流程配置。"
: selectedImageModeMeta.description}
</div>
</div>
) : null}
{type === "image" && modeConfig.modeType !== "legacy" ? (
<div
className={cn(
"rounded-2xl border p-4",
isDark ? "border-white/10 bg-black/20" : "border-slate-200 bg-muted/20"
)}
>
<div className="mb-4">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
{selectedImageModeMeta.label}配置
</div>
<div className={cn("mt-1 text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
{isSceneModeBuilder
? "第一步提示词组合 + 自定义提示词 + 输入图片 + 模板参考图,共同组成生图输入。"
: isDetailReplacementModeBuilder
? "详情替换会使用 SPU 获取到的详情参考图逐张替换,不走原来的详情页生成规划流程。"
: "SKU替换会按输入图与参考图一对一关系执行替换,并保留后续批量下载与文字编辑能力。"}
</div>
</div>
{isSceneModeBuilder ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>产品图槽位数量</div>
<Select
value={String(modeConfig.scene.productImageSlotCount)}
onValueChange={(value) =>
setModeConfig((prev) => ({
...prev,
modeType: "scene_generation",
scene: {
...prev.scene,
productImageSlotCount: Number(value),
},
}))
}
>
<SelectTrigger
className={cn(
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={cn(isDark ? "border-white/10 bg-zinc-950 text-white" : "")}>
{[1, 2, 3, 4].map((count) => (
<SelectItem
key={count}
value={String(count)}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{count} 张产品图
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<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>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
开启后会额外保留 1 个模特图槽位
</div>
</div>
<Switch
checked={modeConfig.scene.includeModelSlot}
onCheckedChange={(checked) =>
setModeConfig((prev) => ({
...prev,
modeType: "scene_generation",
scene: {
...prev.scene,
includeModelSlot: checked,
},
}))
}
/>
</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>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
不强制,但可以预置风格参考图
</div>
</div>
<Switch
checked={modeConfig.scene.allowTemplateReferenceImages}
onCheckedChange={(checked) =>
setModeConfig((prev) => ({
...prev,
modeType: "scene_generation",
scene: {
...prev.scene,
allowTemplateReferenceImages: checked,
},
}))
}
/>
</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>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
关闭后第二步直接锁定模板模型
</div>
</div>
<Switch
checked={modeConfig.scene.allowModelOverride}
onCheckedChange={(checked) =>
setModeConfig((prev) => ({
...prev,
modeType: "scene_generation",
scene: {
...prev.scene,
allowModelOverride: checked,
},
}))
}
/>
</div>
</div>
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
产品图槽位数量
</div>
<Select
value={String(
isDetailReplacementModeBuilder
? modeConfig.detail.productImageSlotCount
: modeConfig.sku.productImageSlotCount
)}
onValueChange={(value) =>
setModeConfig((prev) => ({
...prev,
modeType: isDetailReplacementModeBuilder
? "detail_replacement"
: "sku_replacement",
detail: {
...prev.detail,
productImageSlotCount: isDetailReplacementModeBuilder
? Number(value)
: prev.detail.productImageSlotCount,
},
sku: {
...prev.sku,
productImageSlotCount: isSkuReplacementModeBuilder
? Number(value)
: prev.sku.productImageSlotCount,
},
}))
}
>
<SelectTrigger
className={cn(
isDark
? "border-white/10 bg-black/30 text-white focus:ring-emerald-400/35"
: ""
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={cn(isDark ? "border-white/10 bg-zinc-950 text-white" : "")}>
{[1, 2, 3, 4].map((count) => (
<SelectItem
key={count}
value={String(count)}
className={cn(isDark ? "text-white focus:bg-white/10 focus:text-white" : "")}
>
{count} 张产品图
</SelectItem>
))}
</SelectContent>
</Select>
</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>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
开启后额外保留 1 个模特图槽位
</div>
</div>
<Switch
checked={
isDetailReplacementModeBuilder
? modeConfig.detail.includeModelSlot
: modeConfig.sku.includeModelSlot
}
onCheckedChange={(checked) =>
setModeConfig((prev) => ({
...prev,
modeType: isDetailReplacementModeBuilder
? "detail_replacement"
: "sku_replacement",
detail: {
...prev.detail,
includeModelSlot: isDetailReplacementModeBuilder
? checked
: prev.detail.includeModelSlot,
},
sku: {
...prev.sku,
includeModelSlot: isSkuReplacementModeBuilder
? checked
: prev.sku.includeModelSlot,
},
}))
}
/>
</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>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
结果图生成后,可调用现有文字编辑能力逐屏修改。
</div>
</div>
<Switch
checked={
isDetailReplacementModeBuilder
? modeConfig.detail.enableTextEditing
: modeConfig.sku.enableTextEditing
}
onCheckedChange={(checked) =>
setModeConfig((prev) => ({
...prev,
modeType: isDetailReplacementModeBuilder
? "detail_replacement"
: "sku_replacement",
detail: {
...prev.detail,
enableTextEditing: isDetailReplacementModeBuilder
? checked
: prev.detail.enableTextEditing,
},
sku: {
...prev.sku,
enableTextEditing: isSkuReplacementModeBuilder
? checked
: prev.sku.enableTextEditing,
},
}))
}
/>
</div>
<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替换模式会把用户选中的白底图与参考图建立一对一关系,后续在第二步专用工作台中执行替换。"}
</div>
</div>
)}
</div>
) : null}
<div className="space-y-2">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>描述</div>
<Textarea
@ -553,6 +988,106 @@ export function AgentPresetDialog({
/>
</div>
{isStructuredImageModeBuilder ? (
<div className="space-y-3 rounded-2xl border p-4">
<div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>
{selectedImageModeMeta.label}提示词
</div>
<div className={cn("mt-1 text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
{isSceneModeBuilder
? "这里定义这个模式的基础风格、镜头、氛围和结构规则。第二步会自动叠加第一步组合提示词与用户补充提示词。"
: isDetailReplacementModeBuilder
? "这里定义详情替换时的统一替换规则,例如保留版式、替换主体、保证细节一致等。"
: "这里定义 SKU 替换时的一对一替换规则,例如保留场景、替换主体、统一光影和风格。"}
</div>
</div>
<Textarea
rows={6}
value={slots[0]?.promptTemplate || ""}
onChange={(event) =>
handleSlotChange(0, { promptTemplate: event.target.value })
}
placeholder={
isDetailReplacementModeBuilder
? "例如:保持参考详情图原有版式和信息层级,将商品主体替换为输入图,保留光影、材质和透视关系一致..."
: isSkuReplacementModeBuilder
? "例如:按一对一方式替换参考图中的商品主体,保持场景关系、机位、透视和氛围不变..."
: "例如:生成高质感电商场景图,保持产品主体比例真实,材质细节清晰,构图偏商业摄影..."
}
className={cn(
isDark
? "border-white/10 bg-black/30 text-white placeholder:text-white/22 focus-visible:ring-emerald-400/35"
: ""
)}
/>
{isSceneModeBuilder && modeConfig.scene.allowTemplateReferenceImages ? (
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-3">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>模板参考图</div>
<div className={cn("mt-1 text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
可选上传,不强制;第二步会与用户选择的输入图片一起作为参考图参与生成。
</div>
<div className="mt-3">
<input
ref={(element) => {
fileInputRefs.current[0] = element;
}}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void handleUploadReferenceImages(0, event);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRefs.current[0]?.click()}
disabled={uploadingSlotIndex === 0}
className={cn(
isDark
? "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
: ""
)}
>
{uploadingSlotIndex === 0 ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-1 h-4 w-4" />
)}
上传模板参考图
</Button>
{slots[0]?.localReferenceAssets?.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{slots[0].localReferenceAssets.map((asset, assetIndex) => (
<div key={`${asset.assetId}-${assetIndex}`} className="relative">
<img
src={asset.url}
alt={asset.name || `模板参考图 ${assetIndex + 1}`}
className={cn(
"h-16 w-16 rounded-lg border object-cover",
isDark ? "border-white/10" : ""
)}
/>
</div>
))}
</div>
) : (
<div className={cn("mt-2 text-xs", isDark ? "text-white/40" : "text-muted-foreground")}>
未上传模板参考图
</div>
)}
</div>
</div>
) : null}
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
@ -708,6 +1243,7 @@ export function AgentPresetDialog({
))}
</div>
</div>
)}
</div>
</ScrollArea>

View File

@ -5,12 +5,16 @@ import { Check, ImageIcon, Loader2, RefreshCw, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { AgentWorkflowMaterialSearchResult } from "@/types/agent";
import type {
AgentWorkflowMaterialQueryType,
AgentWorkflowMaterialSearchResult,
} from "@/types/agent";
interface AgentWorkflowMaterialPickerProps {
title: string;
description: string;
value: string;
queryMode: AgentWorkflowMaterialQueryType;
placeholder: string;
buttonLabel: string;
searchNote?: string;
@ -21,7 +25,9 @@ interface AgentWorkflowMaterialPickerProps {
result?: AgentWorkflowMaterialSearchResult | null;
selectedImageIds: string[];
onValueChange: (value: string) => void;
onQueryModeChange: (value: AgentWorkflowMaterialQueryType) => void;
onSearch: () => void;
onSkuOptionSelect?: (skuName: string) => void;
onSelectedImageIdsChange: (next: string[]) => void;
theme?: "light" | "dark";
}
@ -30,6 +36,7 @@ export function AgentWorkflowMaterialPicker({
title,
description,
value,
queryMode,
placeholder,
buttonLabel,
searchNote,
@ -40,7 +47,9 @@ export function AgentWorkflowMaterialPicker({
result,
selectedImageIds,
onValueChange,
onQueryModeChange,
onSearch,
onSkuOptionSelect,
onSelectedImageIdsChange,
theme = "light",
}: AgentWorkflowMaterialPickerProps) {
@ -81,6 +90,30 @@ export function AgentWorkflowMaterialPicker({
</div>
</div>
<div className="flex flex-wrap gap-2">
{(["sku", "spu"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => onQueryModeChange(mode)}
disabled={disabled}
className={cn(
"rounded-full border px-3 py-1.5 text-xs transition-colors",
queryMode === mode
? isDark
? "border-emerald-400/45 bg-emerald-500/[0.12] text-emerald-200"
: "border-primary bg-primary/10 text-primary"
: isDark
? "border-white/10 bg-white/[0.03] text-white/58 hover:border-white/20 hover:text-white"
: "border-border bg-background text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-45"
)}
>
按{mode === "sku" ? "SKU" : "SPU"}搜索
</button>
))}
</div>
<div className="flex flex-col gap-2 md:flex-row">
<Input
value={value}
@ -150,6 +183,16 @@ export function AgentWorkflowMaterialPicker({
>
命中:{result.matchedSku || result.query}
</span>
{result.matchedSpu ? (
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
SPU:{result.matchedSpu}
</span>
) : null}
{result.productModel ? (
<span
className={cn(
@ -199,8 +242,67 @@ export function AgentWorkflowMaterialPicker({
</div>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{result.images.map((image) => {
{result.skuOptions?.length ? (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{result.skuOptions.map((option) => (
<div
key={`${option.skuName}-${option.skuId || "sku"}`}
className={cn(
"overflow-hidden rounded-[24px] border transition-all",
isDark
? "border-white/12 bg-white/[0.02] hover:border-white/22"
: "border-border bg-background"
)}
>
<div className="flex items-center gap-3 p-3">
<div
className={cn(
"h-16 w-16 overflow-hidden rounded-2xl border",
isDark ? "border-white/10 bg-white/[0.03]" : "border-border bg-muted/40"
)}
>
{option.previewImageUrl ? (
<img
src={option.previewImageUrl}
alt={option.previewFileName || option.skuName}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/30">
<ImageIcon className="h-5 w-5" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className={cn("truncate text-sm font-medium", isDark ? "text-white/88" : "")}>
{option.skuName}
</div>
<div className={cn("mt-1 text-[11px]", isDark ? "text-white/45" : "text-muted-foreground")}>
{option.hasWhiteBackground
? `已有 ${option.whiteBackgroundCount} 张白底图`
: "暂无白底图"}
</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={!option.hasWhiteBackground}
onClick={() => onSkuOptionSelect?.(option.skuName)}
>
选择SKU
</Button>
</div>
</div>
))}
</div>
) : null}
{result.images?.length ? (
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{result.images.map((image) => {
const selected = selectedSet.has(image.id);
return (
<button
@ -265,8 +367,9 @@ export function AgentWorkflowMaterialPicker({
</div>
</button>
);
})}
</div>
})}
</div>
) : null}
</div>
) : (
<div
@ -296,7 +399,7 @@ export function AgentWorkflowMaterialPicker({
</div>
)}
{result?.images?.length ? (
{(result?.images?.length || result?.skuOptions?.length) ? (
<div
className={cn(
"flex items-start gap-2 rounded-[20px] px-3 py-2 text-xs",
@ -304,7 +407,9 @@ export function AgentWorkflowMaterialPicker({
)}
>
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
已选中的图片会在下一步优先带入右侧“生图模式”槽位,你也可以进入后继续替换。
{result?.skuOptions?.length
? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。"
: "已选中的图片会在下一步优先带入右侧“生图模式”槽位,你也可以进入后继续替换。"}
</div>
) : null}
</section>

View File

@ -6,7 +6,10 @@ import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMa
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import { cn } from "@/lib/utils";
import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel";
import type { AgentWorkflowMaterialSelections } from "@/types/agent";
import type {
AgentWorkflowMaterialQueryType,
AgentWorkflowMaterialSelections,
} from "@/types/agent";
interface AgentWorkflowMaterialSelectionPanelProps {
inputs: AgentWorkflowInputs;
@ -38,6 +41,13 @@ export function AgentWorkflowMaterialSelectionPanel({
primary: { isLoading: false, errorText: null },
secondary: { isLoading: false, errorText: null },
});
const [materialSearchMode, setMaterialSearchMode] = useState<{
primary: AgentWorkflowMaterialQueryType;
secondary: AgentWorkflowMaterialQueryType;
}>({
primary: "sku",
secondary: "sku",
});
const updateMaterialSearchState = (
source: "primary" | "secondary",
@ -77,8 +87,15 @@ export function AgentWorkflowMaterialSelectionPanel({
}
};
const handleSearchMaterials = async (source: "primary" | "secondary") => {
const keyword = (source === "primary" ? inputs.primarySku : inputs.secondarySku).trim();
const handleSearchMaterials = async (
source: "primary" | "secondary",
options?: {
keyword?: string;
queryType?: AgentWorkflowMaterialQueryType;
}
) => {
const keyword = String(options?.keyword || (source === "primary" ? inputs.primarySku : inputs.secondarySku)).trim();
const queryType = options?.queryType || materialSearchMode[source];
if (!keyword) {
updateMaterialSearchState(source, { errorText: "请先输入搜索关键词" });
return;
@ -87,7 +104,7 @@ export function AgentWorkflowMaterialSelectionPanel({
try {
updateMaterialSearchState(source, { isLoading: true, errorText: null });
const response = await fetch(
`/api/agent-workflow/material-search?keyword=${encodeURIComponent(keyword)}&source=${source}`,
`/api/agent-workflow/material-search?keyword=${encodeURIComponent(keyword)}&source=${source}&queryType=${queryType}`,
{ cache: "no-store" }
);
const result = await response.json().catch(() => ({}));
@ -144,9 +161,14 @@ export function AgentWorkflowMaterialSelectionPanel({
title="主 SKU / SPU"
description="主商品图会优先自动填充到“产品图1”;如果没有搭配图,再继续补到“产品图2”。"
value={inputs.primarySku}
placeholder="输入主 SKU / SPU"
buttonLabel="搜索主图"
searchNote="当前上游接口实际按可命中的 SKU 查图;若提供了 SPU 接口,后续可直接无缝切换。"
queryMode={materialSearchMode.primary}
placeholder={materialSearchMode.primary === "spu" ? "输入主 SPU" : "输入主 SKU"}
buttonLabel={materialSearchMode.primary === "spu" ? "查SKU列表" : "搜索主图"}
searchNote={
materialSearchMode.primary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,点选某个 SKU 后再继续拉取白底图。"
: "按 SKU 搜索会直接拉取白底图,适合你已经知道具体 SKU 的情况。"
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.primary.isLoading}
@ -154,9 +176,29 @@ export function AgentWorkflowMaterialSelectionPanel({
result={materials.primary.result}
selectedImageIds={materials.primary.selectedImageIds}
onValueChange={(value) => handleMaterialInputChange("primary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
primary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("primary");
}}
onSkuOptionSelect={(skuName) => {
onInputsChange({
...inputs,
primarySku: skuName,
});
setMaterialSearchMode((prev) => ({
...prev,
primary: "sku",
}));
void handleSearchMaterials("primary", {
keyword: skuName,
queryType: "sku",
});
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
@ -173,9 +215,14 @@ export function AgentWorkflowMaterialSelectionPanel({
title="搭配 SKU"
description="搭配商品图会优先自动填充到“产品图2”;品牌模特图会在上方单独选择并进入模特图槽位。"
value={inputs.secondarySku}
placeholder="输入搭配 SKU"
buttonLabel="搜索搭配图"
searchNote="选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。"
queryMode={materialSearchMode.secondary}
placeholder={materialSearchMode.secondary === "spu" ? "输入搭配 SPU" : "输入搭配 SKU"}
buttonLabel={materialSearchMode.secondary === "spu" ? "查SKU列表" : "搜索搭配图"}
searchNote={
materialSearchMode.secondary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,再选具体 SKU 获取白底图。"
: "选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。"
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.secondary.isLoading}
@ -183,9 +230,29 @@ export function AgentWorkflowMaterialSelectionPanel({
result={materials.secondary.result}
selectedImageIds={materials.secondary.selectedImageIds}
onValueChange={(value) => handleMaterialInputChange("secondary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
secondary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("secondary");
}}
onSkuOptionSelect={(skuName) => {
onInputsChange({
...inputs,
secondarySku: skuName,
});
setMaterialSearchMode((prev) => ({
...prev,
secondary: "sku",
}));
void handleSearchMaterials("secondary", {
keyword: skuName,
queryType: "sku",
});
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({

View File

@ -389,26 +389,14 @@ export function AgentWorkflowPanelDarkV2({
</div>
</main>
{/* 页脚 */}
<footer className="border-t border-white/5 pb-28 pt-6">
<div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex items-center justify-center gap-6 text-sm text-white/30">
<a href="#" className="hover:text-white/60 transition-colors">使用协议</a>
<a href="#" className="hover:text-white/60 transition-colors">隐私条款</a>
<span>© 2024 AI创作平台</span>
</div>
</div>
</footer>
</div>
{onNextStep ? (
<motion.div
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4 sm:px-6"
className="mx-auto mt-8 w-full max-w-[1480px] px-6 lg:px-8 xl:px-10"
>
<div className="pointer-events-auto mx-auto flex w-full max-w-[45rem] flex-col gap-3 rounded-[24px] border border-white/12 bg-black/88 px-3 py-3 shadow-[0_18px_54px_rgba(0,0,0,0.42)] backdrop-blur-2xl sm:px-4">
<div className="flex w-full flex-col gap-3 rounded-[24px] border border-white/12 bg-black/88 px-3 py-3 shadow-[0_18px_54px_rgba(0,0,0,0.42)] backdrop-blur-2xl sm:px-4">
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[13px] font-medium text-white">工作流进度</div>
@ -461,6 +449,18 @@ export function AgentWorkflowPanelDarkV2({
</div>
</motion.div>
) : null}
{/* 页脚 */}
<footer className="mt-8 border-t border-white/5 pb-8 pt-6">
<div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex items-center justify-center gap-6 text-sm text-white/30">
<a href="#" className="hover:text-white/60 transition-colors">使用协议</a>
<a href="#" className="hover:text-white/60 transition-colors">隐私条款</a>
<span>© 2024 AI创作平台</span>
</div>
</div>
</footer>
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { ArrowRight, ImageIcon, PlayCircle } from "lucide-react";
import { ArrowRight, ImageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowShowcaseConfig } from "@/lib/agents/workflow-showcase-config";
@ -27,7 +27,7 @@ export function AgentWorkflowShowcaseBanner({
const resolved = config || defaultAgentWorkflowShowcaseConfig;
const hasVideo = Boolean(resolved.videoUrl);
const hasPoster = Boolean(resolved.posterUrl);
const shouldRenderMedia = resolved.enabled !== false;
const shouldRenderMedia = resolved.enabled !== false && (hasVideo || hasPoster);
const resolvedActionLabel = actionLabel || resolved.actionText;
return (
@ -99,17 +99,7 @@ export function AgentWorkflowShowcaseBanner({
alt={resolved.overlayText || resolved.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center bg-[radial-gradient(circle_at_top,#1b2430_0%,#0e1013_55%,#070809_100%)] text-center">
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.04]">
<PlayCircle className="h-10 w-10 text-white/25" />
</div>
<div className="text-base font-medium text-white/70">顶部视频海报待配置</div>
<div className="mt-2 max-w-lg text-sm text-white/35">
在智能体系统设置中上传视频或封面图后,这里会自动展示循环播放海报。
</div>
</div>
)}
) : null}
</div>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(16,185,129,0.12),transparent_42%)]" />

View File

@ -955,23 +955,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
[data.src, isProcessing, isThisNodePicking, refreshBoxCutoutMetrics, setPickerMode]
);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
}
clearImageNodeActionRequest();
if (imageNodeActionRequest.action === "box-cutout") {
handleEnterBoxCutoutMode();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
imageNodeActionRequest,
node.id,
]);
const handleBoxCutoutPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!boxCutoutMode || isProcessing) return;
@ -1680,6 +1663,29 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
await detectTextItems(true);
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
}
clearImageNodeActionRequest();
if (imageNodeActionRequest.action === "box-cutout") {
handleEnterBoxCutoutMode();
return;
}
if (imageNodeActionRequest.action === "text-edit") {
void handleOpenTextEdit();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
]);
const handleTextItemChange = (id: string, value: string) => {
setTextEditItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, editedText: value } : item)),

View File

@ -0,0 +1,131 @@
import type {
AgentDetailReplacementReferenceItem,
AgentModeReferenceCandidate,
AgentModeWorkspaceState,
AgentWorkflowMaterialQueryType,
} from "@/types/agent";
export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState {
return {
detailReplacement: {
query: "",
queryType: "spu",
candidates: [],
selectedCandidateId: null,
references: [],
},
skuReplacement: {
query: "",
queryType: "sku",
candidates: [],
selectedCandidateId: null,
references: [],
},
};
}
function normalizeReferenceItems(value: unknown): AgentDetailReplacementReferenceItem[] {
if (!Array.isArray(value)) return [];
return value
.map((item, index) => {
const raw =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const id = String(raw.id || "").trim() || `reference-${index + 1}`;
const label = String(raw.label || "").trim() || `参考图 ${index + 1}`;
const imageUrl = String(raw.imageUrl || "").trim() || null;
return {
id,
label,
imageUrl,
candidateId: String(raw.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(raw.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder))))
: index,
assignedProductIndex: Number.isFinite(Number(raw.assignedProductIndex))
? Math.max(0, Math.min(9, Math.round(Number(raw.assignedProductIndex))))
: null,
};
})
.filter((item) => Boolean(item.id));
}
function normalizeQueryType(value: unknown, fallback: AgentWorkflowMaterialQueryType) {
return value === "sku" || value === "spu" ? value : fallback;
}
function normalizeCandidate(value: unknown, index: number): AgentModeReferenceCandidate | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const raw = value as Record<string, unknown>;
const normalizeUrlList = (input: unknown) =>
Array.isArray(input)
? Array.from(
new Set(
input
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
).slice(0, 64)
: [];
const id = String(raw.id || "").trim() || `candidate-${index + 1}`;
return {
id,
materialId: String(raw.materialId || "").trim() || null,
taskNo: String(raw.taskNo || "").trim() || null,
productModel: String(raw.productModel || "").trim() || null,
categoryName: String(raw.categoryName || "").trim() || null,
shopName: String(raw.shopName || "").trim() || null,
sceneName: String(raw.sceneName || "").trim() || null,
uploadedAt: String(raw.uploadedAt || "").trim() || null,
isFullSet: raw.isFullSet !== false,
mainImageUrls: normalizeUrlList(raw.mainImageUrls),
skuImageUrls: normalizeUrlList(raw.skuImageUrls),
detailImageUrls: normalizeUrlList(raw.detailImageUrls),
allImageUrls: normalizeUrlList(raw.allImageUrls),
};
}
function normalizeCandidates(value: unknown): AgentModeReferenceCandidate[] {
if (!Array.isArray(value)) return [];
return value
.map((item, index) => normalizeCandidate(item, index))
.filter((item): item is AgentModeReferenceCandidate => Boolean(item));
}
export function normalizeAgentModeWorkspaceState(value: unknown): AgentModeWorkspaceState {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const detailRaw =
raw.detailReplacement && typeof raw.detailReplacement === "object" && !Array.isArray(raw.detailReplacement)
? (raw.detailReplacement as Record<string, unknown>)
: {};
const skuRaw =
raw.skuReplacement && typeof raw.skuReplacement === "object" && !Array.isArray(raw.skuReplacement)
? (raw.skuReplacement as Record<string, unknown>)
: {};
return {
detailReplacement: {
query: String(detailRaw.query || detailRaw.spuQuery || "").trim(),
queryType: normalizeQueryType(detailRaw.queryType, "spu"),
candidates: normalizeCandidates(detailRaw.candidates),
selectedCandidateId: String(detailRaw.selectedCandidateId || "").trim() || null,
references: normalizeReferenceItems(detailRaw.references),
},
skuReplacement: {
query: String(skuRaw.query || skuRaw.spuQuery || "").trim(),
queryType: normalizeQueryType(skuRaw.queryType, "sku"),
candidates: normalizeCandidates(skuRaw.candidates),
selectedCandidateId: String(skuRaw.selectedCandidateId || "").trim() || null,
references: normalizeReferenceItems(skuRaw.references),
},
};
}

View File

@ -0,0 +1,164 @@
import type {
AgentPresetModeConfig,
AgentPresetModeType,
} from "@/types/agent";
export const defaultAgentSceneGenerationModeConfig = {
productImageSlotCount: 2,
includeModelSlot: true,
allowTemplateReferenceImages: true,
allowModelOverride: false,
} as const;
export const defaultAgentDetailReplacementModeConfig = {
enableTextEditing: true,
productImageSlotCount: 2,
includeModelSlot: true,
} as const;
export const defaultAgentSkuReplacementModeConfig = {
enableTextEditing: true,
productImageSlotCount: 2,
includeModelSlot: true,
} as const;
export const defaultAgentPresetModeConfig: AgentPresetModeConfig = {
modeType: "legacy",
scene: { ...defaultAgentSceneGenerationModeConfig },
detail: { ...defaultAgentDetailReplacementModeConfig },
sku: { ...defaultAgentSkuReplacementModeConfig },
};
function normalizeBoolean(value: unknown, fallback: boolean) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeInteger(value: unknown, fallback: number, min: number, max: number) {
const normalized = Number(value);
if (!Number.isFinite(normalized)) return fallback;
return Math.min(max, Math.max(min, Math.round(normalized)));
}
export function normalizeAgentPresetModeConfig(value: unknown): AgentPresetModeConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const embedded =
raw.agentModeConfig && typeof raw.agentModeConfig === "object" && !Array.isArray(raw.agentModeConfig)
? (raw.agentModeConfig as Record<string, unknown>)
: raw;
const modeTypeRaw = String(embedded.modeType || "").trim() as AgentPresetModeType;
const modeType: AgentPresetModeType =
modeTypeRaw === "scene_generation" ||
modeTypeRaw === "detail_replacement" ||
modeTypeRaw === "sku_replacement" ||
modeTypeRaw === "legacy"
? modeTypeRaw
: "legacy";
const sceneRaw =
embedded.scene && typeof embedded.scene === "object" && !Array.isArray(embedded.scene)
? (embedded.scene as Record<string, unknown>)
: {};
const detailRaw =
embedded.detail && typeof embedded.detail === "object" && !Array.isArray(embedded.detail)
? (embedded.detail as Record<string, unknown>)
: {};
const skuRaw =
embedded.sku && typeof embedded.sku === "object" && !Array.isArray(embedded.sku)
? (embedded.sku as Record<string, unknown>)
: {};
return {
modeType,
scene: {
productImageSlotCount: normalizeInteger(
sceneRaw.productImageSlotCount,
defaultAgentSceneGenerationModeConfig.productImageSlotCount,
1,
4
),
includeModelSlot: normalizeBoolean(
sceneRaw.includeModelSlot,
defaultAgentSceneGenerationModeConfig.includeModelSlot
),
allowTemplateReferenceImages: normalizeBoolean(
sceneRaw.allowTemplateReferenceImages,
defaultAgentSceneGenerationModeConfig.allowTemplateReferenceImages
),
allowModelOverride: normalizeBoolean(
sceneRaw.allowModelOverride,
defaultAgentSceneGenerationModeConfig.allowModelOverride
),
},
detail: {
enableTextEditing: normalizeBoolean(
detailRaw.enableTextEditing,
defaultAgentDetailReplacementModeConfig.enableTextEditing
),
productImageSlotCount: normalizeInteger(
detailRaw.productImageSlotCount,
defaultAgentDetailReplacementModeConfig.productImageSlotCount,
1,
4
),
includeModelSlot: normalizeBoolean(
detailRaw.includeModelSlot,
defaultAgentDetailReplacementModeConfig.includeModelSlot
),
},
sku: {
enableTextEditing: normalizeBoolean(
skuRaw.enableTextEditing,
defaultAgentSkuReplacementModeConfig.enableTextEditing
),
productImageSlotCount: normalizeInteger(
skuRaw.productImageSlotCount,
defaultAgentSkuReplacementModeConfig.productImageSlotCount,
1,
4
),
includeModelSlot: normalizeBoolean(
skuRaw.includeModelSlot,
defaultAgentSkuReplacementModeConfig.includeModelSlot
),
},
};
}
export function patchAgentDefaultParamsWithModeConfig(
defaultParams: Record<string, any> | null | undefined,
modeConfig: AgentPresetModeConfig
) {
return {
...(defaultParams || {}),
agentModeConfig: modeConfig,
};
}
export function getAgentPresetModeTypeFromDefaultParams(
defaultParams: Record<string, any> | null | undefined
): AgentPresetModeType {
return normalizeAgentPresetModeConfig(defaultParams).modeType;
}
export function getAgentRequiredUploadCount(params: {
presetType: "image" | "video" | "text";
defaultParams?: Record<string, any> | null;
fallback?: number;
}) {
if (params.presetType === "text") return 0;
const modeConfig = normalizeAgentPresetModeConfig(params.defaultParams);
if (params.presetType === "image" && modeConfig.modeType === "scene_generation") {
return modeConfig.scene.productImageSlotCount + (modeConfig.scene.includeModelSlot ? 1 : 0);
}
if (params.presetType === "image" && modeConfig.modeType === "detail_replacement") {
return modeConfig.detail.productImageSlotCount + (modeConfig.detail.includeModelSlot ? 1 : 0);
}
if (params.presetType === "image" && modeConfig.modeType === "sku_replacement") {
return modeConfig.sku.productImageSlotCount + (modeConfig.sku.includeModelSlot ? 1 : 0);
}
return typeof params.fallback === "number" ? params.fallback : 3;
}

View File

@ -1,6 +1,7 @@
import type {
AgentWorkflowMaterialApiConfig,
AgentWorkflowMaterialImage,
AgentWorkflowMaterialSkuOption,
AgentWorkflowMaterialSearchResult,
AgentWorkflowMaterialSelectionState,
AgentWorkflowMaterialSelections,
@ -54,6 +55,30 @@ function normalizeMaterialSearchResult(value: unknown): AgentWorkflowMaterialSea
}
const raw = value as Record<string, unknown>;
const skuOptions: AgentWorkflowMaterialSkuOption[] = Array.isArray(raw.skuOptions)
? raw.skuOptions
.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return null;
}
const skuRaw = item as Record<string, unknown>;
const skuName = sanitizeText(skuRaw.skuName, 64);
if (!skuName) return null;
const normalizedOption: AgentWorkflowMaterialSkuOption = {
skuName,
skuId: sanitizeText(skuRaw.skuId, 128) || null,
hasWhiteBackground: skuRaw.hasWhiteBackground === true,
whiteBackgroundCount: Number.isFinite(Number(skuRaw.whiteBackgroundCount))
? Math.max(0, Math.min(999, Math.round(Number(skuRaw.whiteBackgroundCount))))
: 0,
previewImageUrl: sanitizeText(skuRaw.previewImageUrl, 2000) || null,
previewFileName: sanitizeText(skuRaw.previewFileName, 255) || null,
};
return normalizedOption;
})
.filter((item): item is AgentWorkflowMaterialSkuOption => item !== null)
.slice(0, 100)
: [];
const images = Array.isArray(raw.images)
? raw.images
.map((item, index) => normalizeMaterialImage(item, index))
@ -61,13 +86,15 @@ function normalizeMaterialSearchResult(value: unknown): AgentWorkflowMaterialSea
.slice(0, 32)
: [];
if (images.length === 0) {
if (images.length === 0 && skuOptions.length === 0) {
return null;
}
return {
query: sanitizeText(raw.query, 64),
queryType: raw.queryType === "spu" ? "spu" : "sku",
matchedSku: sanitizeText(raw.matchedSku, 64),
matchedSpu: sanitizeText(raw.matchedSpu, 64) || null,
materialId: sanitizeText(raw.materialId, 128) || null,
productModel: sanitizeText(raw.productModel, 128) || null,
zipUrl: sanitizeText(raw.zipUrl, 2000) || null,
@ -83,6 +110,7 @@ function normalizeMaterialSearchResult(value: unknown): AgentWorkflowMaterialSea
images: images
.slice()
.sort((left, right) => left.sortOrder - right.sortOrder),
skuOptions,
};
}

View File

@ -90,6 +90,7 @@ import {
POINTS_SYSTEM_CONFIG_KEY,
} from "../points/config";
import { inferOriginalImageUrl } from "../image-url";
import { getAgentRequiredUploadCount } from "../agents/preset-mode-config";
// Optionally, if not using email/pass login, you can
// use the Drizzle adapter for Auth.js / NextAuth
@ -2517,12 +2518,14 @@ export async function createAgentPreset(params: {
slots: params.slots,
});
const requiredUploadCount =
params.type === "text"
? 0
: validatedSlots.some((slot) => slot.requireUpload !== false)
? 3
: 0;
const hasUploadRequirement = validatedSlots.some((slot) => slot.requireUpload !== false);
const requiredUploadCount = hasUploadRequirement
? getAgentRequiredUploadCount({
presetType: params.type,
defaultParams: params.defaultParams || null,
fallback: 3,
})
: 0;
const avatarAssetId = String(params.avatarAssetId || "").trim() || null;
if (avatarAssetId) {
await validateOwnedReferenceAssets({
@ -2710,12 +2713,18 @@ export async function updateAgentPreset(params: {
userId: params.userId,
slots: normalizedSlotsInput,
});
const requiredUploadCount =
nextType === "text"
? 0
: validatedSlots.some((slot) => slot.requireUpload !== false)
? 3
: 0;
const nextDefaultParams =
params.defaultParams !== undefined
? params.defaultParams || null
: (existing.defaultParams as Record<string, any> | null);
const hasUploadRequirement = validatedSlots.some((slot) => slot.requireUpload !== false);
const requiredUploadCount = hasUploadRequirement
? getAgentRequiredUploadCount({
presetType: nextType,
defaultParams: nextDefaultParams,
fallback: 3,
})
: 0;
const nextAvatarAssetId =
params.avatarAssetId !== undefined
? String(params.avatarAssetId || "").trim() || null

View File

@ -53,7 +53,7 @@ export interface PickedObject {
};
}
export type ImageNodeActionType = 'box-cutout';
export type ImageNodeActionType = 'box-cutout' | 'text-edit';
export interface ImageNodeActionRequest {
nodeId: string;

View File

@ -2,6 +2,37 @@ export type AgentPresetType = "image" | "video" | "text";
export type AgentPresetVisibility = "public" | "private";
export type AgentCategoryScope = "public" | "private" | "global";
export type AgentPresetStatus = "active" | "disabled" | "hidden" | "blocked";
export type AgentPresetModeType =
| "legacy"
| "scene_generation"
| "detail_replacement"
| "sku_replacement";
export interface AgentSceneGenerationModeConfig {
productImageSlotCount: number;
includeModelSlot: boolean;
allowTemplateReferenceImages: boolean;
allowModelOverride: boolean;
}
export interface AgentDetailReplacementModeConfig {
enableTextEditing: boolean;
productImageSlotCount: number;
includeModelSlot: boolean;
}
export interface AgentSkuReplacementModeConfig {
enableTextEditing: boolean;
productImageSlotCount: number;
includeModelSlot: boolean;
}
export interface AgentPresetModeConfig {
modeType: AgentPresetModeType;
scene: AgentSceneGenerationModeConfig;
detail: AgentDetailReplacementModeConfig;
sku: AgentSkuReplacementModeConfig;
}
export interface AgentCategoryPath {
productCategory?: string | null;
@ -45,6 +76,8 @@ export interface AgentWorkflowMaterialApiConfig {
apiKey: string;
}
export type AgentWorkflowMaterialQueryType = "sku" | "spu";
export interface AgentWorkflowBrandModelImage {
id: string;
assetId?: string | null;
@ -92,9 +125,20 @@ export interface AgentWorkflowMaterialImage {
sortOrder: number;
}
export interface AgentWorkflowMaterialSkuOption {
skuName: string;
skuId?: string | null;
hasWhiteBackground: boolean;
whiteBackgroundCount: number;
previewImageUrl?: string | null;
previewFileName?: string | null;
}
export interface AgentWorkflowMaterialSearchResult {
query: string;
queryType?: AgentWorkflowMaterialQueryType;
matchedSku: string;
matchedSpu?: string | null;
materialId?: string | null;
productModel?: string | null;
zipUrl?: string | null;
@ -102,6 +146,7 @@ export interface AgentWorkflowMaterialSearchResult {
otherCount: number;
totalCount: number;
images: AgentWorkflowMaterialImage[];
skuOptions?: AgentWorkflowMaterialSkuOption[];
}
export interface AgentWorkflowMaterialSelectionState {
@ -146,6 +191,52 @@ export interface AgentWorkflowComposerContext {
promptText: string;
}
export interface AgentDetailReplacementReferenceItem {
id: string;
label: string;
imageUrl?: string | null;
candidateId?: string | null;
sortOrder?: number;
assignedProductIndex?: number | null;
}
export interface AgentModeReferenceCandidate {
id: string;
materialId?: string | null;
taskNo?: string | null;
productModel?: string | null;
categoryName?: string | null;
shopName?: string | null;
sceneName?: string | null;
uploadedAt?: string | null;
isFullSet: boolean;
mainImageUrls: string[];
skuImageUrls: string[];
detailImageUrls: string[];
allImageUrls: string[];
}
export interface AgentDetailReplacementState {
query: string;
queryType: AgentWorkflowMaterialQueryType;
candidates: AgentModeReferenceCandidate[];
selectedCandidateId?: string | null;
references: AgentDetailReplacementReferenceItem[];
}
export interface AgentSkuReplacementState {
query: string;
queryType: AgentWorkflowMaterialQueryType;
candidates: AgentModeReferenceCandidate[];
selectedCandidateId?: string | null;
references: AgentDetailReplacementReferenceItem[];
}
export interface AgentModeWorkspaceState {
detailReplacement: AgentDetailReplacementState;
skuReplacement: AgentSkuReplacementState;
}
export interface AgentWorkflowPersistedState {
inputs: {
primarySku: string;
@ -158,6 +249,7 @@ export interface AgentWorkflowPersistedState {
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
selectedAgentPresetId?: string | null;
currentStage?: "workflow" | "mode";
agentModeState?: AgentModeWorkspaceState;
}
export interface AgentCategoryTreeConfig {