diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 02b0ba782..571e4ba7f 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -8,7 +8,7 @@ import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue } from "@/lib/model-capabilities"; -import { modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; +import { modelCompatibilityError, resolveCompatibleModel, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -153,7 +153,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
将使用当前默认模型与生成参数
) : (
event.stopPropagation()}> - onConfigChange(node.id, { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} /> + onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} /> {mode === "video" ? ( onConfigChange(node.id, videoConfigPatch(key, value))} /> ) : mode === "image" ? ( @@ -227,7 +227,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; const storedModel = node.metadata?.model; const preferredModel = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel; - const model = resolveCompatibleModel(globalConfig, preferredModel, requirements) || preferredModel; + const model = resolveCompatibleModel(globalConfig, preferredModel, mode === "image" ? { ...requirements, imageSize: node.metadata?.size || globalConfig.size || defaultConfig.size } : requirements) || preferredModel; const imageProfile = mode === "image" ? modelCapabilityConfigFor(globalConfig, model).image! : undefined; const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { size: node.metadata?.size || globalConfig.size || defaultConfig.size, quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count) }) : undefined; const videoProfile = mode === "video" ? modelCapabilityConfigFor(globalConfig, model).video! : undefined; diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 7cc448e3e..7e0d2797f 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -8,7 +8,7 @@ import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-genera import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; -import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; +import { resolveCompatibleModel, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -238,7 +238,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi fullWidth config={config} value={config.model} - onChange={(model) => onConfigChange(node.id, { model })} + onChange={(model) => onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} @@ -546,7 +546,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel; const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; const preferredModel = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel; - const model = resolveCompatibleModel(globalConfig, preferredModel, requirements) || preferredModel; + const model = resolveCompatibleModel(globalConfig, preferredModel, mode === "image" ? { ...requirements, imageSize: node.metadata?.size || globalConfig.size || defaultConfig.size } : requirements) || preferredModel; return { ...globalConfig, model, diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index 548a63075..72249081e 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -3,7 +3,8 @@ import { ConfigProvider, Switch } from "antd"; import { type CanvasTheme } from "@/lib/canvas-theme"; import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; -import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities"; +import { normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities"; +import { mergedImageCapabilityConfig } from "@/lib/model-selection"; import { type AiConfig } from "@/stores/use-config-store"; const qualityOptions = [ @@ -50,7 +51,7 @@ type ImageSettingsPanelProps = { export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = true, showCount = true, className = "w-[304px] space-y-3 rounded-2xl px-1 py-0.5", maxCount = 15, quickCount = 3 }: ImageSettingsPanelProps) { const [snapDimensionToStep, setSnapDimensionToStep] = useState(true); - const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!; + const profile = mergedImageCapabilityConfig(config, config.model || config.imageModel); const normalized = normalizeImageValue(profile, config); const quality = normalized.quality; const transparentBackground = normalized.transparentBackground === "true"; diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 36476e734..5e3e9178d 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -145,28 +145,26 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
{group.models.map((modelGroup) => { const selected = modelGroup.models.includes(current); - const model = compatibleModelInGroup(config, modelGroup.models, requirements, selected ? current : undefined); - const displayModel = model || (selected ? current : modelGroup.models[0]); - const disabledReason = model ? "" : modelCompatibilityError(config, modelGroup.models[0], requirements) || "当前输入不符合该模型能力"; + const compatibleModel = compatibleModelInGroup(config, modelGroup.models, requirements, selected ? current : undefined); + const model = compatibleModel || modelGroup.models[0]; + const displayModel = model; + const incompatibleReason = compatibleModel ? "" : modelCompatibilityError(config, modelGroup.models[0], requirements) || "当前输入不符合该模型能力,切换后将重置参数"; return ( ); diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts index 822ab35f2..6d545f728 100644 --- a/web/src/lib/canvas/canvas-project-generation.ts +++ b/web/src/lib/canvas/canvas-project-generation.ts @@ -372,7 +372,8 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode); const preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel; - const model = resolveCompatibleModel(config, preferredModel, requirements) || preferredModel; + const imageSize = mode === "image" ? node?.metadata?.size || config.size || defaultConfig.size : undefined; + const model = resolveCompatibleModel(config, preferredModel, imageSize ? { ...requirements, imageSize } : requirements) || preferredModel; const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, model).image! : undefined; const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts index 7ea92ebaf..2afc949a7 100644 --- a/web/src/lib/model-selection.ts +++ b/web/src/lib/model-selection.ts @@ -1,4 +1,4 @@ -import { modelCapabilityConfigFor, videoDurationAllowed } from "@/lib/model-capabilities"; +import { defaultImageCapabilityConfig, modelCapabilityConfigFor, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities"; import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; export type ModelInputSummary = { @@ -14,6 +14,7 @@ export type ModelRequirements = { input?: ModelInputSummary; videoOperation?: string; videoSeconds?: string; + imageSize?: string; }; export type DisplayModelGroup = { @@ -56,8 +57,9 @@ export function modelCompatibilityError(config: AiConfig, model: string, require if (capability === "image") { if (input.videoCount > 0) return "图片模型不支持参考视频"; if (input.audioCount > 0) return "图片模型不支持参考音频"; - const maxImages = modelCapabilityConfigFor(config, model).image!.references.maxImages; - if (visualInputCount > maxImages) return `最多支持 ${maxImages} 张参考图`; + const image = modelCapabilityConfigFor(config, model).image!; + if (visualInputCount > image.references.maxImages) return `最多支持 ${image.references.maxImages} 张参考图`; + if (requirements.imageSize && !image.size.allowCustom && !image.size.values.includes(requirements.imageSize)) return "不支持当前尺寸"; return ""; } @@ -81,8 +83,15 @@ export function modelCompatibilityError(config: AiConfig, model: string, require } export function compatibleModelInGroup(config: AiConfig, models: string[], requirements?: ModelRequirements, preferred?: string) { - const ordered = preferred && models.includes(preferred) ? [preferred, ...models.filter((model) => model !== preferred)] : models; - return ordered.find((model) => !modelCompatibilityError(config, model, requirements)) || ""; + const compatible = models.filter((model) => !modelCompatibilityError(config, model, requirements)); + if (!compatible.length) return ""; + if (compatible.length === 1) return compatible[0]; + const priceOf = (model: string) => { + const channel = resolveModelChannel(config, model); + const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model)); + return cost && Number.isFinite(cost.unitPriceMicrocredits) ? cost.unitPriceMicrocredits : Number.POSITIVE_INFINITY; + }; + return compatible.sort((left, right) => priceOf(left) - priceOf(right) || (left === preferred ? -1 : right === preferred ? 1 : 0))[0]; } export function resolveCompatibleModel(config: AiConfig, selected: string, requirements?: ModelRequirements) { @@ -94,6 +103,29 @@ export function resolveCompatibleModel(config: AiConfig, selected: string, requi return compatibleModelInGroup(config, selectedGroup.models, requirements, selected); } +// 同显示名分组的模型族:尺寸/比例/分辨率选项取组内全部模型配置的并集, +// 让用户能看到并选择任意成员支持的能力,选中后由兼容路由落到具体模型。 +export function mergedImageCapabilityConfig(config: AiConfig, selected: string): ImageCapabilityConfig { + const options = selectableModelsByCapability(config, "image"); + const group = options.length ? groupModelsByDisplayName(config, options).find((item) => item.models.includes(selected)) : undefined; + const models = group?.models.length ? group.models : [selected]; + const profiles = models.map((model) => modelCapabilityConfigFor(config, model).image).filter((profile): profile is ImageCapabilityConfig => Boolean(profile)); + if (profiles.length <= 1) return profiles[0] || defaultImageCapabilityConfig(); + const values = Array.from(new Set(profiles.flatMap((profile) => profile.size.values))); + return { ...profiles[0], size: { ...profiles[0].size, values } }; +} + +// 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。 +export function defaultImageParamsForModel(config: AiConfig, model: string): Pick { + const image = modelCapabilityConfigFor(config, model).image; + if (!image) return { size: "1:1", quality: "auto", transparentBackground: "false" }; + return { + size: image.size.default || image.size.values[0] || "1:1", + quality: image.quality.default || "auto", + transparentBackground: String(image.transparentBackground.default ?? false), + }; +} + export function maxModelInputCapacity(config: AiConfig, capability: "image" | "video", kind: "image" | "video" | "audio") { const options = selectableModelsByCapability(config, capability); if (!options.length) return null; diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 5b61ca8c7..a56dabed4 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -17,7 +17,7 @@ import { useCopyText } from "@/hooks/use-copy-text"; import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; -import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; +import { resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection"; import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task"; import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image"; import { listAddedSkills, type Skill } from "@/services/api/skills"; @@ -173,7 +173,8 @@ export default function CreatePage() { characterCount: 0, }, videoSeconds: seconds, - }), [attachments, hasPrompt, mode, seconds]); + imageSize: mode === "image" ? ratio : undefined, + }), [attachments, hasPrompt, mode, ratio, seconds]); const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel; const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]); const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]); @@ -1127,15 +1128,17 @@ function GenerationSettingsMenu(props: ComposerProps) { const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio)); const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" }); const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动"; - const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(props.imageProfile.size); - const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(props.imageProfile.size.values) : []; + // 尺寸/比例/分辨率选项取同显示名分组内全部模型的并集,路由模型只决定发送参数。 + const mergedProfile = mergedImageCapabilityConfig(props.config, props.model || props.config.imageModel); + const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(mergedProfile.size); + const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(mergedProfile.size.values) : []; const activeImageResolution = usesImageResolutionPicker ? imageResolutionOption(imageResolutionOptions, props.ratio) : undefined; const activeImageRatio = activeImageResolution?.ratio || imageRatioForSize(props.ratio) || (props.ratio.includes(":") ? props.ratio : "1:1"); const activeImageResolutionChoice: ImageResolutionChoice = activeImageResolution?.tier || "auto"; - const imageResolutionChoiceOptions = usesImageResolutionPicker ? imageResolutionChoices(props.imageProfile.size.values) : []; + const imageResolutionChoiceOptions = usesImageResolutionPicker ? imageResolutionChoices(mergedProfile.size.values) : []; const imageRatios = usesImageResolutionPicker ? Array.from(new Set(imageResolutionOptions.filter((item) => !activeImageResolution || item.tier === activeImageResolution.tier).map((item) => item.ratio))) - : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value); + : mergedProfile.size.values.length ? mergedProfile.size.values : ratioOptions.map((item) => item.value); const ratios = props.mode === "video" ? props.videoProfile.ratios : imageRatios; const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions; const selectImageRatio = (nextRatio: string) => { @@ -1147,20 +1150,20 @@ function GenerationSettingsMenu(props: ComposerProps) { }; const selectImageResolution = (choice: ImageResolutionChoice) => { if (choice === "auto") { - props.setRatio(props.imageProfile.size.values.includes("auto") ? "auto" : activeImageRatio); + props.setRatio(mergedProfile.size.values.includes("auto") ? "auto" : activeImageRatio); return; } const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size; if (nextSize) props.setRatio(nextSize); }; const imageSummary = [ - ...(props.imageProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []), + ...(mergedProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []), ...(props.imageProfile.quality.supported ? [qualityLabel] : []), ...(props.imageProfile.maxOutputs > 1 ? [props.count] : []), ].join(" · "); const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : imageSummary; const panel =
- {props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} + {props.mode === "video" || mergedProfile.size.parameter !== "none" ?
{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
{props.mode !== "video" && mergedProfile.size.allowCustom && (customRatioOpen ? : )}
: null} {props.mode === "video" ?
{resolutions.map((option) => )}
: <> {imageResolutionChoiceOptions.length ?
{imageResolutionChoiceOptions.map((choice) => )}
: null} {props.imageProfile.quality.supported ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
{activeQualityOptions.map((option) => )}
: null}