fix: stabilize ai wash model handling

This commit is contained in:
134 2026-07-01 00:30:56 +08:00
parent 7aca7a764d
commit 91fe35e3b6
3 changed files with 86 additions and 39 deletions

View File

@ -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) => (
<SelectItem key={model.id} value={model.modelId}>
{model.displayName || model.modelId}
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (
@ -2130,7 +2132,7 @@ export function LocalAiImagePipelineSections({
key={model.id}
value={model.modelId}
>
{model.displayName || model.modelId}
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
) : (

View File

@ -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<Record<string, number>>(
(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<Record<string, number>>((accumulator, controlId) => {
accumulator[controlId] =
getPresetControl(defaultPreset, controlId)?.defaultLevel ?? 0;
return accumulator;
}, {})
);
setSelectedLevelIndexByControl(getPresetDefaultLevelIndexByControl(defaultPreset));
setComparePositionById(
enabledPresets.reduce<Record<string, number>>((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<Record<string, number>>((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 (
<div className="relative flex h-full w-full bg-[#ffffff] text-[#111111]">
@ -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"
>
<div className="flex items-center justify-between gap-2">
@ -1309,19 +1331,17 @@ export function AiWashWorkspace({
) : null}
</div>
) : compareSourceUrl ? (
<div className="relative max-h-full max-w-full">
<div className="relative max-h-full max-w-full overflow-hidden rounded-[22px] shadow-[0_16px_44px_rgba(0,0,0,0.07)]">
<img
alt="AI洗图底图"
className="block max-h-[calc(100vh-284px)] max-w-[min(62vw,1080px)] rounded-[22px] object-contain shadow-[0_16px_44px_rgba(0,0,0,0.07)]"
className="block max-h-[calc(100vh-284px)] max-w-[min(62vw,1080px)] object-contain"
src={getDisplayImageUrl(compareSourceUrl, 1800)}
/>
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/58 px-2.5 py-1 text-[11px] text-white">
原图
</div>
{selectedPreviewResult?.status === "running" ||
selectedPreviewResult?.status === "submitting" ||
selectedPreviewResult?.status === "failed" ? (
<div className="absolute inset-0 flex items-center justify-center rounded-[22px] bg-white/58 px-6 text-center backdrop-blur-[1px]">
{previewIsPending ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/78 px-6 text-center backdrop-blur-[2px]">
<div className="max-w-sm rounded-[22px] border border-[#ececec] bg-white/92 px-5 py-4 shadow-[0_14px_34px_rgba(0,0,0,0.07)]">
{selectedPreviewResult.status === "running" ||
selectedPreviewResult.status === "submitting" ? (

View File

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