feat: add workflow appearance recognition controls
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-03 17:36:13 +08:00
parent f6d8a4b8ea
commit 90458511c1
10 changed files with 447 additions and 32 deletions

View File

@ -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 }
);
}
}

View File

@ -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
? {

View File

@ -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",

View File

@ -335,11 +335,13 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [workflowSelections, setWorkflowSelections] = useState<
Partial<Record<AgentWorkflowStepKey, string>>
>({});
const [appearanceRecognitionEnabled, setAppearanceRecognitionEnabled] = useState(true);
const [selectedBrandModel, setSelectedBrandModel] =
useState<AgentWorkflowSelectedBrandModel | null>(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<string | null>(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}

View File

@ -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);

View File

@ -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<Record<"primary" | "secondary", string>>({
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({
</div>
) : null}
<div
className={cn(
"flex items-start justify-between gap-4 rounded-[22px] border px-4 py-3",
isDark ? "border-white/10 bg-white/[0.03]" : "border-[#dbe3ff] bg-white"
)}
>
<div className="space-y-1">
<div
className={cn(
"text-sm font-medium",
isDark ? "text-white/88" : "text-[#334155]"
)}
>
产品外观识别
</div>
<div
className={cn(
"text-xs leading-5",
isDark ? "text-white/55" : "text-[#64748b]"
)}
>
开启后会用 QWEN3-VL 识别当前选中商品图的外观结构和颜色,并拼进生图提示词;关闭后不会调用识别,也不会加入外观锁定。
</div>
</div>
<div className="flex shrink-0 items-center gap-2 pt-0.5">
<span
className={cn(
"text-[11px] font-medium",
appearanceRecognitionEnabled
? isDark
? "text-emerald-300"
: "text-emerald-600"
: isDark
? "text-white/45"
: "text-[#94a3b8]"
)}
>
{appearanceRecognitionEnabled ? "已开启" : "已关闭"}
</span>
<Switch
checked={appearanceRecognitionEnabled}
onCheckedChange={(checked) =>
onAppearanceRecognitionEnabledChange?.(checked === true)
}
disabled={disabled}
aria-label="切换产品外观识别"
className={
isDark
? "data-[state=checked]:border-emerald-400/70 data-[state=checked]:bg-emerald-500"
: undefined
}
/>
</div>
</div>
<AgentWorkflowMaterialPicker
value={inputs.primarySku}
queryMode={materialSearchMode.primary}
@ -604,6 +906,7 @@ export function AgentWorkflowMaterialSelectionPanel({
errorText={materialSearchState.primary.errorText}
result={materials.primary.result}
selectedImageIds={materials.primary.selectedImageIds}
appearanceRecognitionEnabled={appearanceRecognitionEnabled}
selectionLimit={primarySelectionLimit}
selectionLimitLabel={selectionLimitLabel}
onValueChange={(value) => 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)}

View File

@ -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<string, unknown>).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<string, string>)

View File

@ -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<Record<AgentWorkflowStepKey, string>>;
}): 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);

View File

@ -69,8 +69,8 @@ function buildAppearancePrompt() {
return [
"你是电商商品白底图识别器。",
"请只基于商品本体,识别并描述产品的外观结构和颜色,用于后续生图时保持产品结构不要改变。",
"描述重点:1. 产品品类与整体轮廓;2. 关键结构件及数量关系,例如层板、格口、门板、抽屉、腿、扶手、靠背、边框、台面、开孔、开口方向;3. 主色、副色,以及木纹、金属、布艺等肉眼可见材质或纹理。",
"不要描述背景、光影、拍摄角度、水印、文案,也不要输出营销形容词。",
"描述重点:1. 产品品类与整体轮廓;2. 关键结构件及数量关系,例如层板、格口、门板、抽屉、腿、扶手、靠背、边框、台面、开孔、开口方向;3. 主色、副色与颜色分布。",
"不要分析或描述材质、纹理、表面工艺、木纹、金属感、布艺感,也不要描述背景、光影、拍摄角度、水印、文案,更不要输出营销形容词。",
'只输出 JSON:{"appearanceSummary":"..."}',
"appearanceSummary 必须是中文,长度 60 到 120 字,具体、克制、可直接作为生图约束。",
].join("\n");

View File

@ -431,6 +431,7 @@ export interface AgentWorkflowPersistedState {
priceBand: string;
};
materials?: AgentWorkflowMaterialSelections;
appearanceRecognitionEnabled?: boolean;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
selectedAgentPresetId?: string | null;