feat(image): 以插件声明统一图片参数、尺寸选择与定价合同 (#760)
* feat(image): 图片参数 - 统一插件声明配置与生成合同 * docs(image): 图片参数 - 说明声明合同与验证边界 * fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档 * fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方 * fix(image): 调用链 - 保留公开意图并接通独立分辨率 * fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
This commit is contained in:
1 parent
88de4ce422
commit
7466119867
104 files changed
+6013
-568
No files matched your search
@@ -45,6 +45,8 @@ export function agentImageApproval(detail: Record<string, unknown>): AgentImageA
|
||||
channelId: typeof args.channelId === "string" ? args.channelId : "",
|
||||
channelModelKey: typeof args.channelModelKey === "string" ? args.channelModelKey : "",
|
||||
size: args.size,
|
||||
...(typeof args.imageResolution === "string" ? { imageResolution: args.imageResolution } : {}),
|
||||
...(typeof args.imageParameters === "string" ? { imageParameters: args.imageParameters } : {}),
|
||||
quality: typeof args.quality === "string" ? args.quality : "",
|
||||
prompt: args.prompt,
|
||||
referenceNodeIds: Array.isArray(args.referenceNodeIds) ? args.referenceNodeIds.filter((id: unknown): id is string => typeof id === "string") : [],
|
||||
|
||||
@@ -439,6 +439,8 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
const requestedConfig: AiConfig = {
|
||||
...config,
|
||||
quality: node?.metadata?.quality ?? workflowParameterValue("quality") ?? config.quality ?? defaultConfig.quality,
|
||||
imageResolution: node?.metadata?.imageResolution ?? workflowParameterValue("imageResolution") ?? config.imageResolution ?? defaultConfig.imageResolution,
|
||||
imageParameters: node?.metadata?.imageParameters ?? workflowParameterValue("imageParameters") ?? config.imageParameters ?? "{}",
|
||||
size: workflowProvider === "model" ? (node?.metadata?.size ?? config.size ?? defaultConfig.size) : workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size,
|
||||
transparentBackground: node?.metadata?.transparentBackground ?? config.transparentBackground ?? defaultConfig.transparentBackground,
|
||||
videoSeconds:
|
||||
@@ -475,6 +477,8 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
mode === "image"
|
||||
? {
|
||||
size: node?.metadata?.size,
|
||||
imageResolution: node?.metadata?.imageResolution,
|
||||
imageParameters: node?.metadata?.imageParameters,
|
||||
quality: node?.metadata?.quality,
|
||||
transparentBackground: node?.metadata?.transparentBackground,
|
||||
count: requestedConfig.count,
|
||||
@@ -488,6 +492,8 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
},
|
||||
{
|
||||
size: requestedConfig.size,
|
||||
imageResolution: requestedConfig.imageResolution,
|
||||
imageParameters: requestedConfig.imageParameters,
|
||||
quality: requestedConfig.quality,
|
||||
transparentBackground: requestedConfig.transparentBackground,
|
||||
count: requestedConfig.count,
|
||||
@@ -521,6 +527,8 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
runningHub,
|
||||
model,
|
||||
quality: generationDefaults.quality || requestedConfig.quality,
|
||||
imageResolution: generationDefaults.imageResolution || requestedConfig.imageResolution,
|
||||
imageParameters: generationDefaults.imageParameters ?? requestedConfig.imageParameters,
|
||||
size: generationDefaults.size ?? requestedConfig.size,
|
||||
transparentBackground: generationDefaults.transparentBackground || (requestedConfig.transparentBackground === "true" ? "true" : "false"),
|
||||
videoSeconds: generationDefaults.videoSeconds ?? requestedConfig.videoSeconds,
|
||||
|
||||
@@ -6,6 +6,8 @@ export type ModelSelection = { kind: "logical"; logicalModelId: string; channelI
|
||||
|
||||
export type Options = {
|
||||
size?: string;
|
||||
imageResolution?: string;
|
||||
imageParameters?: string;
|
||||
quality?: string;
|
||||
count?: number;
|
||||
transparentBackground?: boolean;
|
||||
@@ -53,6 +55,24 @@ export const GENERATION_OPTION_FIELDS = [
|
||||
"node": "size",
|
||||
"task": "size"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"image"
|
||||
],
|
||||
"name": "imageResolution",
|
||||
"node": "imageResolution",
|
||||
"task": "imageResolution"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"image"
|
||||
],
|
||||
"name": "imageParameters",
|
||||
"node": "imageParameters",
|
||||
"task": "imageParameters"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
|
||||
@@ -199,6 +199,7 @@ export function synchronizeGenerationSpec(node: CanvasNodeData, patch: Partial<C
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "composerContent") && typeof patch.composerContent === "string") next.prompt = patch.composerContent;
|
||||
else if (Object.prototype.hasOwnProperty.call(patch, "prompt") && typeof patch.prompt === "string") next.prompt = patch.prompt;
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "model")) {
|
||||
if (patch.model !== node.metadata?.model && !Object.prototype.hasOwnProperty.call(patch, "imageParameters")) delete next.options.imageParameters;
|
||||
const model = patch.model ? decodeChannelModel(patch.model) : null;
|
||||
next.modelSelection = model ? { kind: "channel", channelId: model.channelId, modelKey: model.model } : undefined;
|
||||
delete (metadata as Record<string, unknown>).logicalModelId;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import type { CapabilitySpec, OptionConstraint } from "@/services/api/logical-models";
|
||||
import { DEFAULT_IMAGE_RATIOS, imagePresetForRatio, imageSizeConfigWithPresets } from "./image-size-presets";
|
||||
|
||||
export type ImageParameterValue = string | number | boolean;
|
||||
export type ImageParameter = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: "string" | "number" | "integer" | "boolean";
|
||||
description?: string;
|
||||
values?: ImageParameterValue[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
maxLength?: number;
|
||||
default?: ImageParameterValue;
|
||||
required?: boolean;
|
||||
pricing?: boolean;
|
||||
binding?: string;
|
||||
encoding?: "pixels" | "ratio";
|
||||
models?: string[];
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export function imageParameterAccepts(field: ImageParameter, value: unknown): value is ImageParameterValue {
|
||||
if (field.type === "string" && (typeof value !== "string" || (field.maxLength !== undefined && value.length > field.maxLength))) return false;
|
||||
if (field.type === "boolean" && typeof value !== "boolean") return false;
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || (field.type === "integer" && !Number.isInteger(value))) return false;
|
||||
if ((field.min !== undefined && value < field.min) || (field.max !== undefined && value > field.max)) return false;
|
||||
if (field.step) {
|
||||
const steps = (value - (field.min ?? 0)) / field.step;
|
||||
if (Math.abs(steps - Math.round(steps)) > 1e-8) return false;
|
||||
}
|
||||
}
|
||||
return !field.values?.length || field.values.includes(value as ImageParameterValue);
|
||||
}
|
||||
|
||||
export function imageParameterDefaults(parameters: ImageParameter[] = []) {
|
||||
return Object.fromEntries(parameters.filter((p) => p.enabled !== false && p.default !== undefined).map((p) => [p.name, p.default!])) as Record<string, ImageParameterValue>;
|
||||
}
|
||||
|
||||
export function parseImageParameters(value: unknown): Record<string, ImageParameterValue> {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v))));
|
||||
}
|
||||
|
||||
export function normalizeImageParameters(parameters: ImageParameter[] = [], input: unknown = {}) {
|
||||
const values = parseImageParameters(input);
|
||||
const result = imageParameterDefaults(parameters);
|
||||
for (const field of parameters) {
|
||||
if (field.enabled !== false && imageParameterAccepts(field, values[field.name])) result[field.name] = values[field.name];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Only transport encoding knows these host controls. All other names are
|
||||
// plugin-defined and travel as typed values without a frontend whitelist.
|
||||
export const imageHostControls = new Set(["size", "imageResolution", "quality", "transparentBackground", "count"]);
|
||||
|
||||
export function imageParameterRequest(profile: ImageCapabilityConfig, config: { imageParameters?: string; size?: string; imageResolution?: string; quality?: string; count?: string; transparentBackground?: string }, strict = false) {
|
||||
if (!profile.parameters) return undefined;
|
||||
if (strict && config.imageParameters) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(config.imageParameters);
|
||||
} catch {
|
||||
throw new Error("图片扩展参数格式无效,请重新设置");
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("图片扩展参数必须是对象");
|
||||
for (const field of profile.parameters) {
|
||||
if (field.enabled !== false && !imageHostControls.has(field.name) && Object.hasOwn(parsed, field.name) && !imageParameterAccepts(field, (parsed as Record<string, unknown>)[field.name])) throw new Error(`${field.label}超出模型支持范围`);
|
||||
}
|
||||
}
|
||||
const values = parseImageParameters(config.imageParameters);
|
||||
for (const field of profile.parameters) {
|
||||
if (!imageHostControls.has(field.name)) continue;
|
||||
const raw = config[field.name as keyof typeof config];
|
||||
if (raw === undefined || raw === "" || ((field.name === "quality" || field.name === "imageResolution") && raw === "auto" && !field.values?.includes("auto"))) continue;
|
||||
values[field.name] = field.type === "boolean" ? raw === "true" : field.type === "number" || field.type === "integer" ? Number(raw) : raw;
|
||||
}
|
||||
const size = profile.parameters.find((p) => p.name === "size" && p.enabled !== false);
|
||||
if (size && values.size !== undefined && !imageParameterAccepts(size, values.size)) {
|
||||
const preset = profile.size.presets?.find((p) => p.size === values.size && (!values.imageResolution || p.tier === values.imageResolution));
|
||||
if (preset && imageParameterAccepts(size, preset.ratio)) values.size = preset.ratio;
|
||||
}
|
||||
if (strict) {
|
||||
for (const field of profile.parameters) {
|
||||
if (field.enabled === false) continue;
|
||||
const value = values[field.name] ?? field.default;
|
||||
if ((value !== undefined && !imageParameterAccepts(field, value)) || (field.required && (value === undefined || value === ""))) throw new Error(`${field.label}超出模型支持范围或尚未填写`);
|
||||
}
|
||||
}
|
||||
return normalizeImageParameters(profile.parameters, values);
|
||||
}
|
||||
|
||||
export function projectImageParameters(profile: ImageCapabilityConfig, parameters: ImageParameter[]): ImageCapabilityConfig {
|
||||
const active = new Map(parameters.filter((p) => p.enabled !== false).map((p) => [p.name, p]));
|
||||
const stringOption = (name: string) => {
|
||||
const p = active.get(name);
|
||||
return { supported: Boolean(p), values: (p?.values || []).map(String), default: String(p?.default ?? p?.values?.[0] ?? "auto") };
|
||||
};
|
||||
const size = active.get("size");
|
||||
return {
|
||||
...profile,
|
||||
parameters,
|
||||
resolutionUsesQuality: false,
|
||||
quality: stringOption("quality"),
|
||||
resolution: stringOption("imageResolution"),
|
||||
transparentBackground: { supported: active.has("transparentBackground"), default: active.get("transparentBackground")?.default === true },
|
||||
maxOutputs: active.get("count")?.max ?? 1,
|
||||
size: size
|
||||
? {
|
||||
...profile.size,
|
||||
parameter: size.encoding ? (size.encoding === "pixels" && !size.values?.some((v) => String(v).includes(":")) ? "size" : "aspect_ratio") : profile.size.parameter === "none" ? "aspect_ratio" : profile.size.parameter,
|
||||
values: (size.values || []).map(String),
|
||||
default: String(size.default ?? size.values?.[0] ?? "auto"),
|
||||
allowCustom: !size.values?.length,
|
||||
presets: profile.size.presets?.filter(
|
||||
(p) =>
|
||||
(!size.values?.length || size.values.includes(p.ratio) || size.values.includes(p.size)) &&
|
||||
(!active.has("imageResolution") || !active.get("imageResolution")?.values?.length || active.get("imageResolution")!.values!.includes(p.tier)),
|
||||
),
|
||||
}
|
||||
: { parameter: "none", values: [], default: "auto", allowCustom: false },
|
||||
};
|
||||
}
|
||||
|
||||
export function updateImageParameterSize(profile: ImageCapabilityConfig, size: ImageCapabilityConfig["size"]): ImageCapabilityConfig {
|
||||
const parameters = (profile.parameters || []).map((field) => {
|
||||
if (field.name === "size") return { ...field, values: size.values, default: size.default };
|
||||
return field;
|
||||
});
|
||||
return projectImageParameters({ ...profile, size }, parameters);
|
||||
}
|
||||
|
||||
export function updateImageParameterSchema(profile: ImageCapabilityConfig, parameters: ImageParameter[], definitions: ImageParameter[] = parameters): ImageCapabilityConfig {
|
||||
const resolution = parameters.find((field) => field.name === "imageResolution");
|
||||
const size = profile.resolution.supported && resolution?.enabled === false ? { ...profile.size, defaultTier: profile.resolution.default } : profile.size;
|
||||
const next = projectImageParameters({ ...profile, size }, parameters);
|
||||
const previous = profile.parameters?.find((field) => field.name === "imageResolution")?.values || [];
|
||||
const added = next.resolution.supported ? next.resolution.values.filter((tier) => !previous.includes(tier)) : [];
|
||||
return initializeImageRatioDefaults(next, definitions, added);
|
||||
}
|
||||
|
||||
export function initializeImageRatioDefaults(profile: ImageCapabilityConfig, definitions: ImageParameter[], tiers?: string[]): ImageCapabilityConfig {
|
||||
const field = profile.parameters?.find((p) => p.name === "size");
|
||||
const definition = definitions.find((p) => p.name === "size");
|
||||
if (!field || field.enabled === false || !definition) return profile;
|
||||
const ratios = DEFAULT_IMAGE_RATIOS.filter((ratio) => imageParameterAccepts(definition, ratio));
|
||||
if (!ratios.length) return profile;
|
||||
if (!profile.resolution.supported && field.encoding !== "pixels" && tiers === undefined) {
|
||||
return projectImageParameters(
|
||||
profile,
|
||||
profile.parameters!.map((p) => (p.name === "size" ? { ...p, values: ratios, default: ratios.includes(String(p.default)) ? p.default : ratios[0] } : p)),
|
||||
);
|
||||
}
|
||||
const existing = profile.size.presets || [];
|
||||
const added = (tiers ?? profile.resolution.values)
|
||||
.filter((tier) => !existing.some((p) => p.tier === tier))
|
||||
.flatMap((tier) =>
|
||||
ratios.flatMap((ratio) => {
|
||||
try {
|
||||
return [imagePresetForRatio(tier, ratio)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!added.length) return profile;
|
||||
return updateImageParameterSize(profile, imageSizeConfigWithPresets(profile, [...existing, ...added]));
|
||||
}
|
||||
|
||||
export function resolveImageParameters(profile: ImageCapabilityConfig, definitions: ImageParameter[], model: string) {
|
||||
const applicable = definitions.filter((p) => !p.models?.length || p.models.includes(model.trim().replace(/^models\//, "")));
|
||||
const configured = new Map(profile.parameters?.map((p) => [p.name, p]));
|
||||
const hasQuality = applicable.some((p) => p.name === "quality");
|
||||
const parameters = applicable.map((definition) => {
|
||||
const current = configured.get(definition.name);
|
||||
if (current) return { ...definition, enabled: current.enabled, values: current.values ?? definition.values, min: current.min ?? definition.min, max: current.max ?? definition.max, step: current.step ?? definition.step, default: current.default };
|
||||
if (profile.parameters) return definition;
|
||||
let enabled = definition.enabled !== false;
|
||||
let values: string[] | undefined;
|
||||
let initial: ImageParameterValue | undefined = definition.default;
|
||||
switch (definition.name) {
|
||||
case "size":
|
||||
enabled = profile.size.parameter !== "none";
|
||||
values = profile.size.values;
|
||||
initial = profile.size.default;
|
||||
break;
|
||||
case "quality":
|
||||
enabled = profile.quality.supported;
|
||||
values = profile.quality.values;
|
||||
initial = profile.quality.default;
|
||||
break;
|
||||
case "imageResolution":
|
||||
enabled = profile.resolution.supported;
|
||||
values = profile.resolution.values;
|
||||
initial = profile.resolution.default;
|
||||
if (!enabled && !hasQuality && profile.quality.supported && profile.quality.values.length && profile.quality.values.every((v) => imageParameterAccepts(definition, v))) {
|
||||
enabled = true;
|
||||
values = profile.quality.values;
|
||||
initial = profile.quality.default;
|
||||
}
|
||||
break;
|
||||
case "transparentBackground":
|
||||
enabled = profile.transparentBackground.supported;
|
||||
initial = profile.transparentBackground.default;
|
||||
break;
|
||||
case "count":
|
||||
return { ...definition, max: Math.min(profile.maxOutputs, definition.max ?? profile.maxOutputs) };
|
||||
}
|
||||
const selected = values?.length ? values.filter((v) => imageParameterAccepts(definition, v)) : definition.values;
|
||||
if (values?.length && !selected?.length) enabled = false;
|
||||
const field = { ...definition, enabled, values: selected };
|
||||
return { ...field, default: imageParameterAccepts(field, initial) ? initial : (selected?.[0] ?? definition.default) };
|
||||
});
|
||||
return projectImageParameters(profile, parameters);
|
||||
}
|
||||
|
||||
export function imageParameterConstraints(parameters: ImageParameter[]) {
|
||||
return Object.fromEntries(
|
||||
parameters
|
||||
.filter((p) => p.enabled !== false)
|
||||
.map((p): [string, OptionConstraint] => [
|
||||
p.name,
|
||||
p.values?.length ? { values: p.values } : p.type === "boolean" ? { values: [false, true] } : p.type === "string" || p.min === undefined || p.max === undefined ? { values: ["*"] } : { min: p.min, max: p.max, step: p.step },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function imageParametersForSpec(spec: CapabilitySpec): ImageParameter[] {
|
||||
return (spec.imageParameters || []).map((p) => {
|
||||
const constraint = spec.options?.[p.name];
|
||||
if (!constraint) return { ...p, enabled: false };
|
||||
const values = constraint.values?.filter((v): v is ImageParameterValue => v !== "*" && (typeof v === "string" || typeof v === "number" || typeof v === "boolean"));
|
||||
return { ...p, enabled: true, ...(values?.length ? { values } : {}), min: constraint.min ?? p.min, max: constraint.max ?? p.max, step: constraint.step ?? p.step };
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { isImageResolutionTier } from "./image-quality";
|
||||
import type { ImageCapabilityConfig } from "./model-capabilities";
|
||||
|
||||
/** 旧图片价格档把 1K/2K/4K 存在 quality;只在读取价格条件时转换。 */
|
||||
export function normalizeImagePriceSelector(input: Record<string, string>) {
|
||||
export function normalizeImagePriceSelector(input: Record<string, string>, declaredParameters = false) {
|
||||
if (declaredParameters) return { ...input };
|
||||
const selector = { ...input };
|
||||
for (const key of ["quality", "resolution", "size"] as const) {
|
||||
if (selector[key]) selector[key] = selector[key].trim().toLowerCase();
|
||||
@@ -15,7 +16,11 @@ export function normalizeImagePriceSelector(input: Record<string, string>) {
|
||||
return selector;
|
||||
}
|
||||
|
||||
export function imagePriceSelector(quality: string, size: string, profile?: ImageCapabilityConfig): Record<string, string> {
|
||||
export function imagePriceSelector(quality: string, size: string, profile?: ImageCapabilityConfig, imageResolution?: string): Record<string, string> {
|
||||
if (profile?.parameters) {
|
||||
const values: Record<string, string | undefined> = { quality, size, imageResolution };
|
||||
return Object.fromEntries(profile.parameters.filter((p) => p.enabled !== false && p.pricing && values[p.name]).map((p) => [p.name, values[p.name]!])) as Record<string, string>;
|
||||
}
|
||||
quality = quality.trim().toLowerCase();
|
||||
size = size.trim().toLowerCase();
|
||||
const selector: Record<string, string> = {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ImageResolutionTier = "1k" | "1.5k" | "2k" | "4k";
|
||||
export type ImageResolutionTier = string;
|
||||
|
||||
export type ImageResolutionOption = {
|
||||
size: string;
|
||||
|
||||
@@ -4,9 +4,22 @@ import type { ImageCapabilityConfig } from "./model-capabilities";
|
||||
export const IMAGE_RESOLUTIONS: ImageResolutionTier[] = ["1k", "2k", "4k"];
|
||||
// 1.5K 只在模型明确配置此档位时出现,既有模型的档位保持原有顺序。
|
||||
export function imageResolutionTiers(profile: ImageCapabilityConfig): ImageResolutionTier[] {
|
||||
if (profile.parameters) return profile.resolution.supported ? profile.resolution.values : [];
|
||||
return profile.size.presets?.some((preset) => preset.tier === "1.5k") || profile.quality.values.includes("1.5k") ? ["1k", "1.5k", "2k", "4k"] : IMAGE_RESOLUTIONS;
|
||||
}
|
||||
export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9"];
|
||||
// Administrator's initial selection; plugin constraints still bound these choices.
|
||||
export const DEFAULT_IMAGE_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"];
|
||||
|
||||
export function imageSingleSizeTier(profile: ImageCapabilityConfig): string | undefined {
|
||||
if (profile.size.defaultTier) return profile.size.defaultTier;
|
||||
const resolution = profile.parameters?.find((p) => p.name === "imageResolution");
|
||||
if (resolution?.default !== undefined && resolution.default !== "auto") return String(resolution.default);
|
||||
const preset = profile.size.presets?.find((p) => p.size === profile.size.default);
|
||||
if (preset) return preset.tier;
|
||||
const tiers = [...new Set(profile.size.presets?.map((p) => p.tier))];
|
||||
return tiers.length === 1 ? tiers[0] : undefined;
|
||||
}
|
||||
const standardSizes: Record<string, string[]> = {
|
||||
"1:1": ["1024x1024", "2048x2048", "2880x2880"],
|
||||
"16:9": ["1824x1024", "2752x1536", "3840x2160"],
|
||||
@@ -56,15 +69,23 @@ export function imagePresetForRatio(tier: ImageResolutionTier, input: string): I
|
||||
}
|
||||
|
||||
export function imageQualityForTier(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
|
||||
const aliases = { "1k": ["1k", "low"], "1.5k": ["1.5k"], "2k": ["2k", "medium"], "4k": ["4k", "high"] }[tier];
|
||||
return profile.quality.supported ? profile.quality.values.find((value) => aliases.includes(value.toLowerCase())) : undefined;
|
||||
if (profile.parameters) return undefined;
|
||||
const aliases: Record<string, string[]> = { "1k": ["1k", "low"], "1.5k": ["1.5k"], "2k": ["2k", "medium"], "4k": ["4k", "high"] };
|
||||
return profile.quality.supported ? profile.quality.values.find((value) => aliases[tier]?.includes(value.toLowerCase())) : undefined;
|
||||
}
|
||||
|
||||
export function imageResolutionUsesQuality(profile: ImageCapabilityConfig) {
|
||||
return profile.size.parameter === "aspect_ratio" && imageResolutionTiers(profile).some((tier) => imageQualityForTier(profile, tier));
|
||||
if (profile.parameters) return false;
|
||||
return (
|
||||
!profile.resolution.supported &&
|
||||
profile.size.parameter === "aspect_ratio" &&
|
||||
profile.quality.supported &&
|
||||
Boolean(profile.resolutionUsesQuality || profile.quality.values.some((value) => IMAGE_RESOLUTIONS.includes(value.toLowerCase() as ImageResolutionTier) || value.toLowerCase() === "1.5k"))
|
||||
);
|
||||
}
|
||||
|
||||
export function imageTierAvailable(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
|
||||
if (profile.parameters) return profile.resolution.supported ? profile.resolution.values.includes(tier) : Boolean(profile.size.presets?.some((preset) => preset.tier === tier));
|
||||
// 质量参数能映射档位时按上游质量裁剪;管理员尺寸预设存在时再取交集,避免默认 low/medium/high 把未启用的 1K 显示出来。
|
||||
if (profile.size.parameter !== "aspect_ratio") return profile.size.parameter !== "none";
|
||||
const configuredTiers = new Set(profile.size.presets?.map((preset) => preset.tier));
|
||||
@@ -79,12 +100,19 @@ export function imageTierAvailable(profile: ImageCapabilityConfig, tier: ImageRe
|
||||
}
|
||||
|
||||
export function imageQualityForSelection(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
|
||||
return imageQualityForTier(profile, tier) || (!imageResolutionUsesQuality(profile) && imageTierAvailable(profile, tier) ? tier : undefined);
|
||||
if (profile.parameters) return undefined;
|
||||
if (imageResolutionUsesQuality(profile)) return imageQualityForTier(profile, tier);
|
||||
return !profile.resolution.supported && !profile.quality.supported && imageTierAvailable(profile, tier) ? tier : undefined;
|
||||
}
|
||||
|
||||
export function imageSizePresets(profile: ImageCapabilityConfig): ImageResolutionOption[] {
|
||||
if (profile.size.parameter === "none") return [];
|
||||
if (profile.parameters && !profile.resolution.supported) {
|
||||
const tier = imageSingleSizeTier(profile);
|
||||
return (profile.size.presets || []).filter((preset) => preset.tier === (tier ?? ""));
|
||||
}
|
||||
if (profile.size.presets) return profile.size.presets;
|
||||
if (profile.parameters) return [];
|
||||
const pixels = buildImageResolutionOptions(profile.size.values);
|
||||
const tiers = profile.size.parameter === "aspect_ratio" ? imageResolutionTiers(profile).filter((tier) => imageQualityForTier(profile, tier)) : [];
|
||||
const ratios = tiers.flatMap((tier) =>
|
||||
@@ -106,7 +134,7 @@ export function imagePresetValue(profile: ImageCapabilityConfig, preset: ImageRe
|
||||
}
|
||||
|
||||
export function imageSizeConfigWithPresets(profile: ImageCapabilityConfig, presets: ImageResolutionOption[]): ImageCapabilityConfig["size"] {
|
||||
const values = [...new Set(presets.map((preset) => (profile.size.parameter === "aspect_ratio" ? preset.ratio : preset.size)))];
|
||||
const values = [...new Set(presets.map((preset) => (profile.size.parameter === "aspect_ratio" ? preset.ratio : preset.size || preset.ratio)))];
|
||||
if (profile.size.values.includes("auto")) values.unshift("auto");
|
||||
if (!values.length) values.push("auto");
|
||||
return { ...profile.size, presets, values, default: values.includes(profile.size.default) ? profile.size.default : values[0] || "auto" };
|
||||
|
||||
@@ -174,6 +174,12 @@ export function workflowFieldSubmissionChoices(field: WorkflowVideoFieldLike) {
|
||||
return workflowFieldChoiceValues(field);
|
||||
}
|
||||
|
||||
export function workflowFieldIsRatioChoices(field: WorkflowVideoFieldLike, options = workflowFieldChoiceValues(field)) {
|
||||
if (!workflowVideoFieldMatches(field, "aspectRatio")) return false;
|
||||
const values = options.map(workflowFieldOptionValue).filter(Boolean);
|
||||
return values.length > 1 && values.every((value) => value.toLowerCase() === "auto" || Boolean(workflowRatioPrefix(value)));
|
||||
}
|
||||
|
||||
export function workflowRatioPrefix(value: string) {
|
||||
const match = value.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)(?:\s|\(|$)/);
|
||||
return match ? `${match[1]}:${match[2]}` : "";
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
|
||||
import { projectImageParameters, normalizeImageParameters, imageParameterRequest } from "./image-parameters";
|
||||
import type { ImageResolutionOption, ImageResolutionTier } from "@/lib/image-resolution-tiers";
|
||||
import { imagePresetForRatio } from "./image-size-presets";
|
||||
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio } from "@/lib/image-size-presets";
|
||||
import { kemeiSeedreamPresets } from "./kemei-image-presets";
|
||||
import { type WorkflowVideoFieldLike, workflowImageCapabilityConfig, workflowVideoCapabilityConfig } from "./model-capabilities-workflow";
|
||||
import { resolveWorkflowVideoScreenSpec } from "./video-screen-specs";
|
||||
|
||||
export { workflowFieldChoiceValues, workflowFieldConfigurationError, workflowFieldCurrentValue, workflowFieldHasStoredValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldPresetOptions, workflowFieldRandomKey, workflowFieldRole, workflowFieldSafeToOverride, workflowFieldSource, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoDefaultSize, workflowVideoFieldsFromJson, type WorkflowFieldNumberBounds, type WorkflowVideoFieldLike } from "./model-capabilities-workflow";
|
||||
export {
|
||||
workflowFieldChoiceValues,
|
||||
workflowFieldConfigurationError,
|
||||
workflowFieldCurrentValue,
|
||||
workflowFieldHasStoredValue,
|
||||
workflowFieldIsRatioChoices,
|
||||
workflowFieldKey,
|
||||
workflowFieldNumberBounds,
|
||||
workflowFieldOptionValue,
|
||||
workflowFieldPresetOptions,
|
||||
workflowFieldRandomKey,
|
||||
workflowFieldRole,
|
||||
workflowFieldSafeToOverride,
|
||||
workflowFieldSource,
|
||||
workflowFieldSubmissionValue,
|
||||
workflowFieldValueError,
|
||||
workflowImageCapabilityConfig,
|
||||
workflowOutputSizeValue,
|
||||
workflowParameterFields,
|
||||
workflowVideoCapabilityConfig,
|
||||
workflowVideoDefaultSize,
|
||||
workflowVideoFieldsFromJson,
|
||||
type WorkflowFieldNumberBounds,
|
||||
type WorkflowVideoFieldLike,
|
||||
} from "./model-capabilities-workflow";
|
||||
|
||||
export type ModelCapabilityConfig = {
|
||||
version: number;
|
||||
@@ -44,6 +69,7 @@ export type ImageSizeParameter = "none" | "size" | "aspect_ratio";
|
||||
export const DEFAULT_VIDEO_PROMPT_MAX_CHARS = 8000;
|
||||
|
||||
export type ImageCapabilityConfig = {
|
||||
parameters?: import("./image-parameters").ImageParameter[];
|
||||
references: {
|
||||
promptMaxChars: number;
|
||||
maxImages: number;
|
||||
@@ -56,12 +82,16 @@ export type ImageCapabilityConfig = {
|
||||
default: string;
|
||||
allowCustom: boolean;
|
||||
presets?: ImageResolutionOption[];
|
||||
defaultTier?: string;
|
||||
};
|
||||
resolution: { supported: boolean; values: string[]; default: string };
|
||||
quality: {
|
||||
supported: boolean;
|
||||
values: string[];
|
||||
default: string;
|
||||
};
|
||||
/** Protocol-declared mapping where quality values select image resolution. */
|
||||
resolutionUsesQuality?: boolean;
|
||||
transparentBackground: { supported: boolean; default: boolean };
|
||||
responseFormat: { supported: boolean };
|
||||
outputFormat: { supported: boolean };
|
||||
@@ -104,19 +134,7 @@ export type VideoCapabilityConfig = {
|
||||
export type VideoScreenSpecConfig = Pick<VideoCapabilityConfig, "ratios" | "defaultRatio" | "resolutions" | "defaultResolution">;
|
||||
|
||||
// 旧版本的“允许自定义”可能只保存了 `*`,前台需要用这组标准值恢复可选项。
|
||||
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;
|
||||
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;
|
||||
|
||||
export function normalizeCapabilityString(value: string) {
|
||||
const normalized = value.trim();
|
||||
@@ -167,6 +185,20 @@ export function normalizeModelCapabilityConfig(config: ModelCapabilityConfig): M
|
||||
};
|
||||
}
|
||||
|
||||
// Only these shipped protocol contracts prove that legacy quality values were resolution tiers.
|
||||
// This is a read projection; persisted capability JSON is not migrated.
|
||||
export function normalizeLegacyImageResolutionConfig(protocol: ModelProtocol | undefined, image: ImageCapabilityConfig): ImageCapabilityConfig {
|
||||
if (image.parameters) return projectImageParameters(image, image.parameters);
|
||||
image = { ...image, resolutionUsesQuality: protocol === "gemini-image" };
|
||||
if (image.resolution.supported || !image.quality.supported) return image;
|
||||
const allowed = protocol === "grok-image" ? new Set(["1k", "2k"]) : protocol === "agnes-image" ? new Set(["1k", "2k", "4k"]) : undefined;
|
||||
if (!allowed) return image;
|
||||
const values = image.quality.values.map((value) => value.trim().toLowerCase());
|
||||
const defaultValue = image.quality.default.trim().toLowerCase();
|
||||
if (!values.length || values.some((value) => !allowed.has(value)) || !allowed.has(defaultValue) || !values.includes(defaultValue)) return image;
|
||||
return { ...image, resolution: { supported: true, values, default: defaultValue }, quality: { supported: false, values: [], default: "auto" } };
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
@@ -215,6 +247,7 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
const image: ImageCapabilityConfig = {
|
||||
references: { promptMaxChars: 32000, maxImages: 16, maxImageBytes: 30 * 1024 * 1024, maskSupported: true },
|
||||
size: { parameter: "size", values: [...defaultImageSizes], default: "1:1", allowCustom: true },
|
||||
resolution: { supported: false, values: [], default: "auto" },
|
||||
quality: { supported: true, values: ["auto", "low", "medium", "high"], default: "auto" },
|
||||
transparentBackground: { supported: true, default: false },
|
||||
responseFormat: { supported: true },
|
||||
@@ -251,10 +284,15 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
}
|
||||
if (protocol === "cangyuan-midjourney-v7" || protocol === "cangyuan-midjourney-v82") {
|
||||
const v7 = protocol === "cangyuan-midjourney-v7";
|
||||
const tier = !v7 && model.trim().toLowerCase().replace(/^models\//, "") === "midjourney-2k" ? "2k" : "1k";
|
||||
const ratios = v7
|
||||
? ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "21:9"]
|
||||
: ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "1:2", "6:11", "5:6", "2:1", "11:6", "6:5"];
|
||||
const tier =
|
||||
!v7 &&
|
||||
model
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^models\//, "") === "midjourney-2k"
|
||||
? "2k"
|
||||
: "1k";
|
||||
const ratios = v7 ? ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "21:9"] : ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "1:2", "6:11", "5:6", "2:1", "11:6", "6:5"];
|
||||
image.references = { ...image.references, promptMaxChars: v7 ? 4000 : 32000, maxImages: v7 ? 5 : 1, maskSupported: !v7 };
|
||||
image.size = { parameter: "aspect_ratio", values: ["auto", ...ratios], default: "auto", allowCustom: false, presets: ratios.map((ratio) => imagePresetForRatio(tier, ratio)) };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
@@ -275,7 +313,8 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
default: "1:1",
|
||||
allowCustom: false,
|
||||
};
|
||||
image.quality = { supported: true, values: ["1k", "2k"], default: "2k" };
|
||||
image.resolution = { supported: true, values: ["1k", "2k"], default: "2k" };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
image.responseFormat = { supported: true };
|
||||
image.outputFormat = { supported: false };
|
||||
@@ -287,6 +326,11 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
image.responseFormat.supported = false;
|
||||
image.outputFormat.supported = false;
|
||||
}
|
||||
if (protocol === "openai-image") {
|
||||
// OpenAI Images 只收 WIDTHxHEIGHT:默认配置声明了 1K/2K/4K,就必须自带档位换算表,
|
||||
// 否则运行时换算会被静默跳过,实际出图档位与所选档位不一致。取值来自本地唯一那张比例表。
|
||||
image.size.presets = IMAGE_RESOLUTIONS.flatMap((tier) => IMAGE_RATIOS.map((ratio) => imagePresetForRatio(tier, ratio)));
|
||||
}
|
||||
if (protocol === "volcengine-jimeng-image") {
|
||||
image.references.maxImages = 14;
|
||||
image.references.maskSupported = false;
|
||||
@@ -308,6 +352,7 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
image.responseFormat = { supported: false };
|
||||
image.outputFormat = { supported: false };
|
||||
image.maxOutputs = 4;
|
||||
image.resolutionUsesQuality = true;
|
||||
}
|
||||
if (protocol === "agnes-image") {
|
||||
// Agnes 图像:size 必填,取 1K/2K/3K/4K 档位或 WxH 精确尺寸,画面比例走独立的 ratio 字段;
|
||||
@@ -321,7 +366,8 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
allowCustom: false,
|
||||
};
|
||||
// 官方档位是 1K/2K/3K/4K,统一层只提供 1k/2k/4k 三档;3K 保留在协议映射里但不在界面露出。
|
||||
image.quality = { supported: true, values: ["1k", "2k", "4k"], default: "2k" };
|
||||
image.resolution = { supported: true, values: ["1k", "2k", "4k"], default: "2k" };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
// 顶层 response_format 是官方明确的错误写法,输出格式只能在 extra_body 内声明。
|
||||
image.responseFormat = { supported: false };
|
||||
@@ -338,7 +384,8 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
default: "1:1",
|
||||
allowCustom: false,
|
||||
};
|
||||
image.quality = { supported: true, values: ["1k", "2k"], default: "2k" };
|
||||
image.resolution = { supported: true, values: ["1k", "2k"], default: "2k" };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
image.responseFormat = { supported: true };
|
||||
image.outputFormat = { supported: false };
|
||||
@@ -467,7 +514,7 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
|
||||
const text = capabilityConfig.text ? { ...fallback.text!, ...capabilityConfig.text, references: { ...fallback.text!.references, ...capabilityConfig.text.references } } : fallback.text;
|
||||
const video = capabilityConfig.video ? { ...fallback.video!, ...capabilityConfig.video, references: { ...fallback.video!.references, ...capabilityConfig.video.references } } : fallback.video;
|
||||
const configuredImage = capabilityConfig.image;
|
||||
const image = configuredImage
|
||||
const configuredImageProfile = configuredImage
|
||||
? (() => {
|
||||
const configuredSize = configuredImage.size;
|
||||
const configuredValues = configuredSize?.values?.map(normalizeCapabilityString);
|
||||
@@ -486,33 +533,69 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
|
||||
default: defaultValue,
|
||||
allowCustom,
|
||||
},
|
||||
resolution: {
|
||||
...fallback.image!.resolution,
|
||||
...configuredImage.resolution,
|
||||
values: normalizeCapabilityStrings(configuredImage.resolution?.values || []),
|
||||
default: normalizeCapabilityString(configuredImage.resolution?.default || fallback.image!.resolution.default),
|
||||
},
|
||||
};
|
||||
})()
|
||||
: fallback.image;
|
||||
const image = configuredImageProfile ? normalizeLegacyImageResolutionConfig(cost?.protocol, configuredImageProfile) : fallback.image;
|
||||
return { ...fallback, ...capabilityConfig, text, image, video };
|
||||
}
|
||||
|
||||
// 工作流字段是供应商参数的唯一事实来源;不能用普通视频模型的固定清晰度列表覆盖它。
|
||||
|
||||
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
|
||||
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; imageResolution?: string; imageParameters?: string; count?: string; transparentBackground?: string }, protocol?: ModelProtocol) {
|
||||
if (profile.parameters) {
|
||||
const values = imageParameterRequest(profile, value) || {};
|
||||
return {
|
||||
size: String(values.size ?? ""),
|
||||
quality: String(values.quality ?? ""),
|
||||
imageResolution: String(values.imageResolution ?? ""),
|
||||
count: String(values.count ?? 1),
|
||||
transparentBackground: String(values.transparentBackground ?? false),
|
||||
imageParameters: JSON.stringify(normalizeImageParameters(profile.parameters, value.imageParameters)),
|
||||
};
|
||||
}
|
||||
const size = normalizeImageSizeSetting(profile, value.size);
|
||||
const requestedQuality = String(value.quality || "").trim().toLowerCase();
|
||||
const requestedQuality = String(value.quality || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
// 比例协议的固定分辨率预设没有独立 quality 字段时,UI 仍需把当前比例对应的
|
||||
// 预设档位带入请求。仅在 quality 未声明支持时启用,避免与 auto/low/medium/high
|
||||
// 这组真实图片质量语义混用。
|
||||
const presetTier = !profile.quality.supported ? imagePresetTierForSelection(profile, size) : undefined;
|
||||
const presetTier = !profile.quality.supported && !profile.resolution.supported ? imagePresetTierForSelection(profile, size) : undefined;
|
||||
const quality = profile.quality.supported
|
||||
? requestedQuality === "auto" || requestedQuality === "any"
|
||||
? "auto"
|
||||
: value.quality && profile.quality.values.includes(value.quality)
|
||||
? value.quality
|
||||
: profile.quality.default || "auto"
|
||||
: requestedQuality === "1k" || requestedQuality === "1.5k" || requestedQuality === "2k" || requestedQuality === "4k"
|
||||
? value.quality
|
||||
: profile.quality.default || "auto"
|
||||
: profile.resolution.supported
|
||||
? profile.quality.default || "auto"
|
||||
: requestedQuality === "1k" || requestedQuality === "1.5k" || requestedQuality === "2k" || requestedQuality === "4k"
|
||||
? requestedQuality
|
||||
: presetTier || profile.quality.default || "auto";
|
||||
const count = String(Math.max(1, Math.min(profile.maxOutputs, Math.floor(Math.abs(Number(value.count)) || 1))));
|
||||
const transparentBackground = profile.transparentBackground.supported && value.transparentBackground === "true" ? "true" : "false";
|
||||
return { size, quality, count, transparentBackground };
|
||||
const requestedResolution = String(value.imageResolution || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const configuredResolution = profile.resolution.values.find((item) => item.trim().toLowerCase() === requestedResolution);
|
||||
const legacyResolutionProtocols = protocol === "grok-image" || protocol === "agnes-image";
|
||||
const legacyResolution = legacyResolutionProtocols && !profile.quality.supported ? profile.resolution.values.find((item) => item.trim().toLowerCase() === requestedQuality) : undefined;
|
||||
const imageResolution = profile.resolution.supported ? configuredResolution || legacyResolution || profile.resolution.default || "auto" : "auto";
|
||||
return {
|
||||
size,
|
||||
quality: profile.parameters && !profile.quality.supported ? "auto" : quality,
|
||||
...(profile.resolution.supported ? { imageResolution } : {}),
|
||||
count,
|
||||
transparentBackground,
|
||||
...(profile.parameters ? { imageParameters: JSON.stringify(normalizeImageParameters(profile.parameters, value.imageParameters)) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function imagePresetTierForSelection(profile: ImageCapabilityConfig, size: string): ImageResolutionTier | undefined {
|
||||
|
||||
@@ -39,7 +39,7 @@ export function priceTiersForCurrentSelection(tiers: ModelPriceTier[], capabilit
|
||||
let bestScore = -1;
|
||||
let matched: ModelPriceTier[] = [];
|
||||
for (const tier of tiers) {
|
||||
const selector = priceSelectorForTier(tier);
|
||||
const selector = priceSelectorForTier(tier, Boolean(modelCapabilityConfigFor(config, config.model || config.imageModel).image?.parameters));
|
||||
const conditions = Object.entries(selector).filter(([, value]) => value && value !== "*");
|
||||
if (conditions.some(([key, value]) => requested[key] !== value)) continue;
|
||||
const score = conditions.length;
|
||||
@@ -139,7 +139,14 @@ function priceSelectorForRequest(capability: ModelCapability | undefined, config
|
||||
}
|
||||
if (capability === "image") {
|
||||
requested.operation = imagePriceOperation(requirements);
|
||||
const options = { ...modelRequestOptions(config, "image"), ...requirements?.options, ...(requirements?.imageSize ? { size: requirements.imageSize } : {}) };
|
||||
const options: Record<string, unknown> = { ...modelRequestOptions(config, "image"), ...requirements?.options, ...(requirements?.imageSize ? { size: requirements.imageSize } : {}) };
|
||||
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel)?.image;
|
||||
if (profile?.parameters) {
|
||||
for (const field of profile.parameters) {
|
||||
if (field.enabled !== false && field.pricing && options[field.name] !== undefined) requested[field.name] = String(options[field.name]);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
const imageQuality = String(options.quality ?? "").trim().toLowerCase();
|
||||
const imageSize = String(options.size ?? "").trim();
|
||||
Object.assign(requested, imagePriceSelector(imageQuality, imageSize, modelCapabilityConfigFor(config, config.model || config.imageModel)?.image));
|
||||
@@ -152,8 +159,8 @@ function imagePriceOperation(requirements?: ModelRequirements) {
|
||||
return (input?.imageCount || 0) + (input?.characterCount || 0) > 0 ? "image_to_image" : "text_to_image";
|
||||
}
|
||||
|
||||
function priceSelectorForTier(tier: ModelPriceTier) {
|
||||
const selector = normalizeImagePriceSelector({ ...(tier.selector || {}) });
|
||||
function priceSelectorForTier(tier: ModelPriceTier, declaredParameters = false) {
|
||||
const selector = normalizeImagePriceSelector({ ...(tier.selector || {}) }, declaredParameters);
|
||||
if (!Object.keys(selector).length) {
|
||||
const resolution = normalizeTierResolution(tier.resolution);
|
||||
if (resolution !== "*") selector.vquality = resolution;
|
||||
|
||||
@@ -2,7 +2,7 @@ export type ModelProtocol = string;
|
||||
export type ProtocolCapability = "text" | "image" | "video" | "audio";
|
||||
export type ModelProtocolParameter = { name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string };
|
||||
export type ModelProtocolWorkflow = { id: string; label: string; providerId: string; capability: ProtocolCapability; parameters: ModelProtocolParameter[]; defaults?: Record<string, string | number | boolean> };
|
||||
export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean; baseUrl?: string; parameters?: ModelProtocolParameter[]; workflows?: ModelProtocolWorkflow[] };
|
||||
export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean; baseUrl?: string; parameters?: ModelProtocolParameter[]; imageParameters?: import("./image-parameters").ImageParameter[]; workflows?: ModelProtocolWorkflow[] };
|
||||
|
||||
export function protocolGroups(protocols: ModelProtocolDefinition[]) {
|
||||
return (["text", "image", "video", "audio"] as ProtocolCapability[]).map((capability) => ({ label: { text: "文本", image: "图片", video: "视频", audio: "音频" }[capability], options: protocols.filter((item) => item.capability === capability && item.enabled !== false).map((item) => ({ label: `${item.label} · ${item.create.replace(/^POST /, "")}`, value: item.value })) }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
||||
import { imageParameterRequest, imageParameterDefaults, imageParameterAccepts } from "./image-parameters";
|
||||
import { imageSizePresets } from "@/lib/image-size-presets";
|
||||
import { modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
@@ -181,10 +182,18 @@ export function modelPromptLengthError(config: AiConfig, model: string, capabili
|
||||
return `当前${label}模型提示词最多 ${maxChars} 个字符,完整提示词为 ${actualChars} 个字符。系统不会自动截断,请精简当前输入、连线内容或技能上下文后重试`;
|
||||
}
|
||||
|
||||
export function modelRequestOptions(config: AiConfig, capability: ModelCapability) {
|
||||
export function modelRequestOptions(config: AiConfig, capability: ModelCapability): Record<string, unknown> {
|
||||
switch (capability) {
|
||||
case "image":
|
||||
return { size: config.size, quality: config.quality, transparentBackground: config.transparentBackground === "true", count: Number(config.count) };
|
||||
return (
|
||||
imageParameterRequest(modelCapabilityConfigFor(config, config.model || config.imageModel).image!, config) ?? {
|
||||
size: config.size,
|
||||
imageResolution: config.imageResolution,
|
||||
quality: config.quality,
|
||||
transparentBackground: config.transparentBackground === "true",
|
||||
count: Number(config.count),
|
||||
}
|
||||
);
|
||||
case "video":
|
||||
return { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" };
|
||||
case "audio":
|
||||
@@ -219,6 +228,9 @@ function logicalModelCompatibilityError(spec: NonNullable<NonNullable<AiConfig["
|
||||
};
|
||||
for (const [name, value] of Object.entries(options)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
const field = spec.imageParameters?.find((p) => p.name === name);
|
||||
if (field && name !== "size" && !imageParameterAccepts(field, value)) return `不支持当前${field.label}`;
|
||||
if ((name === "quality" || name === "imageResolution") && /^(auto|any)$/i.test(String(value).trim())) continue;
|
||||
const constraint = spec.options?.[name];
|
||||
if (!constraint || !logicalOptionMatches(name, constraint, value)) return logicalOptionError(name);
|
||||
}
|
||||
@@ -227,6 +239,7 @@ function logicalModelCompatibilityError(spec: NonNullable<NonNullable<AiConfig["
|
||||
|
||||
function logicalOptionMatches(name: string, constraint: { values?: unknown[]; min?: number; max?: number; step?: number }, value: unknown) {
|
||||
if (constraint.values?.length) {
|
||||
if (constraint.values.includes("*")) return true;
|
||||
const requested = normalizeLogicalOptionValue(name, value);
|
||||
return constraint.values.some((candidate) => normalizeLogicalOptionValue(name, candidate) === requested);
|
||||
}
|
||||
@@ -301,20 +314,21 @@ export function mergedImageCapabilityConfig(config: AiConfig, selected: string):
|
||||
}
|
||||
|
||||
// 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。
|
||||
export function defaultImageParamsForModel(config: AiConfig, model: string): Pick<AiConfig, "size" | "quality" | "transparentBackground"> {
|
||||
export function defaultImageParamsForModel(config: AiConfig, model: string): Pick<AiConfig, "size" | "imageResolution" | "quality" | "transparentBackground" | "imageParameters"> {
|
||||
const image = modelCapabilityConfigFor(config, model).image;
|
||||
if (!image) return { size: "1:1", quality: "auto", transparentBackground: "false" };
|
||||
if (!image) return { size: "1:1", imageResolution: "auto", 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: sizeDefault,
|
||||
imageParameters: JSON.stringify(imageParameterDefaults(image.parameters)),
|
||||
imageResolution: image.resolution.supported ? image.resolution.default || "auto" : "auto",
|
||||
quality: image.quality.default || "auto",
|
||||
transparentBackground: String(image.transparentBackground.default ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export type ModelGenerationDefaults = Pick<AiConfig, "size" | "quality" | "transparentBackground" | "count" | "videoSeconds" | "vquality" | "videoGenerateAudio" | "videoWatermark">;
|
||||
export type ModelGenerationDefaults = Pick<AiConfig, "size" | "imageResolution" | "quality" | "transparentBackground" | "count" | "imageParameters" | "videoSeconds" | "vquality" | "videoGenerateAudio" | "videoWatermark">;
|
||||
|
||||
type ModelVideoBooleanOptions = Pick<AiConfig, "videoGenerateAudio" | "videoWatermark">;
|
||||
|
||||
@@ -337,12 +351,18 @@ export function resolveModelGenerationDefaults(
|
||||
const profile = modelCapabilityConfigFor(config, model);
|
||||
|
||||
if (capability === "image" && profile.image) {
|
||||
const normalized = normalizeImageValue(profile.image, {
|
||||
size: source("size"),
|
||||
quality: source("quality"),
|
||||
count: source("count"),
|
||||
transparentBackground: source("transparentBackground"),
|
||||
});
|
||||
const normalized = normalizeImageValue(
|
||||
profile.image,
|
||||
{
|
||||
size: source("size"),
|
||||
imageResolution: source("imageResolution"),
|
||||
imageParameters: source("imageParameters"),
|
||||
quality: source("quality"),
|
||||
count: source("count"),
|
||||
transparentBackground: source("transparentBackground"),
|
||||
},
|
||||
cost?.protocol,
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth";
|
||||
import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog } from "@/services/api/logical-models";
|
||||
import { projectImageParameters, imageParametersForSpec } from "./image-parameters";
|
||||
import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog, type PublicLogicalModel } from "@/services/api/logical-models";
|
||||
import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { scopedLocalStorage, setActiveUserScope, withUserScopedPersistenceSuppressed } from "@/lib/user-scope";
|
||||
@@ -258,6 +259,11 @@ export function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<
|
||||
});
|
||||
projected.image.maxOutputs = maxNumericOption(spec.options?.count, 1);
|
||||
projected.image.transparentBackground = booleanOption(spec.options?.transparentBackground, defaults.transparentBackground);
|
||||
if (spec.imageParameters)
|
||||
projected.image = projectImageParameters(
|
||||
projected.image,
|
||||
imageParametersForSpec(spec).map((field) => ({ ...field, default: (defaults[field.name] as import("./image-parameters").ImageParameterValue | undefined) ?? field.default })),
|
||||
);
|
||||
}
|
||||
if (spec.capability === "video" && projected.video) {
|
||||
projected.video.references.minImages = spec.inputs?.image?.min ?? 0;
|
||||
|
||||
Reference in new issue
Block a user