fix: guard material search client crashes

This commit is contained in:
Your Name 2026-06-01 14:24:28 +08:00
parent 531a23d15f
commit e8796c74a6
2 changed files with 304 additions and 154 deletions

View File

@ -63,6 +63,40 @@ const MATERIAL_GRID_MIN_CARD_HEIGHT = 258;
const AGENT_STYLE_OPTIMIZED_RENDER_ENABLED =
process.env.NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER !== "false";
function normalizeImageIdList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
function normalizeMaterialImages(value: unknown): AgentWorkflowMaterialImage[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is AgentWorkflowMaterialImage => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return false;
}
const image = item as Partial<AgentWorkflowMaterialImage>;
return Boolean(String(image.id || "").trim());
});
}
function normalizeSkuOptions(
value: AgentWorkflowMaterialSearchResult["skuOptions"] | unknown
): NonNullable<AgentWorkflowMaterialSearchResult["skuOptions"]> {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is NonNullable<AgentWorkflowMaterialSearchResult["skuOptions"]>[number] => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return false;
}
return Boolean(String(item.skuName || "").trim());
});
}
const MaterialImageCard = memo(function MaterialImageCard({
image,
selected,
@ -327,10 +361,14 @@ export function AgentWorkflowMaterialPicker({
const canUploadMaterialImage = Boolean(onUploadImage);
const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all");
const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false);
const selectedSet = useMemo(
() => new Set((selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)),
const normalizedSelectedImageIds = useMemo(
() => normalizeImageIdList(selectedImageIds),
[selectedImageIds]
);
const selectedSet = useMemo(
() => new Set(normalizedSelectedImageIds),
[normalizedSelectedImageIds]
);
const recommendedImageIds = useMemo(
() =>
Array.isArray(result?.recommendedImageIds)
@ -338,7 +376,14 @@ export function AgentWorkflowMaterialPicker({
: [],
[result?.recommendedImageIds]
);
const allImages = result?.images || [];
const allImages = useMemo(
() => normalizeMaterialImages(result?.images),
[result?.images]
);
const skuOptions = useMemo(
() => normalizeSkuOptions(result?.skuOptions),
[result?.skuOptions]
);
const uploadedImages = useMemo(
() =>
allImages
@ -458,7 +503,7 @@ export function AgentWorkflowMaterialPicker({
const handleToggleImage = useCallback((imageId: string) => {
setHasManualSelectionInteraction(true);
const next = new Set(selectedImageIds);
const next = new Set(normalizedSelectedImageIds);
if (next.has(imageId)) {
next.delete(imageId);
} else {
@ -475,7 +520,7 @@ export function AgentWorkflowMaterialPicker({
}, [
onSelectedImageIdsChange,
resolvedSelectionLimit,
selectedImageIds,
normalizedSelectedImageIds,
selectionLimitLabel,
]);
@ -1042,9 +1087,9 @@ export function AgentWorkflowMaterialPicker({
</div>
) : null}
{result.skuOptions?.length ? (
{skuOptions.length ? (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{result.skuOptions.map((option) => (
{skuOptions.map((option) => (
<div
key={`${option.skuName}-${option.skuId || "sku"}`}
className={cn(
@ -1183,7 +1228,7 @@ export function AgentWorkflowMaterialPicker({
</div>
)}
{(result?.images?.length || result?.skuOptions?.length) ? (
{(allImages.length || skuOptions.length) ? (
<div
className={cn(
"flex items-start gap-2 rounded-[20px] px-3 py-2 text-xs",
@ -1191,7 +1236,7 @@ export function AgentWorkflowMaterialPicker({
)}
>
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{result?.skuOptions?.length
{skuOptions.length
? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。"
: totalImageCount > 0
? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"

View File

@ -1,8 +1,9 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Plus, Sparkles } from "lucide-react";
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
import { ClientErrorBoundary } from "@/components/ui/client-error-boundary";
import { Switch } from "@/components/ui/switch";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import { cn } from "@/lib/utils";
@ -31,6 +32,58 @@ interface AgentWorkflowMaterialSelectionPanelProps {
theme?: "light" | "dark";
}
function normalizeImageIdList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
function normalizeMaterialImages(value: unknown): AgentWorkflowMaterialImage[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is AgentWorkflowMaterialImage => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return false;
}
const image = item as Partial<AgentWorkflowMaterialImage>;
return Boolean(String(image.id || "").trim());
});
}
function normalizeMaterialSearchResult(
result?: AgentWorkflowMaterialSearchResult | null
): AgentWorkflowMaterialSearchResult | null {
if (!result || typeof result !== "object" || Array.isArray(result)) {
return null;
}
return {
...result,
images: normalizeMaterialImages(result.images),
recommendedImageIds: normalizeImageIdList(result.recommendedImageIds),
skuOptions: Array.isArray(result.skuOptions)
? result.skuOptions.filter((item) => Boolean(String(item?.skuName || "").trim()))
: [],
};
}
function normalizeMaterialSelections(
value: AgentWorkflowMaterialSelections
): AgentWorkflowMaterialSelections {
return {
primary: {
result: normalizeMaterialSearchResult(value?.primary?.result),
selectedImageIds: normalizeImageIdList(value?.primary?.selectedImageIds),
},
secondary: {
result: normalizeMaterialSearchResult(value?.secondary?.result),
selectedImageIds: normalizeImageIdList(value?.secondary?.selectedImageIds),
},
};
}
function getUploadedWorkflowMaterialImages(
result?: AgentWorkflowMaterialSearchResult | null
): AgentWorkflowMaterialImage[] {
@ -47,13 +100,15 @@ function getSelectedWorkflowMaterialImages(params: {
selectedImageIds: string[];
}): AgentWorkflowMaterialImage[] {
const selectedIds = new Set(
(params.selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)
normalizeImageIdList(params.selectedImageIds)
);
if (!params.result || selectedIds.size === 0) {
return [];
}
return (params.result.images || []).filter((image) => selectedIds.has(image.id));
return normalizeMaterialImages(params.result.images).filter((image) =>
selectedIds.has(image.id)
);
}
function syncWorkflowMaterialAppearanceSummary(params: {
@ -101,7 +156,7 @@ function syncWorkflowMaterialAppearanceSummary(params: {
}
let changed = false;
const nextImages = result.images.map((image) => {
const nextImages = normalizeMaterialImages(result.images).map((image) => {
if (image.id !== targetImage.id) {
return image;
}
@ -217,7 +272,9 @@ function mergeSearchResultWithUploadedImages(params: {
currentResult?: AgentWorkflowMaterialSearchResult | null;
}): AgentWorkflowMaterialSearchResult {
const uploadedImages = getUploadedWorkflowMaterialImages(params.currentResult);
const searchImages = markSearchWorkflowMaterialImages(params.searchResult.images || []);
const searchImages = markSearchWorkflowMaterialImages(
normalizeMaterialImages(params.searchResult.images)
);
return buildWorkflowMaterialResult({
currentResult: {
...params.searchResult,
@ -229,10 +286,34 @@ function mergeSearchResultWithUploadedImages(params: {
});
}
function MaterialPickerErrorFallback({
isDark,
title,
}: {
isDark: boolean;
title: string;
}) {
return (
<div
className={cn(
"rounded-[24px] border px-4 py-5 text-sm leading-6",
isDark
? "border-amber-300/20 bg-amber-300/[0.08] text-amber-50"
: "border-amber-200 bg-amber-50 text-amber-900"
)}
>
<div className="font-medium">{title}暂时无法渲染</div>
<div className={cn("mt-1 text-xs", isDark ? "text-amber-50/70" : "text-amber-800/75")}>
已拦截本次客户端异常,避免整个风格化工作台崩溃。请修改搜索词或刷新后重试。
</div>
</div>
);
}
export function AgentWorkflowMaterialSelectionPanel({
inputs,
onInputsChange,
materials,
materials: rawMaterials,
onMaterialsChange,
appearanceRecognitionEnabled = false,
onAppearanceRecognitionEnabledChange,
@ -245,6 +326,10 @@ export function AgentWorkflowMaterialSelectionPanel({
disabledHint = "请先在下方选择一个生图模式,再启用 SKU / SPU 搜图。",
theme = "light",
}: AgentWorkflowMaterialSelectionPanelProps) {
const materials = useMemo(
() => normalizeMaterialSelections(rawMaterials),
[rawMaterials]
);
const hasSecondaryMaterialContent =
Boolean(String(inputs.secondarySku || "").trim()) ||
Boolean(materials.secondary.result) ||
@ -890,174 +975,75 @@ export function AgentWorkflowMaterialSelectionPanel({
</div>
</div>
<AgentWorkflowMaterialPicker
value={inputs.primarySku}
queryMode={materialSearchMode.primary}
placeholder={materialSearchMode.primary === "spu" ? "输入主 SPU" : "输入主 SKU"}
buttonLabel={materialSearchMode.primary === "spu" ? "查SKU列表" : "搜索主图"}
searchNote={
materialSearchMode.primary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,点选某个 SKU 后再继续拉取白底图。"
: "按 SKU 搜索会直接拉取白底图,适合你已经知道具体 SKU 的情况。"
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.primary.isLoading}
errorText={materialSearchState.primary.errorText}
result={materials.primary.result}
selectedImageIds={materials.primary.selectedImageIds}
appearanceRecognitionEnabled={appearanceRecognitionEnabled}
selectionLimit={primarySelectionLimit}
selectionLimitLabel={selectionLimitLabel}
onValueChange={(value) => handleMaterialInputChange("primary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
primary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("primary");
}}
onUploadImage={(file, options) => handleUploadMaterialImage("primary", file, options)}
onRemoveUploadedImage={(imageId) =>
handleRemoveUploadedMaterialImage("primary", imageId)
}
isUploadingNewImage={materialUploadState.primary.isUploadingNew}
uploadingImageId={materialUploadState.primary.uploadingImageId}
onSkuOptionSelect={(skuName) => {
const selectedOption = materials.primary.result?.skuOptions?.find(
(option) => option.skuName === skuName
);
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
const uploadedImages = getUploadedWorkflowMaterialImages(materials.primary.result);
onInputsChange({
...inputs,
primarySku: skuName,
});
onMaterialsChange({
...materials,
primary: {
result: buildWorkflowMaterialResult({
currentResult: {
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.primary.result,
placeholderCount,
}),
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.primary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
},
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.primary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
fallbackQuery: skuName,
fallbackQueryType: "sku",
}),
selectedImageIds: materials.primary.selectedImageIds.filter((id) =>
uploadedImages.some((image) => image.id === id)
),
},
});
setMaterialSearchMode((prev) => ({
...prev,
primary: "sku",
}));
void handleSearchMaterials("primary", {
keyword: skuName,
queryType: "sku",
});
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
primary: {
...materials.primary,
selectedImageIds: next,
},
})
}
/>
{isSecondaryMaterialVisible ? (
<ClientErrorBoundary
resetKeys={[
"primary",
inputs.primarySku,
materials.primary.result?.matchedSku || "",
materials.primary.selectedImageIds.join("|"),
]}
fallback={<MaterialPickerErrorFallback isDark={isDark} title="主商品素材搜索" />}
>
<AgentWorkflowMaterialPicker
value={inputs.secondarySku}
queryMode={materialSearchMode.secondary}
placeholder={materialSearchMode.secondary === "spu" ? "输入搭配 SPU" : "输入搭配 SKU"}
buttonLabel={materialSearchMode.secondary === "spu" ? "查SKU列表" : "搜索搭配图"}
value={inputs.primarySku}
queryMode={materialSearchMode.primary}
placeholder={materialSearchMode.primary === "spu" ? "输入主 SPU" : "输入主 SKU"}
buttonLabel={materialSearchMode.primary === "spu" ? "查SKU列表" : "搜索主图"}
searchNote={
materialSearchMode.secondary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,再选具体 SKU 获取白底图。"
: "选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。"
materialSearchMode.primary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,点选某个 SKU 后再继续拉取白底图。"
: "按 SKU 搜索会直接拉取白底图,适合你已经知道具体 SKU 的情况。"
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.secondary.isLoading}
errorText={materialSearchState.secondary.errorText}
result={materials.secondary.result}
selectedImageIds={materials.secondary.selectedImageIds}
isLoading={materialSearchState.primary.isLoading}
errorText={materialSearchState.primary.errorText}
result={materials.primary.result}
selectedImageIds={materials.primary.selectedImageIds}
appearanceRecognitionEnabled={appearanceRecognitionEnabled}
selectionLimit={secondarySelectionLimit}
selectionLimit={primarySelectionLimit}
selectionLimitLabel={selectionLimitLabel}
onValueChange={(value) => handleMaterialInputChange("secondary", value)}
onValueChange={(value) => handleMaterialInputChange("primary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
secondary: nextMode,
primary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("secondary");
void handleSearchMaterials("primary");
}}
onUploadImage={(file, options) => handleUploadMaterialImage("secondary", file, options)}
onUploadImage={(file, options) => handleUploadMaterialImage("primary", file, options)}
onRemoveUploadedImage={(imageId) =>
handleRemoveUploadedMaterialImage("secondary", imageId)
handleRemoveUploadedMaterialImage("primary", imageId)
}
isUploadingNewImage={materialUploadState.secondary.isUploadingNew}
uploadingImageId={materialUploadState.secondary.uploadingImageId}
isUploadingNewImage={materialUploadState.primary.isUploadingNew}
uploadingImageId={materialUploadState.primary.uploadingImageId}
onSkuOptionSelect={(skuName) => {
const selectedOption = materials.secondary.result?.skuOptions?.find(
const selectedOption = materials.primary.result?.skuOptions?.find(
(option) => option.skuName === skuName
);
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
const uploadedImages = getUploadedWorkflowMaterialImages(materials.secondary.result);
const uploadedImages = getUploadedWorkflowMaterialImages(materials.primary.result);
onInputsChange({
...inputs,
secondarySku: skuName,
primarySku: skuName,
});
onMaterialsChange({
...materials,
secondary: {
primary: {
result: buildWorkflowMaterialResult({
currentResult: {
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
currentResult: materials.primary.result,
placeholderCount,
}),
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
currentResult: materials.primary.result,
placeholderCount,
}).images.map((image) => ({
...image,
@ -1069,7 +1055,7 @@ export function AgentWorkflowMaterialSelectionPanel({
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
currentResult: materials.primary.result,
placeholderCount,
}).images.map((image) => ({
...image,
@ -1079,16 +1065,16 @@ export function AgentWorkflowMaterialSelectionPanel({
fallbackQuery: skuName,
fallbackQueryType: "sku",
}),
selectedImageIds: materials.secondary.selectedImageIds.filter((id) =>
selectedImageIds: materials.primary.selectedImageIds.filter((id) =>
uploadedImages.some((image) => image.id === id)
),
},
});
setMaterialSearchMode((prev) => ({
...prev,
secondary: "sku",
primary: "sku",
}));
void handleSearchMaterials("secondary", {
void handleSearchMaterials("primary", {
keyword: skuName,
queryType: "sku",
});
@ -1097,13 +1083,132 @@ export function AgentWorkflowMaterialSelectionPanel({
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
secondary: {
...materials.secondary,
primary: {
...materials.primary,
selectedImageIds: next,
},
})
}
/>
</ClientErrorBoundary>
{isSecondaryMaterialVisible ? (
<ClientErrorBoundary
resetKeys={[
"secondary",
inputs.secondarySku,
materials.secondary.result?.matchedSku || "",
materials.secondary.selectedImageIds.join("|"),
]}
fallback={<MaterialPickerErrorFallback isDark={isDark} title="搭配商品素材搜索" />}
>
<AgentWorkflowMaterialPicker
value={inputs.secondarySku}
queryMode={materialSearchMode.secondary}
placeholder={materialSearchMode.secondary === "spu" ? "输入搭配 SPU" : "输入搭配 SKU"}
buttonLabel={materialSearchMode.secondary === "spu" ? "查SKU列表" : "搜索搭配图"}
searchNote={
materialSearchMode.secondary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,再选具体 SKU 获取白底图。"
: "选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。"
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.secondary.isLoading}
errorText={materialSearchState.secondary.errorText}
result={materials.secondary.result}
selectedImageIds={materials.secondary.selectedImageIds}
appearanceRecognitionEnabled={appearanceRecognitionEnabled}
selectionLimit={secondarySelectionLimit}
selectionLimitLabel={selectionLimitLabel}
onValueChange={(value) => handleMaterialInputChange("secondary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
secondary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("secondary");
}}
onUploadImage={(file, options) => handleUploadMaterialImage("secondary", file, options)}
onRemoveUploadedImage={(imageId) =>
handleRemoveUploadedMaterialImage("secondary", imageId)
}
isUploadingNewImage={materialUploadState.secondary.isUploadingNew}
uploadingImageId={materialUploadState.secondary.uploadingImageId}
onSkuOptionSelect={(skuName) => {
const selectedOption = materials.secondary.result?.skuOptions?.find(
(option) => option.skuName === skuName
);
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
const uploadedImages = getUploadedWorkflowMaterialImages(materials.secondary.result);
onInputsChange({
...inputs,
secondarySku: skuName,
});
onMaterialsChange({
...materials,
secondary: {
result: buildWorkflowMaterialResult({
currentResult: {
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
placeholderCount,
}),
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
},
images: [
...uploadedImages,
...buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
placeholderCount,
}).images.map((image) => ({
...image,
sourceType: "search" as const,
})),
],
fallbackQuery: skuName,
fallbackQueryType: "sku",
}),
selectedImageIds: materials.secondary.selectedImageIds.filter((id) =>
uploadedImages.some((image) => image.id === id)
),
},
});
setMaterialSearchMode((prev) => ({
...prev,
secondary: "sku",
}));
void handleSearchMaterials("secondary", {
keyword: skuName,
queryType: "sku",
});
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
secondary: {
...materials.secondary,
selectedImageIds: next,
},
})
}
/>
</ClientErrorBoundary>
) : (
<button
type="button"