feat: add workflow appearance recognition controls
This commit is contained in:
parent
f6d8a4b8ea
commit
90458511c1
48
app/api/agent-workflow/material-appearance/route.ts
Normal file
48
app/api/agent-workflow/material-appearance/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
? {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -69,8 +69,8 @@ function buildAppearancePrompt() {
|
||||
return [
|
||||
"你是电商商品白底图识别器。",
|
||||
"请只基于商品本体,识别并描述产品的外观结构和颜色,用于后续生图时保持产品结构不要改变。",
|
||||
"描述重点:1. 产品品类与整体轮廓;2. 关键结构件及数量关系,例如层板、格口、门板、抽屉、腿、扶手、靠背、边框、台面、开孔、开口方向;3. 主色、副色,以及木纹、金属、布艺等肉眼可见材质或纹理。",
|
||||
"不要描述背景、光影、拍摄角度、水印、文案,也不要输出营销形容词。",
|
||||
"描述重点:1. 产品品类与整体轮廓;2. 关键结构件及数量关系,例如层板、格口、门板、抽屉、腿、扶手、靠背、边框、台面、开孔、开口方向;3. 主色、副色与颜色分布。",
|
||||
"不要分析或描述材质、纹理、表面工艺、木纹、金属感、布艺感,也不要描述背景、光影、拍摄角度、水印、文案,更不要输出营销形容词。",
|
||||
'只输出 JSON:{"appearanceSummary":"..."}',
|
||||
"appearanceSummary 必须是中文,长度 60 到 120 字,具体、克制、可直接作为生图约束。",
|
||||
].join("\n");
|
||||
|
||||
@ -431,6 +431,7 @@ export interface AgentWorkflowPersistedState {
|
||||
priceBand: string;
|
||||
};
|
||||
materials?: AgentWorkflowMaterialSelections;
|
||||
appearanceRecognitionEnabled?: boolean;
|
||||
selections: Partial<Record<AgentWorkflowStepKey, string>>;
|
||||
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
|
||||
selectedAgentPresetId?: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user