feat(image): 以插件声明统一图片参数、尺寸选择与定价合同 (#760)
* feat(image): 图片参数 - 统一插件声明配置与生成合同 * docs(image): 图片参数 - 说明声明合同与验证边界 * fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档 * fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方 * fix(image): 调用链 - 保留公开意图并接通独立分辨率 * fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
This commit is contained in:
1 parent
88de4ce422
commit
7466119867
104 files changed
+6013
-568
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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("、") }] : []),
|
||||
],
|
||||
}));
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ export function agentImageApproval(detail: Record<string, unknown>): AgentImageA
|
||||
channelId: typeof args.channelId === "string" ? args.channelId : "",
|
||||
channelModelKey: typeof args.channelModelKey === "string" ? args.channelModelKey : "",
|
||||
size: args.size,
|
||||
...(typeof args.imageResolution === "string" ? { imageResolution: args.imageResolution } : {}),
|
||||
...(typeof args.imageParameters === "string" ? { imageParameters: args.imageParameters } : {}),
|
||||
quality: typeof args.quality === "string" ? args.quality : "",
|
||||
prompt: args.prompt,
|
||||
referenceNodeIds: Array.isArray(args.referenceNodeIds) ? args.referenceNodeIds.filter((id: unknown): id is string => typeof id === "string") : [],
|
||||
|
||||
@@ -439,6 +439,8 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
const requestedConfig: AiConfig = {
|
||||
...config,
|
||||
quality: node?.metadata?.quality ?? workflowParameterValue("quality") ?? config.quality ?? defaultConfig.quality,
|
||||
imageResolution: node?.metadata?.imageResolution ?? workflowParameterValue("imageResolution") ?? config.imageResolution ?? defaultConfig.imageResolution,
|
||||
imageParameters: node?.metadata?.imageParameters ?? workflowParameterValue("imageParameters") ?? config.imageParameters ?? "{}",
|
||||
size: workflowProvider === "model" ? (node?.metadata?.size ?? config.size ?? defaultConfig.size) : workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size,
|
||||
transparentBackground: node?.metadata?.transparentBackground ?? config.transparentBackground ?? defaultConfig.transparentBackground,
|
||||
videoSeconds:
|
||||
@@ -475,6 +477,8 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
mode === "image"
|
||||
? {
|
||||
size: node?.metadata?.size,
|
||||
imageResolution: node?.metadata?.imageResolution,
|
||||
imageParameters: node?.metadata?.imageParameters,
|
||||
quality: node?.metadata?.quality,
|
||||
transparentBackground: node?.metadata?.transparentBackground,
|
||||
count: requestedConfig.count,
|
||||
@@ -488,6 +492,8 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
},
|
||||
{
|
||||
size: requestedConfig.size,
|
||||
imageResolution: requestedConfig.imageResolution,
|
||||
imageParameters: requestedConfig.imageParameters,
|
||||
quality: requestedConfig.quality,
|
||||
transparentBackground: requestedConfig.transparentBackground,
|
||||
count: requestedConfig.count,
|
||||
@@ -521,6 +527,8 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
runningHub,
|
||||
model,
|
||||
quality: generationDefaults.quality || requestedConfig.quality,
|
||||
imageResolution: generationDefaults.imageResolution || requestedConfig.imageResolution,
|
||||
imageParameters: generationDefaults.imageParameters ?? requestedConfig.imageParameters,
|
||||
size: generationDefaults.size ?? requestedConfig.size,
|
||||
transparentBackground: generationDefaults.transparentBackground || (requestedConfig.transparentBackground === "true" ? "true" : "false"),
|
||||
videoSeconds: generationDefaults.videoSeconds ?? requestedConfig.videoSeconds,
|
||||
|
||||
@@ -6,6 +6,8 @@ export type ModelSelection = { kind: "logical"; logicalModelId: string; channelI
|
||||
|
||||
export type Options = {
|
||||
size?: string;
|
||||
imageResolution?: string;
|
||||
imageParameters?: string;
|
||||
quality?: string;
|
||||
count?: number;
|
||||
transparentBackground?: boolean;
|
||||
@@ -53,6 +55,24 @@ export const GENERATION_OPTION_FIELDS = [
|
||||
"node": "size",
|
||||
"task": "size"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"image"
|
||||
],
|
||||
"name": "imageResolution",
|
||||
"node": "imageResolution",
|
||||
"task": "imageResolution"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
"image"
|
||||
],
|
||||
"name": "imageParameters",
|
||||
"node": "imageParameters",
|
||||
"task": "imageParameters"
|
||||
},
|
||||
{
|
||||
"kind": "string",
|
||||
"modes": [
|
||||
|
||||
@@ -199,6 +199,7 @@ export function synchronizeGenerationSpec(node: CanvasNodeData, patch: Partial<C
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "composerContent") && typeof patch.composerContent === "string") next.prompt = patch.composerContent;
|
||||
else if (Object.prototype.hasOwnProperty.call(patch, "prompt") && typeof patch.prompt === "string") next.prompt = patch.prompt;
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "model")) {
|
||||
if (patch.model !== node.metadata?.model && !Object.prototype.hasOwnProperty.call(patch, "imageParameters")) delete next.options.imageParameters;
|
||||
const model = patch.model ? decodeChannelModel(patch.model) : null;
|
||||
next.modelSelection = model ? { kind: "channel", channelId: model.channelId, modelKey: model.model } : undefined;
|
||||
delete (metadata as Record<string, unknown>).logicalModelId;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import type { CapabilitySpec, OptionConstraint } from "@/services/api/logical-models";
|
||||
import { DEFAULT_IMAGE_RATIOS, imagePresetForRatio, imageSizeConfigWithPresets } from "./image-size-presets";
|
||||
|
||||
export type ImageParameterValue = string | number | boolean;
|
||||
export type ImageParameter = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: "string" | "number" | "integer" | "boolean";
|
||||
description?: string;
|
||||
values?: ImageParameterValue[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
maxLength?: number;
|
||||
default?: ImageParameterValue;
|
||||
required?: boolean;
|
||||
pricing?: boolean;
|
||||
binding?: string;
|
||||
encoding?: "pixels" | "ratio";
|
||||
models?: string[];
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export function imageParameterAccepts(field: ImageParameter, value: unknown): value is ImageParameterValue {
|
||||
if (field.type === "string" && (typeof value !== "string" || (field.maxLength !== undefined && value.length > field.maxLength))) return false;
|
||||
if (field.type === "boolean" && typeof value !== "boolean") return false;
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || (field.type === "integer" && !Number.isInteger(value))) return false;
|
||||
if ((field.min !== undefined && value < field.min) || (field.max !== undefined && value > field.max)) return false;
|
||||
if (field.step) {
|
||||
const steps = (value - (field.min ?? 0)) / field.step;
|
||||
if (Math.abs(steps - Math.round(steps)) > 1e-8) return false;
|
||||
}
|
||||
}
|
||||
return !field.values?.length || field.values.includes(value as ImageParameterValue);
|
||||
}
|
||||
|
||||
export function imageParameterDefaults(parameters: ImageParameter[] = []) {
|
||||
return Object.fromEntries(parameters.filter((p) => p.enabled !== false && p.default !== undefined).map((p) => [p.name, p.default!])) as Record<string, ImageParameterValue>;
|
||||
}
|
||||
|
||||
export function parseImageParameters(value: unknown): Record<string, ImageParameterValue> {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v))));
|
||||
}
|
||||
|
||||
export function normalizeImageParameters(parameters: ImageParameter[] = [], input: unknown = {}) {
|
||||
const values = parseImageParameters(input);
|
||||
const result = imageParameterDefaults(parameters);
|
||||
for (const field of parameters) {
|
||||
if (field.enabled !== false && imageParameterAccepts(field, values[field.name])) result[field.name] = values[field.name];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Only transport encoding knows these host controls. All other names are
|
||||
// plugin-defined and travel as typed values without a frontend whitelist.
|
||||
export const imageHostControls = new Set(["size", "imageResolution", "quality", "transparentBackground", "count"]);
|
||||
|
||||
export function imageParameterRequest(profile: ImageCapabilityConfig, config: { imageParameters?: string; size?: string; imageResolution?: string; quality?: string; count?: string; transparentBackground?: string }, strict = false) {
|
||||
if (!profile.parameters) return undefined;
|
||||
if (strict && config.imageParameters) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(config.imageParameters);
|
||||
} catch {
|
||||
throw new Error("图片扩展参数格式无效,请重新设置");
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("图片扩展参数必须是对象");
|
||||
for (const field of profile.parameters) {
|
||||
if (field.enabled !== false && !imageHostControls.has(field.name) && Object.hasOwn(parsed, field.name) && !imageParameterAccepts(field, (parsed as Record<string, unknown>)[field.name])) throw new Error(`${field.label}超出模型支持范围`);
|
||||
}
|
||||
}
|
||||
const values = parseImageParameters(config.imageParameters);
|
||||
for (const field of profile.parameters) {
|
||||
if (!imageHostControls.has(field.name)) continue;
|
||||
const raw = config[field.name as keyof typeof config];
|
||||
if (raw === undefined || raw === "" || ((field.name === "quality" || field.name === "imageResolution") && raw === "auto" && !field.values?.includes("auto"))) continue;
|
||||
values[field.name] = field.type === "boolean" ? raw === "true" : field.type === "number" || field.type === "integer" ? Number(raw) : raw;
|
||||
}
|
||||
const size = profile.parameters.find((p) => p.name === "size" && p.enabled !== false);
|
||||
if (size && values.size !== undefined && !imageParameterAccepts(size, values.size)) {
|
||||
const preset = profile.size.presets?.find((p) => p.size === values.size && (!values.imageResolution || p.tier === values.imageResolution));
|
||||
if (preset && imageParameterAccepts(size, preset.ratio)) values.size = preset.ratio;
|
||||
}
|
||||
if (strict) {
|
||||
for (const field of profile.parameters) {
|
||||
if (field.enabled === false) continue;
|
||||
const value = values[field.name] ?? field.default;
|
||||
if ((value !== undefined && !imageParameterAccepts(field, value)) || (field.required && (value === undefined || value === ""))) throw new Error(`${field.label}超出模型支持范围或尚未填写`);
|
||||
}
|
||||
}
|
||||
return normalizeImageParameters(profile.parameters, values);
|
||||
}
|
||||
|
||||
export function projectImageParameters(profile: ImageCapabilityConfig, parameters: ImageParameter[]): ImageCapabilityConfig {
|
||||
const active = new Map(parameters.filter((p) => p.enabled !== false).map((p) => [p.name, p]));
|
||||
const stringOption = (name: string) => {
|
||||
const p = active.get(name);
|
||||
return { supported: Boolean(p), values: (p?.values || []).map(String), default: String(p?.default ?? p?.values?.[0] ?? "auto") };
|
||||
};
|
||||
const size = active.get("size");
|
||||
return {
|
||||
...profile,
|
||||
parameters,
|
||||
resolutionUsesQuality: false,
|
||||
quality: stringOption("quality"),
|
||||
resolution: stringOption("imageResolution"),
|
||||
transparentBackground: { supported: active.has("transparentBackground"), default: active.get("transparentBackground")?.default === true },
|
||||
maxOutputs: active.get("count")?.max ?? 1,
|
||||
size: size
|
||||
? {
|
||||
...profile.size,
|
||||
parameter: size.encoding ? (size.encoding === "pixels" && !size.values?.some((v) => String(v).includes(":")) ? "size" : "aspect_ratio") : profile.size.parameter === "none" ? "aspect_ratio" : profile.size.parameter,
|
||||
values: (size.values || []).map(String),
|
||||
default: String(size.default ?? size.values?.[0] ?? "auto"),
|
||||
allowCustom: !size.values?.length,
|
||||
presets: profile.size.presets?.filter(
|
||||
(p) =>
|
||||
(!size.values?.length || size.values.includes(p.ratio) || size.values.includes(p.size)) &&
|
||||
(!active.has("imageResolution") || !active.get("imageResolution")?.values?.length || active.get("imageResolution")!.values!.includes(p.tier)),
|
||||
),
|
||||
}
|
||||
: { parameter: "none", values: [], default: "auto", allowCustom: false },
|
||||
};
|
||||
}
|
||||
|
||||
export function updateImageParameterSize(profile: ImageCapabilityConfig, size: ImageCapabilityConfig["size"]): ImageCapabilityConfig {
|
||||
const parameters = (profile.parameters || []).map((field) => {
|
||||
if (field.name === "size") return { ...field, values: size.values, default: size.default };
|
||||
return field;
|
||||
});
|
||||
return projectImageParameters({ ...profile, size }, parameters);
|
||||
}
|
||||
|
||||
export function updateImageParameterSchema(profile: ImageCapabilityConfig, parameters: ImageParameter[], definitions: ImageParameter[] = parameters): ImageCapabilityConfig {
|
||||
const resolution = parameters.find((field) => field.name === "imageResolution");
|
||||
const size = profile.resolution.supported && resolution?.enabled === false ? { ...profile.size, defaultTier: profile.resolution.default } : profile.size;
|
||||
const next = projectImageParameters({ ...profile, size }, parameters);
|
||||
const previous = profile.parameters?.find((field) => field.name === "imageResolution")?.values || [];
|
||||
const added = next.resolution.supported ? next.resolution.values.filter((tier) => !previous.includes(tier)) : [];
|
||||
return initializeImageRatioDefaults(next, definitions, added);
|
||||
}
|
||||
|
||||
export function initializeImageRatioDefaults(profile: ImageCapabilityConfig, definitions: ImageParameter[], tiers?: string[]): ImageCapabilityConfig {
|
||||
const field = profile.parameters?.find((p) => p.name === "size");
|
||||
const definition = definitions.find((p) => p.name === "size");
|
||||
if (!field || field.enabled === false || !definition) return profile;
|
||||
const ratios = DEFAULT_IMAGE_RATIOS.filter((ratio) => imageParameterAccepts(definition, ratio));
|
||||
if (!ratios.length) return profile;
|
||||
if (!profile.resolution.supported && field.encoding !== "pixels" && tiers === undefined) {
|
||||
return projectImageParameters(
|
||||
profile,
|
||||
profile.parameters!.map((p) => (p.name === "size" ? { ...p, values: ratios, default: ratios.includes(String(p.default)) ? p.default : ratios[0] } : p)),
|
||||
);
|
||||
}
|
||||
const existing = profile.size.presets || [];
|
||||
const added = (tiers ?? profile.resolution.values)
|
||||
.filter((tier) => !existing.some((p) => p.tier === tier))
|
||||
.flatMap((tier) =>
|
||||
ratios.flatMap((ratio) => {
|
||||
try {
|
||||
return [imagePresetForRatio(tier, ratio)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!added.length) return profile;
|
||||
return updateImageParameterSize(profile, imageSizeConfigWithPresets(profile, [...existing, ...added]));
|
||||
}
|
||||
|
||||
export function resolveImageParameters(profile: ImageCapabilityConfig, definitions: ImageParameter[], model: string) {
|
||||
const applicable = definitions.filter((p) => !p.models?.length || p.models.includes(model.trim().replace(/^models\//, "")));
|
||||
const configured = new Map(profile.parameters?.map((p) => [p.name, p]));
|
||||
const hasQuality = applicable.some((p) => p.name === "quality");
|
||||
const parameters = applicable.map((definition) => {
|
||||
const current = configured.get(definition.name);
|
||||
if (current) return { ...definition, enabled: current.enabled, values: current.values ?? definition.values, min: current.min ?? definition.min, max: current.max ?? definition.max, step: current.step ?? definition.step, default: current.default };
|
||||
if (profile.parameters) return definition;
|
||||
let enabled = definition.enabled !== false;
|
||||
let values: string[] | undefined;
|
||||
let initial: ImageParameterValue | undefined = definition.default;
|
||||
switch (definition.name) {
|
||||
case "size":
|
||||
enabled = profile.size.parameter !== "none";
|
||||
values = profile.size.values;
|
||||
initial = profile.size.default;
|
||||
break;
|
||||
case "quality":
|
||||
enabled = profile.quality.supported;
|
||||
values = profile.quality.values;
|
||||
initial = profile.quality.default;
|
||||
break;
|
||||
case "imageResolution":
|
||||
enabled = profile.resolution.supported;
|
||||
values = profile.resolution.values;
|
||||
initial = profile.resolution.default;
|
||||
if (!enabled && !hasQuality && profile.quality.supported && profile.quality.values.length && profile.quality.values.every((v) => imageParameterAccepts(definition, v))) {
|
||||
enabled = true;
|
||||
values = profile.quality.values;
|
||||
initial = profile.quality.default;
|
||||
}
|
||||
break;
|
||||
case "transparentBackground":
|
||||
enabled = profile.transparentBackground.supported;
|
||||
initial = profile.transparentBackground.default;
|
||||
break;
|
||||
case "count":
|
||||
return { ...definition, max: Math.min(profile.maxOutputs, definition.max ?? profile.maxOutputs) };
|
||||
}
|
||||
const selected = values?.length ? values.filter((v) => imageParameterAccepts(definition, v)) : definition.values;
|
||||
if (values?.length && !selected?.length) enabled = false;
|
||||
const field = { ...definition, enabled, values: selected };
|
||||
return { ...field, default: imageParameterAccepts(field, initial) ? initial : (selected?.[0] ?? definition.default) };
|
||||
});
|
||||
return projectImageParameters(profile, parameters);
|
||||
}
|
||||
|
||||
export function imageParameterConstraints(parameters: ImageParameter[]) {
|
||||
return Object.fromEntries(
|
||||
parameters
|
||||
.filter((p) => p.enabled !== false)
|
||||
.map((p): [string, OptionConstraint] => [
|
||||
p.name,
|
||||
p.values?.length ? { values: p.values } : p.type === "boolean" ? { values: [false, true] } : p.type === "string" || p.min === undefined || p.max === undefined ? { values: ["*"] } : { min: p.min, max: p.max, step: p.step },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function imageParametersForSpec(spec: CapabilitySpec): ImageParameter[] {
|
||||
return (spec.imageParameters || []).map((p) => {
|
||||
const constraint = spec.options?.[p.name];
|
||||
if (!constraint) return { ...p, enabled: false };
|
||||
const values = constraint.values?.filter((v): v is ImageParameterValue => v !== "*" && (typeof v === "string" || typeof v === "number" || typeof v === "boolean"));
|
||||
return { ...p, enabled: true, ...(values?.length ? { values } : {}), min: constraint.min ?? p.min, max: constraint.max ?? p.max, step: constraint.step ?? p.step };
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { isImageResolutionTier } from "./image-quality";
|
||||
import type { ImageCapabilityConfig } from "./model-capabilities";
|
||||
|
||||
/** 旧图片价格档把 1K/2K/4K 存在 quality;只在读取价格条件时转换。 */
|
||||
export function normalizeImagePriceSelector(input: Record<string, string>) {
|
||||
export function normalizeImagePriceSelector(input: Record<string, string>, declaredParameters = false) {
|
||||
if (declaredParameters) return { ...input };
|
||||
const selector = { ...input };
|
||||
for (const key of ["quality", "resolution", "size"] as const) {
|
||||
if (selector[key]) selector[key] = selector[key].trim().toLowerCase();
|
||||
@@ -15,7 +16,11 @@ export function normalizeImagePriceSelector(input: Record<string, string>) {
|
||||
return selector;
|
||||
}
|
||||
|
||||
export function imagePriceSelector(quality: string, size: string, profile?: ImageCapabilityConfig): Record<string, string> {
|
||||
export function imagePriceSelector(quality: string, size: string, profile?: ImageCapabilityConfig, imageResolution?: string): Record<string, string> {
|
||||
if (profile?.parameters) {
|
||||
const values: Record<string, string | undefined> = { quality, size, imageResolution };
|
||||
return Object.fromEntries(profile.parameters.filter((p) => p.enabled !== false && p.pricing && values[p.name]).map((p) => [p.name, values[p.name]!])) as Record<string, string>;
|
||||
}
|
||||
quality = quality.trim().toLowerCase();
|
||||
size = size.trim().toLowerCase();
|
||||
const selector: Record<string, string> = {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ImageResolutionTier = "1k" | "1.5k" | "2k" | "4k";
|
||||
export type ImageResolutionTier = string;
|
||||
|
||||
export type ImageResolutionOption = {
|
||||
size: string;
|
||||
|
||||
@@ -4,9 +4,22 @@ import type { ImageCapabilityConfig } from "./model-capabilities";
|
||||
export const IMAGE_RESOLUTIONS: ImageResolutionTier[] = ["1k", "2k", "4k"];
|
||||
// 1.5K 只在模型明确配置此档位时出现,既有模型的档位保持原有顺序。
|
||||
export function imageResolutionTiers(profile: ImageCapabilityConfig): ImageResolutionTier[] {
|
||||
if (profile.parameters) return profile.resolution.supported ? profile.resolution.values : [];
|
||||
return profile.size.presets?.some((preset) => preset.tier === "1.5k") || profile.quality.values.includes("1.5k") ? ["1k", "1.5k", "2k", "4k"] : IMAGE_RESOLUTIONS;
|
||||
}
|
||||
export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9"];
|
||||
// Administrator's initial selection; plugin constraints still bound these choices.
|
||||
export const DEFAULT_IMAGE_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"];
|
||||
|
||||
export function imageSingleSizeTier(profile: ImageCapabilityConfig): string | undefined {
|
||||
if (profile.size.defaultTier) return profile.size.defaultTier;
|
||||
const resolution = profile.parameters?.find((p) => p.name === "imageResolution");
|
||||
if (resolution?.default !== undefined && resolution.default !== "auto") return String(resolution.default);
|
||||
const preset = profile.size.presets?.find((p) => p.size === profile.size.default);
|
||||
if (preset) return preset.tier;
|
||||
const tiers = [...new Set(profile.size.presets?.map((p) => p.tier))];
|
||||
return tiers.length === 1 ? tiers[0] : undefined;
|
||||
}
|
||||
const standardSizes: Record<string, string[]> = {
|
||||
"1:1": ["1024x1024", "2048x2048", "2880x2880"],
|
||||
"16:9": ["1824x1024", "2752x1536", "3840x2160"],
|
||||
@@ -56,15 +69,23 @@ export function imagePresetForRatio(tier: ImageResolutionTier, input: string): I
|
||||
}
|
||||
|
||||
export function imageQualityForTier(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
|
||||
const aliases = { "1k": ["1k", "low"], "1.5k": ["1.5k"], "2k": ["2k", "medium"], "4k": ["4k", "high"] }[tier];
|
||||
return profile.quality.supported ? profile.quality.values.find((value) => aliases.includes(value.toLowerCase())) : undefined;
|
||||
if (profile.parameters) return undefined;
|
||||
const aliases: Record<string, string[]> = { "1k": ["1k", "low"], "1.5k": ["1.5k"], "2k": ["2k", "medium"], "4k": ["4k", "high"] };
|
||||
return profile.quality.supported ? profile.quality.values.find((value) => aliases[tier]?.includes(value.toLowerCase())) : undefined;
|
||||
}
|
||||
|
||||
export function imageResolutionUsesQuality(profile: ImageCapabilityConfig) {
|
||||
return profile.size.parameter === "aspect_ratio" && imageResolutionTiers(profile).some((tier) => imageQualityForTier(profile, tier));
|
||||
if (profile.parameters) return false;
|
||||
return (
|
||||
!profile.resolution.supported &&
|
||||
profile.size.parameter === "aspect_ratio" &&
|
||||
profile.quality.supported &&
|
||||
Boolean(profile.resolutionUsesQuality || profile.quality.values.some((value) => IMAGE_RESOLUTIONS.includes(value.toLowerCase() as ImageResolutionTier) || value.toLowerCase() === "1.5k"))
|
||||
);
|
||||
}
|
||||
|
||||
export function imageTierAvailable(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
|
||||
if (profile.parameters) return profile.resolution.supported ? profile.resolution.values.includes(tier) : Boolean(profile.size.presets?.some((preset) => preset.tier === tier));
|
||||
// 质量参数能映射档位时按上游质量裁剪;管理员尺寸预设存在时再取交集,避免默认 low/medium/high 把未启用的 1K 显示出来。
|
||||
if (profile.size.parameter !== "aspect_ratio") return profile.size.parameter !== "none";
|
||||
const configuredTiers = new Set(profile.size.presets?.map((preset) => preset.tier));
|
||||
@@ -79,12 +100,19 @@ export function imageTierAvailable(profile: ImageCapabilityConfig, tier: ImageRe
|
||||
}
|
||||
|
||||
export function imageQualityForSelection(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
|
||||
return imageQualityForTier(profile, tier) || (!imageResolutionUsesQuality(profile) && imageTierAvailable(profile, tier) ? tier : undefined);
|
||||
if (profile.parameters) return undefined;
|
||||
if (imageResolutionUsesQuality(profile)) return imageQualityForTier(profile, tier);
|
||||
return !profile.resolution.supported && !profile.quality.supported && imageTierAvailable(profile, tier) ? tier : undefined;
|
||||
}
|
||||
|
||||
export function imageSizePresets(profile: ImageCapabilityConfig): ImageResolutionOption[] {
|
||||
if (profile.size.parameter === "none") return [];
|
||||
if (profile.parameters && !profile.resolution.supported) {
|
||||
const tier = imageSingleSizeTier(profile);
|
||||
return (profile.size.presets || []).filter((preset) => preset.tier === (tier ?? ""));
|
||||
}
|
||||
if (profile.size.presets) return profile.size.presets;
|
||||
if (profile.parameters) return [];
|
||||
const pixels = buildImageResolutionOptions(profile.size.values);
|
||||
const tiers = profile.size.parameter === "aspect_ratio" ? imageResolutionTiers(profile).filter((tier) => imageQualityForTier(profile, tier)) : [];
|
||||
const ratios = tiers.flatMap((tier) =>
|
||||
@@ -106,7 +134,7 @@ export function imagePresetValue(profile: ImageCapabilityConfig, preset: ImageRe
|
||||
}
|
||||
|
||||
export function imageSizeConfigWithPresets(profile: ImageCapabilityConfig, presets: ImageResolutionOption[]): ImageCapabilityConfig["size"] {
|
||||
const values = [...new Set(presets.map((preset) => (profile.size.parameter === "aspect_ratio" ? preset.ratio : preset.size)))];
|
||||
const values = [...new Set(presets.map((preset) => (profile.size.parameter === "aspect_ratio" ? preset.ratio : preset.size || preset.ratio)))];
|
||||
if (profile.size.values.includes("auto")) values.unshift("auto");
|
||||
if (!values.length) values.push("auto");
|
||||
return { ...profile.size, presets, values, default: values.includes(profile.size.default) ? profile.size.default : values[0] || "auto" };
|
||||
|
||||
@@ -174,6 +174,12 @@ export function workflowFieldSubmissionChoices(field: WorkflowVideoFieldLike) {
|
||||
return workflowFieldChoiceValues(field);
|
||||
}
|
||||
|
||||
export function workflowFieldIsRatioChoices(field: WorkflowVideoFieldLike, options = workflowFieldChoiceValues(field)) {
|
||||
if (!workflowVideoFieldMatches(field, "aspectRatio")) return false;
|
||||
const values = options.map(workflowFieldOptionValue).filter(Boolean);
|
||||
return values.length > 1 && values.every((value) => value.toLowerCase() === "auto" || Boolean(workflowRatioPrefix(value)));
|
||||
}
|
||||
|
||||
export function workflowRatioPrefix(value: string) {
|
||||
const match = value.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)(?:\s|\(|$)/);
|
||||
return match ? `${match[1]}:${match[2]}` : "";
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
|
||||
import { projectImageParameters, normalizeImageParameters, imageParameterRequest } from "./image-parameters";
|
||||
import type { ImageResolutionOption, ImageResolutionTier } from "@/lib/image-resolution-tiers";
|
||||
import { imagePresetForRatio } from "./image-size-presets";
|
||||
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio } from "@/lib/image-size-presets";
|
||||
import { kemeiSeedreamPresets } from "./kemei-image-presets";
|
||||
import { type WorkflowVideoFieldLike, workflowImageCapabilityConfig, workflowVideoCapabilityConfig } from "./model-capabilities-workflow";
|
||||
import { resolveWorkflowVideoScreenSpec } from "./video-screen-specs";
|
||||
|
||||
export { workflowFieldChoiceValues, workflowFieldConfigurationError, workflowFieldCurrentValue, workflowFieldHasStoredValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldPresetOptions, workflowFieldRandomKey, workflowFieldRole, workflowFieldSafeToOverride, workflowFieldSource, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoDefaultSize, workflowVideoFieldsFromJson, type WorkflowFieldNumberBounds, type WorkflowVideoFieldLike } from "./model-capabilities-workflow";
|
||||
export {
|
||||
workflowFieldChoiceValues,
|
||||
workflowFieldConfigurationError,
|
||||
workflowFieldCurrentValue,
|
||||
workflowFieldHasStoredValue,
|
||||
workflowFieldIsRatioChoices,
|
||||
workflowFieldKey,
|
||||
workflowFieldNumberBounds,
|
||||
workflowFieldOptionValue,
|
||||
workflowFieldPresetOptions,
|
||||
workflowFieldRandomKey,
|
||||
workflowFieldRole,
|
||||
workflowFieldSafeToOverride,
|
||||
workflowFieldSource,
|
||||
workflowFieldSubmissionValue,
|
||||
workflowFieldValueError,
|
||||
workflowImageCapabilityConfig,
|
||||
workflowOutputSizeValue,
|
||||
workflowParameterFields,
|
||||
workflowVideoCapabilityConfig,
|
||||
workflowVideoDefaultSize,
|
||||
workflowVideoFieldsFromJson,
|
||||
type WorkflowFieldNumberBounds,
|
||||
type WorkflowVideoFieldLike,
|
||||
} from "./model-capabilities-workflow";
|
||||
|
||||
export type ModelCapabilityConfig = {
|
||||
version: number;
|
||||
@@ -44,6 +69,7 @@ export type ImageSizeParameter = "none" | "size" | "aspect_ratio";
|
||||
export const DEFAULT_VIDEO_PROMPT_MAX_CHARS = 8000;
|
||||
|
||||
export type ImageCapabilityConfig = {
|
||||
parameters?: import("./image-parameters").ImageParameter[];
|
||||
references: {
|
||||
promptMaxChars: number;
|
||||
maxImages: number;
|
||||
@@ -56,12 +82,16 @@ export type ImageCapabilityConfig = {
|
||||
default: string;
|
||||
allowCustom: boolean;
|
||||
presets?: ImageResolutionOption[];
|
||||
defaultTier?: string;
|
||||
};
|
||||
resolution: { supported: boolean; values: string[]; default: string };
|
||||
quality: {
|
||||
supported: boolean;
|
||||
values: string[];
|
||||
default: string;
|
||||
};
|
||||
/** Protocol-declared mapping where quality values select image resolution. */
|
||||
resolutionUsesQuality?: boolean;
|
||||
transparentBackground: { supported: boolean; default: boolean };
|
||||
responseFormat: { supported: boolean };
|
||||
outputFormat: { supported: boolean };
|
||||
@@ -104,19 +134,7 @@ export type VideoCapabilityConfig = {
|
||||
export type VideoScreenSpecConfig = Pick<VideoCapabilityConfig, "ratios" | "defaultRatio" | "resolutions" | "defaultResolution">;
|
||||
|
||||
// 旧版本的“允许自定义”可能只保存了 `*`,前台需要用这组标准值恢复可选项。
|
||||
export const STANDARD_IMAGE_SIZE_VALUES = [
|
||||
"1:1",
|
||||
"3:2",
|
||||
"2:3",
|
||||
"4:3",
|
||||
"3:4",
|
||||
"16:9",
|
||||
"21:9",
|
||||
"9:16",
|
||||
"1024x1024",
|
||||
"1536x1024",
|
||||
"1024x1536",
|
||||
] as const;
|
||||
export const STANDARD_IMAGE_SIZE_VALUES = ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", "1024x1024", "1536x1024", "1024x1536"] as const;
|
||||
|
||||
export function normalizeCapabilityString(value: string) {
|
||||
const normalized = value.trim();
|
||||
@@ -167,6 +185,20 @@ export function normalizeModelCapabilityConfig(config: ModelCapabilityConfig): M
|
||||
};
|
||||
}
|
||||
|
||||
// Only these shipped protocol contracts prove that legacy quality values were resolution tiers.
|
||||
// This is a read projection; persisted capability JSON is not migrated.
|
||||
export function normalizeLegacyImageResolutionConfig(protocol: ModelProtocol | undefined, image: ImageCapabilityConfig): ImageCapabilityConfig {
|
||||
if (image.parameters) return projectImageParameters(image, image.parameters);
|
||||
image = { ...image, resolutionUsesQuality: protocol === "gemini-image" };
|
||||
if (image.resolution.supported || !image.quality.supported) return image;
|
||||
const allowed = protocol === "grok-image" ? new Set(["1k", "2k"]) : protocol === "agnes-image" ? new Set(["1k", "2k", "4k"]) : undefined;
|
||||
if (!allowed) return image;
|
||||
const values = image.quality.values.map((value) => value.trim().toLowerCase());
|
||||
const defaultValue = image.quality.default.trim().toLowerCase();
|
||||
if (!values.length || values.some((value) => !allowed.has(value)) || !allowed.has(defaultValue) || !values.includes(defaultValue)) return image;
|
||||
return { ...image, resolution: { supported: true, values, default: defaultValue }, quality: { supported: false, values: [], default: "auto" } };
|
||||
}
|
||||
|
||||
// Keep explicit pixel presets for each resolution tier so the settings panel can
|
||||
// switch between 1K, 2K and 4K without silently converting the requested ratio.
|
||||
const defaultImageSizes = [
|
||||
@@ -215,6 +247,7 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
const image: ImageCapabilityConfig = {
|
||||
references: { promptMaxChars: 32000, maxImages: 16, maxImageBytes: 30 * 1024 * 1024, maskSupported: true },
|
||||
size: { parameter: "size", values: [...defaultImageSizes], default: "1:1", allowCustom: true },
|
||||
resolution: { supported: false, values: [], default: "auto" },
|
||||
quality: { supported: true, values: ["auto", "low", "medium", "high"], default: "auto" },
|
||||
transparentBackground: { supported: true, default: false },
|
||||
responseFormat: { supported: true },
|
||||
@@ -251,10 +284,15 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
}
|
||||
if (protocol === "cangyuan-midjourney-v7" || protocol === "cangyuan-midjourney-v82") {
|
||||
const v7 = protocol === "cangyuan-midjourney-v7";
|
||||
const tier = !v7 && model.trim().toLowerCase().replace(/^models\//, "") === "midjourney-2k" ? "2k" : "1k";
|
||||
const ratios = v7
|
||||
? ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "21:9"]
|
||||
: ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "1:2", "6:11", "5:6", "2:1", "11:6", "6:5"];
|
||||
const tier =
|
||||
!v7 &&
|
||||
model
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^models\//, "") === "midjourney-2k"
|
||||
? "2k"
|
||||
: "1k";
|
||||
const ratios = v7 ? ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "21:9"] : ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "1:2", "6:11", "5:6", "2:1", "11:6", "6:5"];
|
||||
image.references = { ...image.references, promptMaxChars: v7 ? 4000 : 32000, maxImages: v7 ? 5 : 1, maskSupported: !v7 };
|
||||
image.size = { parameter: "aspect_ratio", values: ["auto", ...ratios], default: "auto", allowCustom: false, presets: ratios.map((ratio) => imagePresetForRatio(tier, ratio)) };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
@@ -275,7 +313,8 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
default: "1:1",
|
||||
allowCustom: false,
|
||||
};
|
||||
image.quality = { supported: true, values: ["1k", "2k"], default: "2k" };
|
||||
image.resolution = { supported: true, values: ["1k", "2k"], default: "2k" };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
image.responseFormat = { supported: true };
|
||||
image.outputFormat = { supported: false };
|
||||
@@ -287,6 +326,11 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
image.responseFormat.supported = false;
|
||||
image.outputFormat.supported = false;
|
||||
}
|
||||
if (protocol === "openai-image") {
|
||||
// OpenAI Images 只收 WIDTHxHEIGHT:默认配置声明了 1K/2K/4K,就必须自带档位换算表,
|
||||
// 否则运行时换算会被静默跳过,实际出图档位与所选档位不一致。取值来自本地唯一那张比例表。
|
||||
image.size.presets = IMAGE_RESOLUTIONS.flatMap((tier) => IMAGE_RATIOS.map((ratio) => imagePresetForRatio(tier, ratio)));
|
||||
}
|
||||
if (protocol === "volcengine-jimeng-image") {
|
||||
image.references.maxImages = 14;
|
||||
image.references.maskSupported = false;
|
||||
@@ -308,6 +352,7 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
image.responseFormat = { supported: false };
|
||||
image.outputFormat = { supported: false };
|
||||
image.maxOutputs = 4;
|
||||
image.resolutionUsesQuality = true;
|
||||
}
|
||||
if (protocol === "agnes-image") {
|
||||
// Agnes 图像:size 必填,取 1K/2K/3K/4K 档位或 WxH 精确尺寸,画面比例走独立的 ratio 字段;
|
||||
@@ -321,7 +366,8 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
allowCustom: false,
|
||||
};
|
||||
// 官方档位是 1K/2K/3K/4K,统一层只提供 1k/2k/4k 三档;3K 保留在协议映射里但不在界面露出。
|
||||
image.quality = { supported: true, values: ["1k", "2k", "4k"], default: "2k" };
|
||||
image.resolution = { supported: true, values: ["1k", "2k", "4k"], default: "2k" };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
// 顶层 response_format 是官方明确的错误写法,输出格式只能在 extra_body 内声明。
|
||||
image.responseFormat = { supported: false };
|
||||
@@ -338,7 +384,8 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
default: "1:1",
|
||||
allowCustom: false,
|
||||
};
|
||||
image.quality = { supported: true, values: ["1k", "2k"], default: "2k" };
|
||||
image.resolution = { supported: true, values: ["1k", "2k"], default: "2k" };
|
||||
image.quality = { supported: false, values: [], default: "auto" };
|
||||
image.transparentBackground = { supported: false, default: false };
|
||||
image.responseFormat = { supported: true };
|
||||
image.outputFormat = { supported: false };
|
||||
@@ -467,7 +514,7 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
|
||||
const text = capabilityConfig.text ? { ...fallback.text!, ...capabilityConfig.text, references: { ...fallback.text!.references, ...capabilityConfig.text.references } } : fallback.text;
|
||||
const video = capabilityConfig.video ? { ...fallback.video!, ...capabilityConfig.video, references: { ...fallback.video!.references, ...capabilityConfig.video.references } } : fallback.video;
|
||||
const configuredImage = capabilityConfig.image;
|
||||
const image = configuredImage
|
||||
const configuredImageProfile = configuredImage
|
||||
? (() => {
|
||||
const configuredSize = configuredImage.size;
|
||||
const configuredValues = configuredSize?.values?.map(normalizeCapabilityString);
|
||||
@@ -486,33 +533,69 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
|
||||
default: defaultValue,
|
||||
allowCustom,
|
||||
},
|
||||
resolution: {
|
||||
...fallback.image!.resolution,
|
||||
...configuredImage.resolution,
|
||||
values: normalizeCapabilityStrings(configuredImage.resolution?.values || []),
|
||||
default: normalizeCapabilityString(configuredImage.resolution?.default || fallback.image!.resolution.default),
|
||||
},
|
||||
};
|
||||
})()
|
||||
: fallback.image;
|
||||
const image = configuredImageProfile ? normalizeLegacyImageResolutionConfig(cost?.protocol, configuredImageProfile) : fallback.image;
|
||||
return { ...fallback, ...capabilityConfig, text, image, video };
|
||||
}
|
||||
|
||||
// 工作流字段是供应商参数的唯一事实来源;不能用普通视频模型的固定清晰度列表覆盖它。
|
||||
|
||||
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
|
||||
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; imageResolution?: string; imageParameters?: string; count?: string; transparentBackground?: string }, protocol?: ModelProtocol) {
|
||||
if (profile.parameters) {
|
||||
const values = imageParameterRequest(profile, value) || {};
|
||||
return {
|
||||
size: String(values.size ?? ""),
|
||||
quality: String(values.quality ?? ""),
|
||||
imageResolution: String(values.imageResolution ?? ""),
|
||||
count: String(values.count ?? 1),
|
||||
transparentBackground: String(values.transparentBackground ?? false),
|
||||
imageParameters: JSON.stringify(normalizeImageParameters(profile.parameters, value.imageParameters)),
|
||||
};
|
||||
}
|
||||
const size = normalizeImageSizeSetting(profile, value.size);
|
||||
const requestedQuality = String(value.quality || "").trim().toLowerCase();
|
||||
const requestedQuality = String(value.quality || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
// 比例协议的固定分辨率预设没有独立 quality 字段时,UI 仍需把当前比例对应的
|
||||
// 预设档位带入请求。仅在 quality 未声明支持时启用,避免与 auto/low/medium/high
|
||||
// 这组真实图片质量语义混用。
|
||||
const presetTier = !profile.quality.supported ? imagePresetTierForSelection(profile, size) : undefined;
|
||||
const presetTier = !profile.quality.supported && !profile.resolution.supported ? imagePresetTierForSelection(profile, size) : undefined;
|
||||
const quality = profile.quality.supported
|
||||
? requestedQuality === "auto" || requestedQuality === "any"
|
||||
? "auto"
|
||||
: value.quality && profile.quality.values.includes(value.quality)
|
||||
? value.quality
|
||||
: profile.quality.default || "auto"
|
||||
: requestedQuality === "1k" || requestedQuality === "1.5k" || requestedQuality === "2k" || requestedQuality === "4k"
|
||||
? value.quality
|
||||
: profile.quality.default || "auto"
|
||||
: profile.resolution.supported
|
||||
? profile.quality.default || "auto"
|
||||
: requestedQuality === "1k" || requestedQuality === "1.5k" || requestedQuality === "2k" || requestedQuality === "4k"
|
||||
? requestedQuality
|
||||
: presetTier || profile.quality.default || "auto";
|
||||
const count = String(Math.max(1, Math.min(profile.maxOutputs, Math.floor(Math.abs(Number(value.count)) || 1))));
|
||||
const transparentBackground = profile.transparentBackground.supported && value.transparentBackground === "true" ? "true" : "false";
|
||||
return { size, quality, count, transparentBackground };
|
||||
const requestedResolution = String(value.imageResolution || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const configuredResolution = profile.resolution.values.find((item) => item.trim().toLowerCase() === requestedResolution);
|
||||
const legacyResolutionProtocols = protocol === "grok-image" || protocol === "agnes-image";
|
||||
const legacyResolution = legacyResolutionProtocols && !profile.quality.supported ? profile.resolution.values.find((item) => item.trim().toLowerCase() === requestedQuality) : undefined;
|
||||
const imageResolution = profile.resolution.supported ? configuredResolution || legacyResolution || profile.resolution.default || "auto" : "auto";
|
||||
return {
|
||||
size,
|
||||
quality: profile.parameters && !profile.quality.supported ? "auto" : quality,
|
||||
...(profile.resolution.supported ? { imageResolution } : {}),
|
||||
count,
|
||||
transparentBackground,
|
||||
...(profile.parameters ? { imageParameters: JSON.stringify(normalizeImageParameters(profile.parameters, value.imageParameters)) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function imagePresetTierForSelection(profile: ImageCapabilityConfig, size: string): ImageResolutionTier | undefined {
|
||||
|
||||
@@ -39,7 +39,7 @@ export function priceTiersForCurrentSelection(tiers: ModelPriceTier[], capabilit
|
||||
let bestScore = -1;
|
||||
let matched: ModelPriceTier[] = [];
|
||||
for (const tier of tiers) {
|
||||
const selector = priceSelectorForTier(tier);
|
||||
const selector = priceSelectorForTier(tier, Boolean(modelCapabilityConfigFor(config, config.model || config.imageModel).image?.parameters));
|
||||
const conditions = Object.entries(selector).filter(([, value]) => value && value !== "*");
|
||||
if (conditions.some(([key, value]) => requested[key] !== value)) continue;
|
||||
const score = conditions.length;
|
||||
@@ -139,7 +139,14 @@ function priceSelectorForRequest(capability: ModelCapability | undefined, config
|
||||
}
|
||||
if (capability === "image") {
|
||||
requested.operation = imagePriceOperation(requirements);
|
||||
const options = { ...modelRequestOptions(config, "image"), ...requirements?.options, ...(requirements?.imageSize ? { size: requirements.imageSize } : {}) };
|
||||
const options: Record<string, unknown> = { ...modelRequestOptions(config, "image"), ...requirements?.options, ...(requirements?.imageSize ? { size: requirements.imageSize } : {}) };
|
||||
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel)?.image;
|
||||
if (profile?.parameters) {
|
||||
for (const field of profile.parameters) {
|
||||
if (field.enabled !== false && field.pricing && options[field.name] !== undefined) requested[field.name] = String(options[field.name]);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
const imageQuality = String(options.quality ?? "").trim().toLowerCase();
|
||||
const imageSize = String(options.size ?? "").trim();
|
||||
Object.assign(requested, imagePriceSelector(imageQuality, imageSize, modelCapabilityConfigFor(config, config.model || config.imageModel)?.image));
|
||||
@@ -152,8 +159,8 @@ function imagePriceOperation(requirements?: ModelRequirements) {
|
||||
return (input?.imageCount || 0) + (input?.characterCount || 0) > 0 ? "image_to_image" : "text_to_image";
|
||||
}
|
||||
|
||||
function priceSelectorForTier(tier: ModelPriceTier) {
|
||||
const selector = normalizeImagePriceSelector({ ...(tier.selector || {}) });
|
||||
function priceSelectorForTier(tier: ModelPriceTier, declaredParameters = false) {
|
||||
const selector = normalizeImagePriceSelector({ ...(tier.selector || {}) }, declaredParameters);
|
||||
if (!Object.keys(selector).length) {
|
||||
const resolution = normalizeTierResolution(tier.resolution);
|
||||
if (resolution !== "*") selector.vquality = resolution;
|
||||
|
||||
@@ -2,7 +2,7 @@ export type ModelProtocol = string;
|
||||
export type ProtocolCapability = "text" | "image" | "video" | "audio";
|
||||
export type ModelProtocolParameter = { name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string };
|
||||
export type ModelProtocolWorkflow = { id: string; label: string; providerId: string; capability: ProtocolCapability; parameters: ModelProtocolParameter[]; defaults?: Record<string, string | number | boolean> };
|
||||
export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean; baseUrl?: string; parameters?: ModelProtocolParameter[]; workflows?: ModelProtocolWorkflow[] };
|
||||
export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean; baseUrl?: string; parameters?: ModelProtocolParameter[]; imageParameters?: import("./image-parameters").ImageParameter[]; workflows?: ModelProtocolWorkflow[] };
|
||||
|
||||
export function protocolGroups(protocols: ModelProtocolDefinition[]) {
|
||||
return (["text", "image", "video", "audio"] as ProtocolCapability[]).map((capability) => ({ label: { text: "文本", image: "图片", video: "视频", audio: "音频" }[capability], options: protocols.filter((item) => item.capability === capability && item.enabled !== false).map((item) => ({ label: `${item.label} · ${item.create.replace(/^POST /, "")}`, value: item.value })) }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
||||
import { imageParameterRequest, imageParameterDefaults, imageParameterAccepts } from "./image-parameters";
|
||||
import { imageSizePresets } from "@/lib/image-size-presets";
|
||||
import { modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
@@ -181,10 +182,18 @@ export function modelPromptLengthError(config: AiConfig, model: string, capabili
|
||||
return `当前${label}模型提示词最多 ${maxChars} 个字符,完整提示词为 ${actualChars} 个字符。系统不会自动截断,请精简当前输入、连线内容或技能上下文后重试`;
|
||||
}
|
||||
|
||||
export function modelRequestOptions(config: AiConfig, capability: ModelCapability) {
|
||||
export function modelRequestOptions(config: AiConfig, capability: ModelCapability): Record<string, unknown> {
|
||||
switch (capability) {
|
||||
case "image":
|
||||
return { size: config.size, quality: config.quality, transparentBackground: config.transparentBackground === "true", count: Number(config.count) };
|
||||
return (
|
||||
imageParameterRequest(modelCapabilityConfigFor(config, config.model || config.imageModel).image!, config) ?? {
|
||||
size: config.size,
|
||||
imageResolution: config.imageResolution,
|
||||
quality: config.quality,
|
||||
transparentBackground: config.transparentBackground === "true",
|
||||
count: Number(config.count),
|
||||
}
|
||||
);
|
||||
case "video":
|
||||
return { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" };
|
||||
case "audio":
|
||||
@@ -219,6 +228,9 @@ function logicalModelCompatibilityError(spec: NonNullable<NonNullable<AiConfig["
|
||||
};
|
||||
for (const [name, value] of Object.entries(options)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
const field = spec.imageParameters?.find((p) => p.name === name);
|
||||
if (field && name !== "size" && !imageParameterAccepts(field, value)) return `不支持当前${field.label}`;
|
||||
if ((name === "quality" || name === "imageResolution") && /^(auto|any)$/i.test(String(value).trim())) continue;
|
||||
const constraint = spec.options?.[name];
|
||||
if (!constraint || !logicalOptionMatches(name, constraint, value)) return logicalOptionError(name);
|
||||
}
|
||||
@@ -227,6 +239,7 @@ function logicalModelCompatibilityError(spec: NonNullable<NonNullable<AiConfig["
|
||||
|
||||
function logicalOptionMatches(name: string, constraint: { values?: unknown[]; min?: number; max?: number; step?: number }, value: unknown) {
|
||||
if (constraint.values?.length) {
|
||||
if (constraint.values.includes("*")) return true;
|
||||
const requested = normalizeLogicalOptionValue(name, value);
|
||||
return constraint.values.some((candidate) => normalizeLogicalOptionValue(name, candidate) === requested);
|
||||
}
|
||||
@@ -301,20 +314,21 @@ export function mergedImageCapabilityConfig(config: AiConfig, selected: string):
|
||||
}
|
||||
|
||||
// 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。
|
||||
export function defaultImageParamsForModel(config: AiConfig, model: string): Pick<AiConfig, "size" | "quality" | "transparentBackground"> {
|
||||
export function defaultImageParamsForModel(config: AiConfig, model: string): Pick<AiConfig, "size" | "imageResolution" | "quality" | "transparentBackground" | "imageParameters"> {
|
||||
const image = modelCapabilityConfigFor(config, model).image;
|
||||
if (!image) return { size: "1:1", quality: "auto", transparentBackground: "false" };
|
||||
if (!image) return { size: "1:1", imageResolution: "auto", quality: "auto", transparentBackground: "false" };
|
||||
const sizeValues = image.size.values.filter((value) => value !== "*");
|
||||
const sizeDefault = image.size.default !== "*" && image.size.default ? image.size.default : sizeValues[0] || "1:1";
|
||||
return {
|
||||
size: sizeDefault,
|
||||
imageParameters: JSON.stringify(imageParameterDefaults(image.parameters)),
|
||||
imageResolution: image.resolution.supported ? image.resolution.default || "auto" : "auto",
|
||||
quality: image.quality.default || "auto",
|
||||
transparentBackground: String(image.transparentBackground.default ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export type ModelGenerationDefaults = Pick<AiConfig, "size" | "quality" | "transparentBackground" | "count" | "videoSeconds" | "vquality" | "videoGenerateAudio" | "videoWatermark">;
|
||||
export type ModelGenerationDefaults = Pick<AiConfig, "size" | "imageResolution" | "quality" | "transparentBackground" | "count" | "imageParameters" | "videoSeconds" | "vquality" | "videoGenerateAudio" | "videoWatermark">;
|
||||
|
||||
type ModelVideoBooleanOptions = Pick<AiConfig, "videoGenerateAudio" | "videoWatermark">;
|
||||
|
||||
@@ -337,12 +351,18 @@ export function resolveModelGenerationDefaults(
|
||||
const profile = modelCapabilityConfigFor(config, model);
|
||||
|
||||
if (capability === "image" && profile.image) {
|
||||
const normalized = normalizeImageValue(profile.image, {
|
||||
size: source("size"),
|
||||
quality: source("quality"),
|
||||
count: source("count"),
|
||||
transparentBackground: source("transparentBackground"),
|
||||
});
|
||||
const normalized = normalizeImageValue(
|
||||
profile.image,
|
||||
{
|
||||
size: source("size"),
|
||||
imageResolution: source("imageResolution"),
|
||||
imageParameters: source("imageParameters"),
|
||||
quality: source("quality"),
|
||||
count: source("count"),
|
||||
transparentBackground: source("transparentBackground"),
|
||||
},
|
||||
cost?.protocol,
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth";
|
||||
import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog } from "@/services/api/logical-models";
|
||||
import { projectImageParameters, imageParametersForSpec } from "./image-parameters";
|
||||
import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog, type PublicLogicalModel } from "@/services/api/logical-models";
|
||||
import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { scopedLocalStorage, setActiveUserScope, withUserScopedPersistenceSuppressed } from "@/lib/user-scope";
|
||||
@@ -258,6 +259,11 @@ export function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<
|
||||
});
|
||||
projected.image.maxOutputs = maxNumericOption(spec.options?.count, 1);
|
||||
projected.image.transparentBackground = booleanOption(spec.options?.transparentBackground, defaults.transparentBackground);
|
||||
if (spec.imageParameters)
|
||||
projected.image = projectImageParameters(
|
||||
projected.image,
|
||||
imageParametersForSpec(spec).map((field) => ({ ...field, default: (defaults[field.name] as import("./image-parameters").ImageParameterValue | undefined) ?? field.default })),
|
||||
);
|
||||
}
|
||||
if (spec.capability === "video" && projected.video) {
|
||||
projected.video.references.minImages = spec.inputs?.image?.min ?? 0;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ModelCapabilityChoice } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { defaultModelCapabilityConfig, normalizeLegacyImageResolutionConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { imagePresetForRatio, imageSizePresets } from "@/lib/image-size-presets";
|
||||
import { modelProtocolSupportsTokenBilling, type ModelProtocolDefinition } from "@/lib/model-protocols";
|
||||
import type { ChannelModel } from "@/services/api/wallet";
|
||||
import type { ModelTag } from "@/lib/model-tags";
|
||||
import { initializeImageRatioDefaults, resolveImageParameters } from "@/lib/image-parameters";
|
||||
import { defaultPriceTier, legacyPriceTierToForm, normalizeUpstreamModelKey, priceTierToForm, type PriceTierFormValues } from "./channel-model-price-tier-form";
|
||||
|
||||
export type ChannelModelFormValues = {
|
||||
@@ -31,6 +32,16 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols:
|
||||
const capability = item?.capability || "text";
|
||||
const protocol = item ? item.protocol : protocols.find((p) => p.capability === capability && p.enabled !== false)?.value;
|
||||
const upstreamModel = item?.providerModelKey || item?.modelKey || "";
|
||||
const rawCapabilityConfig = item?.capabilityConfig || defaultModelCapabilityConfig(protocol, upstreamModel);
|
||||
let capabilityConfig =
|
||||
capability === "audio"
|
||||
? undefined
|
||||
: (() => {
|
||||
const normalized = normalizeModelCapabilityConfig(rawCapabilityConfig);
|
||||
return normalized.image ? { ...normalized, image: normalizeLegacyImageResolutionConfig(protocol, normalized.image) } : normalized;
|
||||
})();
|
||||
const definitions = protocols.find((p) => p.value === protocol)?.imageParameters;
|
||||
if (capabilityConfig?.image && capability === "image" && definitions) capabilityConfig = { ...capabilityConfig, image: resolveImageParameters(capabilityConfig.image, definitions, upstreamModel) };
|
||||
return {
|
||||
modelKey: item?.modelKey || "",
|
||||
providerModelKey: upstreamModel,
|
||||
@@ -41,9 +52,9 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols:
|
||||
icon: item?.icon || "",
|
||||
capability,
|
||||
protocol,
|
||||
priceTiers: item ? (item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)]) : [defaultPriceTier()],
|
||||
priceTiers: item ? (item.priceTiers?.length ? item.priceTiers.map((tier) => priceTierToForm(tier, capabilityConfig?.image, protocol)) : [legacyPriceTierToForm(item)]) : [defaultPriceTier()],
|
||||
enabled: item?.enabled ?? true,
|
||||
capabilityConfig: capability === "audio" ? undefined : normalizeModelCapabilityConfig(item?.capabilityConfig || defaultModelCapabilityConfig(protocol, upstreamModel)),
|
||||
capabilityConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,13 +73,17 @@ export function duplicateChannelModelValues(item: ChannelModel, protocols: Model
|
||||
export function changeChannelModelCapability(values: ChannelModelFormValues, protocols: ModelProtocolDefinition[]): ChannelModelFormValues {
|
||||
const capability = values.capability;
|
||||
const protocol = protocols.find((p) => p.value === values.protocol && p.capability === capability && p.enabled !== false)?.value || protocols.find((p) => p.capability === capability && p.enabled !== false)?.value;
|
||||
return {
|
||||
const capabilityConfig = capability === "audio" ? undefined : defaultModelCapabilityConfig(protocol, values.providerModelKey?.trim() || values.modelKey.trim());
|
||||
if (capabilityConfig?.image && protocols.find((p) => p.value === protocol)?.imageParameters) { capabilityConfig.image.parameters = []; capabilityConfig.image.size.presets = undefined; }
|
||||
return updateChannelModelUpstreamCapabilities({
|
||||
...values,
|
||||
protocol,
|
||||
capabilityConfig: capability === "audio" ? undefined : defaultModelCapabilityConfig(protocol, values.providerModelKey?.trim() || values.modelKey.trim()),
|
||||
capabilityConfig,
|
||||
priceTiers: values.priceTiers.map((tier) => ({
|
||||
...tier,
|
||||
operation: "*",
|
||||
imageSelector: {},
|
||||
imageResolution: "*",
|
||||
quality: "*",
|
||||
size: "*",
|
||||
resolution: "*",
|
||||
@@ -76,10 +91,31 @@ export function changeChannelModelCapability(values: ChannelModelFormValues, pro
|
||||
videoGenerateAudio: "*",
|
||||
imageCount: 0,
|
||||
})),
|
||||
};
|
||||
}, protocols);
|
||||
}
|
||||
|
||||
export function updateChannelModelUpstreamCapabilities(values: ChannelModelFormValues): ChannelModelFormValues {
|
||||
export function changeChannelModelProtocol(values: ChannelModelFormValues, protocol: string, protocols: ModelProtocolDefinition[] = []): ChannelModelFormValues {
|
||||
const capabilityConfig = values.capability === "audio" ? undefined : defaultModelCapabilityConfig(protocol, values.providerModelKey?.trim() || values.modelKey.trim());
|
||||
if (capabilityConfig?.image && protocols.find((p) => p.value === protocol)?.imageParameters) { capabilityConfig.image.parameters = []; capabilityConfig.image.size.presets = undefined; }
|
||||
const next: ChannelModelFormValues = {
|
||||
...values,
|
||||
protocol,
|
||||
capabilityConfig,
|
||||
priceTiers: values.capability === "image" ? values.priceTiers.map((tier) => ({ ...tier, imageResolution: "*", quality: "*", imageSelector: {} })) : values.priceTiers,
|
||||
};
|
||||
return updateChannelModelUpstreamCapabilities(next, protocols);
|
||||
}
|
||||
|
||||
export function updateChannelModelUpstreamCapabilities(values: ChannelModelFormValues, protocols: ModelProtocolDefinition[] = []): ChannelModelFormValues {
|
||||
const definitions = protocols.find((p) => p.value === values.protocol)?.imageParameters;
|
||||
if (values.capability === "image" && definitions) {
|
||||
const profile = values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, values.providerModelKey || values.modelKey);
|
||||
if (profile.image) {
|
||||
const fresh = profile.image.parameters?.length === 0;
|
||||
const resolved = resolveImageParameters(profile.image, definitions, values.providerModelKey?.trim() || values.modelKey.trim());
|
||||
return { ...values, capabilityConfig: { ...profile, image: fresh ? initializeImageRatioDefaults(resolved, resolved.parameters!) : resolved } };
|
||||
}
|
||||
}
|
||||
if (values.capability !== "image" || values.protocol !== "cangyuan-midjourney-v82") return values;
|
||||
const defaults = defaultModelCapabilityConfig(values.protocol, values.providerModelKey?.trim() || values.modelKey.trim());
|
||||
const image = values.capabilityConfig?.image;
|
||||
@@ -104,7 +140,7 @@ export function validateChannelModelProtocol(capability: string, protocol: strin
|
||||
}
|
||||
}
|
||||
|
||||
export function validateChannelModelPrices(values: Pick<ChannelModelFormValues, "capability" | "protocol" | "priceTiers">) {
|
||||
export function validateChannelModelPrices(values: Pick<ChannelModelFormValues, "capability" | "protocol" | "priceTiers" | "capabilityConfig">) {
|
||||
const { capability, protocol, priceTiers } = values;
|
||||
if (!priceTiers?.length) throw new Error("请至少配置一个价格档");
|
||||
if (priceTiers.filter((tier) => tier.matchMode === "default").length > 1) throw new Error("只能配置一个所有规格统一价格");
|
||||
@@ -124,7 +160,13 @@ export function validateChannelModelPrices(values: Pick<ChannelModelFormValues,
|
||||
if (tier.matchMode === "advanced") {
|
||||
if (tier.operation && tier.operation !== "*" && !operations[capability]?.includes(tier.operation)) fail("生成方式与模型能力不匹配");
|
||||
const specific = (value: string | undefined) => Boolean(value && value !== "*");
|
||||
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.resolution) || specific(tier.size))) || (capability === "video" && specific(tier.resolution)))) fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
|
||||
const image = values.capabilityConfig?.image;
|
||||
const imageCondition = Object.values(tier.imageSelector || {}).some(specific) ||
|
||||
(image?.resolution.supported !== false && specific(tier.imageResolution)) ||
|
||||
(image?.quality.supported !== false && specific(tier.quality)) ||
|
||||
specific(tier.resolution) ||
|
||||
specific(tier.size);
|
||||
if (!(specific(tier.operation) || (capability === "image" && imageCondition) || (capability === "video" && specific(tier.resolution)))) fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
|
||||
}
|
||||
const prices = tier.billingMode !== "token" ? [tier.unitPrice] : capability === "video" ? [tier.outputTokenPrice] : [tier.inputTokenPrice, tier.outputTokenPrice, tier.cachedTokenPrice];
|
||||
if (prices.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分价格必须是 0 到 1000000 之间的有效数值");
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import { defaultPriceTier, normalizeUpstreamModelKey, priceTierPayloadFromForm } from "./channel-model-price-tier-form";
|
||||
import { PriceTierFields } from "./channel-model-price-tier-fields";
|
||||
import { ChannelModelTagsEditor } from "./channel-model-tags-editor";
|
||||
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, updateChannelModelUpstreamCapabilities, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
|
||||
import { changeChannelModelCapability, changeChannelModelProtocol, editorSectionForField, initialChannelModelValues, updateChannelModelUpstreamCapabilities, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
|
||||
|
||||
export function ChannelModelEditor({
|
||||
channel,
|
||||
@@ -77,11 +77,11 @@ export function ChannelModelEditor({
|
||||
form.setFieldsValue(changeChannelModelCapability(form.getFieldsValue(true), protocols));
|
||||
setConfigurationChanged(true);
|
||||
} else if (changed.protocol) {
|
||||
form.setFieldValue("capabilityConfig", modelCapability === "audio" ? undefined : defaultModelCapabilityConfig(changed.protocol, providerModelKey.trim() || modelKey.trim()));
|
||||
form.setFieldsValue(changeChannelModelProtocol(form.getFieldsValue(true), changed.protocol, protocols));
|
||||
setConfigurationChanged(true);
|
||||
} else if (changed.providerModelKey !== undefined || changed.modelKey !== undefined) {
|
||||
const values = form.getFieldsValue(true);
|
||||
const next = updateChannelModelUpstreamCapabilities(values);
|
||||
const next = updateChannelModelUpstreamCapabilities(values, protocols);
|
||||
if (next !== values) {
|
||||
form.setFieldValue("capabilityConfig", next.capabilityConfig);
|
||||
setConfigurationChanged(true);
|
||||
@@ -127,7 +127,7 @@ export function ChannelModelEditor({
|
||||
icon: values.icon?.trim() || "",
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
priceTiers: values.priceTiers.map((tier) => priceTierPayloadFromForm(values.capability, tier, upstreamModel)),
|
||||
priceTiers: values.priceTiers.map((tier) => priceTierPayloadFromForm(values.capability, tier, upstreamModel, capabilityConfig)),
|
||||
enabled: values.enabled !== false,
|
||||
capabilityConfig,
|
||||
};
|
||||
@@ -342,6 +342,7 @@ export function ChannelModelEditor({
|
||||
model={providerModelKey || modelKey}
|
||||
protocol={form.getFieldValue("protocol")}
|
||||
section="references"
|
||||
imageParameters={protocols.find((item) => item.value === modelProtocol)?.imageParameters}
|
||||
workflows={protocols.find((item) => item.value === modelProtocol)?.workflows}
|
||||
value={capabilityConfig}
|
||||
onChange={(next) => {
|
||||
@@ -361,6 +362,7 @@ export function ChannelModelEditor({
|
||||
model={providerModelKey || modelKey}
|
||||
protocol={form.getFieldValue("protocol")}
|
||||
section="protocol"
|
||||
imageParameters={protocols.find((item) => item.value === modelProtocol)?.imageParameters}
|
||||
workflows={protocols.find((item) => item.value === modelProtocol)?.workflows}
|
||||
value={capabilityConfig}
|
||||
onChange={(next) => {
|
||||
|
||||
@@ -4,9 +4,10 @@ import type { ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import type { ModelCapabilityChoice as EditableCapability } from "@/components/model-protocol-picker";
|
||||
import type { ChannelModelFormValues as FormValues } from "./channel-model-editor-form";
|
||||
import { imagePriceQualityOptions, normalizeUpstreamModelKey } from "./channel-model-price-tier-form";
|
||||
import { normalizeUpstreamModelKey } from "./channel-model-price-tier-form";
|
||||
import { CreditCostFields } from "./credit-cost-fields";
|
||||
import { Select } from "@/components/ui/base/select";
|
||||
import { ImageParameterInput } from "@/components/image-parameter-fields";
|
||||
|
||||
export function PriceTierFields({
|
||||
index,
|
||||
@@ -38,6 +39,7 @@ export function PriceTierFields({
|
||||
// 静默改变实际发往供应商的模型,必须显式提示并允许一键恢复“跟随模型默认”。
|
||||
const staleTierUpstream = matchMode === "default" && tierUpstream && modelUpstream && tierUpstream !== modelUpstream ? tierUpstream : "";
|
||||
const video = capabilityConfig?.video;
|
||||
const image = capabilityConfig?.image;
|
||||
const resolutionOptions = Array.from(new Set([...(video?.resolutions || []), ...(protocol === "autodl-comfyui" ? video?.fixedScreenSpec?.resolutions || [] : [])]));
|
||||
const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol);
|
||||
const isVideo = capability === "video";
|
||||
@@ -115,22 +117,26 @@ export function PriceTierFields({
|
||||
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<>
|
||||
<Form.Item className="mb-0" name={[index, "quality"]} label="画质" rules={[{ required: true, message: "请选择画质" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "任意画质", value: "*" },
|
||||
...imagePriceQualityOptions(),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item className="mb-0" name={[index, "resolution"]} label="分辨率">
|
||||
<Select options={[{ label: "任意分辨率", value: "*" }, { label: "1K", value: "1k" }, { label: "2K", value: "2k" }, { label: "4K", value: "4k" }]} />
|
||||
</Form.Item>
|
||||
</>
|
||||
{isImage && image?.parameters
|
||||
? image.parameters
|
||||
.filter((field) => field.enabled !== false && field.pricing)
|
||||
.map((field) => (
|
||||
<Form.Item key={field.name} className="mb-0" name={[index, "imageSelector", field.name]} label={field.label}>
|
||||
<ImagePriceParameterInput field={field} />
|
||||
</Form.Item>
|
||||
))
|
||||
: null}
|
||||
{isImage && !image?.parameters && image?.resolution?.supported ? (
|
||||
<Form.Item className="mb-0" name={[index, "imageResolution"]} label="上游分辨率" rules={[{ required: true, message: "请选择上游分辨率" }]}>
|
||||
<Select options={[{ label: "任意分辨率", value: "*" }, ...image.resolution.values.map((value) => ({ label: value.toUpperCase(), value }))]} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
{isImage && !image?.parameters && image?.quality.supported ? (
|
||||
<Form.Item className="mb-0" name={[index, "quality"]} label="画质" rules={[{ required: true, message: "请选择画质" }]}>
|
||||
<Select options={[{ label: "任意画质", value: "*" }, ...image.quality.values.map((value) => ({ label: value.toUpperCase(), value }))]} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage && !image?.parameters && image?.size.parameter !== "none" ? (
|
||||
<Form.Item className="mb-0" name={[index, "size"]} label="画幅/尺寸">
|
||||
<Input placeholder="任意,或 1:1、16:9、1024x1024" />
|
||||
</Form.Item>
|
||||
@@ -217,6 +223,24 @@ export function PriceTierFields({
|
||||
);
|
||||
}
|
||||
|
||||
function ImagePriceParameterInput({ field, value, onChange }: { field: import("@/lib/image-parameters").ImageParameter; value?: string; onChange?: (value?: string) => void }) {
|
||||
const typed = value === undefined || value === "" || value === "*" ? undefined : field.type === "boolean" ? value === "true" : field.type === "number" || field.type === "integer" ? Number(value) : value;
|
||||
if (field.type === "boolean")
|
||||
return (
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="任意"
|
||||
value={value}
|
||||
options={[
|
||||
{ label: "开启", value: "true" },
|
||||
{ label: "关闭", value: "false" },
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
return <ImageParameterInput field={field} value={typed} onChange={(next) => onChange?.(next === undefined ? undefined : String(next))} />;
|
||||
}
|
||||
|
||||
function operationOptions(capability: EditableCapability | undefined) {
|
||||
const options = [{ label: "任意生成方式", value: "*" }];
|
||||
if (capability === "image") return [...options, { label: "文生图", value: "text_to_image" }, { label: "图生图", value: "image_to_image" }];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { imageQualityLabel } from "@/lib/image-quality";
|
||||
import type { ModelCapabilityChoice } from "@/components/model-protocol-picker";
|
||||
import type { ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet";
|
||||
|
||||
export type PriceTierMatchMode = "default" | "advanced";
|
||||
@@ -12,8 +13,10 @@ export function normalizeUpstreamModelKey(value: unknown): string {
|
||||
}
|
||||
|
||||
export type PriceTierFormValues = {
|
||||
imageSelector?: Record<string, string>;
|
||||
matchMode: PriceTierMatchMode;
|
||||
operation: string;
|
||||
imageResolution: string;
|
||||
quality: string;
|
||||
size: string;
|
||||
resolution: string;
|
||||
@@ -39,6 +42,7 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri
|
||||
return {
|
||||
matchMode,
|
||||
operation: "*",
|
||||
imageResolution: "*",
|
||||
quality: "*",
|
||||
size: "*",
|
||||
resolution: "*",
|
||||
@@ -61,12 +65,21 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri
|
||||
};
|
||||
}
|
||||
|
||||
export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValues {
|
||||
const selector = tier.selector || {};
|
||||
const hasSpecificMatch = [selector.operation, selector.quality, selector.resolution, selector.size].some((value) => value && value !== "*") || (tier.resolution && tier.resolution !== "*");
|
||||
export function priceTierToForm(tier: ChannelModelPriceTier, image?: ImageCapabilityConfig, protocol?: string): PriceTierFormValues {
|
||||
const selector = { ...(tier.selector || {}) };
|
||||
const legacyResolution = selector.quality?.toLowerCase();
|
||||
const legacyResolutionProtocol = protocol === "grok-image" || protocol === "agnes-image";
|
||||
if (legacyResolutionProtocol && image?.resolution.supported && !image.quality.supported && legacyResolution && image.resolution.values.some((value) => value.toLowerCase() === legacyResolution) && !selector.imageResolution) {
|
||||
selector.imageResolution = legacyResolution;
|
||||
delete selector.quality;
|
||||
}
|
||||
const candidates = image?.parameters ? Object.values(selector) : [selector.operation, selector.imageResolution, selector.quality, selector.resolution, selector.size];
|
||||
const hasSpecificMatch = candidates.some((value) => value && value !== "*") || (tier.resolution && tier.resolution !== "*");
|
||||
return {
|
||||
matchMode: hasSpecificMatch ? "advanced" : "default",
|
||||
operation: selector.operation || "*",
|
||||
imageSelector: Object.fromEntries(Object.entries(selector).filter(([name]) => name !== "operation")),
|
||||
imageResolution: selector.imageResolution || "*",
|
||||
quality: selector.quality || "*",
|
||||
size: selector.size || "*",
|
||||
resolution: selector.resolution || tier.resolution || "*",
|
||||
@@ -104,7 +117,7 @@ export function legacyPriceTierToForm(item: ChannelModel): PriceTierFormValues {
|
||||
};
|
||||
}
|
||||
|
||||
export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues) {
|
||||
export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues, capabilityConfig?: ModelCapabilityConfig) {
|
||||
if (tier.matchMode !== "advanced") return {};
|
||||
const selector: Record<string, string> = {};
|
||||
if (tier.operation && tier.operation !== "*") selector.operation = tier.operation;
|
||||
@@ -112,8 +125,16 @@ export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: Pri
|
||||
if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution;
|
||||
}
|
||||
if (capability === "image") {
|
||||
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
|
||||
if (tier.resolution && tier.resolution !== "*") selector.resolution = tier.resolution;
|
||||
if (capabilityConfig?.image?.parameters) {
|
||||
for (const field of capabilityConfig.image.parameters) {
|
||||
if (field.enabled === false || !field.pricing) continue;
|
||||
const value = tier.imageSelector?.[field.name];
|
||||
if (value !== undefined && value !== "" && value !== "*") selector[field.name] = String(value);
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
if (capabilityConfig?.image?.resolution.supported !== false && tier.imageResolution && tier.imageResolution !== "*") selector.imageResolution = tier.imageResolution;
|
||||
if (capabilityConfig?.image?.quality.supported !== false && tier.quality && tier.quality !== "*") selector.quality = tier.quality;
|
||||
if (tier.size && tier.size !== "*") selector.size = tier.size;
|
||||
}
|
||||
return selector;
|
||||
@@ -123,20 +144,16 @@ export function priceTierResolutionFromForm(capability: ModelCapabilityChoice, t
|
||||
return capability === "video" && tier.matchMode === "advanced" ? tier.resolution || "*" : "*";
|
||||
}
|
||||
|
||||
export function imagePriceQualityOptions() {
|
||||
return ["low", "medium", "high", "xhigh", "max", "auto", "1k", "2k", "4k"].map((value) => ({ label: imageQualityLabel(value), value }));
|
||||
}
|
||||
|
||||
export function priceTierVideoSecondsFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues) {
|
||||
void capability;
|
||||
void tier;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues, upstreamModel: string) {
|
||||
export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues, upstreamModel: string, capabilityConfig?: ModelCapabilityConfig) {
|
||||
const videoTokens = capability === "video" && tier.billingMode === "token";
|
||||
return {
|
||||
selector: skuSelectorFromForm(capability, tier),
|
||||
selector: skuSelectorFromForm(capability, tier, capabilityConfig),
|
||||
resolution: priceTierResolutionFromForm(capability, tier),
|
||||
videoSeconds: priceTierVideoSecondsFromForm(capability, tier),
|
||||
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
|
||||
|
||||
@@ -174,7 +174,8 @@ export default function LogicalModelsPage() {
|
||||
message.error("请至少添加一条供应线路");
|
||||
return;
|
||||
}
|
||||
const sourceError = capabilitySourceError(values.capability, modelSourceSpecs, values.capabilitySpec);
|
||||
const currentSpec = normalizeCapabilitySpecForSources(values.capabilitySpec, modelSourceSpecs) || values.capabilitySpec;
|
||||
const sourceError = capabilitySourceError(values.capability, modelSourceSpecs, currentSpec);
|
||||
if (values.enabled && sourceError) {
|
||||
setEditorTab("capabilities");
|
||||
message.error(sourceError);
|
||||
@@ -639,7 +640,9 @@ function RouteFields({
|
||||
.map((item) => (item ? capabilitySpecFromChannelModel(item) : undefined))
|
||||
.filter((item): item is CapabilitySpec => Boolean(item));
|
||||
form.setFieldValue("routes", nextRoutes);
|
||||
if (!hasCapabilityRules(form.getFieldValue("capabilitySpec"))) form.setFieldValue("capabilitySpec", mergeCapabilitySpecs(capability, specs));
|
||||
const current = form.getFieldValue("capabilitySpec");
|
||||
if (capability === "image" || !hasCapabilityRules(current))
|
||||
form.setFieldValue("capabilitySpec", capability === "image" && hasCapabilityRules(current) ? normalizeCapabilitySpecForSources(current, specs) : mergeCapabilitySpecs(capability, specs));
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { ReactNode } from "react";
|
||||
import type { CapabilityImageSize, CapabilitySpec, OptionConstraint } from "@/services/api/logical-models";
|
||||
import type { ChannelModel } from "@/services/api/wallet";
|
||||
import { STANDARD_IMAGE_SIZE_VALUES } from "@/lib/model-capabilities";
|
||||
import { imageSizePresets } from "@/lib/image-size-presets";
|
||||
import { Select } from "@/components/ui/base/select";
|
||||
import { ImageParameterInput } from "@/components/image-parameter-fields";
|
||||
import { imageParameterConstraints, imageParametersForSpec, type ImageParameter } from "@/lib/image-parameters";
|
||||
|
||||
export type CapabilityKind = CapabilitySpec["capability"];
|
||||
export type Scalar = string | number | boolean;
|
||||
@@ -60,6 +63,11 @@ const optionDefinitions: Record<CapabilityKind, Array<{ name: string; label: str
|
||||
],
|
||||
};
|
||||
|
||||
function definitionsFor(capability: CapabilityKind, specs: CapabilitySpec[] = []): Array<{ name: string; label: string; unit?: string }> {
|
||||
if (capability !== "image" || !specs.some((spec) => spec.imageParameters)) return optionDefinitions[capability];
|
||||
return [...new Map(specs.flatMap((spec) => spec.imageParameters || []).map((p) => [p.name, { name: p.name, label: p.label }])).values()];
|
||||
}
|
||||
|
||||
export function capabilityLabel(value: CapabilityKind) {
|
||||
return { text: "文本", image: "图片", video: "视频", audio: "音频" }[value];
|
||||
}
|
||||
@@ -94,6 +102,15 @@ export function capabilitySpecFromChannelModel(item?: ChannelModel): CapabilityS
|
||||
if (capability === "image") {
|
||||
const image = item.capabilityConfig?.image;
|
||||
if (!image) return null;
|
||||
if (image.parameters)
|
||||
return {
|
||||
version: 1,
|
||||
capability,
|
||||
inputs: compactInputs({ image: { min: 0, max: image.references.maxImages }, mask: { min: 0, max: image.references.maskSupported ? 1 : 0 } }),
|
||||
imageParameters: image.parameters.map(({ binding: _binding, models: _models, encoding: _encoding, ...p }) => p),
|
||||
options: imageParameterConstraints(image.parameters),
|
||||
imageSize: image.size.parameter === "none" ? undefined : { parameter: image.size.parameter, allowCustom: image.size.allowCustom, presets: imageSizePresets(image) },
|
||||
};
|
||||
return {
|
||||
version: 1,
|
||||
capability,
|
||||
@@ -147,7 +164,7 @@ export function mergeCapabilitySpecs(capability: CapabilityKind, specs: Capabili
|
||||
result.inputs![definition.name] = widestCoveredInputRange(constraints);
|
||||
}
|
||||
}
|
||||
for (const definition of optionDefinitions[capability]) {
|
||||
for (const definition of definitionsFor(capability, matching)) {
|
||||
const constraints = matching.map((item) => item.options?.[definition.name]).filter(Boolean) as OptionConstraint[];
|
||||
if (constraints.length === 0) continue;
|
||||
if (constraints.some(isWildcardConstraint)) {
|
||||
@@ -178,6 +195,21 @@ export function mergeCapabilitySpecs(capability: CapabilityKind, specs: Capabili
|
||||
result.options![definition.name] = preferredSourceConstraint(constraints);
|
||||
}
|
||||
result.imageSize = mergeImageSize(matching);
|
||||
if (capability === "image" && matching.some((spec) => spec.imageParameters)) {
|
||||
const fields = new Map<string, ImageParameter>();
|
||||
const conflicts = new Set<string>();
|
||||
for (const field of matching.flatMap((spec) => spec.imageParameters || []).filter((p) => p.enabled !== false)) {
|
||||
const previous = fields.get(field.name);
|
||||
if (previous && previous.type !== field.type) conflicts.add(field.name);
|
||||
fields.set(field.name, previous ? { ...previous, pricing: Boolean(previous.pricing && field.pricing) } : field);
|
||||
}
|
||||
for (const name of conflicts) {
|
||||
fields.delete(name);
|
||||
delete result.options![name];
|
||||
}
|
||||
result.imageParameters = [...fields.values()];
|
||||
result.imageParameters = imageParametersForSpec(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -200,6 +232,22 @@ function mergeImageSize(specs: CapabilitySpec[]): CapabilityImageSize | undefine
|
||||
export function normalizeCapabilitySpecForSources(value: CapabilitySpec | undefined, sourceSpecs: CapabilitySpec[]) {
|
||||
if (!value) return value;
|
||||
const source = mergeCapabilitySpecs(value.capability, sourceSpecs);
|
||||
if (value.capability === "image" && source.imageParameters) {
|
||||
const options = Object.fromEntries(
|
||||
Object.entries(source.options || {}).flatMap(([name, available]) => {
|
||||
const selected = value.options?.[name];
|
||||
if (!selected && value.imageParameters?.some((p) => p.name === name)) return [];
|
||||
if (!selected) return [[name, available]];
|
||||
if (selected.values && available.values && !isWildcardConstraint(available)) {
|
||||
const values = selected.values.filter((v) => available.values!.some((a) => scalarKey(a) === scalarKey(v)));
|
||||
return values.length ? [[name, { values }]] : [];
|
||||
}
|
||||
return [[name, optionConstraintCovered(selected, name, sourceSpecs) ? selected : available]];
|
||||
}),
|
||||
);
|
||||
const next = { ...value, options, imageSize: source.imageSize, imageParameters: source.imageParameters };
|
||||
return { ...next, imageParameters: imageParametersForSpec(next) };
|
||||
}
|
||||
const options = { ...(value.options || {}) };
|
||||
for (const [name, constraint] of Object.entries(options)) {
|
||||
const sourceConstraint = source.options?.[name];
|
||||
@@ -234,7 +282,7 @@ export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange
|
||||
const current = value?.capability === capability ? normalizeCapabilitySpecForSources(value, sourceSpecs)! : emptyCapabilitySpec(capability);
|
||||
const hasSource = sourceSpecs.some((item) => item.capability === capability);
|
||||
const sourceInputs = inputDefinitions[capability].filter((item) => source.inputs?.[item.name]);
|
||||
const sourceOptions = optionDefinitions[capability].filter((item) => source.options?.[item.name]);
|
||||
const sourceOptions = definitionsFor(capability, [source]).filter((item) => source.options?.[item.name]);
|
||||
const sourceError = capabilitySourceError(capability, sourceSpecs, current);
|
||||
|
||||
const update = (patch: Partial<CapabilitySpec>) => onChange?.({ ...current, ...patch, version: 1, capability });
|
||||
@@ -337,7 +385,7 @@ export function CapabilityScopeEditor({ capability, sourceSpecs, value, onChange
|
||||
}
|
||||
|
||||
export function DefaultOptionsEditor({ spec, value, onChange }: { spec?: CapabilitySpec; value?: Record<string, unknown>; onChange?: (value: Record<string, Scalar>) => void }) {
|
||||
const options = optionDefinitions[spec?.capability || "text"].filter((item) => spec?.options?.[item.name]);
|
||||
const options = definitionsFor(spec?.capability || "text", spec ? [spec] : []).filter((item) => spec?.options?.[item.name]);
|
||||
if (!options.length) return <div className="rounded-md bg-muted/20 px-3 py-2 text-xs text-foreground/50">当前能力没有需要设置的默认参数。</div>;
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
@@ -352,6 +400,7 @@ export function DefaultOptionsEditor({ spec, value, onChange }: { spec?: Capabil
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-7">
|
||||
<ConstraintValueInput
|
||||
field={spec?.imageParameters?.find((p) => p.name === definition.name)}
|
||||
constraint={constraint}
|
||||
value={selected}
|
||||
allowClear
|
||||
@@ -384,7 +433,7 @@ export function CapabilityRequestEditor({
|
||||
onOptionsChange: (value: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const inputItems = inputDefinitions[spec.capability].filter((item) => spec.inputs?.[item.name]);
|
||||
const optionItems = optionDefinitions[spec.capability].filter((item) => spec.options?.[item.name]);
|
||||
const optionItems = definitionsFor(spec.capability, [spec]).filter((item) => spec.options?.[item.name]);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{inputItems.length ? (
|
||||
@@ -419,6 +468,7 @@ export function CapabilityRequestEditor({
|
||||
{definition.unit ? ` (${definition.unit})` : ""}
|
||||
</span>
|
||||
<ConstraintValueInput
|
||||
field={spec.imageParameters?.find((p) => p.name === definition.name)}
|
||||
constraint={constraint}
|
||||
value={options[definition.name] as Scalar | undefined}
|
||||
allowClear
|
||||
@@ -450,7 +500,7 @@ export function CapabilitySummary({ spec }: { spec: CapabilitySpec }) {
|
||||
labels.push(`${definition.label} ${minimum}-${maximum}${definition.unit}`);
|
||||
}
|
||||
}
|
||||
for (const definition of optionDefinitions[spec.capability]) {
|
||||
for (const definition of definitionsFor(spec.capability, [spec])) {
|
||||
const option = spec.options?.[definition.name];
|
||||
if (!option) continue;
|
||||
labels.push(option.values ? `${definition.label} ${option.values.map(scalarLabel).join("/")}` : `${definition.label} ${option.min}-${option.max}${definition.unit || ""}`);
|
||||
@@ -556,7 +606,8 @@ function OptionRuleEditor({ definition, source, value, onChange }: { definition:
|
||||
);
|
||||
}
|
||||
|
||||
function ConstraintValueInput({ constraint, value, allowClear, onChange }: { constraint: OptionConstraint; value?: Scalar; allowClear?: boolean; onChange: (value?: Scalar) => void }) {
|
||||
function ConstraintValueInput({ constraint, field, value, allowClear, onChange }: { constraint: OptionConstraint; field?: ImageParameter; value?: Scalar; allowClear?: boolean; onChange: (value?: Scalar) => void }) {
|
||||
if (field) return <ImageParameterInput field={field} value={value} onChange={onChange} />;
|
||||
if (constraint.values) {
|
||||
return (
|
||||
<Select
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ImageSizePicker } from "@/components/image-size-picker";
|
||||
import { imageResolutionUsesQuality } from "@/lib/image-size-presets";
|
||||
import { ImageParameterFields } from "@/components/image-parameter-fields";
|
||||
import { imageResolutionUsesQuality, imageSizePresets } from "@/lib/image-size-presets";
|
||||
import { createPortal } from "react-dom";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react";
|
||||
import { Button, Popover } from "antd";
|
||||
@@ -112,6 +113,11 @@ type ComposerProps = {
|
||||
setSeconds: (value: string) => void;
|
||||
quality: string;
|
||||
setQuality: (value: string) => void;
|
||||
imageResolution: string;
|
||||
setImageResolution: (value: string) => void;
|
||||
imageParameters?: string;
|
||||
setImageParameters: (value: string) => void;
|
||||
setTransparentBackground: (value: string) => void;
|
||||
videoQuality: string;
|
||||
setVideoQuality: (value: string) => void;
|
||||
count: string;
|
||||
@@ -496,8 +502,11 @@ function GenerationSettingsMenu(props: ComposerProps) {
|
||||
].join(" · ");
|
||||
const videoRatioSupported = props.mode === "video" && ratios.length > 0;
|
||||
const summary = props.mode === "video" ? [...(videoRatioSupported ? [props.ratio] : []), ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary;
|
||||
const panel = <div className="creation-parameter-menu">
|
||||
{props.mode === "image" ? <ImageSizePicker profile={mergedProfile} size={props.ratio} quality={props.quality} onChange={(size, quality) => { props.setRatio(size); if (quality) props.setQuality(quality); }} /> : videoRatioSupported ? <SettingSection title="画幅" value={props.ratio}><div className="creation-choice-grid is-ratio">{ratios.map((value) => <button key={value} type="button" aria-pressed={value === props.ratio} className={value === props.ratio ? "is-selected" : ""} onClick={() => props.setRatio(value)}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>)}</div></SettingSection> : null}
|
||||
const panel = props.mode === "image" && props.imageProfile.parameters ? <div className="creation-parameter-menu max-h-[65vh] overflow-y-auto"><ImageParameterFields profile={props.imageProfile} config={{ ...props.config, model: props.model, size: props.ratio, quality: props.quality, imageResolution: props.imageResolution, imageParameters: props.imageParameters, count: props.count }} onChange={(key, value) => {
|
||||
const setters = { size: props.setRatio, quality: props.setQuality, imageResolution: props.setImageResolution, imageParameters: props.setImageParameters, count: props.setCount, transparentBackground: props.setTransparentBackground };
|
||||
setters[key](value);
|
||||
}} /></div> : <div className="creation-parameter-menu">
|
||||
{props.mode === "image" ? <ImageSizePicker profile={mergedProfile} size={props.ratio} quality={props.quality} imageResolution={props.imageResolution} onChange={(size, quality, resolution) => { props.setRatio(size); if (quality) props.setQuality(quality); if (resolution) props.setImageResolution(resolution); }} /> : videoRatioSupported ? <SettingSection title="画幅" value={props.ratio}><div className="creation-choice-grid is-ratio">{ratios.map((value) => <button key={value} type="button" aria-pressed={value === props.ratio} className={value === props.ratio ? "is-selected" : ""} onClick={() => props.setRatio(value)}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>)}</div></SettingSection> : null}
|
||||
{props.mode === "image" && referenceImageSizeValue ? <button type="button" className="creation-custom-trigger" onClick={selectReferenceImageSize}>使用参考图尺寸 · {referenceImageSizeLabel}</button> : null}
|
||||
{props.mode === "video" ? (videoResolutionSupported ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : null) : <>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useNavigate, useLocation } from "react-router";
|
||||
|
||||
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
|
||||
import { imageParameterRequest, normalizeImageParameters } from "@/lib/image-parameters";
|
||||
import { creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { continueCreationConversationOnCanvas } from "@/services/creation-canvas-conversation";
|
||||
@@ -17,7 +18,7 @@ import type { BackendGenerationResult } from "@/services/api/generation-task";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { loadCreationConversations, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store";
|
||||
import { resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { resolveModelChannel, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
@@ -116,6 +117,8 @@ export default function CreatePage() {
|
||||
const [ratio, setRatio] = useState("16:9");
|
||||
const [seconds, setSeconds] = useState("6");
|
||||
const [quality, setQuality] = useState("auto");
|
||||
const [imageResolution, setImageResolution] = useState("auto");
|
||||
const [imageParameters, setImageParameters] = useState("{}");
|
||||
const [videoQuality, setVideoQuality] = useState(config.vquality || "720");
|
||||
const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1))));
|
||||
const [textStreaming, setTextStreaming] = useState(() => readComposerPref(TEXT_STREAMING_PREF_KEY, true));
|
||||
@@ -162,11 +165,11 @@ export default function CreatePage() {
|
||||
videoSeconds: mode === "video" ? seconds : undefined,
|
||||
imageSize: mode === "image" ? ratio : undefined,
|
||||
options: mode === "image"
|
||||
? { size: ratio, quality, count: Number(count), transparentBackground: config.transparentBackground === "true" }
|
||||
? imageParameterRequest(modelCapabilityConfigFor(config, preferredModel).image!, { ...config, size: ratio, imageResolution, quality, count, imageParameters }) ?? { size: ratio, imageResolution, quality, count: Number(count), transparentBackground: config.transparentBackground === "true" }
|
||||
: mode === "video"
|
||||
? { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality }
|
||||
: {},
|
||||
}), [attachments, config.transparentBackground, count, hasPrompt, mode, quality, ratio, seconds, videoQuality]);
|
||||
}), [attachments, config, preferredModel, count, hasPrompt, imageResolution, imageParameters, mode, quality, ratio, seconds, videoQuality]);
|
||||
const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel;
|
||||
const generationConfig = useMemo(() => mode === "video"
|
||||
? creationVideoConfig(config, selectedModel, { ratio, seconds, videoQuality })
|
||||
@@ -234,10 +237,14 @@ export default function CreatePage() {
|
||||
const normalized = normalizeImageValue(imageProfile, {
|
||||
size: saved?.ratio || imageProfile.size.default,
|
||||
quality: saved?.quality || imageProfile.quality.default,
|
||||
imageResolution: saved?.imageResolution || imageProfile.resolution.default,
|
||||
imageParameters: saved?.model === selectedModel ? saved.imageParameters : undefined,
|
||||
count: saved?.count || count,
|
||||
});
|
||||
setRatio(normalized.size);
|
||||
setQuality(normalized.quality);
|
||||
setImageResolution(normalized.imageResolution || imageProfile.resolution.default);
|
||||
setImageParameters(JSON.stringify(normalizeImageParameters(imageProfile.parameters, saved?.model === selectedModel ? saved.imageParameters : undefined)));
|
||||
setCount(normalized.count);
|
||||
}, [composerPreferencesHydrated, composerPreferencesInitialized, mode, selectedModel, imageProfile]);
|
||||
|
||||
@@ -423,6 +430,10 @@ export default function CreatePage() {
|
||||
setQuality(value);
|
||||
if (mode === "image") rememberImageSettings({ quality: value });
|
||||
};
|
||||
const setComposerImageResolution = (value: string) => {
|
||||
setImageResolution(value);
|
||||
if (mode === "image") rememberImageSettings({ imageResolution: value });
|
||||
};
|
||||
const setComposerVideoQuality = (value: string) => {
|
||||
setVideoQuality(value);
|
||||
if (mode === "video") rememberVideoSettings({ videoQuality: value });
|
||||
@@ -701,11 +712,17 @@ export default function CreatePage() {
|
||||
const controller = new AbortController();
|
||||
const requestLifecycle = runtime.beginGenerationConsumer(controller.signal);
|
||||
abortRef.current = controller;
|
||||
const normalizedImage = mode === "image" ? normalizeImageValue(imageProfile, { size: ratio, quality, count }) : undefined;
|
||||
const normalizedImage = mode === "image"
|
||||
? imageProfile.parameters
|
||||
? { size: ratio, quality, imageResolution, imageParameters, count }
|
||||
: normalizeImageValue(imageProfile, { size: ratio, quality, imageResolution, imageParameters, count }, resolveModelRequestConfig(config, selectedModel).interfaceType)
|
||||
: undefined;
|
||||
const requestConfig = {
|
||||
...generationConfig,
|
||||
model: selectedModel,
|
||||
imageModel: selectedModel,
|
||||
imageResolution: normalizedImage?.imageResolution || imageResolution,
|
||||
imageParameters: normalizedImage?.imageParameters || imageParameters,
|
||||
videoModel: selectedModel,
|
||||
textModel: selectedModel,
|
||||
...(mode === "image"
|
||||
@@ -1037,6 +1054,11 @@ export default function CreatePage() {
|
||||
setSeconds: setComposerSeconds,
|
||||
quality,
|
||||
setQuality: setComposerQuality,
|
||||
imageResolution,
|
||||
setImageResolution: setComposerImageResolution,
|
||||
imageParameters,
|
||||
setImageParameters: (value: string) => { setImageParameters(value); rememberImageSettings({ model: selectedModel, imageParameters: value }); },
|
||||
setTransparentBackground: (value: string) => useConfigStore.getState().updateConfig("transparentBackground", value),
|
||||
videoQuality,
|
||||
setVideoQuality: setComposerVideoQuality,
|
||||
count,
|
||||
|
||||
@@ -182,6 +182,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
value={activeModelCost?.capabilityConfig || defaultModelCapabilityConfig(activeProtocol, activeModel)}
|
||||
protocol={activeProtocol}
|
||||
workflows={modelProtocolDefinition(activeProtocol, availableProtocols)?.workflows}
|
||||
imageParameters={modelProtocolDefinition(activeProtocol, availableProtocols)?.imageParameters}
|
||||
onChange={(capabilityConfig) => updateCost(activeModel, { capabilityConfig })}
|
||||
/>
|
||||
) : <p className="text-xs text-foreground/50">当前模型类型无需额外配置引用与参数。</p>}
|
||||
|
||||
@@ -8,6 +8,8 @@ export type AgentMediaSettings = {
|
||||
channelId?: string;
|
||||
channelModelKey?: string;
|
||||
size: string;
|
||||
imageResolution?: string;
|
||||
imageParameters?: string;
|
||||
quality: string;
|
||||
durationSeconds?: number;
|
||||
videoGenerateAudio?: boolean;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizeAudioFormatForConfig, normalizeAudioVoiceForConfig } from "@/lib/audio-generation";
|
||||
import { imageParameterRequest } from "@/lib/image-parameters";
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
@@ -321,6 +322,10 @@ function generationMetadata(config: AiConfig, metadata?: Record<string, unknown>
|
||||
const channel = resolveModelChannel(config, config.model);
|
||||
const model = modelOptionName(config.model);
|
||||
const modelCost = channel.modelCosts?.find((item) => item.model === model);
|
||||
if (modelCost?.capability === "image" && modelCost.capabilityConfig?.image?.parameters) {
|
||||
const { providerOptions: _providerOptions, ...rest } = metadata || {};
|
||||
return rest;
|
||||
}
|
||||
const protocol = modelCost?.protocol || channel.interfaceType;
|
||||
if (!protocol) return metadata;
|
||||
const existing = metadata?.providerOptions && typeof metadata.providerOptions === "object" && !Array.isArray(metadata.providerOptions)
|
||||
@@ -404,7 +409,9 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM
|
||||
const workflow = resolveGenerationWorkflowExecution(config, mode);
|
||||
if (workflow) return workflowProviderConfig(config, requestConfig, workflow);
|
||||
const generationOptions = {
|
||||
...(mode === "image" ? { imageParameters: imageParameterRequest(modelCapabilityConfigFor(config, config.model).image!, config, true) } : {}),
|
||||
size: config.size,
|
||||
imageResolution: omittedImageQuality(config.imageResolution),
|
||||
quality: omittedImageQuality(config.quality),
|
||||
transparentBackground: config.transparentBackground,
|
||||
count: config.count,
|
||||
@@ -487,6 +494,10 @@ function workflowPublicExecution(workflow: GenerationWorkflowExecution) {
|
||||
}
|
||||
|
||||
function logicalCapabilityOptions(config: AiConfig, mode: BackendGenerationMode) {
|
||||
if (mode === "image") {
|
||||
const values = imageParameterRequest(modelCapabilityConfigFor(config, config.model).image!, config, true);
|
||||
if (values) return values;
|
||||
}
|
||||
const channel = resolveModelChannel(config, config.model);
|
||||
const spec = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalCapabilitySpec;
|
||||
const candidates: Record<string, unknown> = mode === "image"
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
const requestConfig = resolveModelRequestConfig(config, selectedModel);
|
||||
const imageProfile = modelCapabilityConfigFor(config, selectedModel).image!;
|
||||
validateImageCapability(imageProfile, []);
|
||||
const normalizedImage = normalizeImageValue(imageProfile, config);
|
||||
const normalizedImage = normalizeImageValue(imageProfile, config, requestConfig.interfaceType);
|
||||
const n = Number(normalizedImage.count);
|
||||
if (requestConfig.interfaceType === "gemini-image") {
|
||||
try {
|
||||
@@ -73,7 +73,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
try {
|
||||
const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined;
|
||||
const aspectRatio = size?.includes(":") ? size : undefined;
|
||||
const resolution = normalizeGrokImageResolution(normalizedImage.quality);
|
||||
const resolution = normalizeGrokImageResolution(normalizedImage.imageResolution !== "auto" ? normalizedImage.imageResolution : normalizedImage.quality);
|
||||
const responseData = await postChannelJSON<ImageApiResponse>(
|
||||
requestConfig,
|
||||
aiApiUrl(requestConfig, "/images/generations"),
|
||||
@@ -94,7 +94,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
}
|
||||
}
|
||||
const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined;
|
||||
const requestSize = resolveImageRequestSize(imageProfile, quality, normalizedImage.size);
|
||||
const requestSize = resolveImageRequestSize(imageProfile, imageProfile.resolution.supported ? normalizedImage.imageResolution : quality, normalizedImage.size);
|
||||
const isVolcengineArk = isVolcengineArkImageProtocol(requestConfig.interfaceType);
|
||||
const normalizedRequestSize = requestSize?.parameter === "size" && isVolcengineArk ? { ...requestSize, value: normalizeVolcengineArkImageSize(requestSize.value)! } : requestSize;
|
||||
try {
|
||||
@@ -136,7 +136,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
const requestConfig = resolveModelRequestConfig(config, selectedModel);
|
||||
const imageProfile = modelCapabilityConfigFor(config, selectedModel).image!;
|
||||
validateImageCapability(imageProfile, references, mask);
|
||||
const normalizedImage = normalizeImageValue(imageProfile, config);
|
||||
const normalizedImage = normalizeImageValue(imageProfile, config, requestConfig.interfaceType);
|
||||
const n = Number(normalizedImage.count);
|
||||
const requestPrompt = buildImageReferencePromptText(prompt, references);
|
||||
if (requestConfig.interfaceType === "gemini-image") {
|
||||
@@ -154,7 +154,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
const imageUrl = await grokImageInputURL(references[0]);
|
||||
const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined;
|
||||
const aspectRatio = size?.includes(":") ? size : undefined;
|
||||
const resolution = normalizeGrokImageResolution(normalizedImage.quality);
|
||||
const resolution = normalizeGrokImageResolution(normalizedImage.imageResolution !== "auto" ? normalizedImage.imageResolution : normalizedImage.quality);
|
||||
const response = await postChannelJSON<ImageApiResponse>(
|
||||
requestConfig,
|
||||
aiApiUrl(requestConfig, "/images/edits"),
|
||||
@@ -178,7 +178,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
if (isVolcengineArkImageProtocol(requestConfig.interfaceType)) {
|
||||
if (mask) throw new Error("火山方舟图片协议不支持蒙版编辑,请移除蒙版后重试");
|
||||
const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined;
|
||||
const sizeRequest = resolveImageRequestSize(imageProfile, quality, normalizedImage.size);
|
||||
const sizeRequest = resolveImageRequestSize(imageProfile, imageProfile.resolution.supported ? normalizedImage.imageResolution : quality, normalizedImage.size);
|
||||
const requestSize = sizeRequest?.parameter === "size" ? { ...sizeRequest, value: normalizeVolcengineArkImageSize(sizeRequest.value)! } : sizeRequest;
|
||||
try {
|
||||
const images = await Promise.all(references.map((image) => imageToDataUrl(image)));
|
||||
@@ -201,7 +201,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
}
|
||||
}
|
||||
const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined;
|
||||
const requestSize = resolveImageRequestSize(imageProfile, quality, normalizedImage.size);
|
||||
const requestSize = resolveImageRequestSize(imageProfile, imageProfile.resolution.supported ? normalizedImage.imageResolution : quality, normalizedImage.size);
|
||||
const formData = new FormData();
|
||||
formData.set("model", requestConfig.model);
|
||||
formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt));
|
||||
|
||||
@@ -11,6 +11,7 @@ export type CapabilityImageSize = {
|
||||
presets?: CapabilityImageSizePreset[];
|
||||
};
|
||||
export type CapabilitySpec = {
|
||||
imageParameters?: import("@/lib/image-parameters").ImageParameter[];
|
||||
version: 1;
|
||||
capability: "text" | "image" | "video" | "audio";
|
||||
operations?: string[];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { http } from "@/services/api/request";
|
||||
import type { ModelProtocolDefinition, ProtocolCapability } from "@/lib/model-protocols";
|
||||
|
||||
type PluginProviderCatalogItem = {
|
||||
imageParameters?: import("@/lib/image-parameters").ImageParameter[];
|
||||
id: string;
|
||||
version: string;
|
||||
name: string;
|
||||
@@ -50,6 +51,7 @@ function toProviderDefinition(item: PluginProviderCatalogItem): ModelProtocolDef
|
||||
enabled: item.enabled && !item.unavailableReason,
|
||||
baseUrl: item.baseUrl,
|
||||
parameters: item.parameters || [],
|
||||
imageParameters: item.imageParameters,
|
||||
workflows: item.workflows || [],
|
||||
};
|
||||
}
|
||||
@@ -157,6 +157,8 @@ export type AiConfig = {
|
||||
textModels: string[];
|
||||
audioModels: string[];
|
||||
quality: string;
|
||||
imageResolution: string;
|
||||
imageParameters?: string;
|
||||
size: string;
|
||||
transparentBackground: string;
|
||||
count: string;
|
||||
@@ -211,6 +213,7 @@ export const defaultConfig: AiConfig = {
|
||||
textModels: [],
|
||||
audioModels: [],
|
||||
quality: "auto",
|
||||
imageResolution: "auto",
|
||||
size: "1:1",
|
||||
transparentBackground: "false",
|
||||
count: "1",
|
||||
|
||||
@@ -6,8 +6,11 @@ import { scopedLocalStorage } from "@/lib/user-scope";
|
||||
export type CreationModePreference = "text" | "image" | "video";
|
||||
|
||||
export type CreationImagePreferences = {
|
||||
model?: string;
|
||||
imageParameters?: string;
|
||||
ratio?: string;
|
||||
quality?: string;
|
||||
imageResolution?: string;
|
||||
count?: string;
|
||||
};
|
||||
|
||||
@@ -41,8 +44,11 @@ function normalizeImagePreferences(value: unknown): CreationImagePreferences | u
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const preferences = {
|
||||
...(nonEmptyString(raw.model) ? { model: raw.model } : {}),
|
||||
...(nonEmptyString(raw.imageParameters) ? { imageParameters: raw.imageParameters } : {}),
|
||||
...(nonEmptyString(raw.ratio) ? { ratio: raw.ratio } : {}),
|
||||
...(nonEmptyString(raw.quality) ? { quality: raw.quality } : {}),
|
||||
...(nonEmptyString(raw.imageResolution) ? { imageResolution: raw.imageResolution } : {}),
|
||||
...(nonEmptyString(raw.count) ? { count: raw.count } : {}),
|
||||
};
|
||||
return Object.keys(preferences).length ? preferences : undefined;
|
||||
|
||||
@@ -288,6 +288,8 @@ export type CanvasNodeMetadata = {
|
||||
workflowParameters?: Record<string, unknown>;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
imageResolution?: string;
|
||||
imageParameters?: string;
|
||||
transparentBackground?: string;
|
||||
count?: number;
|
||||
textCount?: number;
|
||||
|
||||
Reference in new issue
Block a user