From 90458511c15804011c9ddd0b75fb61478ad2283b Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 3 Apr 2026 17:36:13 +0800 Subject: [PATCH] feat: add workflow appearance recognition controls --- .../material-appearance/route.ts | 48 +++ .../agent-workflow/material-search/route.ts | 20 +- .../agent-workflow/material-upload/route.ts | 19 +- app/canvas/[id]/page.tsx | 21 ++ .../agents/AgentWorkflowMaterialPicker.tsx | 28 +- .../AgentWorkflowMaterialSelectionPanel.tsx | 306 +++++++++++++++++- lib/agents/generation-project-state.ts | 2 + lib/agents/workflow-composer.ts | 30 +- lib/agents/workflow-material-appearance.ts | 4 +- types/agent.ts | 1 + 10 files changed, 447 insertions(+), 32 deletions(-) create mode 100644 app/api/agent-workflow/material-appearance/route.ts diff --git a/app/api/agent-workflow/material-appearance/route.ts b/app/api/agent-workflow/material-appearance/route.ts new file mode 100644 index 000000000..0a8a48dfd --- /dev/null +++ b/app/api/agent-workflow/material-appearance/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { analyzeWorkflowMaterialAppearanceFromImage } from "@/lib/agents/workflow-material-appearance"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +const payloadSchema = z.object({ + imageUrl: z.string().trim().url().max(2000), +}); + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const payload = payloadSchema.parse(await request.json().catch(() => ({}))); + const appearanceSummary = await analyzeWorkflowMaterialAppearanceFromImage( + payload.imageUrl + ).catch((error) => { + console.warn("[Agent Workflow Material Appearance] Analyze failed:", error); + return null; + }); + + return NextResponse.json({ + success: true, + data: { + appearanceSummary, + }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.issues[0]?.message || "请求参数不合法" }, + { status: 400 } + ); + } + + console.error("[Agent Workflow Material Appearance] Failed:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "识别失败" }, + { status: 500 } + ); + } +} diff --git a/app/api/agent-workflow/material-search/route.ts b/app/api/agent-workflow/material-search/route.ts index 1317cabe1..1da626478 100644 --- a/app/api/agent-workflow/material-search/route.ts +++ b/app/api/agent-workflow/material-search/route.ts @@ -24,6 +24,7 @@ const querySchema = z.object({ source: z.enum(["primary", "secondary"]).optional(), queryType: z.enum(["sku", "spu"]).optional(), requiredCount: z.coerce.number().int().min(1).max(4).optional(), + appearanceRecognitionEnabled: z.enum(["true", "false", "1", "0"]).optional(), }); const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i; @@ -700,7 +701,12 @@ export async function GET(request: NextRequest) { source: request.nextUrl.searchParams.get("source"), queryType: request.nextUrl.searchParams.get("queryType"), requiredCount: request.nextUrl.searchParams.get("requiredCount") || undefined, + appearanceRecognitionEnabled: + request.nextUrl.searchParams.get("appearanceRecognitionEnabled") || undefined, }); + const appearanceRecognitionEnabled = + parsed.appearanceRecognitionEnabled !== "false" && + parsed.appearanceRecognitionEnabled !== "0"; const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY }); const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value); @@ -884,12 +890,14 @@ export async function GET(request: NextRequest) { requiredCount, }) : []; - const firstImageAppearanceSummary = await analyzeWorkflowMaterialAppearanceFromImage( - importedImages[0]?.imageUrl || "" - ).catch((error) => { - console.warn("[Agent Workflow Material Search] Appearance analysis failed:", error); - return null; - }); + const firstImageAppearanceSummary = appearanceRecognitionEnabled + ? await analyzeWorkflowMaterialAppearanceFromImage( + importedImages[0]?.imageUrl || "" + ).catch((error) => { + console.warn("[Agent Workflow Material Search] Appearance analysis failed:", error); + return null; + }) + : null; const annotatedImages = importedImages.map((image, index) => index === 0 && firstImageAppearanceSummary ? { diff --git a/app/api/agent-workflow/material-upload/route.ts b/app/api/agent-workflow/material-upload/route.ts index 13afa4f02..ebd4265ee 100644 --- a/app/api/agent-workflow/material-upload/route.ts +++ b/app/api/agent-workflow/material-upload/route.ts @@ -16,6 +16,7 @@ const payloadSchema = z.object({ matchedSku: z.string().trim().max(64).optional(), matchedSpu: z.string().trim().max(64).optional(), productModel: z.string().trim().max(128).optional(), + appearanceRecognitionEnabled: z.enum(["true", "false", "1", "0"]).optional(), }); function getOptionalFormString(value: FormDataEntryValue | null) { @@ -89,7 +90,13 @@ export async function POST(request: NextRequest) { matchedSku: getOptionalFormString(formData.get("matchedSku")), matchedSpu: getOptionalFormString(formData.get("matchedSpu")), productModel: getOptionalFormString(formData.get("productModel")), + appearanceRecognitionEnabled: getOptionalFormString( + formData.get("appearanceRecognitionEnabled") + ), }); + const appearanceRecognitionEnabled = + payload.appearanceRecognitionEnabled !== "false" && + payload.appearanceRecognitionEnabled !== "0"; const arrayBuffer = await file.arrayBuffer(); const imageBuffer = Buffer.from(arrayBuffer); @@ -110,12 +117,12 @@ export async function POST(request: NextRequest) { const originalFileName = String(file.name || "").trim() || `workflow-upload-${Date.now()}.png`; const originalStem = sanitizeFilenameStem(originalFileName) || `workflow-upload-${Date.now()}`; const displayName = `${originalStem}-rmbg.png`; - const appearanceSummary = await analyzeWorkflowMaterialAppearanceFromImage( - result.original_url - ).catch((error) => { - console.warn("[Agent Workflow Material Upload] Appearance analysis failed:", error); - return null; - }); + const appearanceSummary = appearanceRecognitionEnabled + ? await analyzeWorkflowMaterialAppearanceFromImage(result.original_url).catch((error) => { + console.warn("[Agent Workflow Material Upload] Appearance analysis failed:", error); + return null; + }) + : null; const asset = await upsertMediaAsset({ userId: session.user.id, mediaType: "image", diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 5002a54f2..8054fc596 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -335,11 +335,13 @@ export default function CanvasEditorPage({ params }: PageProps) { const [workflowSelections, setWorkflowSelections] = useState< Partial> >({}); + const [appearanceRecognitionEnabled, setAppearanceRecognitionEnabled] = useState(true); const [selectedBrandModel, setSelectedBrandModel] = useState(null); const workflowInputsRef = useRef(workflowInputs); const workflowMaterialsRef = useRef(workflowMaterials); const workflowSelectionsRef = useRef(workflowSelections); + const appearanceRecognitionEnabledRef = useRef(appearanceRecognitionEnabled); const selectedBrandModelRef = useRef(selectedBrandModel); const [selectedAgentPresetId, setSelectedAgentPresetId] = useState(null); const [currentAgentStage, setCurrentAgentStage] = useState<"workflow" | "mode">("workflow"); @@ -499,6 +501,7 @@ export default function CanvasEditorPage({ params }: PageProps) { priceBand: workflowInputs.priceBand.trim(), }, materials: workflowMaterials, + appearanceRecognitionEnabled, selections: sanitizedWorkflowSelections, selectedBrandModel, selectedAgentPresetId: selectedAgentPresetId || null, @@ -508,6 +511,7 @@ export default function CanvasEditorPage({ params }: PageProps) { }, [ agentModeState, currentAgentStage, + appearanceRecognitionEnabled, selectedAgentPresetId, selectedBrandModel, workflowInputs, @@ -583,6 +587,9 @@ export default function CanvasEditorPage({ params }: PageProps) { setWorkflowMaterials( normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials) ); + setAppearanceRecognitionEnabled( + persistedWorkflow.appearanceRecognitionEnabled !== false + ); setWorkflowSelections(persistedWorkflow.selections || {}); setSelectedBrandModel( persistedWorkflow.selectedBrandModel @@ -781,6 +788,7 @@ export default function CanvasEditorPage({ params }: PageProps) { priceBand: String(parsed.inputs?.priceBand || "").trim(), }); setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(parsed.materials)); + setAppearanceRecognitionEnabled(parsed.appearanceRecognitionEnabled !== false); setWorkflowSelections(parsed.selections || {}); setSelectedBrandModel( parsed.selectedBrandModel @@ -818,6 +826,7 @@ export default function CanvasEditorPage({ params }: PageProps) { JSON.stringify({ inputs: workflowInputs, materials: workflowMaterials, + appearanceRecognitionEnabled, selections: sanitizedWorkflowSelections, selectedBrandModel, selectedAgentPresetId, @@ -833,6 +842,7 @@ export default function CanvasEditorPage({ params }: PageProps) { workflowHydrationSource, workflowInputs, workflowMaterials, + appearanceRecognitionEnabled, sanitizedWorkflowSelections, workflowStorageKey, selectedBrandModel, @@ -910,6 +920,9 @@ export default function CanvasEditorPage({ params }: PageProps) { priceBand: String(persistedWorkflow.inputs?.priceBand || "").trim(), }); setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials)); + setAppearanceRecognitionEnabled( + persistedWorkflow.appearanceRecognitionEnabled !== false + ); setWorkflowSelections(persistedWorkflow.selections || {}); setSelectedBrandModel(persistedWorkflow.selectedBrandModel || null); setSelectedAgentPresetId(String(persistedWorkflow.selectedAgentPresetId || "").trim() || null); @@ -1715,9 +1728,11 @@ export default function CanvasEditorPage({ params }: PageProps) { inputs: workflowInputs, materials: workflowMaterials, selectedBrandModel, + appearanceRecognitionEnabled, selections: sanitizedWorkflowSelections, }); }, [ + appearanceRecognitionEnabled, resolvedWorkflowUiConfig, selectedBrandModel, sanitizedWorkflowSelections, @@ -1733,6 +1748,9 @@ export default function CanvasEditorPage({ params }: PageProps) { useEffect(() => { workflowSelectionsRef.current = sanitizedWorkflowSelections; }, [sanitizedWorkflowSelections]); + useEffect(() => { + appearanceRecognitionEnabledRef.current = appearanceRecognitionEnabled; + }, [appearanceRecognitionEnabled]); useEffect(() => { selectedBrandModelRef.current = selectedBrandModel; }, [selectedBrandModel]); @@ -1795,6 +1813,7 @@ export default function CanvasEditorPage({ params }: PageProps) { inputs: workflowInputsRef.current, materials: workflowMaterialsRef.current, selectedBrandModel: selectedBrandModelRef.current, + appearanceRecognitionEnabled: appearanceRecognitionEnabledRef.current, selections: workflowSelectionsRef.current, }); }, [resolvedWorkflowUiConfig]); @@ -2150,6 +2169,8 @@ export default function CanvasEditorPage({ params }: PageProps) { onInputsChange={handleWorkflowInputsChange} materials={workflowMaterials} onMaterialsChange={handleWorkflowMaterialsChange} + appearanceRecognitionEnabled={appearanceRecognitionEnabled} + onAppearanceRecognitionEnabledChange={setAppearanceRecognitionEnabled} productSelectionLimit={materialProductSelectionLimit} totalUploadRequirement={materialTotalUploadRequirement} modelImageRequirement={materialModelImageRequirement} diff --git a/components/agents/AgentWorkflowMaterialPicker.tsx b/components/agents/AgentWorkflowMaterialPicker.tsx index 9d5cbf580..6f2673f81 100644 --- a/components/agents/AgentWorkflowMaterialPicker.tsx +++ b/components/agents/AgentWorkflowMaterialPicker.tsx @@ -42,6 +42,7 @@ interface AgentWorkflowMaterialPickerProps { onRemoveUploadedImage?: (imageId: string) => void; isUploadingNewImage?: boolean; uploadingImageId?: string | null; + appearanceRecognitionEnabled?: boolean; theme?: "light" | "dark"; } @@ -70,6 +71,7 @@ export function AgentWorkflowMaterialPicker({ onRemoveUploadedImage, isUploadingNewImage = false, uploadingImageId = null, + appearanceRecognitionEnabled = true, theme = "light", }: AgentWorkflowMaterialPickerProps) { const isDark = theme === "dark"; @@ -100,6 +102,13 @@ export function AgentWorkflowMaterialPicker({ () => allImages.filter((item) => item.sourceType !== "upload"), [allImages] ); + const selectedPrimaryImage = useMemo( + () => allImages.find((item) => selectedSet.has(item.id)) || null, + [allImages, selectedSet] + ); + const visibleAppearanceSummary = selectedPrimaryImage + ? selectedPrimaryImage.appearanceSummary || null + : allImages[0]?.appearanceSummary || result?.goodsInfo?.appearanceSummary || null; const selectedCount = allImages.filter((item) => selectedSet.has(item.id)).length; const selectedGalleryCount = galleryImages.filter((item) => selectedSet.has(item.id)).length; const resolvedSelectionLimit = @@ -135,16 +144,15 @@ export function AgentWorkflowMaterialPicker({ label: "板材类型", value: result?.goodsInfo?.boardType || null, }, - { - label: "外观识别", - value: - result?.images - ?.slice() - .sort((left, right) => left.sortOrder - right.sortOrder)[0]?.appearanceSummary || - result?.goodsInfo?.appearanceSummary || - null, - fullWidth: true, - }, + ...(appearanceRecognitionEnabled + ? [ + { + label: "外观识别", + value: visibleAppearanceSummary, + fullWidth: true, + }, + ] + : []), ].filter((item) => Boolean(item.value)); const remainingSelectableCount = resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0); diff --git a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx index 64c14ed5a..c1a4c8444 100644 --- a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx +++ b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Plus, Sparkles } from "lucide-react"; import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker"; +import { Switch } from "@/components/ui/switch"; import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config"; import { cn } from "@/lib/utils"; import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel"; @@ -18,6 +19,8 @@ interface AgentWorkflowMaterialSelectionPanelProps { onInputsChange: (next: AgentWorkflowInputs) => void; materials: AgentWorkflowMaterialSelections; onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void; + appearanceRecognitionEnabled?: boolean; + onAppearanceRecognitionEnabledChange?: (next: boolean) => void; productSelectionLimit?: number | null; totalUploadRequirement?: number | null; modelImageRequirement?: number | null; @@ -39,6 +42,100 @@ function getUploadedWorkflowMaterialImages( : []; } +function getSelectedWorkflowMaterialImages(params: { + result?: AgentWorkflowMaterialSearchResult | null; + selectedImageIds: string[]; +}): AgentWorkflowMaterialImage[] { + const selectedIds = new Set( + (params.selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean) + ); + if (!params.result || selectedIds.size === 0) { + return []; + } + + return (params.result.images || []).filter((image) => selectedIds.has(image.id)); +} + +function syncWorkflowMaterialAppearanceSummary(params: { + result?: AgentWorkflowMaterialSearchResult | null; + selectedImageIds: string[]; + appearanceSummary?: string | null; + imageId?: string | null; +}): AgentWorkflowMaterialSearchResult | null | undefined { + const result = params.result; + if (!result) { + return result; + } + + const selectedImages = getSelectedWorkflowMaterialImages({ + result, + selectedImageIds: params.selectedImageIds, + }); + const targetImage = + (params.imageId + ? selectedImages.find((image) => image.id === params.imageId) + : selectedImages[0]) || null; + + if (!targetImage) { + return result; + } + + const nextSummary = String( + params.appearanceSummary ?? targetImage.appearanceSummary ?? "" + ).trim(); + const currentGoodsSummary = String(result.goodsInfo?.appearanceSummary || "").trim(); + + if (!nextSummary) { + if (!currentGoodsSummary) { + return result; + } + return { + ...result, + goodsInfo: result.goodsInfo + ? { + ...result.goodsInfo, + appearanceSummary: null, + } + : null, + }; + } + + let changed = false; + const nextImages = result.images.map((image) => { + if (image.id !== targetImage.id) { + return image; + } + if (String(image.appearanceSummary || "").trim() === nextSummary) { + return image; + } + changed = true; + return { + ...image, + appearanceSummary: nextSummary, + }; + }); + const nextGoodsInfo = + currentGoodsSummary === nextSummary && result.goodsInfo + ? result.goodsInfo + : { + ...(result.goodsInfo || {}), + appearanceSummary: nextSummary, + }; + if (currentGoodsSummary !== nextSummary) { + changed = true; + } + + if (!changed) { + return result; + } + + return { + ...result, + images: nextImages, + goodsInfo: nextGoodsInfo, + }; +} + function markSearchWorkflowMaterialImages( images: AgentWorkflowMaterialImage[] ): AgentWorkflowMaterialImage[] { @@ -137,6 +234,8 @@ export function AgentWorkflowMaterialSelectionPanel({ onInputsChange, materials, onMaterialsChange, + appearanceRecognitionEnabled = true, + onAppearanceRecognitionEnabledChange, productSelectionLimit = null, totalUploadRequirement = null, modelImageRequirement = 0, @@ -209,6 +308,11 @@ export function AgentWorkflowMaterialSelectionPanel({ const [isSecondaryMaterialVisible, setIsSecondaryMaterialVisible] = useState( hasSecondaryMaterialContent ); + const latestMaterialsRef = useRef(materials); + const appearanceRequestRef = useRef>({ + primary: "", + secondary: "", + }); const normalizedRawProductSelectionLimit = typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0 ? Math.max(1, Math.round(productSelectionLimit)) @@ -260,6 +364,10 @@ export function AgentWorkflowMaterialSelectionPanel({ } }, [hasSecondaryMaterialContent]); + useEffect(() => { + latestMaterialsRef.current = materials; + }, [materials]); + const updateMaterialSearchState = ( source: "primary" | "secondary", updates: Partial<{ isLoading: boolean; errorText: string | null }> @@ -296,6 +404,137 @@ export function AgentWorkflowMaterialSelectionPanel({ return materials[source].result?.queryType || materialSearchMode[source]; }; + const ensureSelectedImageAppearanceSummary = async ( + source: "primary" | "secondary" + ) => { + if (!appearanceRecognitionEnabled) { + appearanceRequestRef.current[source] = ""; + return; + } + + const currentSourceMaterials = latestMaterialsRef.current[source]; + const currentResult = currentSourceMaterials.result; + const selectedImages = getSelectedWorkflowMaterialImages({ + result: currentResult, + selectedImageIds: currentSourceMaterials.selectedImageIds, + }); + const firstSelectedImage = selectedImages[0]; + + if (!currentResult || !firstSelectedImage) { + appearanceRequestRef.current[source] = ""; + return; + } + + const syncedResult = syncWorkflowMaterialAppearanceSummary({ + result: currentResult, + selectedImageIds: currentSourceMaterials.selectedImageIds, + }); + + if (syncedResult !== currentResult) { + const nextMaterials = { + ...latestMaterialsRef.current, + [source]: { + ...currentSourceMaterials, + result: syncedResult, + }, + }; + latestMaterialsRef.current = nextMaterials; + onMaterialsChange(nextMaterials); + } + + if (!String(firstSelectedImage.imageUrl || "").trim()) { + appearanceRequestRef.current[source] = ""; + return; + } + + if (String(firstSelectedImage.appearanceSummary || "").trim()) { + appearanceRequestRef.current[source] = ""; + return; + } + + const requestKey = `${firstSelectedImage.id}:${firstSelectedImage.imageUrl}`; + if (appearanceRequestRef.current[source] === requestKey) { + return; + } + appearanceRequestRef.current[source] = requestKey; + + try { + const response = await fetch("/api/agent-workflow/material-appearance", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + imageUrl: firstSelectedImage.imageUrl, + }), + }); + const payload = await response.json().catch(() => ({})); + const appearanceSummary = String(payload?.data?.appearanceSummary || "").trim(); + + if (!response.ok || !payload?.success || !appearanceSummary) { + return; + } + + const latestSourceMaterials = latestMaterialsRef.current[source]; + const latestSelectedImages = getSelectedWorkflowMaterialImages({ + result: latestSourceMaterials.result, + selectedImageIds: latestSourceMaterials.selectedImageIds, + }); + const latestFirstSelectedImage = latestSelectedImages[0]; + + if (!latestFirstSelectedImage || latestFirstSelectedImage.id !== firstSelectedImage.id) { + return; + } + + const nextResult = syncWorkflowMaterialAppearanceSummary({ + result: latestSourceMaterials.result, + selectedImageIds: latestSourceMaterials.selectedImageIds, + appearanceSummary, + imageId: latestFirstSelectedImage.id, + }); + if (nextResult === latestSourceMaterials.result) { + return; + } + + const nextMaterials = { + ...latestMaterialsRef.current, + [source]: { + ...latestSourceMaterials, + result: nextResult, + }, + }; + latestMaterialsRef.current = nextMaterials; + onMaterialsChange(nextMaterials); + } finally { + if (appearanceRequestRef.current[source] === requestKey) { + appearanceRequestRef.current[source] = ""; + } + } + }; + + const primaryAppearanceSyncKey = [ + materials.primary.selectedImageIds.join("|"), + (materials.primary.result?.images || []) + .map((image) => `${image.id}:${String(image.appearanceSummary || "").trim()}`) + .join("|"), + String(materials.primary.result?.goodsInfo?.appearanceSummary || "").trim(), + ].join("::"); + const secondaryAppearanceSyncKey = [ + materials.secondary.selectedImageIds.join("|"), + (materials.secondary.result?.images || []) + .map((image) => `${image.id}:${String(image.appearanceSummary || "").trim()}`) + .join("|"), + String(materials.secondary.result?.goodsInfo?.appearanceSummary || "").trim(), + ].join("::"); + + useEffect(() => { + void ensureSelectedImageAppearanceSummary("primary"); + }, [appearanceRecognitionEnabled, primaryAppearanceSyncKey]); + + useEffect(() => { + void ensureSelectedImageAppearanceSummary("secondary"); + }, [appearanceRecognitionEnabled, secondaryAppearanceSyncKey]); + const handleMaterialInputChange = (source: "primary" | "secondary", value: string) => { const inputKey = source === "primary" ? "primarySku" : "secondarySku"; const trimmedValue = value.trim().toUpperCase(); @@ -353,6 +592,10 @@ export function AgentWorkflowMaterialSelectionPanel({ if (requiredCount) { searchParams.set("requiredCount", String(requiredCount)); } + searchParams.set( + "appearanceRecognitionEnabled", + appearanceRecognitionEnabled ? "true" : "false" + ); const response = await fetch( `/api/agent-workflow/material-search?${searchParams.toString()}`, { cache: "no-store" } @@ -435,6 +678,10 @@ export function AgentWorkflowMaterialSelectionPanel({ if (currentResult?.productModel) { formData.set("productModel", currentResult.productModel); } + formData.set( + "appearanceRecognitionEnabled", + appearanceRecognitionEnabled ? "true" : "false" + ); const response = await fetch("/api/agent-workflow/material-upload", { method: "POST", @@ -588,6 +835,61 @@ export function AgentWorkflowMaterialSelectionPanel({ ) : null} +
+
+
+ 产品外观识别 +
+
+ 开启后会用 QWEN3-VL 识别当前选中商品图的外观结构和颜色,并拼进生图提示词;关闭后不会调用识别,也不会加入外观锁定。 +
+
+
+ + {appearanceRecognitionEnabled ? "已开启" : "已关闭"} + + + onAppearanceRecognitionEnabledChange?.(checked === true) + } + disabled={disabled} + aria-label="切换产品外观识别" + className={ + isDark + ? "data-[state=checked]:border-emerald-400/70 data-[state=checked]:bg-emerald-500" + : undefined + } + /> +
+
+ handleMaterialInputChange("primary", value)} @@ -711,6 +1014,7 @@ export function AgentWorkflowMaterialSelectionPanel({ errorText={materialSearchState.secondary.errorText} result={materials.secondary.result} selectedImageIds={materials.secondary.selectedImageIds} + appearanceRecognitionEnabled={appearanceRecognitionEnabled} selectionLimit={secondarySelectionLimit} selectionLimitLabel={selectionLimitLabel} onValueChange={(value) => handleMaterialInputChange("secondary", value)} diff --git a/lib/agents/generation-project-state.ts b/lib/agents/generation-project-state.ts index f0fa97af8..51ffabeb4 100644 --- a/lib/agents/generation-project-state.ts +++ b/lib/agents/generation-project-state.ts @@ -40,6 +40,7 @@ export function createEmptyAgentWorkflowPersistedState(): AgentWorkflowPersisted priceBand: "", }, materials: createEmptyAgentWorkflowMaterialSelections(), + appearanceRecognitionEnabled: true, selections: {}, selectedBrandModel: null, selectedAgentPresetId: null, @@ -238,6 +239,7 @@ function normalizeWorkflowState(value: unknown): AgentWorkflowPersistedState { priceBand: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record).priceBand || "" : "").trim(), }, materials: normalizeAgentWorkflowMaterialSelections(raw.materials), + appearanceRecognitionEnabled: raw.appearanceRecognitionEnabled !== false, selections: raw.selections && typeof raw.selections === "object" && !Array.isArray(raw.selections) ? (raw.selections as Record) diff --git a/lib/agents/workflow-composer.ts b/lib/agents/workflow-composer.ts index 27ef50aff..98252b318 100644 --- a/lib/agents/workflow-composer.ts +++ b/lib/agents/workflow-composer.ts @@ -79,13 +79,26 @@ function buildGoodsInfoSizeConstraintLine(params: { function buildGoodsInfoAppearanceConstraintLine(params: { label: string; - result?: AgentWorkflowMaterialSelections["primary"]["result"] | null; + selection?: AgentWorkflowMaterialSelections["primary"] | null; + enabled?: boolean; }) { + if (params.enabled === false) { + return ""; + } + + const result = params.selection?.result || null; + const selectedImageIds = new Set( + (params.selection?.selectedImageIds || []) + .map((item) => String(item || "").trim()) + .filter(Boolean) + ); + const selectedPrimaryImage = + result?.images?.find((image) => selectedImageIds.has(image.id)) || null; + const fallbackPrimaryImage = result?.images?.[0] || null; const appearanceSummary = String( - params.result?.images?.slice().sort((left, right) => left.sortOrder - right.sortOrder)[0] - ?.appearanceSummary || - params.result?.goodsInfo?.appearanceSummary || - "" + selectedPrimaryImage + ? selectedPrimaryImage.appearanceSummary || "" + : fallbackPrimaryImage?.appearanceSummary || result?.goodsInfo?.appearanceSummary || "" ).trim(); if (!appearanceSummary) { @@ -302,6 +315,7 @@ export function buildAgentWorkflowComposerContext(params: { inputs: AgentWorkflowInputsValue; materials?: AgentWorkflowMaterialSelections | null; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; + appearanceRecognitionEnabled?: boolean; selections: Partial>; }): AgentWorkflowComposerContext | null { const config = params.config || defaultAgentWorkflowUiConfig; @@ -442,7 +456,8 @@ export function buildAgentWorkflowComposerContext(params: { } const primaryGoodsInfoAppearanceConstraint = buildGoodsInfoAppearanceConstraintLine({ label: "主SKU", - result: params.materials?.primary?.result || null, + selection: params.materials?.primary || null, + enabled: params.appearanceRecognitionEnabled !== false, }); if (primaryGoodsInfoAppearanceConstraint) { primaryLockLines.push(primaryGoodsInfoAppearanceConstraint); @@ -465,7 +480,8 @@ export function buildAgentWorkflowComposerContext(params: { } const secondaryGoodsInfoAppearanceConstraint = buildGoodsInfoAppearanceConstraintLine({ label: "搭配SKU", - result: params.materials?.secondary?.result || null, + selection: params.materials?.secondary || null, + enabled: params.appearanceRecognitionEnabled !== false, }); if (secondaryGoodsInfoAppearanceConstraint) { secondaryLockLines.push(secondaryGoodsInfoAppearanceConstraint); diff --git a/lib/agents/workflow-material-appearance.ts b/lib/agents/workflow-material-appearance.ts index d7919b43f..294a9b4a0 100644 --- a/lib/agents/workflow-material-appearance.ts +++ b/lib/agents/workflow-material-appearance.ts @@ -69,8 +69,8 @@ function buildAppearancePrompt() { return [ "你是电商商品白底图识别器。", "请只基于商品本体,识别并描述产品的外观结构和颜色,用于后续生图时保持产品结构不要改变。", - "描述重点:1. 产品品类与整体轮廓;2. 关键结构件及数量关系,例如层板、格口、门板、抽屉、腿、扶手、靠背、边框、台面、开孔、开口方向;3. 主色、副色,以及木纹、金属、布艺等肉眼可见材质或纹理。", - "不要描述背景、光影、拍摄角度、水印、文案,也不要输出营销形容词。", + "描述重点:1. 产品品类与整体轮廓;2. 关键结构件及数量关系,例如层板、格口、门板、抽屉、腿、扶手、靠背、边框、台面、开孔、开口方向;3. 主色、副色与颜色分布。", + "不要分析或描述材质、纹理、表面工艺、木纹、金属感、布艺感,也不要描述背景、光影、拍摄角度、水印、文案,更不要输出营销形容词。", '只输出 JSON:{"appearanceSummary":"..."}', "appearanceSummary 必须是中文,长度 60 到 120 字,具体、克制、可直接作为生图约束。", ].join("\n"); diff --git a/types/agent.ts b/types/agent.ts index bdf99e230..5e800c5d4 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -431,6 +431,7 @@ export interface AgentWorkflowPersistedState { priceBand: string; }; materials?: AgentWorkflowMaterialSelections; + appearanceRecognitionEnabled?: boolean; selections: Partial>; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; selectedAgentPresetId?: string | null;