From 91fe35e3b603963fef0dce4046eb317358050a81 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 1 Jul 2026 00:30:56 +0800 Subject: [PATCH] fix: stabilize ai wash model handling --- .../image-pipeline-sections.tsx | 8 +- components/canvas/AiWashWorkspace.tsx | 86 ++++++++++++------- lib/ai/reference-image-capability.ts | 31 ++++++- 3 files changed, 86 insertions(+), 39 deletions(-) diff --git a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx index c6b27768f..f082bbd52 100644 --- a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx +++ b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx @@ -109,8 +109,10 @@ export function LocalAiImagePipelineSections({ handleSaveLayerSplitConfig, }: LocalAiImagePipelineSectionsProps) { const availableImageModels = (modelsData?.models || []).filter( - (model) => model.supportsImageGen && model.isEnabled && !model.isHidden + (model) => model.supportsImageGen && model.isEnabled ); + const formatAiWashModelOptionLabel = (model: (typeof availableImageModels)[number]) => + `${model.displayName || model.modelId}${model.isHidden ? " · 前台隐藏" : ""}`; const getAiWashControlToneCopy = (controlId: AiWashControlId) => { if (controlId === "creativity") { return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。"; @@ -1768,7 +1770,7 @@ export function LocalAiImagePipelineSections({ {availableImageModels.length > 0 ? ( availableImageModels.map((model) => ( - {model.displayName || model.modelId} + {formatAiWashModelOptionLabel(model)} )) ) : ( @@ -2130,7 +2132,7 @@ export function LocalAiImagePipelineSections({ key={model.id} value={model.modelId} > - {model.displayName || model.modelId} + {formatAiWashModelOptionLabel(model)} )) ) : ( diff --git a/components/canvas/AiWashWorkspace.tsx b/components/canvas/AiWashWorkspace.tsx index 31cc089d5..5e878afe9 100644 --- a/components/canvas/AiWashWorkspace.tsx +++ b/components/canvas/AiWashWorkspace.tsx @@ -341,6 +341,17 @@ function getControlLevelByIndex( return control.levels[clampedIndex] || control.levels[0] || null; } +function getPresetDefaultLevelIndexByControl(preset?: AiWashPreset | null) { + if (!preset) return {}; + return AI_WASH_CONTROL_ORDER.reduce>( + (accumulator, controlId) => { + accumulator[controlId] = getPresetControl(preset, controlId)?.defaultLevel ?? 0; + return accumulator; + }, + {} + ); +} + function getAiWashStatusLabel(status?: AiWashResultStatus) { switch (status) { case "submitting": @@ -481,17 +492,6 @@ export function AiWashWorkspace({ () => getEnabledAiWashPresets(config).filter((preset) => preset.enabled), [config] ); - const primaryPreset = useMemo( - () => enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0] || null, - [enabledPresets] - ); - const uiControls = useMemo( - () => - AI_WASH_CONTROL_ORDER.map( - (controlId) => getPresetControl(primaryPreset as AiWashPreset, controlId) - ).filter(Boolean) as AiWashControl[], - [primaryPreset] - ); const compareSourceUrl = String( historySourceOverride || sourceImageUrl || "" ).trim(); @@ -516,6 +516,22 @@ export function AiWashWorkspace({ selectedPreviewResult?.status === "succeeded" ? selectedPreviewResult : null, [selectedPreviewResult] ); + const selectedPreset = useMemo( + () => + enabledPresets.find((preset) => preset.id === selectedResultId) || + enabledPresets.find((preset) => preset.id === selectedPreviewResult?.presetId) || + enabledPresets.find((preset) => preset.isDefault) || + enabledPresets[0] || + null, + [enabledPresets, selectedPreviewResult?.presetId, selectedResultId] + ); + const uiControls = useMemo( + () => + AI_WASH_CONTROL_ORDER.map( + (controlId) => getPresetControl(selectedPreset, controlId) + ).filter(Boolean) as AiWashControl[], + [selectedPreset] + ); const visibleHistoryBatches = useMemo(() => { const scoped = persistedHistoryBatches .filter( @@ -595,13 +611,7 @@ export function AiWashWorkspace({ const defaultPreset = enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0]; setSelectedResultId(defaultPreset?.id || ""); - setSelectedLevelIndexByControl( - AI_WASH_CONTROL_ORDER.reduce>((accumulator, controlId) => { - accumulator[controlId] = - getPresetControl(defaultPreset, controlId)?.defaultLevel ?? 0; - return accumulator; - }, {}) - ); + setSelectedLevelIndexByControl(getPresetDefaultLevelIndexByControl(defaultPreset)); setComparePositionById( enabledPresets.reduce>((accumulator, preset) => { accumulator[preset.id] = 50; @@ -626,10 +636,10 @@ export function AiWashWorkspace({ setLastErrorMessage(""); setHistorySourceOverride(""); setResults(createIdleResultCards(enabledPresets)); - setSelectedResultId( - (enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0])?.id || - "" - ); + const defaultPreset = + enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0] || null; + setSelectedResultId(defaultPreset?.id || ""); + setSelectedLevelIndexByControl(getPresetDefaultLevelIndexByControl(defaultPreset)); }, [enabledPresets, sourceImageUrl]); const persistHistoryBatches = useCallback( @@ -968,15 +978,18 @@ export function AiWashWorkspace({ }, [compareSourceUrl, onApplyResult, selectedResult]); const handleLoadHistoryBatch = useCallback((entry: AiWashHistoryBatch) => { + const loadedPresetId = + (entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || ""; + const loadedPreset = + enabledPresets.find((preset) => preset.id === loadedPresetId) || null; setResults( entry.items.map((item) => ({ ...item, status: "succeeded", })) ); - setSelectedResultId( - (entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || "" - ); + setSelectedResultId(loadedPresetId); + setSelectedLevelIndexByControl(getPresetDefaultLevelIndexByControl(loadedPreset)); setHistorySourceOverride(entry.sourceImageUrl || ""); setComparePositionById( entry.items.reduce>((accumulator, item) => { @@ -986,7 +999,7 @@ export function AiWashWorkspace({ ); setLastErrorMessage(""); toast.success("已加载历史 AI洗图记录"); - }, []); + }, [enabledPresets]); const handleDeleteHistoryBatch = useCallback( (entryId: string) => { @@ -1002,6 +1015,10 @@ export function AiWashWorkspace({ ? comparePositionById[selectedPreviewResult.presetId] ?? 50 : 50; const previewHasImage = Boolean(selectedPreviewResult?.imageUrl); + const previewIsPending = + selectedPreviewResult?.status === "running" || + selectedPreviewResult?.status === "submitting" || + selectedPreviewResult?.status === "failed"; return (
@@ -1088,7 +1105,12 @@ export function AiWashWorkspace({ : "border-[#e9e9e9] bg-white/92 text-[#666666] hover:border-[#d6d6d6] hover:text-[#111111]" )} key={item.preset.id} - onClick={() => setSelectedResultId(item.preset.id)} + onClick={() => { + setSelectedResultId(item.preset.id); + setSelectedLevelIndexByControl( + getPresetDefaultLevelIndexByControl(item.preset) + ); + }} type="button" >
@@ -1309,19 +1331,17 @@ export function AiWashWorkspace({ ) : null}
) : compareSourceUrl ? ( -
+
AI洗图底图
原图
- {selectedPreviewResult?.status === "running" || - selectedPreviewResult?.status === "submitting" || - selectedPreviewResult?.status === "failed" ? ( -
+ {previewIsPending ? ( +
{selectedPreviewResult.status === "running" || selectedPreviewResult.status === "submitting" ? ( diff --git a/lib/ai/reference-image-capability.ts b/lib/ai/reference-image-capability.ts index d29d8b925..3da4e001d 100644 --- a/lib/ai/reference-image-capability.ts +++ b/lib/ai/reference-image-capability.ts @@ -1,4 +1,5 @@ type ModelDescriptor = { + displayName?: unknown; modelId?: unknown; supportsVision?: boolean | null; capabilities?: unknown; @@ -11,6 +12,8 @@ type ProviderDescriptor = { const BFL_REFERENCE_IMAGE_MODEL_REGEX = /^(?:bfl\/)?(?:flux-2-(?:max|flex|pro(?:-preview)?|klein-(?:4b|9b)(?:-preview)?)|flux-kontext-(?:pro|max))$/i; +const REFERENCE_IMAGE_MODE_REGEX = + /^(?:image-edit|image-to-image|img2img|reference-images?|image-reference|reference-image)$/i; function readStringField(source: unknown, key: string) { if (!source || typeof source !== "object" || !(key in source)) return ""; @@ -28,14 +31,29 @@ function normalizeBflModelId(value: unknown) { .replace(/[?#].*$/, ""); } +function readStringArrayField(source: unknown, key: string) { + if (!source || typeof source !== "object" || !(key in source)) return []; + const value = (source as Record)[key]; + if (!Array.isArray(value)) return []; + return value.map((item) => String(item || "").trim()).filter(Boolean); +} + +function hasReferenceImageMode(value: unknown) { + if (!value || typeof value !== "object") return false; + const supportedModes = [ + ...readStringArrayField(value, "supportedModes"), + ...readStringArrayField(value, "modes"), + ]; + return supportedModes.some((mode) => REFERENCE_IMAGE_MODE_REGEX.test(mode)); +} + export function isKnownReferenceImageModel( model: ModelDescriptor | null | undefined, provider?: ProviderDescriptor | null ) { const providerType = String(provider?.type || "").trim(); - if (providerType !== "bfl-image") return false; - const candidates = [ + model?.displayName, model?.modelId, readStringField(model?.defaultParams, "endpoint"), readStringField(model?.defaultParams, "submitEndpoint"), @@ -51,9 +69,16 @@ export function isKnownReferenceImageModel( candidates.push(readStringField(capabilitiesEndpoints, "submit")); - return candidates + const matchesKnownBflModel = candidates .map(normalizeBflModelId) .some((modelId) => BFL_REFERENCE_IMAGE_MODEL_REGEX.test(modelId)); + if (matchesKnownBflModel) return true; + + if (providerType === "bfl-image") { + return hasReferenceImageMode(model?.capabilities) || hasReferenceImageMode(model?.defaultParams); + } + + return false; } export function modelLacksReferenceImageInputSupport(