feat(image): 以插件声明统一图片参数、尺寸选择与定价合同 (#760)

* feat(image): 图片参数 - 统一插件声明配置与生成合同

* docs(image): 图片参数 - 说明声明合同与验证边界

* fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档

* fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方

* fix(image): 调用链 - 保留公开意图并接通独立分辨率

* fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
This commit is contained in:
huiku
2026-10-13 19:18:21 +08:00
committed by GitHub
parent 88de4ce422
commit 7466119867
104 changed files with 6013 additions and 568 deletions

View File

@@ -12,7 +12,7 @@ import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
export type BatchGenerationSettings = Pick<AiConfig, "model" | "imageModel" | "quality" | "size" | "transparentBackground" | "count"> & { cameraControl?: CameraControlOptions };
export type BatchGenerationSettings = Pick<AiConfig, "model" | "imageModel" | "imageResolution" | "imageParameters" | "quality" | "size" | "transparentBackground" | "count"> & { cameraControl?: CameraControlOptions };
type BatchGenerationSettingsDialogProps = {
open: boolean;
@@ -35,7 +35,7 @@ export function BatchGenerationSettingsDialog({ open, config, rowCount, concurre
}
}, [config, open]);
const handleConfigChange = (key: "quality" | "size" | "transparentBackground" | "count", value: string) => {
const handleConfigChange = (key: "quality" | "imageResolution" | "imageParameters" | "size" | "transparentBackground" | "count", value: string) => {
setGenerationConfig((current) => ({ ...current, [key]: value }));
};
@@ -102,6 +102,8 @@ export function BatchGenerationSettingsDialog({ open, config, rowCount, concurre
onClick={() => onConfirm({
model: generationConfig.model,
imageModel: generationConfig.imageModel,
imageResolution: generationConfig.imageResolution,
imageParameters: generationConfig.imageParameters,
quality: generationConfig.quality,
size: generationConfig.size,
transparentBackground: generationConfig.transparentBackground,

View File

@@ -6,7 +6,7 @@ import { agentApprovalModel, agentApprovalModelSelection, type AgentImageApprova
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import type { AgentMediaSettings } from "@/services/api/agent";
import { PUBLIC_MODEL_CATALOG_ID, useEffectiveConfig } from "@/stores/use-config-store";
import { PUBLIC_MODEL_CATALOG_ID, resolveModelRequestConfig, useEffectiveConfig } from "@/stores/use-config-store";
export function CanvasAgentImageApprovalSettings({ initial, value, onChange, theme, disabled }: {
initial: AgentImageApproval;
@@ -29,12 +29,13 @@ export function CanvasAgentImageApprovalSettings({ initial, value, onChange, the
if (disabled) return;
const profile = modelCapabilityConfigFor(config, next).image;
if (!profile) return;
const normalized = normalizeImageValue(profile, settings);
onChange({ ...agentApprovalModelSelection(config, next), size: normalized.size, quality: profile.quality.supported ? normalized.quality : "" });
const normalized = normalizeImageValue(profile, settings, resolveModelRequestConfig(config, next).interfaceType);
onChange({ ...agentApprovalModelSelection(config, next), size: normalized.size, imageResolution: normalized.imageResolution, imageParameters: normalized.imageParameters, quality: profile.quality.supported ? normalized.quality : "" });
};
const changeOption = (key: "quality" | "size" | "transparentBackground" | "count", next: string) => {
if (disabled || (key !== "size" && key !== "quality")) return;
const options = { size: settings.size, quality: settings.quality, [key]: next };
const changeOption = (key: "quality" | "imageResolution" | "imageParameters" | "size" | "transparentBackground" | "count", next: string) => {
if (disabled || (key !== "size" && key !== "quality" && key !== "imageResolution" && key !== "imageParameters")) return;
if (key === "imageParameters") { onChange({ ...settings, imageParameters: next }); return; }
const options = { size: settings.size, imageResolution: settings.imageResolution, quality: settings.quality, [key]: next };
const selected = resolveCompatibleModel(config, model, { ...requirements, imageSize: options.size, options: { ...options, count: 1 } });
if (!selected) return;
onChange({ ...agentApprovalModelSelection(config, selected), ...options });
@@ -44,8 +45,8 @@ export function CanvasAgentImageApprovalSettings({ initial, value, onChange, the
<div className="text-xs" style={{ color: theme.node.muted }}>生成模型</div>
<ModelPicker config={config} capability="image" value={model} onChange={changeModel} requirements={requirements} variant="creation" fullWidth showOptionPrices placeholder="选择生成模型" popoverClassName="agent-model-picker-popover" />
</div>
{model ? <ImageSettingsPanel config={{ ...config, model, imageModel: model, size: settings.size, quality: settings.quality, count: "1" }} onConfigChange={changeOption} theme={theme} showTitle={false} showCount={false} showTransparent={false} className="min-w-0 space-y-3" /> : <p className="text-xs" style={{ color: theme.node.muted }}>当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。</p>}
<p className="text-xs" style={{ color: theme.node.text }}>本次规格:{settings.size}{settings.quality ? ` · ${settings.quality}` : ""}</p>
{model ? <ImageSettingsPanel config={{ ...config, model, imageModel: model, size: settings.size, imageResolution: settings.imageResolution || "auto", imageParameters: settings.imageParameters, quality: settings.quality, count: "1" }} onConfigChange={changeOption} theme={theme} showTitle={false} showCount={false} showTransparent={false} className="min-w-0 space-y-3" /> : <p className="text-xs" style={{ color: theme.node.muted }}>当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。</p>}
<p className="text-xs" style={{ color: theme.node.text }}>本次规格:{settings.size}{settings.imageResolution && settings.imageResolution !== "auto" ? ` · ${settings.imageResolution}` : ""}{settings.quality ? ` · ${settings.quality}` : ""}</p>
<p className="text-xs" style={{ color: theme.node.muted }}>修改仅用于本次生成,费用按最终模型和规格计算。</p>
</fieldset>;
}

View File

@@ -8,7 +8,7 @@ import { Switch } from "@/components/ui/base/switch";
import { configuredModelMatchesCapability, defaultConfig, modelOptionName, normalizeRunningHubCapability, resolveModelChannel, useEffectiveConfig, type AiConfig, type RunningHubCapability, type RunningHubWorkflow, type RunningHubWorkflowKind } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities";
import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldIsRatioChoices, workflowFieldKey, workflowFieldNumberBounds, workflowFieldOptionValue, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities";
import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { canonicalGenerationMetadata, nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
@@ -363,11 +363,20 @@ export function WorkflowParameterControls({ fields, node, theme, onConfigChange,
const fieldType = ["FLOAT", "INT", "INTEGER"].includes(rawFieldType) ? "NUMBER" : rawFieldType;
const options = workflowFieldChoiceValues(field);
const selectOptions = options.length ? options : fieldType === "SELECT" && value !== "" ? [value] : [];
const ratioOptions = workflowFieldIsRatioChoices(field, selectOptions);
const bounds = workflowFieldNumberBounds(field);
const numeric = fieldType === "NUMBER" || fieldType === "SLIDER" || typeof value === "number" || bounds.min !== undefined || bounds.max !== undefined || bounds.step !== undefined;
const numericValue = value === "" || !Number.isFinite(Number(value)) ? undefined : Number(value);
const valueError = randomEnabled ? "" : workflowFieldValueError(field, value);
const control = fieldType === "SELECT" || selectOptions.length ? (
const control = ratioOptions ? (
<div className="flex min-w-0 flex-wrap gap-1">
{selectOptions.map((option) => {
const optionValue = workflowFieldOptionValue(option);
const selected = optionValue === workflowFieldOptionValue(value);
return <button key={optionValue} type="button" className="min-h-7 rounded-md px-2 py-1 text-[var(--fs-label)] font-medium transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1" style={{ background: selected ? theme.accent.primary : "transparent", color: selected ? theme.accent.onPrimary : theme.node.text, outlineColor: theme.node.muted }} aria-pressed={selected} onClick={() => update(field, optionValue)}>{optionValue}</button>;
})}
</div>
) : fieldType === "SELECT" || selectOptions.length ? (
<Select status={valueError ? "error" : undefined} size="small" className="w-full" value={value === "" ? undefined : value as string | number} options={selectOptions.map((option) => ({ label: workflowParameterOptionLabel(option), value: workflowParameterOptionValue(option) }))} onChange={(next) => update(field, next)} />
) : fieldType === "BOOLEAN" || typeof value === "boolean" ? (
<Switch size="sm" checked={value === true || value === "true"} onChange={(checked) => update(field, checked)} />
@@ -533,6 +542,8 @@ function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode
mode === "image"
? {
size: node.metadata?.size,
imageResolution: node.metadata?.imageResolution,
imageParameters: node.metadata?.imageParameters,
quality: node.metadata?.quality,
transparentBackground: node.metadata?.transparentBackground,
count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count),
@@ -546,6 +557,8 @@ function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode
},
{
size: globalConfig.size || defaultConfig.size,
imageResolution: globalConfig.imageResolution || defaultConfig.imageResolution,
imageParameters: globalConfig.imageParameters ?? "{}",
quality: globalConfig.quality || defaultConfig.quality,
transparentBackground: globalConfig.transparentBackground || defaultConfig.transparentBackground,
count: String(globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count),
@@ -561,6 +574,8 @@ function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode
taskWorkflowProvider: "model",
model,
quality: generationDefaults.quality || globalConfig.quality || defaultConfig.quality,
imageResolution: generationDefaults.imageResolution || globalConfig.imageResolution || defaultConfig.imageResolution,
imageParameters: generationDefaults.imageParameters ?? globalConfig.imageParameters ?? "{}",
size: generationDefaults.size ?? globalConfig.size ?? defaultConfig.size,
transparentBackground: generationDefaults.transparentBackground || "false",
videoSeconds: generationDefaults.videoSeconds ?? normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),

View File

@@ -27,8 +27,10 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, showCount =
if (profile.quality.supported) parts.push(imageQualityLabel(current.quality));
if (showCount && profile.maxOutputs > 1) parts.push(`${current.count} 张`);
if (profile.transparentBackground.supported && current.transparentBackground === "true") parts.push("透明");
if (!parts.length && !profile.transparentBackground.supported) return null;
return <CanvasSettingsPopover {...popover} label="图像设置" summary={parts.join(" · ") || "背景"} width={420}>
<ImageSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showCount={showCount} quickCount={3} className="space-y-3" />
</CanvasSettingsPopover>;
if (!parts.length && !profile.transparentBackground.supported && !profile.parameters?.some((p) => p.enabled !== false)) return null;
return (
<CanvasSettingsPopover {...popover} label="图像设置" summary={parts.join(" · ") || "背景"} width={420}>
<ImageSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showCount={showCount} quickCount={3} className="space-y-3" />
</CanvasSettingsPopover>
);
}

View File

@@ -34,6 +34,8 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo
mode === "image"
? {
size: node.metadata?.size,
imageResolution: node.metadata?.imageResolution,
imageParameters: node.metadata?.imageParameters,
quality: node.metadata?.quality,
transparentBackground: node.metadata?.transparentBackground,
videoWatermark: node.metadata?.watermark,
@@ -48,6 +50,8 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo
},
{
size: globalConfig.size || defaultConfig.size,
imageResolution: globalConfig.imageResolution || defaultConfig.imageResolution,
imageParameters: globalConfig.imageParameters ?? "{}",
quality: globalConfig.quality || defaultConfig.quality,
transparentBackground: globalConfig.transparentBackground || defaultConfig.transparentBackground,
count: String(globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count),
@@ -61,6 +65,8 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo
...globalConfig,
model,
quality: defaults.quality ?? globalConfig.quality ?? defaultConfig.quality,
imageResolution: defaults.imageResolution ?? globalConfig.imageResolution ?? defaultConfig.imageResolution,
imageParameters: defaults.imageParameters ?? globalConfig.imageParameters ?? "{}",
size: defaults.size ?? globalConfig.size ?? defaultConfig.size,
transparentBackground: defaults.transparentBackground ?? "false",
videoSeconds: defaults.videoSeconds ?? normalizeVideoDuration(globalConfig.videoSeconds ?? defaultConfig.videoSeconds),

View File

@@ -0,0 +1,403 @@
import { Input, InputNumber, Switch } from "antd";
import { useEffect, useRef, useState } from "react";
import { ImageSizePresetsEditor } from "./image-size-presets-editor";
import { ImageSizePicker } from "./image-size-picker";
import { imageSingleSizeTier, imageSizeConfigWithPresets, imageSizePresets } from "@/lib/image-size-presets";
import { Select } from "@/components/ui/base/select";
import { imageHostControls, imageParameterAccepts, imageParameterRequest, parseImageParameters, projectImageParameters, updateImageParameterSize, type ImageParameter, type ImageParameterValue } from "@/lib/image-parameters";
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
import type { AiConfig } from "@/stores/use-config-store";
export function ImageParameterInput({
field,
value,
disabled,
inlineChoices = false,
onChange,
}: {
field: ImageParameter;
value?: ImageParameterValue;
disabled?: boolean;
inlineChoices?: boolean;
onChange: (value: ImageParameterValue | undefined) => void;
}) {
const [draft, setDraft] = useState(value);
const editing = useRef(false);
useEffect(() => {
if (!editing.current) setDraft(value);
}, [value]);
const changeDraft = (next: ImageParameterValue | undefined) => {
setDraft(next);
onChange(next);
};
const invalid = draft !== undefined && !imageParameterAccepts(field, draft);
if (field.values?.length) {
if (inlineChoices) {
const ratios = field.encoding === "ratio" || (field.name === "size" && field.values.some((item) => typeof item === "string" && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(item)));
return (
<div className="image-size-picker">
<div className={ratios ? "image-size-ratios" : "image-size-tiers"} role="group" aria-label={field.label}>
{field.values.map((option) => {
const dimensions = ratios && typeof option === "string" ? option.split(":").map(Number) : [];
const [width, height] = dimensions;
return (
<button type="button" key={`${typeof option}:${option}`} disabled={disabled} aria-pressed={value === option} onClick={() => onChange(option)}>
{width > 0 && height > 0 ? <span aria-hidden="true" className="image-size-ratio-icon" style={{ width: 24 * Math.min(1, width / height), height: 24 * Math.min(1, height / width) }} /> : null}
<span>{typeof option === "boolean" ? (option ? "开启" : "关闭") : String(option)}</span>
</button>
);
})}
</div>
{!field.required && field.default === undefined ? (
<button type="button" className="image-size-auto" disabled={disabled} aria-pressed={value === undefined} onClick={() => onChange(undefined)}>
跟随模型默认
</button>
) : null}
</div>
);
}
return (
<Select
className="w-full"
ariaLabel={field.label}
disabled={disabled}
allowClear={!field.required}
value={value === undefined ? undefined : String(value)}
placeholder="跟随模型默认"
options={field.values.map((v) => ({ value: String(v), label: typeof v === "boolean" ? (v ? "开启" : "关闭") : String(v) }))}
onChange={(next) => onChange(field.values!.find((v) => String(v) === next))}
/>
);
}
if (field.type === "boolean") return <Switch aria-label={field.label} disabled={disabled} checked={value === true} onChange={onChange} />;
if (field.type === "integer" || field.type === "number")
return (
<InputNumber
className="w-full"
aria-label={field.label}
disabled={disabled}
value={typeof draft === "number" ? draft : null}
status={invalid ? "error" : undefined}
changeOnBlur={false}
onFocus={() => {
editing.current = true;
}}
onBlur={() => {
editing.current = false;
}}
min={field.min}
max={field.max}
step={field.step ?? (field.type === "integer" ? 1 : undefined)}
precision={field.type === "integer" ? 0 : undefined}
placeholder="跟随模型默认"
onChange={(next) => changeDraft(next ?? undefined)}
/>
);
return (
<Input
aria-label={field.label}
disabled={disabled}
value={typeof draft === "string" ? draft : ""}
status={invalid ? "error" : undefined}
maxLength={field.maxLength}
placeholder="跟随模型默认"
onFocus={() => {
editing.current = true;
}}
onBlur={() => {
editing.current = false;
}}
onChange={(event) => changeDraft(event.target.value || undefined)}
/>
);
}
export function ImageParameterFields({
profile,
config,
onChange,
showCount = true,
showSize = true,
showQuality = true,
showTransparent = true,
}: {
profile: ImageCapabilityConfig;
config: AiConfig;
onChange: (key: "size" | "quality" | "imageResolution" | "transparentBackground" | "count" | "imageParameters", value: string) => void;
showCount?: boolean;
showSize?: boolean;
showQuality?: boolean;
showTransparent?: boolean;
}) {
const values = imageParameterRequest(profile, config) || {};
const sizeField = profile.parameters?.find((p) => p.name === "size");
const usesSizePresets = profile.resolution.supported || profile.size.parameter === "size" || sizeField?.encoding === "pixels" || profile.parameters?.some((p) => p.name === "imageResolution");
const groupedSize = showSize && profile.size.parameter !== "none" && usesSizePresets && imageSizePresets(profile).length > 0;
const fields = (profile.parameters || []).filter(
(p) =>
p.enabled !== false &&
(!groupedSize || (p.name !== "size" && p.name !== "imageResolution")) &&
(p.name !== "count" || (showCount && (p.max ?? 1) > 1)) &&
(p.name !== "size" || showSize) &&
(p.name !== "quality" || showQuality) &&
(p.name !== "transparentBackground" || showTransparent),
);
return (
<div className="space-y-3" data-canvas-no-zoom onPointerDown={(event) => event.stopPropagation()}>
{groupedSize ? (
<ImageSizePicker
profile={profile}
size={String(values.size ?? profile.size.default)}
imageResolution={String(values.imageResolution ?? profile.resolution.default)}
quality={String(values.quality ?? profile.quality.default)}
onChange={(size, _quality, resolution) => {
onChange("size", size);
if (resolution !== undefined) onChange("imageResolution", resolution);
}}
/>
) : null}
{fields.map((field) => (
<div className="space-y-1.5" key={field.name}>
<div className="text-xs font-medium">{field.label}</div>
<ImageParameterInput
field={field}
inlineChoices
value={values[field.name]}
onChange={(next) => {
if (imageHostControls.has(field.name)) {
onChange(field.name as "size" | "quality" | "imageResolution" | "transparentBackground" | "count", next === undefined ? "" : String(next));
return;
}
const parameters = parseImageParameters(config.imageParameters);
if (next === undefined) delete parameters[field.name];
else parameters[field.name] = next;
onChange("imageParameters", JSON.stringify(parameters));
}}
/>
{field.description ? <p className="text-xs leading-relaxed text-foreground/55">{field.description}</p> : null}
</div>
))}
</div>
);
}
export function ImageParameterSchemaEditor({ definitions, parameters, disabled, onChange }: { definitions: ImageParameter[]; parameters: ImageParameter[]; disabled?: boolean; onChange: (parameters: ImageParameter[]) => void }) {
const update = (name: string, patch: Partial<ImageParameter>) => onChange(parameters.map((p) => (p.name === name ? { ...p, ...patch } : p)));
if (!parameters.length) return null;
return (
<div className="space-y-3">
<p className="text-xs leading-relaxed text-foreground/55">选择此模型支持的参数和范围。生成界面同步显示启用项,标记“可计价”的参数可用于规格价格。</p>
{parameters.map((field) => {
const definition = definitions.find((p) => p.name === field.name) || field;
return (
<section key={field.name} className="rounded-lg border border-border p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<span className="text-sm font-medium">{field.label}</span>
{definition.pricing ? <span className="ml-2 text-xs text-foreground/50">可计价</span> : null}
</div>
<Switch aria-label={`启用${field.label}`} checked={field.enabled !== false} disabled={disabled || definition.required} onChange={(enabled) => update(field.name, { enabled })} />
</div>
{field.description ? <p className="text-xs text-foreground/55">{field.description}</p> : null}
{field.enabled !== false ? (
<div className="grid gap-3 sm:grid-cols-2">
{definition.values?.length || field.type === "string" ? (
<label className="space-y-1.5 text-xs">
<span>支持值{!definition.values?.length ? "(留空允许自由输入)" : ""}</span>
<Select
className="w-full"
ariaLabel={`${field.label}支持值`}
mode={definition.values?.length ? "multiple" : "tags"}
disabled={disabled}
value={(field.values || []).map(String)}
options={(definition.values || []).map((v) => ({ label: String(v), value: String(v) }))}
tokenSeparators={[","]}
onChange={(items) => {
const values = items.map((item) => definition.values?.find((v) => String(v) === item) ?? item);
const next = { ...field, values };
update(field.name, { values, default: imageParameterAccepts(next, field.default) ? field.default : values[0] });
}}
/>
</label>
) : null}
{(field.type === "number" || field.type === "integer") && !definition.values?.length ? (
<div className="grid grid-cols-2 gap-2">
<label className="space-y-1.5 text-xs">
<span>最小值</span>
<InputNumber
className="w-full"
aria-label={`${field.label}最小值`}
disabled={disabled}
min={definition.min}
max={field.max}
value={field.min}
onChange={(min) => update(field.name, { min: min ?? definition.min })}
/>
</label>
<label className="space-y-1.5 text-xs">
<span>最大值</span>
<InputNumber
className="w-full"
aria-label={`${field.label}最大值`}
disabled={disabled}
min={field.min}
max={definition.max}
value={field.max}
onChange={(max) => update(field.name, { max: max ?? definition.max })}
/>
</label>
</div>
) : null}
<div className="space-y-1.5 text-xs">
<span>默认值</span>
<ImageParameterInput field={field} value={field.default} disabled={disabled} onChange={(value) => update(field.name, { default: value })} />
</div>
</div>
) : null}
</section>
);
})}
</div>
);
}
export function ImageParameterSizeEditor({ profile, definition, disabled, onChange }: { profile: ImageCapabilityConfig; definition: ImageParameter; disabled?: boolean; onChange: (profile: ImageCapabilityConfig) => void }) {
const [draft, setDraft] = useState("");
const [error, setError] = useState("");
const field = profile.parameters!.find((p) => p.name === "size")!;
const pixelSize = field.encoding === "pixels";
const grouped = profile.resolution.supported;
const singleTier = imageSingleSizeTier(profile);
const tiers = grouped ? profile.resolution.values : [singleTier ?? ""];
const updateField = (patch: Partial<ImageParameter>) =>
onChange(
projectImageParameters(
profile,
profile.parameters!.map((p) => (p.name === "size" ? { ...p, ...patch } : p)),
),
);
const updateSize = (size: ImageCapabilityConfig["size"]) => {
if (!grouped) {
const retained = (profile.size.presets || []).filter((p) => p.tier !== (singleTier ?? ""));
const merged = imageSizeConfigWithPresets(profile, [...retained, ...(size.presets || [])]);
onChange(updateImageParameterSize(profile, { ...merged, default: size.default, defaultTier: singleTier }));
} else onChange(updateImageParameterSize(profile, size));
};
const ratios = [...new Set([...(definition.values || []), ...profile.size.values].map(String))];
const toggle = (ratio: string) => {
const values = profile.size.values.includes(ratio) ? profile.size.values.filter((v) => v !== ratio) : [...profile.size.values, ratio];
updateField({ values, default: values.includes(profile.size.default) ? profile.size.default : values[0] });
};
const addRatio = () => {
const value = draft.trim().replace(/\s/g, "").replace(":", ":");
const parts = value.split(":").map(Number);
if (parts.length !== 2 || parts.some((v) => !Number.isFinite(v) || v <= 0)) {
setError("请输入正数比例,例如 17:11");
return;
}
if (!profile.size.values.includes(value)) toggle(value);
setDraft("");
setError("");
};
const activePresets = imageSizePresets(profile);
const editorProfile = !grouped && pixelSize && activePresets.length ? { ...profile, size: imageSizeConfigWithPresets(profile, activePresets) } : profile;
return (
<section className="admin-capability-parameter-card admin-image-size-card space-y-3 rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium">尺寸参数</h3>
<p className="text-xs text-foreground/55">{grouped ? "按分辨率配置可用画幅,直接点选或输入比例" : "直接点选支持的比例"}</p>
</div>
<Switch aria-label={`启用${field.label}`} disabled={disabled || definition.required} checked={field.enabled !== false} onChange={(enabled) => updateField({ enabled })} />
</div>
{!grouped && pixelSize && singleTier === undefined && (profile.size.presets?.length ?? 0) > 0 ? (
<p role="alert" className="image-size-error">
已有多组尺寸,请先开启分辨率并设置默认档位,再关闭分辨率。
</p>
) : null}
{field.enabled !== false ? (
grouped || pixelSize ? (
<ImageSizePresetsEditor
profile={editorProfile}
tiers={tiers}
grouped={grouped}
ratios={definition.values?.filter((v) => String(v).includes(":")).map(String)}
allowCustomRatio={!definition.values?.length}
disabled={disabled}
onChange={updateSize}
/>
) : (
<>
<div className="image-size-picker">
<div className="image-size-ratios" role="group" aria-label="支持的宽高比">
{ratios.map((ratio) => (
<button type="button" key={ratio} disabled={disabled} aria-pressed={profile.size.values.includes(ratio)} onClick={() => toggle(ratio)}>
{ratio}
</button>
))}
</div>
</div>
{!definition.values?.length ? (
<div className="image-size-preset-add">
<Input aria-label="自定义比例" placeholder="其他比例,如 17:11" value={draft} disabled={disabled} onChange={(e) => setDraft(e.target.value)} onPressEnter={addRatio} />
<button type="button" disabled={disabled || !draft.trim()} onClick={addRatio}>
添加
</button>
</div>
) : null}
{error ? <p role="alert">{error}</p> : null}
<div className="image-size-defaults">
<strong>默认输出</strong>
<div className="image-size-default-options" role="group" aria-label="默认输出">
{profile.size.values.map((value) => (
<button type="button" key={value} disabled={disabled} aria-pressed={value === profile.size.default} onClick={() => updateField({ default: value })}>
{value}
</button>
))}
</div>
</div>
</>
)
) : null}
</section>
);
}
export function ImagePixelMappingEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) {
const sizeField = profile.parameters?.find((p) => p.name === "size" && p.enabled !== false && p.encoding === "pixels");
if (!sizeField || !profile.size.values.some((v) => v.includes(":"))) return null;
const tiers = profile.resolution.supported ? profile.resolution.values : [""];
const presets = profile.size.presets || [];
const rows = tiers.flatMap((tier) => profile.size.values.filter((value) => value.includes(":")).map((ratio) => presets.find((p) => p.tier === tier && p.ratio === ratio) || { tier, ratio, width: 0, height: 0, size: "" }));
const update = (index: number, key: "width" | "height", value: number | null) => {
onChange({
...profile.size,
presets: rows.map((p, i) => {
if (i !== index) return p;
const next = { ...p, [key]: value ?? 0 };
return { ...next, size: `${next.width}x${next.height}` };
}),
});
};
return (
<section className="rounded-lg border border-border p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">比例对应的像素尺寸</h3>
<p className="mt-1 text-xs leading-relaxed text-foreground/55">此接口接收像素。按已启用的分辨率和比例填写尺寸,保存后用于请求换算。</p>
</div>
<div className="space-y-2">
{rows.map((preset, index) => (
<div key={`${preset.tier}:${preset.ratio}`} className="grid grid-cols-[1fr_1fr_1fr] items-center gap-2">
<span className="text-xs">
{preset.tier ? `${preset.tier.toUpperCase()} · ` : ""}
{preset.ratio}
</span>
<InputNumber className="w-full" aria-label={`${preset.tier} ${preset.ratio} 宽度`} min={1} precision={0} disabled={disabled} value={preset.width || null} placeholder="宽度" onChange={(v) => update(index, "width", v)} />
<InputNumber className="w-full" aria-label={`${preset.tier} ${preset.ratio} 高度`} min={1} precision={0} disabled={disabled} value={preset.height || null} placeholder="高度" onChange={(v) => update(index, "height", v)} />
</div>
))}
</div>
<p className="text-xs text-foreground/60">尺寸以供应商支持范围为准,也可将画幅支持值直接配置为像素尺寸。</p>
</section>
);
}

View File

@@ -1,5 +1,6 @@
import { ImageSizePicker } from "./image-size-picker";
import { imageResolutionUsesQuality } from "@/lib/image-size-presets";
import { ImageParameterFields } from "./image-parameter-fields";
import { imageResolutionUsesQuality, imageSizePresets } from "@/lib/image-size-presets";
import { type ReactNode } from "react";
import { ConfigProvider } from "antd";
import { Switch } from "@/components/ui/base/switch";
@@ -36,7 +37,7 @@ const aspectOptions: AspectOption[] = [
type ImageSettingsPanelProps = {
config: AiConfig;
onConfigChange: (key: "quality" | "size" | "transparentBackground" | "count", value: string) => void;
onConfigChange: (key: "quality" | "imageResolution" | "size" | "transparentBackground" | "count" | "imageParameters", value: string) => void;
theme: CanvasTheme;
showTitle?: boolean;
showQuality?: boolean;
@@ -60,6 +61,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
const activeSize = normalized.size;
const activeQualityOptions = profile.quality.values.map((value) => ({ value, label: imageQualityLabel(value) }));
const priceTiers = imageModelPriceTiers(config);
if (profile.parameters) return <ImageSettingsTheme theme={theme}><div className={className}>{showTitle ? <div className="mb-3 text-base font-semibold">图像设置</div> : null}<ImageParameterFields profile={profile} config={config} onChange={onConfigChange} showSize={showSize} showCount={showCount} showQuality={showQuality} showTransparent={showTransparent} /></div></ImageSettingsTheme>;
return (
<ImageSettingsTheme theme={theme}>

View File

@@ -109,6 +109,9 @@
.image-size-presets-editor {
container: image-presets / inline-size;
}
.image-size-preset-group.is-ungrouped {
grid-template-columns: minmax(0, 1fr);
}
.image-size-editor-intro {
margin: 0 0 2px;
color: var(--muted-foreground);
@@ -159,8 +162,8 @@
}
.image-size-preset-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 84px), 1fr));
gap: 6px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 168px), 1fr));
gap: 12px;
}
.image-size-preset-option-wrap {
display: grid;
@@ -171,9 +174,9 @@
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 3px;
gap: 6px;
color: var(--muted-foreground);
font-size: var(--fs-tiny);
font-size: var(--fs-label);
font-variant-numeric: tabular-nums;
}
.image-size-preset-dimensions label {
@@ -187,10 +190,15 @@
.image-size-preset-dimensions .ant-input-number {
width: 100%;
min-width: 0;
padding-inline: 0;
}
.image-size-preset-dimensions .ant-input-number-input {
padding-inline: 5px;
font-size: var(--fs-tiny);
padding-inline: 6px;
color: var(--foreground);
font-size: var(--fs-body);
font-weight: 500;
font-variant-numeric: tabular-nums;
text-align: center;
}
.image-size-preset-option,
.image-size-default-options button {
@@ -220,7 +228,7 @@
flex: 0 0 auto;
}
.image-size-preset-option small {
color: var(--muted-foreground);
color: var(--foreground);
font-size: var(--fs-label);
font-variant-numeric: tabular-nums;
white-space: nowrap;

View File

@@ -7,8 +7,23 @@ import { buildImageResolutionOptions, type ImageResolutionOption, type ImageReso
import { resolveImageRequestSize, validateImageSize } from "@/services/api/image-validation";
export function ImageSizePicker({ profile, size, quality, onChange }: { profile: ImageCapabilityConfig; size: string; quality?: string; onChange: (size: string, quality?: string) => void }) {
const IMAGE_RESOLUTIONS = imageResolutionTiers(profile);
export function ImageSizePicker({
profile,
size,
quality,
imageResolution,
canSelectResolution,
onChange,
}: {
profile: ImageCapabilityConfig;
size: string;
quality?: string;
imageResolution?: string;
canSelectResolution?: (resolution: string) => boolean;
onChange: (size: string, quality?: string, resolution?: string) => void;
}) {
const ungrouped = Boolean(profile.parameters && !profile.resolution.supported);
const IMAGE_RESOLUTIONS = ungrouped ? [...new Set(imageSizePresets(profile).map((p) => p.tier))] : imageResolutionTiers(profile);
const presets = imageSizePresets(profile).filter((preset) => imageTierAvailable(profile, preset.tier));
const visibleTiers = IMAGE_RESOLUTIONS.filter((value) => presets.some((preset) => preset.tier === value));
const [chosenTier, setChosenTier] = useState<ImageResolutionTier>("1k");
@@ -16,9 +31,12 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile:
const [custom, setCustom] = useState("");
const [error, setError] = useState("");
const qualityTier = IMAGE_RESOLUTIONS.find((value) => value === (quality || "").toLowerCase());
const resolutionTier = IMAGE_RESOLUTIONS.find((value) => value === (imageResolution || "").toLowerCase());
const selectedTier = profile.resolution.supported ? resolutionTier : qualityTier;
const matches = (preset: ImageResolutionOption) => {
if (imagePresetValue(profile, preset) !== size) return false;
if (profile.size.parameter !== "aspect_ratio") return true;
if (profile.resolution.supported) return !resolutionTier || resolutionTier === preset.tier;
if (imageResolutionUsesQuality(profile)) return imageQualityForTier(profile, preset.tier) === (quality || profile.quality.default);
return !qualityTier || qualityTier === preset.tier;
};
@@ -50,19 +68,26 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile:
presets.find((preset) => preset.size === resolvedLegacySize) ||
(restoredCustom && imageTierAvailable(profile, restoredCustom.tier) ? restoredCustom : undefined);
const available = (value: ImageResolutionTier) => imageTierAvailable(profile, value) && (profile.size.allowCustom || presets.some((preset) => preset.tier === value));
const tier =
(active && visibleTiers.includes(active.tier) ? active.tier : undefined) ||
(profile.size.parameter === "aspect_ratio" && imageResolutionUsesQuality(profile) ? visibleTiers.find((value) => imageQualityForTier(profile, value) === (quality || profile.quality.default)) : undefined) ||
(qualityTier && visibleTiers.includes(qualityTier) ? qualityTier : undefined) ||
(visibleTiers.includes(chosenTier) ? chosenTier : visibleTiers[0]) ||
"1k";
const tier = ungrouped
? (IMAGE_RESOLUTIONS[0] ?? "")
: (active && visibleTiers.includes(active.tier) ? active.tier : undefined) ||
(profile.size.parameter === "aspect_ratio" && imageResolutionUsesQuality(profile) ? visibleTiers.find((value) => imageQualityForTier(profile, value) === (quality || profile.quality.default)) : undefined) ||
(selectedTier && visibleTiers.includes(selectedTier) ? selectedTier : undefined) ||
(visibleTiers.includes(chosenTier) ? chosenTier : visibleTiers[0]) ||
"1k";
const choices = presets.filter((preset) => preset.tier === tier);
const select = (preset: ImageResolutionOption) => {
if (!available(preset.tier)) return;
if (profile.resolution.supported && canSelectResolution && !canSelectResolution(preset.tier)) return;
const resolution = profile.resolution.values.find((value) => value.toLowerCase() === preset.tier);
setChosenTier(preset.tier);
setCustomPreset(preset);
setError("");
onChange(imagePresetValue(profile, preset), profile.size.parameter === "aspect_ratio" ? imageQualityForSelection(profile, preset.tier) : undefined);
onChange(
imagePresetValue(profile, preset),
profile.resolution.supported ? undefined : profile.size.parameter === "aspect_ratio" ? imageQualityForSelection(profile, preset.tier) : undefined,
profile.resolution.supported ? resolution || preset.tier : undefined,
);
};
const makeCustom = (next: ImageResolutionTier, ratio: string) => {
const preset = imagePresetForRatio(next, ratio);
@@ -116,10 +141,43 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile:
setError(reason instanceof Error ? reason.message : "尺寸格式无效");
}
};
if (profile.size.parameter === "none") return null;
if (profile.size.parameter === "none")
return profile.resolution.supported ? (
<div className="image-size-picker">
<div className="image-size-label">分辨率</div>
<div className="image-size-tiers">
{profile.resolution.values.map((value) => (
<button type="button" key={value} aria-pressed={value === imageResolution} disabled={Boolean(canSelectResolution && !canSelectResolution(value))} onClick={() => onChange(size, undefined, value)}>
{value.toUpperCase()}
</button>
))}
</div>
</div>
) : null;
return (
<div className="image-size-picker">
{visibleTiers.length ? (
{profile.resolution.supported ? (
<>
<div className="image-size-label">分辨率</div>
<div className="image-size-tiers">
{profile.resolution.values.map((value) => (
<button
type="button"
key={value}
aria-pressed={value === imageResolution}
disabled={Boolean(canSelectResolution && !canSelectResolution(value))}
onClick={() => {
const next = value.toLowerCase() as ImageResolutionTier;
if (size !== "auto" && visibleTiers.includes(next)) changeTier(next);
else onChange(size, undefined, value);
}}
>
{value.toUpperCase()}
</button>
))}
</div>
</>
) : !ungrouped && visibleTiers.length && (profile.size.parameter === "size" || !profile.quality.supported || imageResolutionUsesQuality(profile)) ? (
<>
<div className="image-size-label">分辨率</div>
<div className="image-size-tiers">

View File

@@ -4,16 +4,21 @@ import { Check, Plus } from "lucide-react";
import { Switch } from "@/components/ui/base/switch";
import "./image-size-picker.css";
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
import { IMAGE_RATIOS, imageResolutionTiers, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import { DEFAULT_IMAGE_RATIOS, IMAGE_RATIOS, imageResolutionTiers, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import { validateImageSize } from "@/services/api/image-validation";
import type { ImageResolutionTier } from "@/lib/image-resolution-tiers";
export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) {
const IMAGE_RESOLUTIONS = imageResolutionTiers(profile);
export function ImageSizePresetsEditor({ profile, disabled, onChange, tiers, ratios: allowedRatios, allowCustomRatio = true, grouped = true }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void; tiers?: string[]; ratios?: string[]; allowCustomRatio?: boolean; grouped?: boolean }) {
const IMAGE_RESOLUTIONS = tiers ?? imageResolutionTiers(profile);
const presets = imageSizePresets(profile);
const id = useId();
const [drafts, setDrafts] = useState<Partial<Record<ImageResolutionTier, string>>>({});
const [errors, setErrors] = useState<Partial<Record<ImageResolutionTier, string>>>({});
const candidate = (tier: string, ratio: string) => {
const existing = presets.find((preset) => preset.tier === tier && preset.ratio === ratio);
if (existing) return existing;
try { return imagePresetForRatio(tier, ratio); } catch { return { tier, ratio, width: 0, height: 0, size: "" }; }
};
const toggleTier = (tier: ImageResolutionTier, ratios: string[], enabled: boolean) => {
if (disabled) return;
update(tier, enabled ? ratios : []);
@@ -24,8 +29,8 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
const next = [
...new Map(
ratios.map((ratio) => {
const normalized = imagePresetForRatio(tier, ratio);
const preset = presets.find((preset) => preset.tier === tier && preset.ratio === normalized.ratio) || normalized;
if (!/^\d+[::]\d+$/.test(ratio) || ratio.split(/[::]/).some((part) => Number(part) <= 0)) throw new Error("请输入正数比例,例如 16:9");
const preset = candidate(tier, ratio);
return [preset.ratio, preset] as const;
}),
).values(),
@@ -49,7 +54,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
const width = field === "width" ? value : selected.width;
const height = field === "height" ? value : selected.height;
try {
validateImageSize(width, height);
if (width > 0 && height > 0) validateImageSize(width, height, !profile.parameters);
const next = presets.map((preset) => preset === selected ? { ...preset, width, height, size: `${width}x${height}` } : preset);
onChange(imageSizeConfigWithPresets(profile, next));
setErrors((current) => ({ ...current, [tier]: "" }));
@@ -69,34 +74,35 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
};
return (
<div className="image-size-presets-editor">
<p className="image-size-editor-intro">点选支持的比例,再次点击可移除。尺寸自动换算,已有精确尺寸保持不变。</p>
<p className="image-size-editor-intro">点选支持的比例,再次点击可移除。已有精确尺寸保持不变,有换算规则时自动填入。</p>
{IMAGE_RESOLUTIONS.map((tier) => {
const items = presets.filter((preset) => preset.tier === tier);
const ratios = [...new Set([...IMAGE_RATIOS, ...items.map((item) => item.ratio)])];
const ratios = [...new Set([...(allowedRatios ?? IMAGE_RATIOS), ...items.map((item) => item.ratio)])];
return (
<section key={tier} className="image-size-preset-group" aria-labelledby={`${id}-${tier}-title`}>
<header className="image-size-preset-heading">
<section key={tier} className={`image-size-preset-group${grouped ? "" : " is-ungrouped"}`} aria-label={grouped ? undefined : "比例配置"} aria-labelledby={grouped ? `${id}-${tier}-title` : undefined}>
{grouped ? <header className="image-size-preset-heading">
<div className="image-size-preset-heading-label">
<strong id={`${id}-${tier}-title`}>{tier.toUpperCase()}</strong>
<span>{items.length ? `${items.length} 个比例` : "未启用"}</span>
<Switch size="sm" checked={items.length > 0} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} />
{!profile.parameters ? <Switch size="sm" checked={items.length > 0} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} /> : null}
</div>
</header>
{!items.length && DEFAULT_IMAGE_RATIOS.some((ratio) => ratios.includes(ratio)) ? <Button size="small" disabled={disabled} onClick={() => update(tier, DEFAULT_IMAGE_RATIOS.filter((ratio) => ratios.includes(ratio)))}>选择常用比例</Button> : null}
</header> : null}
<div className="image-size-preset-body">
{imageResolutionUsesQuality(profile) && !imageTierAvailable(profile, tier) ? <p className="image-size-hint">需先配置此档位对应的质量值,用户端才会开放。</p> : null}
<div className="image-size-preset-options" role="group" aria-label={`${tier.toUpperCase()} 支持的宽高比`}>
<div className="image-size-preset-options" role="group" aria-label={grouped ? `${tier.toUpperCase()} 支持的宽高比` : "支持的宽高比"}>
{ratios.map((ratio) => {
const selected = items.find((item) => item.ratio === ratio);
const dimensions = selected || imagePresetForRatio(tier, ratio);
const dimensions = selected || candidate(tier, ratio);
return (
<div key={ratio} className="image-size-preset-option-wrap">
<button
type="button"
disabled={disabled}
aria-pressed={Boolean(selected)}
aria-label={`${tier.toUpperCase()} ${ratio}`}
aria-label={grouped ? `${tier.toUpperCase()} ${ratio}` : ratio}
className="image-size-preset-option"
title={`${ratio} · ${dimensions.width} × ${dimensions.height} px${selected ? " · 点击移除" : " · 点击添加"}`}
title={`${ratio}${dimensions.width && dimensions.height ? ` · ${dimensions.width} × ${dimensions.height} px` : ""}${selected ? " · 点击移除" : " · 点击添加"}`}
onClick={() => update(tier, selected ? items.filter((item) => item.ratio !== ratio).map((item) => item.ratio) : [...items.map((item) => item.ratio), ratio])}
>
<span className="image-size-preset-option-label">
@@ -104,19 +110,19 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
{selected ? <Check size={12} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
</span>
<small>
{dimensions.width} × {dimensions.height}
{dimensions.width && dimensions.height ? `${dimensions.width} × ${dimensions.height}` : "设置尺寸"}
</small>
</button>
{selected ? (
<div className="image-size-preset-dimensions" aria-label={`${tier.toUpperCase()} ${ratio} 精确像素`}>
<label>
<span>宽</span>
<InputNumber min={1} precision={0} value={selected.width} disabled={disabled} aria-label={`${tier.toUpperCase()} ${ratio} 宽度像素`} onChange={(value) => editDimensions(tier, ratio, "width", value)} />
<InputNumber controls={false} min={1} precision={0} value={selected.width || null} disabled={disabled} aria-label={`${grouped ? tier.toUpperCase() + " " : ""}${ratio} 宽度像素`} onChange={(value) => editDimensions(tier, ratio, "width", value)} />
</label>
<span aria-hidden="true">×</span>
<label>
<span>高</span>
<InputNumber min={1} precision={0} value={selected.height} disabled={disabled} aria-label={`${tier.toUpperCase()} ${ratio} 高度像素`} onChange={(value) => editDimensions(tier, ratio, "height", value)} />
<InputNumber controls={false} min={1} precision={0} value={selected.height || null} disabled={disabled} aria-label={`${grouped ? tier.toUpperCase() + " " : ""}${ratio} 高度像素`} onChange={(value) => editDimensions(tier, ratio, "height", value)} />
</label>
</div>
) : null}
@@ -124,7 +130,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
);
})}
</div>
<div className="image-size-preset-add">
{allowCustomRatio ? <div className="image-size-preset-add">
<Input
aria-label={`${tier.toUpperCase()} 自定义比例`}
aria-invalid={Boolean(errors[tier])}
@@ -145,7 +151,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
<Button disabled={disabled || !drafts[tier]?.trim()} icon={<Plus size={14} />} onClick={() => addRatio(tier)} aria-label={`添加 ${tier.toUpperCase()} 比例`}>
添加
</Button>
</div>
</div> : null}
{errors[tier] ? (
<p id={`${id}-${tier}-error`} role="alert" className="image-size-error">
{errors[tier]}
@@ -163,7 +169,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
<div className="image-size-default-options" role="group" aria-label="默认输出">
{profile.size.values.map((value) => {
const preset = presets.find((item) => (profile.size.parameter === "aspect_ratio" ? item.ratio === value : item.size === value));
const label = value === "auto" ? "自动" : preset && profile.size.parameter === "size" ? `${preset.tier.toUpperCase()} · ${preset.ratio}` : value;
const label = value === "auto" ? "自动" : preset && profile.size.parameter === "size" ? `${grouped ? preset.tier.toUpperCase() + " · " : ""}${preset.ratio}` : value;
return (
<button key={value} type="button" disabled={disabled} aria-pressed={profile.size.default === value} title={value} aria-label={`默认输出 ${label} ${value}`} onClick={() => onChange({ ...profile.size, default: value })}>
{label}

View File

@@ -7,6 +7,8 @@ import type { ReactNode } from "react";
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type TextCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
import { ImageParameterSchemaEditor, ImageParameterSizeEditor } from "@/components/image-parameter-fields";
import { updateImageParameterSchema, resolveImageParameters, type ImageParameter } from "@/lib/image-parameters";
import { cleanVideoScreenSpecValues, resolveWorkflowVideoScreenSpec, updateWorkflowVideoScreenSpec } from "@/lib/video-screen-specs";
import { VIDEO_RESOLUTION_CAPABILITY_OPTIONS } from "@/lib/video-generation-options";
import { Select } from "@/components/ui/base/select";
@@ -35,16 +37,17 @@ type Props = {
capability?: "text" | "image" | "video";
model?: string;
workflows?: ModelProtocolWorkflow[];
imageParameters?: ImageParameter[];
disabled?: boolean;
section?: "all" | "protocol" | "references";
};
export function ModelCapabilityEditor({ value, onChange, protocol, capability = "video", model = "", workflows = [], disabled = false, section = "all" }: Props) {
export function ModelCapabilityEditor({ value, onChange, protocol, capability = "video", model = "", workflows = [], imageParameters, disabled = false, section = "all" }: Props) {
if (capability === "text") {
return <TextCapabilityEditor value={value} onChange={onChange} protocol={protocol} disabled={disabled} section={section} />;
}
if (capability === "image") {
return <ImageCapabilityEditor value={value} onChange={onChange} protocol={protocol} model={model} disabled={disabled} section={section} />;
return <ImageCapabilityEditor value={value} onChange={onChange} protocol={protocol} model={model} imageParameters={imageParameters} disabled={disabled} section={section} />;
}
const workflow = workflows.find((item) => item.id === model.trim().replace(/^models\//, "") && item.providerId === protocol);
const storedProfile = normalizeModelCapabilityConfig(value || defaultModelCapabilityConfig(protocol, model)).video!;
@@ -435,13 +438,24 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled, section }:
);
}
function ImageCapabilityEditor({ value, onChange, protocol, model, disabled, section }: Required<Pick<Props, "model" | "disabled">> & Pick<Props, "value" | "onChange" | "protocol" | "section">) {
const profile = normalizeModelCapabilityConfig(value || { version: 1, image: defaultImageCapabilityConfig(protocol, model) }).image!;
function ImageCapabilityEditor({ value, onChange, protocol, model, imageParameters, disabled, section }: Required<Pick<Props, "model" | "disabled">> & Pick<Props, "value" | "onChange" | "protocol" | "section" | "imageParameters">) {
const stored = normalizeModelCapabilityConfig(value || { version: 1, image: defaultImageCapabilityConfig(protocol, model) }).image!;
const profile = imageParameters ? resolveImageParameters(stored, imageParameters, model) : stored;
const update = (patch: Partial<ImageCapabilityConfig>) => onChange?.({ version: 1, image: { ...profile, ...patch } });
const updateReferences = (patch: Partial<ImageCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
const updateSize = (patch: Partial<ImageCapabilityConfig["size"]>) => update({ size: { ...profile.size, ...patch } });
const updateQuality = (patch: Partial<ImageCapabilityConfig["quality"]>) => update({ quality: { ...profile.quality, ...patch } });
if (section !== "references" && profile.parameters) {
const definitions = imageParameters || profile.parameters;
const size = profile.parameters.find((p) => p.name === "size");
return <div className="space-y-4">
<ImageParameterSchemaEditor definitions={definitions} parameters={profile.parameters.filter((p) => p.name === "imageResolution")} disabled={disabled} onChange={(parameters) => onChange?.({ version: 1, image: updateImageParameterSchema(profile, profile.parameters!.map((p) => parameters.find((next) => next.name === p.name) || p), definitions) })} />
{size ? <ImageParameterSizeEditor profile={profile} definition={definitions.find((p) => p.name === "size") || size} disabled={disabled} onChange={(image) => onChange?.({ version: 1, image })} /> : null}
<ImageParameterSchemaEditor definitions={definitions} parameters={profile.parameters.filter((p) => p.name !== "size" && p.name !== "imageResolution")} disabled={disabled} onChange={(parameters) => onChange?.({ version: 1, image: updateImageParameterSchema(profile, profile.parameters!.map((p) => parameters.find((next) => next.name === p.name) || p)) })} />
</div>;
}
if (section === "references") {
return (
<div className="admin-capability-reference-editor">

View File

@@ -28,6 +28,7 @@ export function ModelProtocolBrowser({
{ label: "请求体", value: protocol.contentType },
{ label: "响应", value: protocol.poll ? "异步轮询" : "同步响应" },
{ label: "来源", value: protocol.media },
...(protocol.imageParameters ? [{ label: "图片参数", value: protocol.imageParameters.map((p) => p.label).join("、") }] : []),
],
}));