diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index 90e7e06b0..c6ebe646c 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -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(defaultAgentWorkflowUiConfig); const [materialApiConfig, setMaterialApiConfig] = useState( defaultAgentWorkflowMaterialApiConfig ); + const [brandModelConfig, setBrandModelConfig] = useState( + defaultAgentWorkflowBrandModelConfig + ); const [isSaving, setIsSaving] = useState(false); const [uploadingKey, setUploadingKey] = useState(null); const fileInputRefs = useRef>({}); @@ -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 + ) => NonNullable + ) => { + 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 (
@@ -296,6 +425,115 @@ export function AgentWorkflowSettingsClient() { + + + 品牌模特图库 + + 第二步会根据第一步选择的品牌,展示这里配置的品牌模特图供用户单选,并自动带入生图流程的模特图槽位。 + + + + {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 ( +
+
+
+
{brandName}
+
+ 当前已配置 {images.length} 张模特图 +
+
+
+ { + fileInputRefs.current[refKey] = element; + }} + type="file" + accept="image/*" + multiple + className="hidden" + onChange={(event) => { + void handleUploadBrandModelImages(brandName, event.target.files); + event.target.value = ""; + }} + /> + +
+
+ + {images.length > 0 ? ( +
+ {images.map((image) => ( +
+
+ {image.displayName +
+
+
+
+ {image.displayName || `${brandName} 模特图`} +
+
+ +
+
+ ))} +
+ ) : ( +
+ 当前品牌还没有上传模特图,上传后第二步会自动按品牌展示给用户选择。 +
+ )} +
+ ); + }) + ) : ( +
+ 先在上方“品牌列表”里配置品牌,随后即可为每个品牌上传对应的模特图。 +
+ )} +
+
+ {sortedSteps.map((step) => ( diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 0b9e57f2f..be6ecf2ea 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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, diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 5c41687d6..9827942e2 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -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) diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index c0587bfb3..438b0101e 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -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> >({}); + const [selectedBrandModel, setSelectedBrandModel] = + useState(null); const [selectedAgentPresetId, setSelectedAgentPresetId] = useState(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) { 生图模式
- 先在本页选择生图模式,再使用上方 SKU / SPU 取图,右侧全屏工作区会自动进入对应图片槽位并展示结果。 + 先在本页选择生图模式,再按品牌选择模特图并搜索 SKU / SPU 图片,右侧全屏工作区会自动进入对应图片槽位并展示结果。
第 2 步 / 2 步 @@ -994,13 +1066,18 @@ export default function CanvasEditorPage({ params }: PageProps) {
+ @@ -1059,6 +1136,7 @@ export default function CanvasEditorPage({ params }: PageProps) { chatId={`canvas-${id}`} projectId={id} selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null} + selectedBrandModel={selectedBrandModel} workflowContext={workflowContext} /> diff --git a/components/agents/AgentGenerationWorkspace.tsx b/components/agents/AgentGenerationWorkspace.tsx index d403ef326..a9cdd8091 100644 --- a/components/agents/AgentGenerationWorkspace.tsx +++ b/components/agents/AgentGenerationWorkspace.tsx @@ -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>([]); const hiddenPromptRef = useRef(null); const generatedImageKeysRef = useRef>(new Set()); + const lastSyncedBrandModelUrlRef = useRef(null); const [input, setInput] = useState(""); - const [attachments, setAttachments] = useState([]); + const [attachments, setAttachments] = useState>([]); const [slotUploading, setSlotUploading] = useState(null); const [selectedModelId, setSelectedModelId] = useState("auto"); const [selectedResultUrl, setSelectedResultUrl] = useState(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 = 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 = 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(() => { 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({
图片槽位
- 主 SKU / 搭配 SKU 选中的图片会优先进入前两个槽位,模特图可手动上传。 + 主 SKU / 搭配 SKU 选中的图片会优先进入前两个槽位,品牌模特选择会自动进入第三个模特图槽位,你也可以手动替换。
{agentUploadSlots.map((slot, index) => { diff --git a/components/agents/AgentWorkflowBrandModelPicker.tsx b/components/agents/AgentWorkflowBrandModelPicker.tsx new file mode 100644 index 000000000..7c6f7b3c9 --- /dev/null +++ b/components/agents/AgentWorkflowBrandModelPicker.tsx @@ -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 ( +
+
+
+ + 品牌模特选择 +
+
+ 根据第一步选择的品牌,展示管理员预先上传的模特图。选中后会自动带入右侧“模特图”槽位。 +
+
+ + {!normalizedBrand ? ( +
+ + 先回到第一步选择品牌,第二步才会按品牌展示可选模特图。 +
+ ) : images.length > 0 ? ( +
+
+
+ 品牌:{normalizedBrand} + + 共 {images.length} 张模特图 + + {selectedBrandModel ? ( + 已选 1 张 + ) : null} +
+ +
+ +
+ {images.map((image) => { + const selected = + selectedBrandModel?.brandName === normalizedBrand && + selectedBrandModel?.id === image.id; + + return ( + + ); + })} +
+
+ ) : ( +
+ + 品牌“{normalizedBrand}”暂未配置模特图库,请到系统设置上传对应模特图。 +
+ )} +
+ ); +} diff --git a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx index 82deeeb67..5d1a04e46 100644 --- a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx +++ b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx @@ -149,7 +149,7 @@ export function AgentWorkflowMaterialSelectionPanel({ ; + 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; + 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; + const rawEntries = Array.isArray(raw.entries) ? raw.entries : []; + const entryMap = new Map(); + + 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(); + [...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")), + }; +} diff --git a/lib/agents/workflow-composer.ts b/lib/agents/workflow-composer.ts index a2315c9f1..791dbe821 100644 --- a/lib/agents/workflow-composer.ts +++ b/lib/agents/workflow-composer.ts @@ -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>; }): 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, + }, + ] + : []), ]; } diff --git a/types/agent.ts b/types/agent.ts index e31f9a552..174c5f13b 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -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>; + selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; selectedAgentPresetId?: string | null; currentStage?: "workflow" | "mode"; }