fix(platform): 模型能力 - 修复前台默认参数与后台空状态
This commit is contained in:
@@ -59,11 +59,14 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
const count = Math.max(1, Math.min(effectiveMaxCount, Number(normalized.count)));
|
||||
const activeSize = normalized.size;
|
||||
const pixelSizeValues = profile.size.values.filter((value) => value.trim().toLowerCase() !== "auto");
|
||||
const usesResolutionPicker = supportsImageResolutionPresets(profile.size);
|
||||
const resolutionOptions = usesResolutionPicker ? buildImageResolutionOptions(pixelSizeValues) : [];
|
||||
const hasResolutionPresets = supportsImageResolutionPresets(profile.size);
|
||||
const resolutionOptions = hasResolutionPresets ? buildImageResolutionOptions(pixelSizeValues) : [];
|
||||
const activeResolution = activeSize === "auto" ? undefined : imageResolutionOption(resolutionOptions, activeSize);
|
||||
const activeRatio = activeResolution?.ratio || imageRatioForSize(activeSize);
|
||||
const resolutionChoices = usesResolutionPicker ? imageResolutionChoices(profile.size.values) : [];
|
||||
const resolutionChoices = hasResolutionPresets ? imageResolutionChoices(profile.size.values) : [];
|
||||
// 只有一个分辨率层级时,分辨率切换器没有实际选择意义;更重要的是不能因此把比例列表裁剪成当前层级的 3 个像素尺寸。
|
||||
// 例如历史 `*` 配置恢复为标准值后,虽然包含 1024x1024/1536x1024/1024x1536,实际仍应展示完整的比例和尺寸选项。
|
||||
const usesResolutionPicker = resolutionChoices.length > 1;
|
||||
const availableAspects: AspectOption[] = usesResolutionPicker && activeSize === "auto"
|
||||
? []
|
||||
: usesResolutionPicker && activeResolution
|
||||
@@ -158,7 +161,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
</div>
|
||||
</div> : null}
|
||||
{availableAspects.length ? <div className="space-y-2">
|
||||
<SettingTitle color={theme.node.muted}>宽高比</SettingTitle>
|
||||
<SettingTitle color={theme.node.muted}>尺寸或比例</SettingTitle>
|
||||
<div className="grid grid-cols-4 gap-1.5 min-[380px]:grid-cols-5">
|
||||
{availableAspects.map((item) => (
|
||||
<button
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
capabilitySourceError,
|
||||
emptyCapabilitySpec,
|
||||
mergeCapabilitySpecs,
|
||||
normalizeCapabilitySpecForSources,
|
||||
operationLabel,
|
||||
sanitizeDefaults,
|
||||
type CapabilityKind,
|
||||
@@ -157,7 +158,7 @@ export default function LogicalModelsPage() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = logicalModelPayload(values);
|
||||
const payload = logicalModelPayload(values, modelSourceSpecs);
|
||||
await (editingModel ? updateAdminLogicalModel(editingModel.id, payload) : createAdminLogicalModel(payload));
|
||||
setEditingModel(undefined);
|
||||
await reload();
|
||||
@@ -450,7 +451,7 @@ function RouteFields({
|
||||
const options = selectOptions.map((option) => ({ ...option, disabled: option.disabled || selectedByOthers.has(option.value) }));
|
||||
const selected = variants.find((item) => item.id === routes[field.name]?.physicalVariantId);
|
||||
return (
|
||||
<div key={field.key} className="rounded-lg border border-border bg-muted/5 p-4">
|
||||
<div key={field.key} className="rounded-lg border border-border bg-muted/5 p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold">供应配置 {fields.indexOf(field) + 1}</div>
|
||||
@@ -616,8 +617,8 @@ function logicalModelToForm(item: AdminLogicalModel): LogicalModelFormValues {
|
||||
};
|
||||
}
|
||||
|
||||
function logicalModelPayload(values: LogicalModelFormValues): LogicalModelMutation {
|
||||
const capabilitySpec = { ...values.capabilitySpec, capability: values.capability, version: 1 as const };
|
||||
function logicalModelPayload(values: LogicalModelFormValues, sourceSpecs: CapabilitySpec[] = []): LogicalModelMutation {
|
||||
const capabilitySpec = normalizeCapabilitySpecForSources({ ...values.capabilitySpec, capability: values.capability, version: 1 as const }, sourceSpecs) || emptyCapabilitySpec(values.capability);
|
||||
return {
|
||||
code: values.code.trim(),
|
||||
name: values.name.trim(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
import type { CapabilitySpec, OptionConstraint } from "@/services/api/logical-models";
|
||||
import type { ChannelModel } from "@/services/api/wallet";
|
||||
import { STANDARD_IMAGE_SIZE_VALUES } from "@/lib/model-capabilities";
|
||||
|
||||
export type CapabilityKind = CapabilitySpec["capability"];
|
||||
export type Scalar = string | number | boolean;
|
||||
@@ -64,6 +65,13 @@ export function emptyCapabilitySpec(capability: CapabilityKind): CapabilitySpec
|
||||
return { version: 1, capability, operations: [], inputs: {}, options: {} };
|
||||
}
|
||||
|
||||
function imageSizeOptionValues(values: string[], allowCustom: boolean) {
|
||||
const concreteValues = Array.from(new Set(values.map((value) => value.trim()).filter((value) => value && value !== "*")));
|
||||
const result = concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES];
|
||||
if (allowCustom) result.push("*");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function capabilitySpecFromChannelModel(item?: ChannelModel): CapabilitySpec | null {
|
||||
if (!item?.capability) return null;
|
||||
const capability = item.capability;
|
||||
@@ -91,12 +99,7 @@ export function capabilitySpecFromChannelModel(item?: ChannelModel): CapabilityS
|
||||
mask: { min: 0, max: image.references.maskSupported ? 1 : 0 },
|
||||
}),
|
||||
options: compactOptions({
|
||||
size:
|
||||
image.size.parameter === "none"
|
||||
? undefined
|
||||
: image.size.allowCustom
|
||||
? { values: ["*"] }
|
||||
: { values: image.size.values },
|
||||
size: image.size.parameter === "none" ? undefined : { values: imageSizeOptionValues(image.size.values, image.size.allowCustom) },
|
||||
quality: image.quality.supported ? { values: image.quality.values } : undefined,
|
||||
transparentBackground: { values: image.transparentBackground.supported ? [false, true] : [false] },
|
||||
count: { min: 1, max: image.maxOutputs, step: 1 },
|
||||
@@ -106,9 +109,7 @@ export function capabilitySpecFromChannelModel(item?: ChannelModel): CapabilityS
|
||||
if (capability === "video") {
|
||||
const video = item.capabilityConfig?.video;
|
||||
if (!video) return null;
|
||||
const duration: OptionConstraint = video.duration.selection === "enum"
|
||||
? { values: video.duration.values || [] }
|
||||
: { min: video.duration.min, max: video.duration.max, step: video.duration.step };
|
||||
const duration: OptionConstraint = video.duration.selection === "enum" ? { values: video.duration.values || [] } : { min: video.duration.min, max: video.duration.max, step: video.duration.step };
|
||||
return {
|
||||
version: 1,
|
||||
capability,
|
||||
@@ -146,11 +147,13 @@ export function mergeCapabilitySpecs(capability: CapabilityKind, specs: Capabili
|
||||
const constraints = matching.map((item) => item.options?.[definition.name]).filter(Boolean) as OptionConstraint[];
|
||||
if (constraints.length === 0) continue;
|
||||
if (constraints.some(isWildcardConstraint)) {
|
||||
result.options![definition.name] = { values: ["*"] };
|
||||
const values = uniqueScalars(constraints.flatMap((item) => item.values || []));
|
||||
result.options![definition.name] = { values: definition.name === "size" ? imageSizeOptionValues(values.map(String), true) : values };
|
||||
continue;
|
||||
}
|
||||
if (constraints.every((item) => item.values)) {
|
||||
result.options![definition.name] = { values: uniqueScalars(constraints.flatMap((item) => item.values || [])) };
|
||||
const values = uniqueScalars(constraints.flatMap((item) => item.values || []));
|
||||
result.options![definition.name] = { values: definition.name === "size" ? imageSizeOptionValues(values.map(String), values.includes("*")) : values };
|
||||
continue;
|
||||
}
|
||||
const minimums = constraints.map((item) => item.min).filter((value): value is number => typeof value === "number");
|
||||
@@ -173,6 +176,22 @@ export function mergeCapabilitySpecs(capability: CapabilityKind, specs: Capabili
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版本把“允许自定义”保存成只有 `*` 的能力范围。
|
||||
* 管理端编辑时用当前供应线路的标准值补回展示范围,保存后即可完成数据修复。
|
||||
*/
|
||||
export function normalizeCapabilitySpecForSources(value: CapabilitySpec | undefined, sourceSpecs: CapabilitySpec[]) {
|
||||
if (!value) return value;
|
||||
const source = mergeCapabilitySpecs(value.capability, sourceSpecs);
|
||||
const options = { ...(value.options || {}) };
|
||||
for (const [name, constraint] of Object.entries(options)) {
|
||||
const sourceConstraint = source.options?.[name];
|
||||
if (!sourceConstraint || !isWildcardConstraint(constraint) || !sourceConstraint.values) continue;
|
||||
options[name] = { values: uniqueScalars([...(sourceConstraint.values || []), ...(constraint.values || [])]) };
|
||||
}
|
||||
return { ...value, options };
|
||||
}
|
||||
|
||||
export function capabilitySourceError(capability: CapabilityKind, sourceSpecs: CapabilitySpec[], value?: CapabilitySpec) {
|
||||
const matching = sourceSpecs.filter((item) => item.capability === capability);
|
||||
const current = value?.capability === capability ? value : emptyCapabilitySpec(capability);
|
||||
@@ -193,9 +212,9 @@ export function capabilitySourceError(capability: CapabilityKind, sourceSpecs: C
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange, mode }: CapabilityScopeEditorProps) {
|
||||
export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange }: CapabilityScopeEditorProps) {
|
||||
const source = mergeCapabilitySpecs(capability, sourceSpecs);
|
||||
const current = value?.capability === capability ? value : emptyCapabilitySpec(capability);
|
||||
const current = value?.capability === capability ? normalizeCapabilitySpecForSources(value, sourceSpecs)! : emptyCapabilitySpec(capability);
|
||||
const hasSource = sourceSpecs.some((item) => item.capability === capability);
|
||||
const sourceInputs = inputDefinitions[capability].filter((item) => source.inputs?.[item.name]);
|
||||
const sourceOptions = optionDefinitions[capability].filter((item) => source.options?.[item.name]);
|
||||
@@ -204,14 +223,14 @@ export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange
|
||||
const update = (patch: Partial<CapabilitySpec>) => onChange?.({ ...current, ...patch, version: 1, capability });
|
||||
const reset = () => onChange?.(source);
|
||||
|
||||
if (!hasSource) {
|
||||
return <Alert type="warning" showIcon message="没有可用的能力来源" description={mode === "route" ? "请先到渠道模型补充能力配置。" : "请先选择至少一条已配置的供应线路。"} />;
|
||||
}
|
||||
if (!hasSource) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex justify-end">
|
||||
<Button size="small" type="text" icon={<RotateCcw className="size-3.5" />} onClick={reset}>恢复全部可用范围</Button>
|
||||
<Button size="small" type="text" icon={<RotateCcw className="size-3.5" />} onClick={reset}>
|
||||
恢复全部可用范围
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sourceError ? <Alert type="warning" showIcon message="当前选择需要调整" description={`${sourceError}。可重新选择,或采用全部可用范围。`} /> : null}
|
||||
@@ -237,10 +256,37 @@ export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange
|
||||
const selected = current.inputs?.[definition.name];
|
||||
return (
|
||||
<div key={definition.name} className="grid grid-cols-12 items-center gap-2 py-3 first:pt-0 last:pb-0">
|
||||
<div className="col-span-12 sm:col-span-5"><div className="text-sm font-medium">{definition.label}</div><div className="text-xs text-foreground/45">线路支持 {limit.min}-{limit.max} {definition.unit}</div></div>
|
||||
<div className="col-span-12 sm:col-span-1"><Switch size="small" checked={Boolean(selected)} onChange={(enabled) => updateInput(current, definition.name, enabled ? limit : undefined, update)} /></div>
|
||||
<div className="col-span-6 sm:col-span-3"><NumberInput label="最少" unit={definition.unit} value={selected?.min} min={limit.min} max={selected?.max ?? limit.max} disabled={!selected} onChange={(next) => updateInput(current, definition.name, { min: next ?? limit.min, max: selected?.max ?? limit.max }, update)} /></div>
|
||||
<div className="col-span-6 sm:col-span-3"><NumberInput label="最多" unit={definition.unit} value={selected?.max} min={selected?.min ?? limit.min} max={limit.max} disabled={!selected} onChange={(next) => updateInput(current, definition.name, { min: selected?.min ?? limit.min, max: next ?? limit.max }, update)} /></div>
|
||||
<div className="col-span-12 sm:col-span-5">
|
||||
<div className="text-sm font-medium">{definition.label}</div>
|
||||
<div className="text-xs text-foreground/45">
|
||||
线路支持 {limit.min}-{limit.max} {definition.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-1">
|
||||
<Switch size="small" checked={Boolean(selected)} onChange={(enabled) => updateInput(current, definition.name, enabled ? limit : undefined, update)} />
|
||||
</div>
|
||||
<div className="col-span-6 sm:col-span-3">
|
||||
<NumberInput
|
||||
label="最少"
|
||||
unit={definition.unit}
|
||||
value={selected?.min}
|
||||
min={limit.min}
|
||||
max={selected?.max ?? limit.max}
|
||||
disabled={!selected}
|
||||
onChange={(next) => updateInput(current, definition.name, { min: next ?? limit.min, max: selected?.max ?? limit.max }, update)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6 sm:col-span-3">
|
||||
<NumberInput
|
||||
label="最多"
|
||||
unit={definition.unit}
|
||||
value={selected?.max}
|
||||
min={selected?.min ?? limit.min}
|
||||
max={limit.max}
|
||||
disabled={!selected}
|
||||
onChange={(next) => updateInput(current, definition.name, { min: selected?.min ?? limit.min, max: next ?? limit.max }, update)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -252,7 +298,13 @@ export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange
|
||||
<CapabilityBlock title="生成参数">
|
||||
<div className="divide-y divide-border">
|
||||
{sourceOptions.map((definition) => (
|
||||
<OptionRuleEditor key={definition.name} definition={definition} source={source.options![definition.name]} value={current.options?.[definition.name]} onChange={(constraint) => updateOption(current, definition.name, constraint, update)} />
|
||||
<OptionRuleEditor
|
||||
key={definition.name}
|
||||
definition={definition}
|
||||
source={source.options![definition.name]}
|
||||
value={current.options?.[definition.name]}
|
||||
onChange={(constraint) => updateOption(current, definition.name, constraint, update)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CapabilityBlock>
|
||||
@@ -273,13 +325,23 @@ export function DefaultOptionsEditor({ spec, value, onChange }: { spec?: Capabil
|
||||
const selected = value?.[definition.name] as Scalar | undefined;
|
||||
return (
|
||||
<div key={definition.name} className="grid grid-cols-12 items-center gap-2 py-3 first:pt-0 last:pb-0">
|
||||
<div className="col-span-12 sm:col-span-5"><div className="text-sm font-medium">{definition.label}</div><div className="text-xs text-foreground/45">留空时由创作端或线路默认值决定</div></div>
|
||||
<div className="col-span-12 sm:col-span-7"><ConstraintValueInput constraint={constraint} value={selected} allowClear onChange={(next) => {
|
||||
const defaults = { ...(value || {}) } as Record<string, Scalar>;
|
||||
if (next === undefined) delete defaults[definition.name];
|
||||
else defaults[definition.name] = next;
|
||||
onChange?.(defaults);
|
||||
}} /></div>
|
||||
<div className="col-span-12 sm:col-span-5">
|
||||
<div className="text-sm font-medium">{definition.label}</div>
|
||||
<div className="text-xs text-foreground/45">留空时由创作端或线路默认值决定</div>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-7">
|
||||
<ConstraintValueInput
|
||||
constraint={constraint}
|
||||
value={selected}
|
||||
allowClear
|
||||
onChange={(next) => {
|
||||
const defaults = { ...(value || {}) } as Record<string, Scalar>;
|
||||
if (next === undefined) delete defaults[definition.name];
|
||||
else defaults[definition.name] = next;
|
||||
onChange?.(defaults);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -287,7 +349,13 @@ export function DefaultOptionsEditor({ spec, value, onChange }: { spec?: Capabil
|
||||
);
|
||||
}
|
||||
|
||||
export function CapabilityRequestEditor({ spec, inputs, options, onInputsChange, onOptionsChange }: {
|
||||
export function CapabilityRequestEditor({
|
||||
spec,
|
||||
inputs,
|
||||
options,
|
||||
onInputsChange,
|
||||
onOptionsChange,
|
||||
}: {
|
||||
spec: CapabilitySpec;
|
||||
inputs: Record<string, number>;
|
||||
options: Record<string, unknown>;
|
||||
@@ -325,7 +393,10 @@ export function CapabilityRequestEditor({ spec, inputs, options, onInputsChange,
|
||||
const constraint = spec.options![definition.name];
|
||||
return (
|
||||
<label key={definition.name} className="min-w-0">
|
||||
<span className="mb-1 block text-xs text-foreground/45">{definition.label}{definition.unit ? ` (${definition.unit})` : ""}</span>
|
||||
<span className="mb-1 block text-xs text-foreground/45">
|
||||
{definition.label}
|
||||
{definition.unit ? ` (${definition.unit})` : ""}
|
||||
</span>
|
||||
<ConstraintValueInput
|
||||
constraint={constraint}
|
||||
value={options[definition.name] as Scalar | undefined}
|
||||
@@ -365,23 +436,53 @@ export function CapabilitySummary({ spec }: { spec: CapabilitySpec }) {
|
||||
}
|
||||
if (spec.operations?.length) labels.unshift(spec.operations.map(operationLabel).join("/"));
|
||||
if (!labels.length) return <span className="text-xs text-foreground/45">基础能力</span>;
|
||||
return <div className="flex max-w-lg flex-wrap gap-1">{labels.slice(0, 4).map((label) => <Tag key={label}>{label}</Tag>)}{labels.length > 4 ? <Tag>+{labels.length - 4}</Tag> : null}</div>;
|
||||
return (
|
||||
<div className="flex max-w-lg flex-wrap gap-1">
|
||||
{labels.slice(0, 4).map((label) => (
|
||||
<Tag key={label}>{label}</Tag>
|
||||
))}
|
||||
{labels.length > 4 ? <Tag>+{labels.length - 4}</Tag> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeDefaults(spec: CapabilitySpec, defaults: Record<string, unknown> | undefined) {
|
||||
return Object.fromEntries(Object.entries(defaults || {}).filter(([name, value]) => spec.options?.[name] && constraintIncludes(spec.options[name], value)));
|
||||
const result: Record<string, Scalar> = {};
|
||||
for (const [name, rawValue] of Object.entries(defaults || {})) {
|
||||
const constraint = spec.options?.[name];
|
||||
if (!constraint || !constraintIncludes(constraint, rawValue)) continue;
|
||||
// `*` 是能力匹配用的通配符,不是可发送给模型的默认参数。
|
||||
if (scalarKey(rawValue) === "*") {
|
||||
const concrete = constraint.values?.find((item) => scalarKey(item) !== "*");
|
||||
if (concrete !== undefined) result[name] = normalizeScalar(concrete) as Scalar;
|
||||
continue;
|
||||
}
|
||||
result[name] = normalizeScalar(rawValue) as Scalar;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function CapabilityBlock({ title, children }: { title: string; children: ReactNode }) {
|
||||
return <section className="space-y-2"><div className="text-xs font-semibold text-foreground/65">{title}</div>{children}</section>;
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div className="text-xs font-semibold text-foreground/65">{title}</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionRuleEditor({ definition, source, value, onChange }: { definition: { name: string; label: string; unit?: string }; source: OptionConstraint; value?: OptionConstraint; onChange: (value?: OptionConstraint) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-12 items-center gap-2 py-3 first:pt-0 last:pb-0">
|
||||
<div className="col-span-12 sm:col-span-5"><div className="text-sm font-medium">{definition.label}</div><div className="text-xs text-foreground/45">{constraintSummary(source, definition.unit)}</div></div>
|
||||
<div className="col-span-12 sm:col-span-1"><Switch size="small" checked={Boolean(value)} onChange={(enabled) => onChange(enabled ? source : undefined)} /></div>
|
||||
<div className="col-span-12 sm:col-span-6">{source.values ? (
|
||||
<div className="col-span-12 sm:col-span-5">
|
||||
<div className="text-sm font-medium">{definition.label}</div>
|
||||
<div className="text-xs text-foreground/45">{constraintSummary(source, definition.unit)}</div>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-1">
|
||||
<Switch size="small" checked={Boolean(value)} onChange={(enabled) => onChange(enabled ? source : undefined)} />
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
{source.values ? (
|
||||
<Select
|
||||
className="w-full"
|
||||
mode="multiple"
|
||||
@@ -392,24 +493,70 @@ function OptionRuleEditor({ definition, source, value, onChange }: { definition:
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<label><span className="mb-1 block text-xs text-foreground/45">最小值</span><InputNumber className="w-full" disabled={!value} min={source.min} max={value?.max ?? source.max} value={value?.min} onChange={(next) => onChange({ ...value, min: next ?? source.min, max: value?.max ?? source.max, step: value?.step ?? source.step })} /></label>
|
||||
<label><span className="mb-1 block text-xs text-foreground/45">最大值</span><InputNumber className="w-full" disabled={!value} min={value?.min ?? source.min} max={source.max} value={value?.max} onChange={(next) => onChange({ ...value, min: value?.min ?? source.min, max: next ?? source.max, step: value?.step ?? source.step })} /></label>
|
||||
<label><span className="mb-1 block text-xs text-foreground/45">调整步长</span><InputNumber className="w-full" disabled={!value} min={source.step || 0.000001} max={source.max} value={value?.step} onChange={(next) => onChange({ ...value, min: value?.min ?? source.min, max: value?.max ?? source.max, step: next ?? source.step })} /></label>
|
||||
<label>
|
||||
<span className="mb-1 block text-xs text-foreground/45">最小值</span>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
disabled={!value}
|
||||
min={source.min}
|
||||
max={value?.max ?? source.max}
|
||||
value={value?.min}
|
||||
onChange={(next) => onChange({ ...value, min: next ?? source.min, max: value?.max ?? source.max, step: value?.step ?? source.step })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="mb-1 block text-xs text-foreground/45">最大值</span>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
disabled={!value}
|
||||
min={value?.min ?? source.min}
|
||||
max={source.max}
|
||||
value={value?.max}
|
||||
onChange={(next) => onChange({ ...value, min: value?.min ?? source.min, max: next ?? source.max, step: value?.step ?? source.step })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="mb-1 block text-xs text-foreground/45">调整步长</span>
|
||||
<InputNumber
|
||||
className="w-full"
|
||||
disabled={!value}
|
||||
min={source.step || 0.000001}
|
||||
max={source.max}
|
||||
value={value?.step}
|
||||
onChange={(next) => onChange({ ...value, min: value?.min ?? source.min, max: value?.max ?? source.max, step: next ?? source.step })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConstraintValueInput({ constraint, value, allowClear, onChange }: { constraint: OptionConstraint; value?: Scalar; allowClear?: boolean; onChange: (value?: Scalar) => void }) {
|
||||
if (constraint.values) {
|
||||
return <Select className="w-full" allowClear={allowClear} value={value === undefined ? undefined : scalarKey(value)} options={constraint.values.map((item) => ({ value: scalarKey(item), label: scalarLabel(item) }))} onChange={(key) => onChange(key === undefined ? undefined : normalizeScalar(constraint.values!.find((item) => scalarKey(item) === key)) as Scalar)} />;
|
||||
return (
|
||||
<Select
|
||||
className="w-full"
|
||||
allowClear={allowClear}
|
||||
value={value === undefined ? undefined : scalarKey(value)}
|
||||
options={constraint.values.map((item) => ({ value: scalarKey(item), label: scalarLabel(item) }))}
|
||||
onChange={(key) => onChange(key === undefined ? undefined : (normalizeScalar(constraint.values!.find((item) => scalarKey(item) === key)) as Scalar))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <InputNumber className="w-full" min={constraint.min} max={constraint.max} step={constraint.step} value={typeof value === "number" ? value : undefined} onChange={(next) => onChange(next ?? undefined)} />;
|
||||
}
|
||||
|
||||
function NumberInput({ label, unit, value, min, max, disabled, onChange }: { label: string; unit: string; value?: number; min?: number; max?: number; disabled?: boolean; onChange: (value: number | null) => void }) {
|
||||
return <label className="min-w-0"><span className="mb-1 block text-xs text-foreground/45">{label} ({unit})</span><InputNumber className="w-full" precision={0} disabled={disabled} value={value} min={min} max={max} onChange={onChange} /></label>;
|
||||
return (
|
||||
<label className="min-w-0">
|
||||
<span className="mb-1 block text-xs text-foreground/45">
|
||||
{label} ({unit})
|
||||
</span>
|
||||
<InputNumber className="w-full" precision={0} disabled={disabled} value={value} min={min} max={max} onChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function updateInput(current: CapabilitySpec, name: string, constraint: { min: number; max: number } | undefined, update: (patch: Partial<CapabilitySpec>) => void) {
|
||||
@@ -450,12 +597,12 @@ function widestCoveredInputRange(constraints: Array<{ min: number; max: number }
|
||||
return merged.reduce((best, current) => {
|
||||
const bestSize = best.max - best.min;
|
||||
const currentSize = current.max - current.min;
|
||||
return currentSize > bestSize || currentSize === bestSize && current.min < best.min ? current : best;
|
||||
return currentSize > bestSize || (currentSize === bestSize && current.min < best.min) ? current : best;
|
||||
});
|
||||
}
|
||||
|
||||
function preferredSourceConstraint(constraints: OptionConstraint[]) {
|
||||
return constraints.reduce((best, current) => constraintBreadth(current) > constraintBreadth(best) ? current : best);
|
||||
return constraints.reduce((best, current) => (constraintBreadth(current) > constraintBreadth(best) ? current : best));
|
||||
}
|
||||
|
||||
function constraintBreadth(constraint: OptionConstraint) {
|
||||
@@ -474,6 +621,7 @@ function scalarLabel(value: unknown) {
|
||||
const normalized = normalizeScalar(value);
|
||||
if (normalized === true) return "支持";
|
||||
if (normalized === false) return "不启用";
|
||||
if (normalized === "*") return "自定义";
|
||||
return String(normalized);
|
||||
}
|
||||
|
||||
@@ -483,16 +631,18 @@ function normalizeScalar(value: unknown) {
|
||||
}
|
||||
|
||||
export function operationLabel(value: string) {
|
||||
return {
|
||||
text_to_video: "文生视频",
|
||||
image_to_video: "图生视频",
|
||||
extend: "视频续写",
|
||||
inpaint: "局部修改",
|
||||
replace_element: "元素替换",
|
||||
camera_motion: "运镜调整",
|
||||
style_transfer: "风格迁移",
|
||||
audio_to_video: "音频生视频",
|
||||
}[value] || value;
|
||||
return (
|
||||
{
|
||||
text_to_video: "文生视频",
|
||||
image_to_video: "图生视频",
|
||||
extend: "视频续写",
|
||||
inpaint: "局部修改",
|
||||
replace_element: "元素替换",
|
||||
camera_motion: "运镜调整",
|
||||
style_transfer: "风格迁移",
|
||||
audio_to_video: "音频生视频",
|
||||
}[value] || value
|
||||
);
|
||||
}
|
||||
|
||||
function inputLabel(capability: CapabilityKind, name: string) {
|
||||
@@ -509,7 +659,7 @@ function constraintSummary(constraint: OptionConstraint, unit = "") {
|
||||
}
|
||||
|
||||
function constraintIncludes(constraint: OptionConstraint, value: unknown) {
|
||||
if (constraint.values?.length === 1 && constraint.values[0] === "*") return true;
|
||||
if (isWildcardConstraint(constraint)) return true;
|
||||
if (constraint.values) return constraint.values.some((item) => scalarKey(item) === scalarKey(value));
|
||||
if (typeof value !== "number") return false;
|
||||
if ((constraint.min !== undefined && value < constraint.min - 1e-9) || (constraint.max !== undefined && value > constraint.max + 1e-9)) return false;
|
||||
@@ -585,7 +735,7 @@ function continuousOptionRangeCovered(minimum: number, maximum: number, constrai
|
||||
}
|
||||
|
||||
function isWildcardConstraint(constraint: OptionConstraint) {
|
||||
return constraint.values?.length === 1 && constraint.values[0] === "*";
|
||||
return constraint.values?.some((value) => normalizeScalar(value) === "*") || false;
|
||||
}
|
||||
|
||||
function approximatelyInteger(value: number) {
|
||||
|
||||
@@ -196,7 +196,12 @@ export default function CreatePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "image") return;
|
||||
const normalized = normalizeImageValue(imageProfile, { size: ratio, quality, count });
|
||||
// 前台逻辑模型的默认参数优先于旧的全局创作参数;否则旧的合法值会一直覆盖后台刚配置的默认值。
|
||||
const normalized = normalizeImageValue(imageProfile, {
|
||||
size: imageProfile.size.default,
|
||||
quality: imageProfile.quality.default,
|
||||
count,
|
||||
});
|
||||
setRatio(normalized.size);
|
||||
setQuality(normalized.quality);
|
||||
setCount(normalized.count);
|
||||
@@ -204,7 +209,12 @@ export default function CreatePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "video") return;
|
||||
const normalized = normalizeVideoValue(videoProfile, { seconds, ratio, resolution: `${videoQuality}p` });
|
||||
// 前台逻辑模型的默认参数必须直接落到创作端状态,提交任务时才不会被旧状态覆盖。
|
||||
const normalized = normalizeVideoValue(videoProfile, {
|
||||
seconds: String(videoProfile.duration.default),
|
||||
ratio: videoProfile.defaultRatio,
|
||||
resolution: videoProfile.defaultResolution,
|
||||
});
|
||||
setSeconds(normalized.seconds);
|
||||
setRatio(normalized.ratio);
|
||||
setVideoQuality(normalized.resolution.replace(/p$/i, ""));
|
||||
|
||||
Reference in New Issue
Block a user