feat(image): 以插件声明统一图片参数、尺寸选择与定价合同 (#760)
* feat(image): 图片参数 - 统一插件声明配置与生成合同 * docs(image): 图片参数 - 说明声明合同与验证边界 * fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档 * fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方 * fix(image): 调用链 - 保留公开意图并接通独立分辨率 * fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
403
web/src/components/image-parameter-fields.tsx
Normal file
403
web/src/components/image-parameter-fields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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("、") }] : []),
|
||||
],
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user