feat: upgrade agent workflow generation modes
This commit is contained in:
parent
eb56b7967d
commit
b6db6a671c
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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) {
|
||||
|
||||
275
app/api/agent-workflow/reference-search/route.ts
Normal file
275
app/api/agent-workflow/reference-search/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
@ -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>
|
||||
|
||||
{/* 可见性标签 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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%)]" />
|
||||
|
||||
@ -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)),
|
||||
|
||||
131
lib/agents/mode-workspace-state.ts
Normal file
131
lib/agents/mode-workspace-state.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
164
lib/agents/preset-mode-config.ts
Normal file
164
lib/agents/preset-mode-config.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -53,7 +53,7 @@ export interface PickedObject {
|
||||
};
|
||||
}
|
||||
|
||||
export type ImageNodeActionType = 'box-cutout';
|
||||
export type ImageNodeActionType = 'box-cutout' | 'text-edit';
|
||||
|
||||
export interface ImageNodeActionRequest {
|
||||
nodeId: string;
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user