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

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

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

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

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

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

* fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
This commit is contained in:
huiku authored and GitHub committed 2026-10-13 19:18:21 +08:00
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),