fix(platform): 模型能力 - 修复前台默认参数与后台空状态

This commit is contained in:
ddCat
2026-08-19 09:08:34 +08:00
parent d2a089f1ec
commit 6a698129bd
14 changed files with 518 additions and 120 deletions

View File

@@ -74,6 +74,21 @@ export type VideoCapabilityConfig = {
defaultOperation: string;
};
// 旧版本的“允许自定义”可能只保存了 `*`,前台需要用这组标准值恢复可选项。
export const STANDARD_IMAGE_SIZE_VALUES = [
"1:1",
"3:2",
"2:3",
"4:3",
"3:4",
"16:9",
"21:9",
"9:16",
"1024x1024",
"1536x1024",
"1024x1536",
] as const;
// Keep explicit pixel presets for each resolution tier so the settings panel can
// switch between 1K, 2K and 4K without silently converting the requested ratio.
const defaultImageSizes = [
@@ -258,7 +273,30 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
if (!cost?.capabilityConfig) return fallback;
const text = cost.capabilityConfig.text ? { ...fallback.text!, ...cost.capabilityConfig.text, references: { ...fallback.text!.references, ...cost.capabilityConfig.text.references } } : fallback.text;
const video = cost.capabilityConfig.video ? { ...fallback.video!, ...cost.capabilityConfig.video, references: { ...fallback.video!.references, ...cost.capabilityConfig.video.references } } : fallback.video;
return { ...fallback, ...cost.capabilityConfig, text, image: cost.capabilityConfig.image || fallback.image, video };
const configuredImage = cost.capabilityConfig.image;
const image = configuredImage
? (() => {
const configuredSize = configuredImage.size;
const configuredValues = configuredSize?.values;
const allowCustom = Boolean(configuredSize?.allowCustom || configuredValues?.includes("*"));
const concreteValues = configuredValues?.filter((value) => value !== "*") || [];
const values = !configuredValues ? fallback.image!.size.values : concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES];
const configuredDefault = configuredSize?.default?.trim();
const defaultValue = configuredDefault && configuredDefault !== "*" && values.includes(configuredDefault) ? configuredDefault : values.find((value) => value !== "*") || fallback.image!.size.default;
return {
...fallback.image!,
...configuredImage,
size: {
...fallback.image!.size,
...configuredSize,
values,
default: defaultValue,
allowCustom,
},
};
})()
: fallback.image;
return { ...fallback, ...cost.capabilityConfig, text, image, video };
}
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
@@ -287,7 +325,8 @@ export function imageSizeRequest(profile: ImageCapabilityConfig, value?: string)
export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { seconds?: string; ratio?: string; resolution?: string }) {
const duration = profile.duration.selection === "enum" ? ((profile.duration.values || []).includes(Number(value.seconds)) ? Number(value.seconds) : profile.duration.default) : normalizeRangeDuration(profile, Number(value.seconds));
const ratio = profile.ratios.includes(value.ratio || "") ? value.ratio! : profile.defaultRatio;
const resolution = profile.resolutions.includes(value.resolution || "") ? value.resolution! : profile.defaultResolution;
// 前端状态历史上保存过 `720`,而能力配置和供应商通常使用 `720p`;统一按能力中的原始值返回,避免被误判为不支持。
const resolution = videoResolutionRequest(profile, value.resolution) || profile.defaultResolution || profile.resolutions[0] || "";
return { seconds: String(duration), ratio, resolution };
}

View File

@@ -1,4 +1,4 @@
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
export type ModelInputSummary = {
@@ -133,13 +133,13 @@ function logicalModelCompatibilityError(spec: NonNullable<NonNullable<AiConfig["
for (const [name, value] of Object.entries(options)) {
if (value === undefined || value === null || value === "") continue;
const constraint = spec.options?.[name];
if (!constraint || !logicalOptionMatches(constraint, value)) return logicalOptionError(name);
if (!constraint || !logicalOptionMatches(name, constraint, value)) return logicalOptionError(name);
}
return "";
}
function logicalOptionMatches(constraint: { values?: unknown[]; min?: number; max?: number; step?: number }, value: unknown) {
if (constraint.values?.length) return constraint.values.some((candidate) => String(candidate).toLowerCase() === String(value).toLowerCase());
function logicalOptionMatches(name: string, constraint: { values?: unknown[]; min?: number; max?: number; step?: number }, value: unknown) {
if (constraint.values?.length) return constraint.values.some((candidate) => logicalOptionValueMatches(name, candidate, value));
const numeric = Number(value);
if (!Number.isFinite(numeric)) return false;
if (constraint.min !== undefined && numeric < constraint.min) return false;
@@ -148,6 +148,13 @@ function logicalOptionMatches(constraint: { values?: unknown[]; min?: number; ma
return true;
}
function logicalOptionValueMatches(name: string, candidate: unknown, value: unknown) {
const left = String(candidate).trim().toLowerCase();
const right = String(value).trim().toLowerCase();
if (name === "vquality" || name === "resolution") return left.replace(/p$/, "") === right.replace(/p$/, "");
return left === right;
}
function logicalOptionError(name: string) {
const label: Record<string, string> = {
size: "尺寸",
@@ -196,17 +203,21 @@ export function mergedImageCapabilityConfig(config: AiConfig, selected: string):
const selectedProfile = modelCapabilityConfigFor(config, selected).image;
const profiles = models.map((model) => modelCapabilityConfigFor(config, model).image).filter((profile): profile is ImageCapabilityConfig => Boolean(profile));
if (profiles.length <= 1) return selectedProfile || defaultImageCapabilityConfig();
const values = Array.from(new Set(profiles.flatMap((profile) => profile.size.values)));
const concreteValues = Array.from(new Set(profiles.flatMap((profile) => profile.size.values).filter((value) => value !== "*")));
const allowCustom = profiles.some((profile) => profile.size.allowCustom || profile.size.values.includes("*"));
const values = concreteValues.length ? concreteValues : allowCustom ? [...STANDARD_IMAGE_SIZE_VALUES] : [];
const base = selectedProfile || profiles[0];
return { ...base, size: { ...base.size, values } };
return { ...base, size: { ...base.size, values, allowCustom } };
}
// 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。
export function defaultImageParamsForModel(config: AiConfig, model: string): Pick<AiConfig, "size" | "quality" | "transparentBackground"> {
const image = modelCapabilityConfigFor(config, model).image;
if (!image) return { size: "1:1", quality: "auto", transparentBackground: "false" };
const sizeValues = image.size.values.filter((value) => value !== "*");
const sizeDefault = image.size.default !== "*" && image.size.default ? image.size.default : sizeValues[0] || "1:1";
return {
size: image.size.default || image.size.values[0] || "1:1",
size: sizeDefault,
quality: image.quality.default || "auto",
transparentBackground: String(image.transparentBackground.default ?? false),
};

View File

@@ -6,7 +6,7 @@ import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelChannel } from "@/stores/use-config-store";
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { useUserStore } from "@/stores/use-user-store";
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "@/services/user-data-sync";
import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle";
@@ -109,9 +109,14 @@ function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string,
projected.image.size = { parameter: "none", values: [], default: "auto", allowCustom: false };
projected.image.quality = { supported: false, values: [], default: "auto" };
projected.image.transparentBackground = { supported: false, default: false };
applyStringOption(spec.options?.size || spec.options?.aspectRatio, defaults.size, (values, initial) => {
projected.image!.size = { parameter: "size", values, default: initial, allowCustom: false };
});
const sizeOption = spec.options?.size || spec.options?.aspectRatio;
const sizeValues = stringValues(sizeOption);
const sizeAllowsCustom = sizeValues.includes("*");
const concreteSizeValues = sizeValues.filter((value) => value !== "*");
const sizePresets = concreteSizeValues.length ? concreteSizeValues : sizeAllowsCustom ? [...STANDARD_IMAGE_SIZE_VALUES] : [];
if (sizePresets.length || sizeAllowsCustom) {
projected.image!.size = { parameter: "size", values: sizePresets, default: concreteDefault(defaults.size, sizePresets, "1:1"), allowCustom: sizeAllowsCustom };
}
applyStringOption(spec.options?.quality, defaults.quality, (values, initial) => {
projected.image!.quality = { supported: true, values, default: initial };
});
@@ -129,7 +134,7 @@ function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string,
if (duration?.values?.length) projected.video.duration = { selection: "enum", values: duration.values.map(Number).filter(Number.isFinite), default: Number(defaults.videoSeconds ?? duration.values[0]) };
else if (duration?.min !== undefined && duration.max !== undefined) projected.video.duration = { selection: "range", min: duration.min, max: duration.max, step: duration.step || 1, default: Number(defaults.videoSeconds ?? duration.min) };
projected.video.ratios = stringValues(spec.options?.size || spec.options?.aspectRatio);
projected.video.defaultRatio = String(defaults.size ?? projected.video.ratios[0] ?? "");
projected.video.defaultRatio = concreteDefault(defaults.size, projected.video.ratios, "");
projected.video.resolutions = stringValues(spec.options?.vquality || spec.options?.resolution);
projected.video.defaultResolution = String(defaults.vquality ?? projected.video.resolutions[0] ?? "");
projected.video.generateAudio = booleanOption(spec.options?.videoGenerateAudio, defaults.videoGenerateAudio);
@@ -140,11 +145,19 @@ function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string,
function applyStringOption(option: OptionConstraint | undefined, fallback: unknown, apply: (values: string[], initial: string) => void) {
const values = stringValues(option);
if (values.length) apply(values, String(fallback ?? values[0]));
if (values.length) apply(values, concreteDefault(fallback, values, values[0]));
}
function stringValues(option?: OptionConstraint) {
return (option?.values || []).map(String);
return (option?.values || [])
.map(String)
.map((value) => value.trim())
.filter(Boolean);
}
function concreteDefault(value: unknown, values: string[], fallback: string) {
const candidate = String(value ?? "").trim();
return candidate && candidate !== "*" && values.includes(candidate) ? candidate : values.find((item) => item !== "*") || fallback;
}
function maxNumericOption(option: OptionConstraint | undefined, fallback: number) {