From 38f115109932848f8aa09bcbef4e04991df7a188 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 17 Mar 2026 20:26:58 +0800 Subject: [PATCH] feat: add workflow material selection flow --- .../agent-workflow-settings-client.tsx | 76 ++++- .../agent-workflow/material-search/route.ts | 319 ++++++++++++++++++ app/api/site-settings/route.ts | 156 +++++++++ app/canvas/[id]/page.tsx | 39 ++- .../agents/AgentWorkflowMaterialPicker.tsx | 223 ++++++++++++ .../AgentWorkflowMaterialSelectionPanel.tsx | 171 ++++++++++ components/agents/AgentWorkflowPanel.tsx | 68 +++- components/canvas/CanvasChatPanel.tsx | 138 +++++++- lib/agents/workflow-composer.ts | 89 +++++ lib/agents/workflow-material-config.ts | 166 +++++++++ lib/db/schema.ts | 50 +++ types/agent.ts | 57 ++++ 12 files changed, 1526 insertions(+), 26 deletions(-) create mode 100644 app/api/agent-workflow/material-search/route.ts create mode 100644 components/agents/AgentWorkflowMaterialPicker.tsx create mode 100644 components/agents/AgentWorkflowMaterialSelectionPanel.tsx create mode 100644 lib/agents/workflow-material-config.ts 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 b1a3ad684..90e7e06b0 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 { + defaultAgentWorkflowMaterialApiConfig, + normalizeAgentWorkflowMaterialApiConfig, +} from "@/lib/agents/workflow-material-config"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -23,15 +27,19 @@ 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 { AgentWorkflowUiConfig } from "@/types/agent"; +import type { AgentWorkflowMaterialApiConfig, AgentWorkflowUiConfig } from "@/types/agent"; const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json()); export function AgentWorkflowSettingsClient() { const { data, isLoading, mutate } = useSWR<{ agentWorkflowUiConfig?: AgentWorkflowUiConfig; + agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig; }>("/api/site-settings", fetcher); const [config, setConfig] = useState(defaultAgentWorkflowUiConfig); + const [materialApiConfig, setMaterialApiConfig] = useState( + defaultAgentWorkflowMaterialApiConfig + ); const [isSaving, setIsSaving] = useState(false); const [uploadingKey, setUploadingKey] = useState(null); const fileInputRefs = useRef>({}); @@ -39,6 +47,9 @@ export function AgentWorkflowSettingsClient() { useEffect(() => { if (!data) return; setConfig(normalizeAgentWorkflowUiConfig(data.agentWorkflowUiConfig)); + setMaterialApiConfig( + normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig) + ); }, [data]); const sortedSteps = useMemo( @@ -110,6 +121,7 @@ export function AgentWorkflowSettingsClient() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ agentWorkflowUiConfig: config, + agentWorkflowMaterialApiConfig: materialApiConfig, }), }); @@ -171,6 +183,68 @@ export function AgentWorkflowSettingsClient() { ) : (
+ + + 素材搜索接口配置 + + 配置主 SPU / 搭配 SKU 搜图所需的接口地址与 API Key。Key 会按系统设置方式存储到数据库中,不会下发到前台页面。 + + + +
+
+
启用素材搜索
+
+ 开启后,智能体工作流页面支持搜索并选择白底素材图。 +
+
+ + setMaterialApiConfig((prev) => ({ + ...prev, + enabled: checked, + })) + } + /> +
+ +
+
+ + + setMaterialApiConfig((prev) => ({ + ...prev, + baseUrl: event.target.value, + })) + } + placeholder="https://sc.jiansun.vip/api/external" + /> +
+
+ + + setMaterialApiConfig((prev) => ({ + ...prev, + apiKey: event.target.value, + })) + } + placeholder="请输入素材搜索 API Key" + /> +
+
+ +
+ 当前适配的是白底图搜索接口;前台不会直接暴露 Key,而是由服务端代查并转存图片。 +
+
+
+ 基础下拉配置 diff --git a/app/api/agent-workflow/material-search/route.ts b/app/api/agent-workflow/material-search/route.ts new file mode 100644 index 000000000..415c7f84a --- /dev/null +++ b/app/api/agent-workflow/material-search/route.ts @@ -0,0 +1,319 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { getSystemSetting, upsertMediaAsset } from "@/lib/db/queries"; +import { uploadFile } from "@/lib/storage/service"; +import { + AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + normalizeAgentWorkflowMaterialApiConfig, +} from "@/lib/agents/workflow-material-config"; + +export const maxDuration = 180; + +const querySchema = z.object({ + keyword: z.string().trim().min(1).max(64), + source: z.enum(["primary", "secondary"]).optional(), +}); + +type ExternalImageRecord = { + key?: string; + label?: string; + fileName?: string; + fileSize?: number; + downloadUrl?: string; +}; + +type ExternalSearchResponse = { + success?: boolean; + data?: { + sku?: string; + materialId?: string; + productModel?: string; + whiteBackground?: { + fixed?: ExternalImageRecord[]; + other?: ExternalImageRecord[]; + }; + downloadUrls?: { + zip?: string; + }; + }; + error?: { + code?: string; + message?: string; + }; +}; + +function normalizeBaseUrl(value: string) { + return String(value || "").trim().replace(/\/+$/, ""); +} + +function sanitizeFilenamePart(value: string) { + return String(value || "") + .trim() + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "-") + .slice(0, 80); +} + +function getExtensionFromMimeType(mimeType: string) { + const normalized = String(mimeType || "").toLowerCase(); + if (normalized.includes("png")) return "png"; + if (normalized.includes("webp")) return "webp"; + if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg"; + if (normalized.includes("gif")) return "gif"; + return "png"; +} + +function ensureFilenameWithExtension(filename: string, mimeType: string) { + const trimmed = sanitizeFilenamePart(filename) || `workflow-material-${Date.now()}`; + if (/\.[a-z0-9]+$/i.test(trimmed)) { + return trimmed; + } + return `${trimmed}.${getExtensionFromMimeType(mimeType)}`; +} + +async function importExternalImage(params: { + userId: string; + keyword: string; + source: "primary" | "secondary"; + matchedSku: string; + productModel?: string | null; + group: "fixed" | "other"; + sortOrder: number; + image: ExternalImageRecord; +}) { + const downloadUrl = String(params.image.downloadUrl || "").trim(); + if (!downloadUrl) return null; + + const imageResponse = await fetch(downloadUrl, { + cache: "no-store", + redirect: "follow", + }); + if (!imageResponse.ok) { + throw new Error(`下载图片失败(${imageResponse.status})`); + } + + const imageBuffer = await imageResponse.arrayBuffer(); + const mimeType = + String(imageResponse.headers.get("content-type") || "").split(";")[0].trim() || "image/png"; + const filename = ensureFilenameWithExtension( + params.image.fileName || + params.image.label || + `${params.matchedSku || params.keyword}-${params.group}-${params.sortOrder + 1}`, + mimeType + ); + const uploaded = await uploadFile(filename, imageBuffer); + + let assetId: string | null = null; + try { + const asset = await upsertMediaAsset({ + userId: params.userId, + mediaType: "image", + sourceType: "agent_workflow_material_search", + status: "ready", + originalUrl: uploaded.url, + previewUrl: uploaded.url, + thumbnailUrl: uploaded.url, + mimeType: uploaded.contentType || mimeType, + displayName: filename, + sourceTable: "agent_workflow_material_search", + metadata: { + keyword: params.keyword, + source: params.source, + matchedSku: params.matchedSku, + productModel: params.productModel || null, + group: params.group, + externalKey: String(params.image.key || "").trim() || null, + fileSize: Number(params.image.fileSize || 0) || null, + pathname: uploaded.pathname, + }, + }); + assetId = asset?.id ?? null; + } catch (error) { + console.warn("[Agent Workflow Material Search] Failed to register media asset:", error); + } + + return { + id: assetId || `${params.group}-${params.sortOrder + 1}`, + key: String(params.image.key || "").trim() || `${params.group}-${params.sortOrder + 1}`, + label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`, + fileName: filename, + imageUrl: uploaded.url, + assetId, + mimeType: uploaded.contentType || mimeType, + group: params.group, + sortOrder: params.sortOrder, + }; +} + +function mapUpstreamErrorStatus(code: string) { + if (code === "SKU_NOT_FOUND" || code === "WHITE_BACKGROUND_NOT_FOUND") { + return 404; + } + if (code === "INVALID_API_KEY") { + return 500; + } + if (code === "SKU_NOT_UNIQUE") { + return 409; + } + return 502; +} + +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"), + source: request.nextUrl.searchParams.get("source"), + }); + + 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 upstreamUrl = `${normalizeBaseUrl( + config.baseUrl + )}/standard-model-white-background?sku=${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 ExternalSearchResponse; + + 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 matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword; + const productModel = String(upstreamResult.data.productModel || "").trim() || null; + const fixedImages = Array.isArray(upstreamResult.data.whiteBackground?.fixed) + ? upstreamResult.data.whiteBackground?.fixed || [] + : []; + const otherImages = Array.isArray(upstreamResult.data.whiteBackground?.other) + ? upstreamResult.data.whiteBackground?.other || [] + : []; + + const importedImages = ( + await Promise.all([ + ...fixedImages.map((image, index) => + importExternalImage({ + userId: session.user.id, + keyword: normalizedKeyword, + source: parsed.source || "primary", + matchedSku, + productModel, + group: "fixed", + sortOrder: index, + image, + }).catch((error) => { + console.warn("[Agent Workflow Material Search] Fixed image import failed:", error); + return null; + }) + ), + ...otherImages.map((image, index) => + importExternalImage({ + userId: session.user.id, + keyword: normalizedKeyword, + source: parsed.source || "primary", + matchedSku, + productModel, + group: "other", + sortOrder: fixedImages.length + index, + image, + }).catch((error) => { + console.warn("[Agent Workflow Material Search] Other image import failed:", error); + return null; + }) + ), + ]) + ).filter( + ( + item + ): item is { + id: string; + key: string; + label: string; + fileName: string; + imageUrl: string; + assetId: string | null; + mimeType: string; + group: "fixed" | "other"; + sortOrder: number; + } => Boolean(item) + ); + + if (importedImages.length === 0) { + return NextResponse.json( + { + success: false, + error: { + code: "IMAGE_IMPORT_FAILED", + message: "已获取到素材记录,但图片导入失败,请稍后重试", + }, + }, + { status: 502 } + ); + } + + return NextResponse.json({ + success: true, + data: { + query: normalizedKeyword, + matchedSku, + materialId: String(upstreamResult.data.materialId || "").trim() || null, + productModel, + zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null, + fixedCount: importedImages.filter((item) => item.group === "fixed").length, + otherCount: importedImages.filter((item) => item.group === "other").length, + totalCount: importedImages.length, + images: importedImages, + }, + }); + } catch (error) { + console.error("[Agent Workflow Material Search] Error:", error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "搜索参数无效", details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: "素材搜索失败,请稍后重试" }, + { status: 500 } + ); + } +} diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 561aad139..5c41687d6 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -34,6 +34,12 @@ import { defaultAgentWorkflowUiConfig, normalizeAgentWorkflowUiConfig, } from "@/lib/agents/workflow-ui-config"; +import { + AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + defaultAgentWorkflowMaterialApiConfig, + normalizeAgentWorkflowMaterialApiConfig, + normalizeAgentWorkflowMaterialApiConfigForPublic, +} from "@/lib/agents/workflow-material-config"; import { defaultOssImageMigrationConfig, normalizeOssImageMigrationConfig, @@ -220,6 +226,7 @@ export async function GET() { agentCategoryTreeConfigSetting, agentSecondaryTypeConfigSetting, agentWorkflowUiConfigSetting, + agentWorkflowMaterialApiConfigSetting, ] = await Promise.all([ db.select().from(siteSettings).limit(1).then(rows => rows[0]), db @@ -336,6 +343,12 @@ export async function GET() { .where(eq(systemSettings.key, AGENT_WORKFLOW_UI_CONFIG_KEY)) .limit(1) .then(rows => rows[0]), + db + .select() + .from(systemSettings) + .where(eq(systemSettings.key, AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY)) + .limit(1) + .then(rows => rows[0]), ]); const notification = notificationSetting?.value || { enabled: false, title: "", content: "" }; @@ -380,6 +393,11 @@ export async function GET() { const agentWorkflowUiConfig = normalizeAgentWorkflowUiConfig( agentWorkflowUiConfigSetting?.value ); + const agentWorkflowMaterialApiConfig = isAdmin + ? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfigSetting?.value) + : normalizeAgentWorkflowMaterialApiConfigForPublic( + agentWorkflowMaterialApiConfigSetting?.value + ); // 如果没有设置记录,返回默认值 const baseSettings = settings || { @@ -408,6 +426,7 @@ export async function GET() { agentCategoryTreeConfig: normalizeAgentCategoryTreeConfig(undefined), agentSecondaryTypeConfig: defaultAgentSecondaryTypeConfig, agentWorkflowUiConfig: defaultAgentWorkflowUiConfig, + agentWorkflowMaterialApiConfig: defaultAgentWorkflowMaterialApiConfig, realtimeCanvasEnabled: true, realtimeCanvasServiceEnabled: true, storageFailoverConfig, @@ -452,6 +471,7 @@ export async function GET() { agentCategoryTreeConfig, agentSecondaryTypeConfig, agentWorkflowUiConfig, + agentWorkflowMaterialApiConfig, membershipConfig, membershipUserOverrides, pointsSystemConfig, @@ -549,6 +569,13 @@ const updateSchema = z.object({ .max(20), }) .optional(), + agentWorkflowMaterialApiConfig: z + .object({ + enabled: z.boolean().optional(), + baseUrl: z.string().trim().max(255).optional(), + apiKey: z.string().trim().max(500).optional(), + }) + .optional(), realtimeCanvasEnabled: z.boolean().optional(), aiDetailGenerationEnabled: z.boolean().optional(), canvasDefaultModelId: z.string().nullable().optional(), @@ -627,6 +654,7 @@ export async function PUT(request: NextRequest) { agentCategoryTreeConfig, agentSecondaryTypeConfig, agentWorkflowUiConfig, + agentWorkflowMaterialApiConfig, membershipConfig, membershipUserOverrides, pointsSystemConfig, @@ -851,6 +879,28 @@ export async function PUT(request: NextRequest) { }); } + if (agentWorkflowMaterialApiConfig !== undefined) { + const normalized = normalizeAgentWorkflowMaterialApiConfig( + agentWorkflowMaterialApiConfig + ); + await db + .insert(systemSettings) + .values({ + key: AGENT_WORKFLOW_MATERIAL_API_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 @@ -1023,6 +1073,14 @@ export async function PUT(request: NextRequest) { agentSecondaryTypeConfig !== undefined ? normalizeAgentSecondaryTypeConfig(agentSecondaryTypeConfig) : defaultAgentSecondaryTypeConfig, + agentWorkflowUiConfig: + agentWorkflowUiConfig !== undefined + ? normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig) + : defaultAgentWorkflowUiConfig, + agentWorkflowMaterialApiConfig: + agentWorkflowMaterialApiConfig !== undefined + ? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfig) + : defaultAgentWorkflowMaterialApiConfig, membershipConfig: membershipConfig !== undefined ? normalizeMembershipConfig(membershipConfig) @@ -1231,6 +1289,88 @@ export async function PUT(request: NextRequest) { }); } + if (agentCategoryTreeConfig !== undefined) { + const normalized = normalizeAgentCategoryTreeConfig(agentCategoryTreeConfig); + await db + .insert(systemSettings) + .values({ + key: AGENT_CATEGORY_TREE_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 (agentSecondaryTypeConfig !== undefined) { + const normalized = normalizeAgentSecondaryTypeConfig(agentSecondaryTypeConfig); + await db + .insert(systemSettings) + .values({ + key: AGENT_SECONDARY_TYPE_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 (agentWorkflowUiConfig !== undefined) { + const normalized = normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig); + await db + .insert(systemSettings) + .values({ + key: AGENT_WORKFLOW_UI_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 (agentWorkflowMaterialApiConfig !== undefined) { + const normalized = normalizeAgentWorkflowMaterialApiConfig( + agentWorkflowMaterialApiConfig + ); + await db + .insert(systemSettings) + .values({ + key: AGENT_WORKFLOW_MATERIAL_API_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 @@ -1395,6 +1535,22 @@ export async function PUT(request: NextRequest) { contactConfig !== undefined ? normalizeContactConfig(contactConfig) : normalizeContactConfig(undefined), + agentCategoryTreeConfig: + agentCategoryTreeConfig !== undefined + ? normalizeAgentCategoryTreeConfig(agentCategoryTreeConfig) + : normalizeAgentCategoryTreeConfig(undefined), + agentSecondaryTypeConfig: + agentSecondaryTypeConfig !== undefined + ? normalizeAgentSecondaryTypeConfig(agentSecondaryTypeConfig) + : defaultAgentSecondaryTypeConfig, + agentWorkflowUiConfig: + agentWorkflowUiConfig !== undefined + ? normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig) + : defaultAgentWorkflowUiConfig, + agentWorkflowMaterialApiConfig: + agentWorkflowMaterialApiConfig !== undefined + ? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfig) + : defaultAgentWorkflowMaterialApiConfig, membershipConfig: membershipConfig !== undefined ? normalizeMembershipConfig(membershipConfig) diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index e803309b0..a817cbb7a 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -74,12 +74,17 @@ import { defaultAgentWorkflowUiConfig, normalizeAgentWorkflowUiConfig, } from "@/lib/agents/workflow-ui-config"; +import { + createEmptyAgentWorkflowMaterialSelections, + normalizeAgentWorkflowMaterialSelections, +} from "@/lib/agents/workflow-material-config"; import { buildAgentWorkflowComposerContext, getAgentWorkflowProgress, } from "@/lib/agents/workflow-composer"; import type { AgentWorkflowComposerContext, + AgentWorkflowMaterialSelections, AgentWorkflowPersistedState, AgentWorkflowStepKey, } from "@/types/agent"; @@ -161,6 +166,9 @@ export default function CanvasEditorPage({ params }: PageProps) { brand: "", store: "", }); + const [workflowMaterials, setWorkflowMaterials] = useState( + () => createEmptyAgentWorkflowMaterialSelections() + ); const [workflowSelections, setWorkflowSelections] = useState< Partial> >({}); @@ -267,6 +275,7 @@ export default function CanvasEditorPage({ params }: PageProps) { brand: "", store: "", }); + setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections()); setWorkflowSelections({}); setSelectedAgentPresetId(null); @@ -287,6 +296,9 @@ export default function CanvasEditorPage({ params }: PageProps) { brand: String(persistedWorkflow.inputs?.brand || "").trim(), store: String(persistedWorkflow.inputs?.store || "").trim(), }); + setWorkflowMaterials( + normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials) + ); setWorkflowSelections(persistedWorkflow.selections || {}); setSelectedAgentPresetId( String(persistedWorkflow.selectedAgentPresetId || "").trim() || null @@ -342,6 +354,7 @@ export default function CanvasEditorPage({ params }: PageProps) { brand: workflowInputs.brand.trim(), store: workflowInputs.store.trim(), }, + materials: workflowMaterials, selections: workflowSelections, selectedAgentPresetId: selectedAgentPresetId || null, currentStage: currentAgentStage, @@ -351,7 +364,14 @@ export default function CanvasEditorPage({ params }: PageProps) { ...getCanvasState(), workflowState, }; - }, [getCanvasState, workflowInputs, workflowSelections, selectedAgentPresetId]); + }, [ + currentAgentStage, + getCanvasState, + selectedAgentPresetId, + workflowInputs, + workflowMaterials, + workflowSelections, + ]); useEffect(() => { if (typeof window === "undefined") return; @@ -371,6 +391,7 @@ export default function CanvasEditorPage({ params }: PageProps) { brand: String(parsed.inputs?.brand || "").trim(), store: String(parsed.inputs?.store || "").trim(), }); + setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(parsed.materials)); setWorkflowSelections(parsed.selections || {}); setSelectedAgentPresetId(String(parsed.selectedAgentPresetId || "").trim() || null); setCurrentAgentStage(parsed.currentStage === "mode" ? "mode" : "workflow"); @@ -389,6 +410,7 @@ export default function CanvasEditorPage({ params }: PageProps) { workflowStorageKey, JSON.stringify({ inputs: workflowInputs, + materials: workflowMaterials, selections: workflowSelections, selectedAgentPresetId, currentAgentStage, @@ -400,6 +422,7 @@ export default function CanvasEditorPage({ params }: PageProps) { }, [ workflowHydrationSource, workflowInputs, + workflowMaterials, workflowSelections, workflowStorageKey, selectedAgentPresetId, @@ -836,9 +859,10 @@ export default function CanvasEditorPage({ params }: PageProps) { return buildAgentWorkflowComposerContext({ config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, inputs: workflowInputs, + materials: workflowMaterials, selections: workflowSelections, }); - }, [resolvedWorkflowUiConfig, workflowInputs, workflowSelections]); + }, [resolvedWorkflowUiConfig, workflowInputs, workflowMaterials, workflowSelections]); const workflowProgress = useMemo( () => getAgentWorkflowProgress({ @@ -910,6 +934,8 @@ export default function CanvasEditorPage({ params }: PageProps) { config={resolvedWorkflowUiConfig} inputs={workflowInputs} onInputsChange={setWorkflowInputs} + materials={workflowMaterials} + onMaterialsChange={setWorkflowMaterials} selections={workflowSelections} onSelectionsChange={setWorkflowSelections} onNextStep={() => setCurrentAgentStage("mode")} @@ -920,6 +946,7 @@ export default function CanvasEditorPage({ params }: PageProps) { brand: "", store: "", }); + setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections()); setWorkflowSelections({}); setSelectedAgentPresetId(null); setCurrentAgentStage("workflow"); @@ -936,7 +963,7 @@ export default function CanvasEditorPage({ params }: PageProps) { 生图模式
- 先完成第一步工作流,再选择具体的生图模式模板。 + 先选择生图模式,右侧再搜索 SKU / SPU 选图,并自动进入对应图片槽位。
第 2 步 / 2 步 @@ -964,7 +991,7 @@ export default function CanvasEditorPage({ params }: PageProps) { selectedPresetId={selectedAgentPresetId} onSelectPreset={setSelectedAgentPresetId} title="生图模式" - description="选择生图模式后,右侧对话框会按其要求显示上传槽位" + description="先选择生图模式,右侧会出现对应槽位和 SKU / SPU 搜图入口" searchPlaceholder="搜索生图模式" publicTitle="公共生图模式" privateTitle="我的生图模式" @@ -985,6 +1012,10 @@ export default function CanvasEditorPage({ params }: PageProps) { selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null} chatScope="agent" workflowContext={workflowContext} + workflowInputs={workflowInputs} + onWorkflowInputsChange={setWorkflowInputs} + workflowMaterials={workflowMaterials} + onWorkflowMaterialsChange={setWorkflowMaterials} />
diff --git a/components/agents/AgentWorkflowMaterialPicker.tsx b/components/agents/AgentWorkflowMaterialPicker.tsx new file mode 100644 index 000000000..21f5d5cdb --- /dev/null +++ b/components/agents/AgentWorkflowMaterialPicker.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useMemo } from "react"; +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"; + +interface AgentWorkflowMaterialPickerProps { + title: string; + description: string; + value: string; + placeholder: string; + buttonLabel: string; + searchNote?: string; + isLoading?: boolean; + errorText?: string | null; + result?: AgentWorkflowMaterialSearchResult | null; + selectedImageIds: string[]; + onValueChange: (value: string) => void; + onSearch: () => void; + onSelectedImageIdsChange: (next: string[]) => void; +} + +export function AgentWorkflowMaterialPicker({ + title, + description, + value, + placeholder, + buttonLabel, + searchNote, + isLoading = false, + errorText, + result, + selectedImageIds, + onValueChange, + onSearch, + onSelectedImageIdsChange, +}: AgentWorkflowMaterialPickerProps) { + const selectedSet = useMemo( + () => new Set((selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)), + [selectedImageIds] + ); + const selectedCount = result?.images + ? result.images.filter((item) => selectedSet.has(item.id)).length + : 0; + + const handleToggleImage = (imageId: string) => { + const next = new Set(selectedImageIds); + if (next.has(imageId)) { + next.delete(imageId); + } else { + next.add(imageId); + } + onSelectedImageIdsChange(Array.from(next)); + }; + + const handleSelectAll = () => { + onSelectedImageIdsChange(result?.images?.map((item) => item.id) || []); + }; + + return ( +
+
+
{title}
+
{description}
+
+ +
+ onValueChange(event.target.value)} + placeholder={placeholder} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onSearch(); + } + }} + /> + +
+ + {searchNote ? ( +
{searchNote}
+ ) : null} + + {errorText ? ( +
+ {errorText} +
+ ) : null} + + {result ? ( +
+
+
+ + 命中:{result.matchedSku || result.query} + + {result.productModel ? ( + + 型号:{result.productModel} + + ) : null} + + 共 {result.totalCount} 张 + + + 已选 {selectedCount} 张 + +
+
+ + +
+
+ +
+ {result.images.map((image) => { + const selected = selectedSet.has(image.id); + return ( + + ); + })} +
+
+ ) : ( +
+ {isLoading ? ( + <> + + 正在搜索并导入图片,请稍候... + + ) : ( + <> + + {value.trim() ? "输入后点击搜索,从素材库拉取白底图。" : "先输入关键词,再搜索素材图。"} + + )} +
+ )} + + {result?.images?.length ? ( +
+ + 已选中的图片会在下一步优先带入右侧“生图模式”槽位,你也可以进入后继续替换。 +
+ ) : null} +
+ ); +} diff --git a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx new file mode 100644 index 000000000..bc272f940 --- /dev/null +++ b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import { Sparkles } from "lucide-react"; +import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker"; +import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config"; +import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel"; +import type { AgentWorkflowMaterialSelections } from "@/types/agent"; + +interface AgentWorkflowMaterialSelectionPanelProps { + inputs: AgentWorkflowInputs; + onInputsChange: (next: AgentWorkflowInputs) => void; + materials: AgentWorkflowMaterialSelections; + onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void; + title?: string; + description?: string; +} + +export function AgentWorkflowMaterialSelectionPanel({ + inputs, + onInputsChange, + materials, + onMaterialsChange, + title = "SKU / SPU 选图", + description = "先确定生图模式,再搜索主 SKU 或搭配 SKU 的白底图。选中的图片会自动进入对应槽位。", +}: AgentWorkflowMaterialSelectionPanelProps) { + const [materialSearchState, setMaterialSearchState] = useState<{ + primary: { isLoading: boolean; errorText: string | null }; + secondary: { isLoading: boolean; errorText: string | null }; + }>({ + primary: { isLoading: false, errorText: null }, + secondary: { isLoading: false, errorText: null }, + }); + + const updateMaterialSearchState = ( + source: "primary" | "secondary", + updates: Partial<{ isLoading: boolean; errorText: string | null }> + ) => { + setMaterialSearchState((prev) => ({ + ...prev, + [source]: { + ...prev[source], + ...updates, + }, + })); + }; + + const handleMaterialInputChange = (source: "primary" | "secondary", value: string) => { + const inputKey = source === "primary" ? "primarySku" : "secondarySku"; + const trimmedValue = value.trim().toUpperCase(); + const currentQuery = String(materials[source]?.result?.query || "").trim().toUpperCase(); + + onInputsChange({ + ...inputs, + [inputKey]: value, + }); + + if ( + trimmedValue !== currentQuery && + (materials[source]?.result || materials[source]?.selectedImageIds.length) + ) { + onMaterialsChange({ + ...materials, + [source]: createEmptyAgentWorkflowMaterialSelection(), + }); + } + + if (materialSearchState[source].errorText) { + updateMaterialSearchState(source, { errorText: null }); + } + }; + + const handleSearchMaterials = async (source: "primary" | "secondary") => { + const keyword = (source === "primary" ? inputs.primarySku : inputs.secondarySku).trim(); + if (!keyword) { + updateMaterialSearchState(source, { errorText: "请先输入搜索关键词" }); + return; + } + + try { + updateMaterialSearchState(source, { isLoading: true, errorText: null }); + const response = await fetch( + `/api/agent-workflow/material-search?keyword=${encodeURIComponent(keyword)}&source=${source}`, + { cache: "no-store" } + ); + const result = await response.json().catch(() => ({})); + + if (!response.ok || !result?.success || !result?.data) { + throw new Error(result?.error?.message || result?.error || "搜索失败"); + } + + onMaterialsChange({ + ...materials, + [source]: { + result: result.data, + selectedImageIds: [], + }, + }); + } catch (error) { + updateMaterialSearchState(source, { + errorText: error instanceof Error ? error.message : "搜索失败", + }); + } finally { + updateMaterialSearchState(source, { isLoading: false }); + } + }; + + return ( +
+
+
+ + {title} +
+
{description}
+
+ + handleMaterialInputChange("primary", value)} + onSearch={() => { + void handleSearchMaterials("primary"); + }} + onSelectedImageIdsChange={(next) => + onMaterialsChange({ + ...materials, + primary: { + ...materials.primary, + selectedImageIds: next, + }, + }) + } + /> + + handleMaterialInputChange("secondary", value)} + onSearch={() => { + void handleSearchMaterials("secondary"); + }} + onSelectedImageIdsChange={(next) => + onMaterialsChange({ + ...materials, + secondary: { + ...materials.secondary, + selectedImageIds: next, + }, + }) + } + /> +
+ ); +} diff --git a/components/agents/AgentWorkflowPanel.tsx b/components/agents/AgentWorkflowPanel.tsx index c7cd26bd1..3860f9b67 100644 --- a/components/agents/AgentWorkflowPanel.tsx +++ b/components/agents/AgentWorkflowPanel.tsx @@ -4,18 +4,19 @@ import { useMemo } from "react"; import { ArrowRight, Copy, RotateCcw, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { SearchableSelect } from "@/components/ui/searchable-select"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config"; +import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config"; import { buildAgentWorkflowComposerContext, getAgentWorkflowProgress, } from "@/lib/agents/workflow-composer"; import type { AgentWorkflowComposerSelection, + AgentWorkflowMaterialSelections, AgentWorkflowStepKey, AgentWorkflowUiConfig, } from "@/types/agent"; @@ -31,6 +32,8 @@ interface AgentWorkflowPanelProps { config?: AgentWorkflowUiConfig | null; inputs: AgentWorkflowInputs; onInputsChange: (next: AgentWorkflowInputs) => void; + materials: AgentWorkflowMaterialSelections; + onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void; selections: Partial>; onSelectionsChange: (next: Partial>) => void; onReset?: () => void; @@ -67,6 +70,8 @@ export function AgentWorkflowPanel({ config, inputs, onInputsChange, + materials, + onMaterialsChange, selections, onSelectionsChange, onReset, @@ -105,9 +110,10 @@ export function AgentWorkflowPanel({ buildAgentWorkflowComposerContext({ config: resolvedConfig, inputs, + materials, selections, }), - [inputs, resolvedConfig, selections] + [inputs, materials, resolvedConfig, selections] ); const progress = useMemo( () => getAgentWorkflowProgress({ config: resolvedConfig, selections }), @@ -158,6 +164,16 @@ export function AgentWorkflowPanel({ onReset(); return; } + onInputsChange({ + primarySku: "", + secondarySku: "", + brand: "", + store: "", + }); + onMaterialsChange({ + primary: createEmptyAgentWorkflowMaterialSelection(), + secondary: createEmptyAgentWorkflowMaterialSelection(), + }); onSelectionsChange({}); }} > @@ -170,22 +186,8 @@ export function AgentWorkflowPanel({
-
基础输入
+
基础配置
- - onInputsChange({ ...inputs, primarySku: event.target.value }) - } - placeholder="主 SKU" - /> - - onInputsChange({ ...inputs, secondarySku: event.target.value }) - } - placeholder="搭配 SKU" - /> )} +
+ SKU / SPU 搜图已移动到下一步“生图模式”中,选中的图片会直接进入对应生图槽位。 +
{resolvedConfig.steps.map((step) => ( @@ -270,7 +275,12 @@ export function AgentWorkflowPanel({
当前工作流摘要
- {inputs.primarySku || inputs.secondarySku || inputs.brand || inputs.store ? ( + {inputs.primarySku || + inputs.secondarySku || + inputs.brand || + inputs.store || + materials.primary.selectedImageIds.length > 0 || + materials.secondary.selectedImageIds.length > 0 ? (
{inputs.primarySku ? ( 主SKU:{inputs.primarySku} @@ -284,6 +294,16 @@ export function AgentWorkflowPanel({ {inputs.store ? ( 店铺:{inputs.store} ) : null} + {materials.primary.selectedImageIds.length > 0 ? ( + + 主SPU图:已选 {materials.primary.selectedImageIds.length} 张 + + ) : null} + {materials.secondary.selectedImageIds.length > 0 ? ( + + 搭配SKU图:已选 {materials.secondary.selectedImageIds.length} 张 + + ) : null}
) : null} {summary.length > 0 ? ( @@ -303,6 +323,18 @@ export function AgentWorkflowPanel({ 还没有选择工作流方案,先从上面的卡片开始。
)} + {composerContext?.referenceImages.length ? ( +
+
已选素材图
+
+ {composerContext.referenceImages.map((image) => ( + + {image.sourceLabel} · {image.label} + + ))} +
+
+ ) : null}
diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 71272827c..60aafde40 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -9,6 +9,7 @@ import useSWR, { useSWRConfig } from "swr"; import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { AgentWorkflowMaterialSelectionPanel } from "@/components/agents/AgentWorkflowMaterialSelectionPanel"; import { Collapsible, CollapsibleContent, @@ -48,13 +49,18 @@ import { } from "@/lib/image-url"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import type { Attachment, ChatMessage } from "@/lib/types"; -import type { AgentPreset, AgentWorkflowComposerContext } from "@/types/agent"; +import type { + AgentPreset, + AgentWorkflowComposerContext, + AgentWorkflowMaterialSelections, +} from "@/types/agent"; import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch"; import { DEFAULT_CANVAS_SKILL_TAGS_TITLE, normalizeCanvasSkillTags, type CanvasSkillTag, } from "@/lib/canvas-skill-tags"; +import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel"; import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-icons"; import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar"; @@ -111,6 +117,10 @@ interface CanvasChatPanelProps { selectedAgentPresetId?: string | null; chatScope?: "canvas" | "agent"; workflowContext?: AgentWorkflowComposerContext | null; + workflowInputs?: AgentWorkflowInputs | null; + onWorkflowInputsChange?: ((next: AgentWorkflowInputs) => void) | null; + workflowMaterials?: AgentWorkflowMaterialSelections | null; + onWorkflowMaterialsChange?: ((next: AgentWorkflowMaterialSelections) => void) | null; } type VideoStoryboardStreamData = @@ -1683,6 +1693,10 @@ export function CanvasChatPanel({ selectedAgentPresetId, chatScope = "canvas", workflowContext = null, + workflowInputs = null, + onWorkflowInputsChange = null, + workflowMaterials = null, + onWorkflowMaterialsChange = null, }: CanvasChatPanelProps) { const { data: session } = useSession(); const [input, setInput] = useState(""); @@ -1751,6 +1765,8 @@ export function CanvasChatPanel({ const detailModeSwitchRef = useRef(false); const hiddenPromptRef = useRef(null); const autoContextRef = useRef(null); + const lastWorkflowAutofillSignatureRef = useRef(null); + const lastWorkflowAutofillUrlsRef = useRef([]); const pendingLaunchRef = useRef(null); const [launchAutoSendTick, setLaunchAutoSendTick] = useState(0); const hasInitializedChatStateRef = useRef(false); @@ -2810,11 +2826,16 @@ export function CanvasChatPanel({ () => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null), [agentPresetResponse] ); + const workflowReferenceImages = workflowContext?.referenceImages || []; + const canManageWorkflowMaterials = Boolean( + workflowInputs && workflowMaterials && onWorkflowInputsChange && onWorkflowMaterialsChange + ); const hasWorkflowContext = Boolean( workflowContext && (String(workflowContext.summaryText || "").trim() || String(workflowContext.promptText || "").trim() || - workflowContext.selections.length > 0) + workflowContext.selections.length > 0 || + workflowReferenceImages.length > 0) ); const SelectedSkillTagIcon = useMemo( () => getCanvasSkillIconComponent(selectedSkillTag?.iconName), @@ -3321,6 +3342,97 @@ export function CanvasChatPanel({ }); }, [agentUploadSlots.length, hasAgentUploadSlots]); + useEffect(() => { + if (!hasAgentUploadSlots || workflowReferenceImages.length === 0) { + lastWorkflowAutofillSignatureRef.current = null; + lastWorkflowAutofillUrlsRef.current = []; + return; + } + + const primaryImages = workflowReferenceImages.filter((image) => image.source === "primary"); + const secondaryImages = workflowReferenceImages.filter((image) => image.source === "secondary"); + const usedUrls = new Set(); + const pickNextImage = ( + candidates: typeof workflowReferenceImages + ) => + candidates.find((image) => { + if (!image?.url || usedUrls.has(image.url)) return false; + usedUrls.add(image.url); + return true; + }) || null; + + const productSlot1Image = pickNextImage(primaryImages) || pickNextImage(secondaryImages); + const productSlot2Image = + pickNextImage(secondaryImages) || + pickNextImage(primaryImages) || + pickNextImage(workflowReferenceImages); + + const suggestedSlots: Array = Array.from( + { length: agentUploadSlots.length }, + () => null + ); + const toAttachment = (image: (typeof workflowReferenceImages)[number] | null) => + image + ? { + url: image.url, + name: image.name || image.label || image.sourceLabel, + contentType: image.mimeType || "image/png", + assetId: image.assetId || undefined, + } + : null; + + if (agentUploadSlots.length > 0) { + suggestedSlots[0] = toAttachment(productSlot1Image); + } + if (agentUploadSlots.length > 1) { + suggestedSlots[1] = toAttachment(productSlot2Image); + } + + const nextSuggestedUrls = suggestedSlots + .filter((item): item is PickedObjectAttachment => Boolean(item)) + .map((item) => item.url); + if (nextSuggestedUrls.length === 0) return; + + const nextSignature = JSON.stringify({ + presetId: selectedAgentPresetId || "", + urls: nextSuggestedUrls, + }); + if (lastWorkflowAutofillSignatureRef.current === nextSignature) { + return; + } + + setAttachments((current) => { + const previousAutoUrls = new Set(lastWorkflowAutofillUrlsRef.current); + const next: Array = Array.from( + { length: agentUploadSlots.length }, + (_, index) => current[index] ?? null + ); + let changed = false; + + suggestedSlots.forEach((attachment, index) => { + if (!attachment || index >= 2) return; + const existing = next[index]; + const canReplace = !existing?.url || previousAutoUrls.has(existing.url); + if (!canReplace) return; + if (existing?.url === attachment.url) return; + next[index] = attachment; + changed = true; + }); + + if (!changed) { + return current; + } + lastWorkflowAutofillSignatureRef.current = nextSignature; + lastWorkflowAutofillUrlsRef.current = nextSuggestedUrls; + return next.filter((item): item is PickedObjectAttachment => Boolean(item)); + }); + }, [ + agentUploadSlots.length, + hasAgentUploadSlots, + selectedAgentPresetId, + workflowReferenceImages, + ]); + useEffect(() => { if (!currentModel) return; if (currentModel.id !== "auto" && isDetailGenerationMode) { @@ -9356,6 +9468,26 @@ export function CanvasChatPanel({ ) : null} )} + {!isDetailGenerationMode && + activeComposerMode === "agent" && + selectedAgentPreset && + hasAgentUploadSlots && + canManageWorkflowMaterials && + workflowInputs && + workflowMaterials && + onWorkflowInputsChange && + onWorkflowMaterialsChange ? ( +
+ +
+ ) : null}
- 智能体需要上传 3 张图片:产品图1、产品图2、模特图;已选中画布图片时,点击槽位可直接填充,未选中则打开上传。 + 智能体需要上传 3 张图片:产品图1、产品图2、模特图;已选主 SKU / 搭配 SKU 的素材图会优先进入前两个产品槽位,模特图继续手动上传或替换。
)} diff --git a/lib/agents/workflow-composer.ts b/lib/agents/workflow-composer.ts index 22ed549ea..a2315c9f1 100644 --- a/lib/agents/workflow-composer.ts +++ b/lib/agents/workflow-composer.ts @@ -1,6 +1,8 @@ import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config"; import type { AgentWorkflowComposerContext, + AgentWorkflowMaterialSelections, + AgentWorkflowReferenceImage, AgentWorkflowStepKey, AgentWorkflowUiConfig, } from "@/types/agent"; @@ -15,6 +17,7 @@ export interface AgentWorkflowInputsValue { export function buildAgentWorkflowComposerContext(params: { config?: AgentWorkflowUiConfig | null; inputs: AgentWorkflowInputsValue; + materials?: AgentWorkflowMaterialSelections | null; selections: Partial>; }): AgentWorkflowComposerContext | null { const config = params.config || defaultAgentWorkflowUiConfig; @@ -37,6 +40,10 @@ export function buildAgentWorkflowComposerContext(params: { }; }) .filter(Boolean) as AgentWorkflowComposerContext["selections"]; + const referenceImages = getSelectedWorkflowReferenceImages({ + inputs: params.inputs, + materials: params.materials, + }); const summaryParts: string[] = []; if (params.inputs.primarySku.trim()) { @@ -51,6 +58,16 @@ export function buildAgentWorkflowComposerContext(params: { if (params.inputs.store.trim()) { summaryParts.push(`店铺:${params.inputs.store.trim()}`); } + if (referenceImages.some((item) => item.source === "primary")) { + summaryParts.push( + `主SPU图:${referenceImages.filter((item) => item.source === "primary").length}张` + ); + } + if (referenceImages.some((item) => item.source === "secondary")) { + summaryParts.push( + `搭配SKU图:${referenceImages.filter((item) => item.source === "secondary").length}张` + ); + } selections.forEach((item) => { summaryParts.push(`${item.stepTitle}:${item.optionName}`); }); @@ -72,6 +89,30 @@ export function buildAgentWorkflowComposerContext(params: { if (params.inputs.store.trim()) { promptLines.push(`店铺:${params.inputs.store.trim()}`); } + if (referenceImages.some((item) => item.source === "primary")) { + const labels = referenceImages + .filter((item) => item.source === "primary") + .map((item) => item.label) + .filter(Boolean) + .slice(0, 8); + promptLines.push( + `主SPU参考图:已选${referenceImages.filter((item) => item.source === "primary").length}张${ + labels.length > 0 ? `(${labels.join("、")})` : "" + }` + ); + } + if (referenceImages.some((item) => item.source === "secondary")) { + const labels = referenceImages + .filter((item) => item.source === "secondary") + .map((item) => item.label) + .filter(Boolean) + .slice(0, 8); + promptLines.push( + `搭配SKU参考图:已选${referenceImages.filter((item) => item.source === "secondary").length}张${ + labels.length > 0 ? `(${labels.join("、")})` : "" + }` + ); + } selections.forEach((item) => { promptLines.push(`${item.stepTitle}:${item.promptFragment || item.optionName}`); }); @@ -82,11 +123,59 @@ export function buildAgentWorkflowComposerContext(params: { brand: params.inputs.brand.trim() || undefined, store: params.inputs.store.trim() || undefined, selections, + referenceImages, summaryText: summaryParts.join(" / "), promptText: promptLines.join("\n"), }; } +export function getSelectedWorkflowReferenceImages(params: { + inputs: AgentWorkflowInputsValue; + materials?: AgentWorkflowMaterialSelections | null; +}): AgentWorkflowReferenceImage[] { + const materials = params.materials; + if (!materials) return []; + + const collect = ( + source: "primary" | "secondary", + sourceLabel: string, + fallbackSku: string + ) => { + const state = materials[source]; + const result = state?.result; + if (!result || !Array.isArray(result.images) || result.images.length === 0) { + return [] as AgentWorkflowReferenceImage[]; + } + + const selectedIds = new Set( + (state.selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean) + ); + if (selectedIds.size === 0) { + return [] as AgentWorkflowReferenceImage[]; + } + + return result.images + .filter((item) => selectedIds.has(item.id)) + .map((item) => ({ + id: item.id, + source, + sourceLabel, + sku: result.matchedSku || fallbackSku || undefined, + productModel: result.productModel || null, + label: item.label, + name: item.fileName || item.label, + url: item.imageUrl, + assetId: item.assetId || null, + mimeType: item.mimeType || null, + })); + }; + + return [ + ...collect("primary", "主SPU", params.inputs.primarySku.trim()), + ...collect("secondary", "搭配SKU", params.inputs.secondarySku.trim()), + ]; +} + export function getAgentWorkflowProgress(params: { config?: AgentWorkflowUiConfig | null; selections: Partial>; diff --git a/lib/agents/workflow-material-config.ts b/lib/agents/workflow-material-config.ts new file mode 100644 index 000000000..d188d2b88 --- /dev/null +++ b/lib/agents/workflow-material-config.ts @@ -0,0 +1,166 @@ +import type { + AgentWorkflowMaterialApiConfig, + AgentWorkflowMaterialImage, + AgentWorkflowMaterialSearchResult, + AgentWorkflowMaterialSelectionState, + AgentWorkflowMaterialSelections, +} from "@/types/agent"; + +export const AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY = "agent_workflow_material_api_config"; + +export const defaultAgentWorkflowMaterialApiConfig: AgentWorkflowMaterialApiConfig = { + enabled: false, + baseUrl: "https://sc.jiansun.vip/api/external", + apiKey: "", +}; + +function sanitizeText(value: unknown, maxLength: number) { + return String(value || "").trim().slice(0, maxLength); +} + +function normalizeMaterialImage( + value: unknown, + index: number +): AgentWorkflowMaterialImage | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const raw = value as Record; + const imageUrl = sanitizeText(raw.imageUrl, 2000); + if (!imageUrl) return null; + + const id = sanitizeText(raw.id, 128) || `image-${index + 1}`; + const group = raw.group === "other" ? "other" : "fixed"; + + return { + id, + key: sanitizeText(raw.key, 64) || id, + label: sanitizeText(raw.label, 128) || `图片${index + 1}`, + fileName: sanitizeText(raw.fileName, 255) || `image-${index + 1}`, + imageUrl, + assetId: sanitizeText(raw.assetId, 128) || null, + mimeType: sanitizeText(raw.mimeType, 128) || null, + group, + sortOrder: Number.isFinite(Number(raw.sortOrder)) + ? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder)))) + : index, + }; +} + +function normalizeMaterialSearchResult(value: unknown): AgentWorkflowMaterialSearchResult | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const raw = value as Record; + const images = Array.isArray(raw.images) + ? raw.images + .map((item, index) => normalizeMaterialImage(item, index)) + .filter((item): item is AgentWorkflowMaterialImage => Boolean(item)) + .slice(0, 32) + : []; + + if (images.length === 0) { + return null; + } + + return { + query: sanitizeText(raw.query, 64), + matchedSku: sanitizeText(raw.matchedSku, 64), + materialId: sanitizeText(raw.materialId, 128) || null, + productModel: sanitizeText(raw.productModel, 128) || null, + zipUrl: sanitizeText(raw.zipUrl, 2000) || null, + fixedCount: Number.isFinite(Number(raw.fixedCount)) + ? Math.max(0, Math.min(999, Math.round(Number(raw.fixedCount)))) + : images.filter((item) => item.group === "fixed").length, + otherCount: Number.isFinite(Number(raw.otherCount)) + ? Math.max(0, Math.min(999, Math.round(Number(raw.otherCount)))) + : images.filter((item) => item.group === "other").length, + totalCount: Number.isFinite(Number(raw.totalCount)) + ? Math.max(0, Math.min(999, Math.round(Number(raw.totalCount)))) + : images.length, + images: images + .slice() + .sort((left, right) => left.sortOrder - right.sortOrder), + }; +} + +export function createEmptyAgentWorkflowMaterialSelection(): AgentWorkflowMaterialSelectionState { + return { + result: null, + selectedImageIds: [], + }; +} + +export function createEmptyAgentWorkflowMaterialSelections(): AgentWorkflowMaterialSelections { + return { + primary: createEmptyAgentWorkflowMaterialSelection(), + secondary: createEmptyAgentWorkflowMaterialSelection(), + }; +} + +function normalizeMaterialSelectionState(value: unknown): AgentWorkflowMaterialSelectionState { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + const result = normalizeMaterialSearchResult(raw.result); + const validIds = new Set((result?.images || []).map((item) => item.id)); + const selectedImageIds = Array.isArray(raw.selectedImageIds) + ? Array.from( + new Set( + raw.selectedImageIds + .map((item) => sanitizeText(item, 128)) + .filter((item) => item && validIds.has(item)) + .slice(0, 32) + ) + ) + : []; + + return { + result, + selectedImageIds, + }; +} + +export function normalizeAgentWorkflowMaterialSelections( + value: unknown +): AgentWorkflowMaterialSelections { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + + return { + primary: normalizeMaterialSelectionState(raw.primary), + secondary: normalizeMaterialSelectionState(raw.secondary), + }; +} + +export function normalizeAgentWorkflowMaterialApiConfig( + value: unknown +): AgentWorkflowMaterialApiConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return defaultAgentWorkflowMaterialApiConfig; + } + + const raw = value as Partial; + + return { + enabled: raw.enabled === true, + baseUrl: + sanitizeText(raw.baseUrl, 255) || defaultAgentWorkflowMaterialApiConfig.baseUrl, + apiKey: sanitizeText(raw.apiKey, 500), + }; +} + +export function normalizeAgentWorkflowMaterialApiConfigForPublic( + value: unknown +): AgentWorkflowMaterialApiConfig { + const config = normalizeAgentWorkflowMaterialApiConfig(value); + return { + ...config, + apiKey: "", + }; +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 728635035..a311fe4b3 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -608,6 +608,56 @@ export const canvasProject = pgTable("CanvasProject", { brand?: string; store?: string; }; + materials?: { + primary?: { + result?: { + query?: string; + matchedSku?: string; + materialId?: string | null; + productModel?: string | null; + zipUrl?: string | null; + fixedCount?: number; + otherCount?: number; + totalCount?: number; + images?: Array<{ + id?: string; + key?: string; + label?: string; + fileName?: string; + imageUrl?: string; + assetId?: string | null; + mimeType?: string | null; + group?: "fixed" | "other"; + sortOrder?: number; + }>; + } | null; + selectedImageIds?: string[]; + }; + secondary?: { + result?: { + query?: string; + matchedSku?: string; + materialId?: string | null; + productModel?: string | null; + zipUrl?: string | null; + fixedCount?: number; + otherCount?: number; + totalCount?: number; + images?: Array<{ + id?: string; + key?: string; + label?: string; + fileName?: string; + imageUrl?: string; + assetId?: string | null; + mimeType?: string | null; + group?: "fixed" | "other"; + sortOrder?: number; + }>; + } | null; + selectedImageIds?: string[]; + }; + }; selections?: Record; selectedAgentPresetId?: string | null; currentStage?: "workflow" | "mode"; diff --git a/types/agent.ts b/types/agent.ts index 1c2a02294..e31f9a552 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -47,6 +47,61 @@ export interface AgentWorkflowUiConfig { steps: AgentWorkflowStepUiConfig[]; } +export interface AgentWorkflowMaterialApiConfig { + enabled: boolean; + baseUrl: string; + apiKey: string; +} + +export type AgentWorkflowMaterialSource = "primary" | "secondary"; + +export interface AgentWorkflowMaterialImage { + id: string; + key: string; + label: string; + fileName: string; + imageUrl: string; + assetId?: string | null; + mimeType?: string | null; + group: "fixed" | "other"; + sortOrder: number; +} + +export interface AgentWorkflowMaterialSearchResult { + query: string; + matchedSku: string; + materialId?: string | null; + productModel?: string | null; + zipUrl?: string | null; + fixedCount: number; + otherCount: number; + totalCount: number; + images: AgentWorkflowMaterialImage[]; +} + +export interface AgentWorkflowMaterialSelectionState { + result?: AgentWorkflowMaterialSearchResult | null; + selectedImageIds: string[]; +} + +export interface AgentWorkflowMaterialSelections { + primary: AgentWorkflowMaterialSelectionState; + secondary: AgentWorkflowMaterialSelectionState; +} + +export interface AgentWorkflowReferenceImage { + id: string; + source: AgentWorkflowMaterialSource; + sourceLabel: string; + sku?: string; + productModel?: string | null; + label: string; + name: string; + url: string; + assetId?: string | null; + mimeType?: string | null; +} + export interface AgentWorkflowComposerSelection { stepKey: AgentWorkflowStepKey; stepTitle: string; @@ -61,6 +116,7 @@ export interface AgentWorkflowComposerContext { brand?: string; store?: string; selections: AgentWorkflowComposerSelection[]; + referenceImages: AgentWorkflowReferenceImage[]; summaryText: string; promptText: string; } @@ -72,6 +128,7 @@ export interface AgentWorkflowPersistedState { brand: string; store: string; }; + materials?: AgentWorkflowMaterialSelections; selections: Partial>; selectedAgentPresetId?: string | null; currentStage?: "workflow" | "mode";