From e8796c74a6bf89352ac22dd74114e9d79de3d77a Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 1 Jun 2026 14:24:28 +0800 Subject: [PATCH] fix: guard material search client crashes --- .../agents/AgentWorkflowMaterialPicker.tsx | 63 ++- .../AgentWorkflowMaterialSelectionPanel.tsx | 395 +++++++++++------- 2 files changed, 304 insertions(+), 154 deletions(-) diff --git a/components/agents/AgentWorkflowMaterialPicker.tsx b/components/agents/AgentWorkflowMaterialPicker.tsx index e4ea1d7ff..f71b12fc5 100644 --- a/components/agents/AgentWorkflowMaterialPicker.tsx +++ b/components/agents/AgentWorkflowMaterialPicker.tsx @@ -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; + return Boolean(String(image.id || "").trim()); + }); +} + +function normalizeSkuOptions( + value: AgentWorkflowMaterialSearchResult["skuOptions"] | unknown +): NonNullable { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is NonNullable[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({ ) : null} - {result.skuOptions?.length ? ( + {skuOptions.length ? (
- {result.skuOptions.map((option) => ( + {skuOptions.map((option) => (
)} - {(result?.images?.length || result?.skuOptions?.length) ? ( + {(allImages.length || skuOptions.length) ? (
- {result?.skuOptions?.length + {skuOptions.length ? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。" : totalImageCount > 0 ? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。" diff --git a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx index 581eb4e9e..7c4309ffd 100644 --- a/components/agents/AgentWorkflowMaterialSelectionPanel.tsx +++ b/components/agents/AgentWorkflowMaterialSelectionPanel.tsx @@ -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; + 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 ( +
+
{title}暂时无法渲染
+
+ 已拦截本次客户端异常,避免整个风格化工作台崩溃。请修改搜索词或刷新后重试。 +
+
+ ); +} + 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({
- 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 ? ( + } + > 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, }, }) } /> + + + {isSecondaryMaterialVisible ? ( + } + > + 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, + }, + }) + } + /> + ) : (