feat: add brand model selection to agent flow
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-17 22:42:03 +08:00
parent afc9e187b6
commit dae5f59417
10 changed files with 822 additions and 22 deletions

View File

@ -16,6 +16,10 @@ import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
defaultAgentWorkflowBrandModelConfig,
normalizeAgentWorkflowBrandModelConfig,
} from "@/lib/agents/workflow-brand-model-config";
import {
defaultAgentWorkflowMaterialApiConfig,
normalizeAgentWorkflowMaterialApiConfig,
@ -27,7 +31,11 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import type { AgentWorkflowMaterialApiConfig, AgentWorkflowUiConfig } from "@/types/agent";
import type {
AgentWorkflowBrandModelConfig,
AgentWorkflowMaterialApiConfig,
AgentWorkflowUiConfig,
} from "@/types/agent";
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
@ -35,11 +43,15 @@ export function AgentWorkflowSettingsClient() {
const { data, isLoading, mutate } = useSWR<{
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
}>("/api/site-settings", fetcher);
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>(
defaultAgentWorkflowMaterialApiConfig
);
const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>(
defaultAgentWorkflowBrandModelConfig
);
const [isSaving, setIsSaving] = useState(false);
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
@ -50,8 +62,24 @@ export function AgentWorkflowSettingsClient() {
setMaterialApiConfig(
normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig)
);
setBrandModelConfig(
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
);
}, [data]);
const configuredBrands = useMemo(
() => config.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config.inputOptions?.brands]
);
const brandModelBrands = useMemo(() => {
return Array.from(
new Set([
...configuredBrands,
...brandModelConfig.entries.map((entry) => entry.brandName),
])
).sort((left, right) => left.localeCompare(right, "zh-CN"));
}, [brandModelConfig.entries, configuredBrands]);
const sortedSteps = useMemo(
() => [...config.steps].sort((left, right) => left.sortOrder - right.sortOrder),
[config.steps]
@ -122,6 +150,7 @@ export function AgentWorkflowSettingsClient() {
body: JSON.stringify({
agentWorkflowUiConfig: config,
agentWorkflowMaterialApiConfig: materialApiConfig,
agentWorkflowBrandModelConfig: brandModelConfig,
}),
});
@ -138,6 +167,106 @@ export function AgentWorkflowSettingsClient() {
}
};
const updateBrandModelEntry = (
brandName: string,
updater: (
entry: NonNullable<AgentWorkflowBrandModelConfig["entries"][number]>
) => NonNullable<AgentWorkflowBrandModelConfig["entries"][number]>
) => {
setBrandModelConfig((prev) => {
const entryIndex = prev.entries.findIndex((entry) => entry.brandName === brandName);
const currentEntry =
entryIndex >= 0
? prev.entries[entryIndex]
: {
brandName,
images: [],
};
const nextEntry = updater(currentEntry);
const nextEntries =
entryIndex >= 0
? prev.entries.map((entry, index) => (index === entryIndex ? nextEntry : entry))
: [...prev.entries, nextEntry];
return {
entries: nextEntries
.filter((entry) => entry.images.length > 0 || configuredBrands.includes(entry.brandName))
.map((entry) => ({
...entry,
images: entry.images
.slice()
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((image, index) => ({
...image,
sortOrder: index,
})),
})),
};
});
};
const handleUploadBrandModelImages = async (brandName: string, files: FileList | null) => {
const pickedFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
if (pickedFiles.length === 0) {
toast.error("请先选择图片文件");
return;
}
const refKey = `brand-model:${brandName}`;
try {
setUploadingKey(refKey);
const uploadedImages: AgentWorkflowBrandModelConfig["entries"][number]["images"] = [];
for (const file of pickedFiles) {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
uploadedImages.push({
id:
(typeof result.assetId === "string" && result.assetId.trim()) ||
`${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
assetId: typeof result.assetId === "string" ? result.assetId : null,
imageUrl: result.url,
displayName: file.name,
mimeType: file.type || null,
sortOrder: 0,
});
}
updateBrandModelEntry(brandName, (entry) => ({
...entry,
images: [
...entry.images,
...uploadedImages.map((image, index) => ({
...image,
sortOrder: entry.images.length + index,
})),
],
}));
toast.success(
uploadedImages.length === 1 ? "模特图上传成功" : `已上传 ${uploadedImages.length} 张模特图`
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "模特图上传失败");
} finally {
setUploadingKey(null);
}
};
const handleRemoveBrandModelImage = (brandName: string, imageId: string) => {
updateBrandModelEntry(brandName, (entry) => ({
...entry,
images: entry.images.filter((image) => image.id !== imageId),
}));
};
return (
<div className="container mx-auto space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
@ -296,6 +425,115 @@ export function AgentWorkflowSettingsClient() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>品牌模特图库</CardTitle>
<CardDescription>
第二步会根据第一步选择的品牌,展示这里配置的品牌模特图供用户单选,并自动带入生图流程的模特图槽位。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{brandModelBrands.length > 0 ? (
brandModelBrands.map((brandName) => {
const refKey = `brand-model:${brandName}`;
const entry =
brandModelConfig.entries.find((item) => item.brandName === brandName) || null;
const images = entry?.images || [];
const isUploading = uploadingKey === refKey;
return (
<div key={brandName} className="rounded-2xl border bg-muted/10 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{brandName}</div>
<div className="mt-1 text-xs text-muted-foreground">
当前已配置 {images.length} 张模特图
</div>
</div>
<div className="flex items-center gap-2">
<input
ref={(element) => {
fileInputRefs.current[refKey] = element;
}}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void handleUploadBrandModelImages(brandName, event.target.files);
event.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRefs.current[refKey]?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传模特图
</Button>
</div>
</div>
{images.length > 0 ? (
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6">
{images.map((image) => (
<div
key={image.id}
className="overflow-hidden rounded-2xl border bg-background"
>
<div className="relative aspect-square overflow-hidden bg-muted/30">
<img
src={image.imageUrl}
alt={image.displayName || `${brandName} 模特图`}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
<div className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0 text-[11px] text-muted-foreground">
<div className="truncate">
{image.displayName || `${brandName} 模特图`}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
onClick={() =>
handleRemoveBrandModelImage(brandName, image.id)
}
>
删除
</Button>
</div>
</div>
))}
</div>
) : (
<div className="mt-4 rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
当前品牌还没有上传模特图,上传后第二步会自动按品牌展示给用户选择。
</div>
)}
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
先在上方“品牌列表”里配置品牌,随后即可为每个品牌上传对应的模特图。
</div>
)}
</CardContent>
</Card>
{sortedSteps.map((step) => (
<Card key={step.key}>
<CardHeader>

View File

@ -2741,12 +2741,21 @@ function normalizeAgentSupplementPrompt(text: string, presetName?: string | null
return normalized;
}
function normalizeAgentWorkflowPrompt(text: string) {
return String(text || "").trim();
}
function buildAgentSlotPrompt(params: {
workflowPrompt?: string | null;
slotPrompt: string;
slotTitle?: string | null;
userSupplementPrompt?: string | null;
}) {
const lines: string[] = [];
const workflowPrompt = normalizeAgentWorkflowPrompt(params.workflowPrompt || "");
if (workflowPrompt) {
lines.push(workflowPrompt);
}
const slotTitle = String(params.slotTitle || "").trim();
if (slotTitle) {
lines.push(`【槽位】${slotTitle}`);
@ -5521,6 +5530,7 @@ export async function POST(request: Request) {
),
);
const slotPrompt = buildAgentSlotPrompt({
workflowPrompt: hiddenPromptText || null,
slotPrompt: slot.promptTemplate,
slotTitle: slot.title || slot.uploadLabel || null,
userSupplementPrompt: sanitizedUserSupplementPrompt || null,

View File

@ -34,6 +34,11 @@ import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
} from "@/lib/agents/workflow-ui-config";
import {
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
defaultAgentWorkflowBrandModelConfig,
normalizeAgentWorkflowBrandModelConfig,
} from "@/lib/agents/workflow-brand-model-config";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
defaultAgentWorkflowMaterialApiConfig,
@ -227,6 +232,7 @@ export async function GET() {
agentSecondaryTypeConfigSetting,
agentWorkflowUiConfigSetting,
agentWorkflowMaterialApiConfigSetting,
agentWorkflowBrandModelConfigSetting,
] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db
@ -349,6 +355,12 @@ export async function GET() {
.where(eq(systemSettings.key, AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@ -398,6 +410,9 @@ export async function GET() {
: normalizeAgentWorkflowMaterialApiConfigForPublic(
agentWorkflowMaterialApiConfigSetting?.value
);
const agentWorkflowBrandModelConfig = normalizeAgentWorkflowBrandModelConfig(
agentWorkflowBrandModelConfigSetting?.value
);
// 如果没有设置记录,返回默认值
const baseSettings = settings || {
@ -427,6 +442,7 @@ export async function GET() {
agentSecondaryTypeConfig: defaultAgentSecondaryTypeConfig,
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
agentWorkflowMaterialApiConfig: defaultAgentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig: defaultAgentWorkflowBrandModelConfig,
realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true,
storageFailoverConfig,
@ -472,6 +488,7 @@ export async function GET() {
agentSecondaryTypeConfig,
agentWorkflowUiConfig,
agentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@ -576,6 +593,29 @@ const updateSchema = z.object({
apiKey: z.string().trim().max(500).optional(),
})
.optional(),
agentWorkflowBrandModelConfig: z
.object({
entries: z
.array(
z.object({
brandName: z.string().trim().min(1).max(64),
images: z
.array(
z.object({
id: z.string().trim().min(1).max(128).optional(),
assetId: z.string().trim().max(128).nullable().optional(),
imageUrl: z.string().trim().min(1).max(2000),
displayName: z.string().trim().max(255).nullable().optional(),
mimeType: z.string().trim().max(128).nullable().optional(),
sortOrder: z.number().min(0).max(999).optional(),
})
)
.max(100),
})
)
.max(300),
})
.optional(),
realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
@ -655,6 +695,7 @@ export async function PUT(request: NextRequest) {
agentSecondaryTypeConfig,
agentWorkflowUiConfig,
agentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@ -901,6 +942,28 @@ export async function PUT(request: NextRequest) {
});
}
if (agentWorkflowBrandModelConfig !== undefined) {
const normalized = normalizeAgentWorkflowBrandModelConfig(
agentWorkflowBrandModelConfig
);
await db
.insert(systemSettings)
.values({
key: AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
@ -1081,6 +1144,10 @@ export async function PUT(request: NextRequest) {
agentWorkflowMaterialApiConfig !== undefined
? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfig)
: defaultAgentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig:
agentWorkflowBrandModelConfig !== undefined
? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig)
: defaultAgentWorkflowBrandModelConfig,
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)
@ -1371,6 +1438,28 @@ export async function PUT(request: NextRequest) {
});
}
if (agentWorkflowBrandModelConfig !== undefined) {
const normalized = normalizeAgentWorkflowBrandModelConfig(
agentWorkflowBrandModelConfig
);
await db
.insert(systemSettings)
.values({
key: AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
@ -1551,6 +1640,10 @@ export async function PUT(request: NextRequest) {
agentWorkflowMaterialApiConfig !== undefined
? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfig)
: defaultAgentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig:
agentWorkflowBrandModelConfig !== undefined
? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig)
: defaultAgentWorkflowBrandModelConfig,
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)

View File

@ -63,6 +63,7 @@ import {
type AgentWorkflowInputs,
} from "@/components/agents/AgentWorkflowPanel";
import { AgentGenerationWorkspace } from "@/components/agents/AgentGenerationWorkspace";
import { AgentWorkflowBrandModelPicker } from "@/components/agents/AgentWorkflowBrandModelPicker";
import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel";
import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel";
import { CanvasWorkspace } from "@/components/canvas";
@ -89,6 +90,7 @@ import type {
AgentWorkflowComposerContext,
AgentWorkflowMaterialSelections,
AgentWorkflowPersistedState,
AgentWorkflowSelectedBrandModel,
AgentWorkflowStepKey,
} from "@/types/agent";
@ -175,6 +177,8 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [workflowSelections, setWorkflowSelections] = useState<
Partial<Record<AgentWorkflowStepKey, string>>
>({});
const [selectedBrandModel, setSelectedBrandModel] =
useState<AgentWorkflowSelectedBrandModel | null>(null);
const [selectedAgentPresetId, setSelectedAgentPresetId] = useState<string | null>(null);
const [currentAgentStage, setCurrentAgentStage] = useState<"workflow" | "mode">("workflow");
const [workflowHydrationSource, setWorkflowHydrationSource] = useState<
@ -280,6 +284,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
});
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
setWorkflowSelections({});
setSelectedBrandModel(null);
setSelectedAgentPresetId(null);
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
@ -303,6 +308,33 @@ export default function CanvasEditorPage({ params }: PageProps) {
normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials)
);
setWorkflowSelections(persistedWorkflow.selections || {});
setSelectedBrandModel(
persistedWorkflow.selectedBrandModel
? {
...persistedWorkflow.selectedBrandModel,
brandName: String(persistedWorkflow.selectedBrandModel.brandName || "").trim(),
id: String(persistedWorkflow.selectedBrandModel.id || "").trim(),
imageUrl: String(persistedWorkflow.selectedBrandModel.imageUrl || "").trim(),
assetId:
String(persistedWorkflow.selectedBrandModel.assetId || "").trim() || null,
displayName:
String(persistedWorkflow.selectedBrandModel.displayName || "").trim() || null,
mimeType:
String(persistedWorkflow.selectedBrandModel.mimeType || "").trim() || null,
sortOrder: Number.isFinite(
Number(persistedWorkflow.selectedBrandModel.sortOrder)
)
? Math.max(
0,
Math.min(
999,
Math.round(Number(persistedWorkflow.selectedBrandModel.sortOrder))
)
)
: 0,
}
: null
);
setSelectedAgentPresetId(
String(persistedWorkflow.selectedAgentPresetId || "").trim() || null
);
@ -359,6 +391,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
},
materials: workflowMaterials,
selections: workflowSelections,
selectedBrandModel,
selectedAgentPresetId: selectedAgentPresetId || null,
currentStage: currentAgentStage,
};
@ -371,6 +404,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
currentAgentStage,
getCanvasState,
selectedAgentPresetId,
selectedBrandModel,
workflowInputs,
workflowMaterials,
workflowSelections,
@ -396,6 +430,22 @@ export default function CanvasEditorPage({ params }: PageProps) {
});
setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(parsed.materials));
setWorkflowSelections(parsed.selections || {});
setSelectedBrandModel(
parsed.selectedBrandModel
? {
...parsed.selectedBrandModel,
brandName: String(parsed.selectedBrandModel.brandName || "").trim(),
id: String(parsed.selectedBrandModel.id || "").trim(),
imageUrl: String(parsed.selectedBrandModel.imageUrl || "").trim(),
assetId: String(parsed.selectedBrandModel.assetId || "").trim() || null,
displayName: String(parsed.selectedBrandModel.displayName || "").trim() || null,
mimeType: String(parsed.selectedBrandModel.mimeType || "").trim() || null,
sortOrder: Number.isFinite(Number(parsed.selectedBrandModel.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(parsed.selectedBrandModel.sortOrder))))
: 0,
}
: null
);
setSelectedAgentPresetId(String(parsed.selectedAgentPresetId || "").trim() || null);
setCurrentAgentStage(parsed.currentStage === "mode" ? "mode" : "workflow");
} catch (error) {
@ -415,6 +465,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
inputs: workflowInputs,
materials: workflowMaterials,
selections: workflowSelections,
selectedBrandModel,
selectedAgentPresetId,
currentAgentStage,
})
@ -428,10 +479,23 @@ export default function CanvasEditorPage({ params }: PageProps) {
workflowMaterials,
workflowSelections,
workflowStorageKey,
selectedBrandModel,
selectedAgentPresetId,
currentAgentStage,
]);
useEffect(() => {
if (!selectedBrandModel) return;
const normalizedBrand = workflowInputs.brand.trim().toLowerCase();
if (!normalizedBrand) {
setSelectedBrandModel(null);
return;
}
if (selectedBrandModel.brandName.toLowerCase() !== normalizedBrand) {
setSelectedBrandModel(null);
}
}, [selectedBrandModel, workflowInputs.brand]);
const persistCanvasState = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => {
const canvasData = buildPersistedCanvasData();
@ -863,9 +927,16 @@ export default function CanvasEditorPage({ params }: PageProps) {
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
inputs: workflowInputs,
materials: workflowMaterials,
selectedBrandModel,
selections: workflowSelections,
});
}, [resolvedWorkflowUiConfig, workflowInputs, workflowMaterials, workflowSelections]);
}, [
resolvedWorkflowUiConfig,
selectedBrandModel,
workflowInputs,
workflowMaterials,
workflowSelections,
]);
const workflowProgress = useMemo(
() =>
getAgentWorkflowProgress({
@ -951,6 +1022,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
});
setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections());
setWorkflowSelections({});
setSelectedBrandModel(null);
setSelectedAgentPresetId(null);
setCurrentAgentStage("workflow");
}}
@ -970,7 +1042,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
生图模式
</div>
<div className="mt-1 text-xs text-muted-foreground">
先在本页选择生图模式,再使用上方 SKU / SPU 取图,右侧全屏工作区会自动进入对应图片槽位并展示结果。
先在本页选择生图模式,再按品牌选择模特图并搜索 SKU / SPU 图片,右侧全屏工作区会自动进入对应图片槽位并展示结果。
</div>
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
第 2 步 / 2 步
@ -994,13 +1066,18 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-4 p-4">
<AgentWorkflowBrandModelPicker
brand={workflowInputs.brand}
selectedBrandModel={selectedBrandModel}
onSelectBrandModel={setSelectedBrandModel}
/>
<AgentWorkflowMaterialSelectionPanel
inputs={workflowInputs}
onInputsChange={setWorkflowInputs}
materials={workflowMaterials}
onMaterialsChange={setWorkflowMaterials}
title="SKU / SPU 获取图片"
description="第二步直接在这里搜索主 SKU / SPU 与搭配 SKU 图片,选中的图片会自动进入右侧对应产品槽位。"
description="第二步直接在这里搜索主 SKU / SPU 与搭配 SKU 图片,选中的图片会自动进入右侧前两个产品槽位。"
disabled={!selectedAgentPresetId}
disabledHint="请先选择一个生图模式,系统才能把你选中的图片自动送入对应生图槽位。"
/>
@ -1059,6 +1136,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
chatId={`canvas-${id}`}
projectId={id}
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
selectedBrandModel={selectedBrandModel}
workflowContext={workflowContext}
/>
</ClientErrorBoundary>

View File

@ -30,12 +30,17 @@ import { buildImageProxyUrl } from "@/lib/image-delivery";
import { extractImageUrlsFromText } from "@/lib/image-url";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
import type { AgentPreset, AgentWorkflowComposerContext } from "@/types/agent";
import type {
AgentPreset,
AgentWorkflowComposerContext,
AgentWorkflowSelectedBrandModel,
} from "@/types/agent";
interface AgentGenerationWorkspaceProps {
chatId: string;
projectId: string;
selectedAgentPresetId?: string | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | null;
}
@ -120,6 +125,7 @@ export function AgentGenerationWorkspace({
chatId,
projectId,
selectedAgentPresetId,
selectedBrandModel = null,
workflowContext = null,
}: AgentGenerationWorkspaceProps) {
const rawChatId = useMemo(
@ -131,8 +137,9 @@ export function AgentGenerationWorkspace({
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const hiddenPromptRef = useRef<string | null>(null);
const generatedImageKeysRef = useRef<Set<string>>(new Set());
const lastSyncedBrandModelUrlRef = useRef<string | null>(null);
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<PickedAttachment[]>([]);
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>([]);
const [slotUploading, setSlotUploading] = useState<number | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string>("auto");
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
@ -279,6 +286,7 @@ export function AgentGenerationWorkspace({
);
const primaryImages = referenceImages.filter((image) => image.source === "primary");
const secondaryImages = referenceImages.filter((image) => image.source === "secondary");
const brandModelImages = referenceImages.filter((image) => image.source === "brandModel");
const toAttachment = (image: (typeof referenceImages)[number] | undefined) =>
image
? {
@ -291,21 +299,66 @@ export function AgentGenerationWorkspace({
nextSlots[0] = toAttachment(primaryImages[0] || secondaryImages[0] || referenceImages[0]);
nextSlots[1] = toAttachment(secondaryImages[0] || primaryImages[1] || referenceImages[1]);
nextSlots[2] = toAttachment(brandModelImages[0]);
setAttachments((current) => {
const result: Array<PickedAttachment | null> = Array.from(
{ length: agentUploadSlots.length },
(_, index) => current[index] || null
);
for (let index = 0; index < 2; index += 1) {
for (let index = 0; index < 3; index += 1) {
if (!result[index] && nextSlots[index]) {
result[index] = nextSlots[index] as PickedAttachment;
}
}
return result.filter((item): item is PickedAttachment => Boolean(item));
return result;
});
}, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]);
useEffect(() => {
if (!hasAgentUploadSlots) {
lastSyncedBrandModelUrlRef.current = null;
return;
}
const nextAttachment = selectedBrandModel?.imageUrl
? {
url: selectedBrandModel.imageUrl,
name: selectedBrandModel.displayName || `${selectedBrandModel.brandName} 模特图`,
contentType: selectedBrandModel.mimeType || "image/png",
assetId: selectedBrandModel.assetId || undefined,
}
: null;
setAttachments((current) => {
const slots: Array<PickedAttachment | null> = Array.from(
{ length: Math.max(agentUploadSlots.length, 3) },
(_, index) => current[index] ?? null
);
const currentSlot = slots[2] ?? null;
const previousSyncedUrl = lastSyncedBrandModelUrlRef.current;
if (nextAttachment) {
slots[2] = nextAttachment;
} else if (previousSyncedUrl && currentSlot?.url === previousSyncedUrl) {
slots[2] = null;
}
return slots;
});
lastSyncedBrandModelUrlRef.current = nextAttachment?.url || null;
}, [
agentUploadSlots.length,
hasAgentUploadSlots,
selectedBrandModel?.assetId,
selectedBrandModel?.brandName,
selectedBrandModel?.displayName,
selectedBrandModel?.id,
selectedBrandModel?.imageUrl,
selectedBrandModel?.mimeType,
]);
const generatedGroups = useMemo<GeneratedImageGroup[]>(() => {
return messages
.filter((message) => message.role === "assistant")
@ -383,10 +436,7 @@ export function AgentGenerationWorkspace({
(_, index) => current[index] ?? null
);
slots[slotIndex] = attachment;
while (slots.length > 0 && !slots[slots.length - 1]) {
slots.pop();
}
return slots.filter((item): item is PickedAttachment => Boolean(item));
return slots;
});
}, [agentUploadSlots.length]);
@ -435,7 +485,8 @@ export function AgentGenerationWorkspace({
return;
}
if (hasAgentUploadSlots && attachments.length < requiredUploadCount) {
const uploadedAttachmentCount = attachments.filter(Boolean).length;
if (hasAgentUploadSlots && uploadedAttachmentCount < requiredUploadCount) {
toast.error(`当前模式需要上传 ${requiredUploadCount} 张图片`);
return;
}
@ -452,6 +503,7 @@ export function AgentGenerationWorkspace({
> = [];
attachments.forEach((attachment) => {
if (!attachment) return;
parts.push({
type: "file",
mediaType: attachment.contentType || "image/jpeg",
@ -462,14 +514,11 @@ export function AgentGenerationWorkspace({
});
let textContent = input.trim();
if (!textContent && workflowContext?.summaryText) {
if (!textContent && selectedAgentPreset?.type === "text" && workflowContext?.summaryText) {
textContent = `请基于当前工作流设定生成方案:${workflowContext.summaryText}`;
}
if (!textContent && selectedAgentPreset) {
textContent =
selectedAgentPreset.type === "text"
? `请按「${selectedAgentPreset.name}」输出内容`
: `请按「${selectedAgentPreset.name}」模式处理产品图1、产品图2和模特图。`;
if (!textContent && selectedAgentPreset?.type === "text" && selectedAgentPreset) {
textContent = `请按「${selectedAgentPreset.name}」输出内容`;
}
if (textContent) {
parts.push({ type: "text", text: textContent });
@ -550,7 +599,7 @@ export function AgentGenerationWorkspace({
<div className="rounded-3xl border bg-muted/10 p-4">
<div className="text-sm font-medium">图片槽位</div>
<div className="mt-1 text-xs text-muted-foreground">
主 SKU / 搭配 SKU 选中的图片会优先进入前两个槽位,模特图可手动上传。
主 SKU / 搭配 SKU 选中的图片会优先进入前两个槽位,品牌模特选择会自动进入第三个模特图槽位,你也可以手动替换。
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{agentUploadSlots.map((slot, index) => {

View File

@ -0,0 +1,159 @@
"use client";
import { useMemo } from "react";
import useSWR from "swr";
import { Check, ImageIcon, UserRound } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn, fetcher } from "@/lib/utils";
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
import type {
AgentWorkflowBrandModelConfig,
AgentWorkflowSelectedBrandModel,
} from "@/types/agent";
interface AgentWorkflowBrandModelPickerProps {
brand: string;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
onSelectBrandModel: (value: AgentWorkflowSelectedBrandModel | null) => void;
}
export function AgentWorkflowBrandModelPicker({
brand,
selectedBrandModel = null,
onSelectBrandModel,
}: AgentWorkflowBrandModelPickerProps) {
const { data } = useSWR<{ agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig }>(
"/api/site-settings",
fetcher
);
const normalizedBrand = brand.trim();
const config = useMemo(
() => normalizeAgentWorkflowBrandModelConfig(data?.agentWorkflowBrandModelConfig),
[data?.agentWorkflowBrandModelConfig]
);
const matchedEntry = useMemo(
() =>
config.entries.find(
(entry) => entry.brandName.toLowerCase() === normalizedBrand.toLowerCase()
) || null,
[config.entries, normalizedBrand]
);
const images = matchedEntry?.images || [];
return (
<section className="space-y-3 rounded-2xl border border-[#dbe3ff] bg-[#f8faff] p-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-[#334155]">
<UserRound className="h-4 w-4 text-[#4f46e5]" />
<span>品牌模特选择</span>
</div>
<div className="text-xs leading-5 text-[#5b6472]">
根据第一步选择的品牌,展示管理员预先上传的模特图。选中后会自动带入右侧“模特图”槽位。
</div>
</div>
{!normalizedBrand ? (
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground">
<ImageIcon className="mb-2 h-5 w-5 opacity-60" />
先回到第一步选择品牌,第二步才会按品牌展示可选模特图。
</div>
) : images.length > 0 ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-background px-2 py-1">品牌:{normalizedBrand}</span>
<span className="rounded-full bg-background px-2 py-1">
共 {images.length} 张模特图
</span>
{selectedBrandModel ? (
<span className="rounded-full bg-background px-2 py-1">已选 1 张</span>
) : null}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onSelectBrandModel(null)}
disabled={!selectedBrandModel}
>
清空选择
</Button>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{images.map((image) => {
const selected =
selectedBrandModel?.brandName === normalizedBrand &&
selectedBrandModel?.id === image.id;
return (
<button
key={image.id}
type="button"
onClick={() =>
onSelectBrandModel(
selected
? null
: {
...image,
brandName: normalizedBrand,
}
)
}
className={cn(
"group overflow-hidden rounded-2xl border bg-background text-left transition-all",
selected
? "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: "border-border hover:border-primary/40 hover:shadow-sm"
)}
>
<div className="relative aspect-square overflow-hidden bg-muted/40">
<img
src={image.imageUrl}
alt={image.displayName || `${normalizedBrand} 模特图`}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
品牌模特
</span>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
selected
? "border-primary bg-primary"
: "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100"
)}
>
<Check className="h-3.5 w-3.5" />
</span>
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent px-3 py-2 text-[11px] text-white">
{image.displayName || `${normalizedBrand} 模特图`}
</div>
</div>
<div className="space-y-1 px-3 py-2">
<div className="truncate text-xs font-medium">
{image.displayName || `${normalizedBrand} 模特图`}
</div>
<div className="text-[11px] text-muted-foreground">
点击{selected ? "取消" : "选择"},自动进入模特图槽位
</div>
</div>
</button>
);
})}
</div>
</div>
) : (
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground">
<ImageIcon className="mb-2 h-5 w-5 opacity-60" />
品牌“{normalizedBrand}”暂未配置模特图库,请到系统设置上传对应模特图。
</div>
)}
</section>
);
}

View File

@ -149,7 +149,7 @@ export function AgentWorkflowMaterialSelectionPanel({
<AgentWorkflowMaterialPicker
title="搭配 SKU"
description="搭配商品图会优先自动填充到“产品图2”;模特图仍保留手动上传。"
description="搭配商品图会优先自动填充到“产品图2”;品牌模特图会在上方单独选择并进入模特图槽位。"
value={inputs.secondarySku}
placeholder="输入搭配 SKU"
buttonLabel="搜索搭配图"

View File

@ -0,0 +1,115 @@
import type {
AgentWorkflowBrandModelConfig,
AgentWorkflowBrandModelEntry,
AgentWorkflowBrandModelImage,
} from "@/types/agent";
export const AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY = "agent_workflow_brand_model_config";
export const defaultAgentWorkflowBrandModelConfig: AgentWorkflowBrandModelConfig = {
entries: [],
};
function sanitizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
function normalizeBrandModelImage(
value: unknown,
index: number
): AgentWorkflowBrandModelImage | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const raw = value as Record<string, unknown>;
const imageUrl = sanitizeText(raw.imageUrl, 2000);
if (!imageUrl) return null;
const assetId = sanitizeText(raw.assetId, 128) || null;
const id = sanitizeText(raw.id, 128) || assetId || `brand-model-${index + 1}`;
return {
id,
assetId,
imageUrl,
displayName: sanitizeText(raw.displayName, 255) || null,
mimeType: sanitizeText(raw.mimeType, 128) || null,
sortOrder: Number.isFinite(Number(raw.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder))))
: index,
};
}
function normalizeBrandModelEntry(
value: unknown,
index: number
): AgentWorkflowBrandModelEntry | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const raw = value as Record<string, unknown>;
const brandName = sanitizeText(raw.brandName, 64);
if (!brandName) return null;
const rawImages = Array.isArray(raw.images) ? raw.images : [];
const images = rawImages
.map((item, imageIndex) => normalizeBrandModelImage(item, imageIndex))
.filter((item): item is AgentWorkflowBrandModelImage => Boolean(item))
.sort((left, right) => left.sortOrder - right.sortOrder)
.slice(0, 100);
return {
brandName,
images,
};
}
export function normalizeAgentWorkflowBrandModelConfig(
value: unknown
): AgentWorkflowBrandModelConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaultAgentWorkflowBrandModelConfig;
}
const raw = value as Record<string, unknown>;
const rawEntries = Array.isArray(raw.entries) ? raw.entries : [];
const entryMap = new Map<string, AgentWorkflowBrandModelEntry>();
rawEntries.forEach((item, index) => {
const normalized = normalizeBrandModelEntry(item, index);
if (!normalized) return;
const existing = entryMap.get(normalized.brandName);
if (!existing) {
entryMap.set(normalized.brandName, normalized);
return;
}
const imageMap = new Map<string, AgentWorkflowBrandModelImage>();
[...existing.images, ...normalized.images].forEach((image, imageIndex) => {
const key = image.assetId || image.id || image.imageUrl || `image-${imageIndex + 1}`;
if (!imageMap.has(key)) {
imageMap.set(key, {
...image,
sortOrder: imageMap.size,
});
}
});
entryMap.set(normalized.brandName, {
brandName: normalized.brandName,
images: Array.from(imageMap.values()),
});
});
return {
entries: Array.from(entryMap.values())
.map((entry) => ({
...entry,
images: entry.images.slice().sort((left, right) => left.sortOrder - right.sortOrder),
}))
.sort((left, right) => left.brandName.localeCompare(right.brandName, "zh-CN")),
};
}

View File

@ -3,6 +3,7 @@ import type {
AgentWorkflowComposerContext,
AgentWorkflowMaterialSelections,
AgentWorkflowReferenceImage,
AgentWorkflowSelectedBrandModel,
AgentWorkflowStepKey,
AgentWorkflowUiConfig,
} from "@/types/agent";
@ -18,6 +19,7 @@ export function buildAgentWorkflowComposerContext(params: {
config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputsValue;
materials?: AgentWorkflowMaterialSelections | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}): AgentWorkflowComposerContext | null {
const config = params.config || defaultAgentWorkflowUiConfig;
@ -43,6 +45,7 @@ export function buildAgentWorkflowComposerContext(params: {
const referenceImages = getSelectedWorkflowReferenceImages({
inputs: params.inputs,
materials: params.materials,
selectedBrandModel: params.selectedBrandModel,
});
const summaryParts: string[] = [];
@ -68,6 +71,11 @@ export function buildAgentWorkflowComposerContext(params: {
`搭配SKU图:${referenceImages.filter((item) => item.source === "secondary").length}张`
);
}
if (referenceImages.some((item) => item.source === "brandModel")) {
summaryParts.push(
`品牌模特图:${referenceImages.filter((item) => item.source === "brandModel").length}张`
);
}
selections.forEach((item) => {
summaryParts.push(`${item.stepTitle}:${item.optionName}`);
});
@ -113,6 +121,18 @@ export function buildAgentWorkflowComposerContext(params: {
}`
);
}
if (referenceImages.some((item) => item.source === "brandModel")) {
const labels = referenceImages
.filter((item) => item.source === "brandModel")
.map((item) => item.label)
.filter(Boolean)
.slice(0, 8);
promptLines.push(
`品牌模特图:已选${referenceImages.filter((item) => item.source === "brandModel").length}张${
labels.length > 0 ? `(${labels.join("、")})` : ""
}`
);
}
selections.forEach((item) => {
promptLines.push(`${item.stepTitle}:${item.promptFragment || item.optionName}`);
});
@ -132,6 +152,7 @@ export function buildAgentWorkflowComposerContext(params: {
export function getSelectedWorkflowReferenceImages(params: {
inputs: AgentWorkflowInputsValue;
materials?: AgentWorkflowMaterialSelections | null;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
}): AgentWorkflowReferenceImage[] {
const materials = params.materials;
if (!materials) return [];
@ -173,6 +194,20 @@ export function getSelectedWorkflowReferenceImages(params: {
return [
...collect("primary", "主SPU", params.inputs.primarySku.trim()),
...collect("secondary", "搭配SKU", params.inputs.secondarySku.trim()),
...(params.selectedBrandModel?.imageUrl
? [
{
id: params.selectedBrandModel.id,
source: "brandModel" as const,
sourceLabel: "品牌模特",
label: params.selectedBrandModel.displayName || params.selectedBrandModel.brandName,
name: params.selectedBrandModel.displayName || params.selectedBrandModel.brandName,
url: params.selectedBrandModel.imageUrl,
assetId: params.selectedBrandModel.assetId || null,
mimeType: params.selectedBrandModel.mimeType || null,
},
]
: []),
];
}

View File

@ -53,7 +53,29 @@ export interface AgentWorkflowMaterialApiConfig {
apiKey: string;
}
export type AgentWorkflowMaterialSource = "primary" | "secondary";
export interface AgentWorkflowBrandModelImage {
id: string;
assetId?: string | null;
imageUrl: string;
displayName?: string | null;
mimeType?: string | null;
sortOrder: number;
}
export interface AgentWorkflowBrandModelEntry {
brandName: string;
images: AgentWorkflowBrandModelImage[];
}
export interface AgentWorkflowBrandModelConfig {
entries: AgentWorkflowBrandModelEntry[];
}
export interface AgentWorkflowSelectedBrandModel extends AgentWorkflowBrandModelImage {
brandName: string;
}
export type AgentWorkflowMaterialSource = "primary" | "secondary" | "brandModel";
export interface AgentWorkflowMaterialImage {
id: string;
@ -130,6 +152,7 @@ export interface AgentWorkflowPersistedState {
};
materials?: AgentWorkflowMaterialSelections;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
selectedAgentPresetId?: string | null;
currentStage?: "workflow" | "mode";
}