package app import ( "encoding/json" "errors" "fmt" "strings" "infinite-canvas/backend/internal/model" ) // ModelCapabilityConfig 是模型能力声明,不包含供应商字段名;协议适配器负责把统一参数映射到上游请求。 type ModelCapabilityConfig struct { Version int `json:"version"` Text *TextCapabilityConfig `json:"text,omitempty"` Image *ImageCapabilityConfig `json:"image,omitempty"` Video *VideoCapabilityConfig `json:"video,omitempty"` } type TextCapabilityConfig struct { // Streaming controls whether this model accepts upstream SSE text responses. // A nil value is treated as true for backwards compatibility with older configs. Streaming *bool `json:"streaming,omitempty"` // ContextWindowTokens is the provider's total input plus output context window. // It is a model contract, not an application transport ceiling. ContextWindowTokens int `json:"contextWindowTokens"` // MaxOutputTokens is the provider's maximum completion/reasoning budget. MaxOutputTokens int `json:"maxOutputTokens"` References TextReferenceConfig `json:"references"` } type TextReferenceConfig struct { PromptMaxChars int `json:"promptMaxChars"` MaxImages int `json:"maxImages"` MaxImageBytes int64 `json:"maxImageBytes"` MaxVideos int `json:"maxVideos"` MaxVideoBytes int64 `json:"maxVideoBytes"` } type ImageCapabilityConfig struct { References ImageReferenceConfig `json:"references"` Size ImageSizeConfig `json:"size"` Quality ImageQualityConfig `json:"quality"` TransparentBackground VideoBooleanConfig `json:"transparentBackground"` ResponseFormat ParameterSupport `json:"responseFormat"` OutputFormat ParameterSupport `json:"outputFormat"` MaxOutputs int `json:"maxOutputs"` } type ImageReferenceConfig struct { PromptMaxChars int `json:"promptMaxChars"` MaxImages int `json:"maxImages"` MaxImageBytes int64 `json:"maxImageBytes"` MaskSupported bool `json:"maskSupported"` } type ImageSizeConfig struct { Parameter string `json:"parameter"` Values []string `json:"values"` Default string `json:"default"` AllowCustom bool `json:"allowCustom"` Presets []ImageSizePreset `json:"presets,omitempty"` } type ImageSizePreset struct { Tier string `json:"tier"` Ratio string `json:"ratio"` Size string `json:"size"` Width int `json:"width"` Height int `json:"height"` } type ImageQualityConfig struct { Supported bool `json:"supported"` Values []string `json:"values"` Default string `json:"default"` } type ParameterSupport struct { Supported bool `json:"supported"` } type VideoCapabilityConfig struct { References VideoReferenceConfig `json:"references"` Duration VideoDurationConfig `json:"duration"` DurationSupported *bool `json:"durationSupported,omitempty"` Ratios []string `json:"ratios"` DefaultRatio string `json:"defaultRatio"` Resolutions []string `json:"resolutions"` DefaultResolution string `json:"defaultResolution"` FixedScreenSpec *VideoScreenSpecConfig `json:"fixedScreenSpec,omitempty"` GenerateAudio VideoBooleanConfig `json:"generateAudio"` Watermark VideoBooleanConfig `json:"watermark"` Operations []string `json:"operations"` DefaultOperation string `json:"defaultOperation"` } type VideoScreenSpecConfig struct { Ratios []string `json:"ratios"` DefaultRatio string `json:"defaultRatio"` Resolutions []string `json:"resolutions"` DefaultResolution string `json:"defaultResolution"` } type VideoReferenceConfig struct { PromptMaxChars int `json:"promptMaxChars"` MinImages int `json:"minImages"` MaxImages int `json:"maxImages"` MaxImageBytes int64 `json:"maxImageBytes"` MaxVideos int `json:"maxVideos"` MaxVideoBytes int64 `json:"maxVideoBytes"` MaxVideoDuration int `json:"maxVideoDurationSeconds"` MaxAudios int `json:"maxAudios"` MaxAudioBytes int64 `json:"maxAudioBytes"` MaxAudioDuration int `json:"maxAudioDurationSeconds"` } // DefaultVideoPromptMaxChars 是普通视频模型提示词字符数的默认上限。 // 视频提示词由输入框文本、连线内容和技能上下文合成,远长于用户手输内容; // 默认值过小会把画布工作流正常可用的提示词拦在本地。管理员仍可按模型覆盖。 const DefaultVideoPromptMaxChars = 8000 type VideoDurationConfig struct { Selection string `json:"selection"` Min int `json:"min,omitempty"` Max int `json:"max,omitempty"` Step int `json:"step,omitempty"` Values []int `json:"values,omitempty"` Default int `json:"default"` } type VideoBooleanConfig struct { Supported bool `json:"supported"` Default bool `json:"default"` } func DecodeModelCapabilityConfig(raw string) (*ModelCapabilityConfig, error) { if strings.TrimSpace(raw) == "" { return nil, nil } var value ModelCapabilityConfig if err := json.Unmarshal([]byte(raw), &value); err != nil { return nil, err } return &value, nil } // normalizedChannelModelCapability 从持久化记录恢复渠道模型的权威能力合同。 // 目录读取可以选择隔离损坏记录;任务创建等写路径必须把错误向上返回并失败关闭。 func normalizedChannelModelCapability(channelModel *model.ChannelModel) (*ModelCapabilityConfig, error) { if channelModel == nil { return nil, errors.New("渠道模型为空") } capability := normalizeCapability(channelModel.Capability) if capability == "audio" { return nil, nil } if capability != "text" && capability != "image" && capability != "video" { return nil, fmt.Errorf("不支持的渠道模型能力:%s", channelModel.Capability) } config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON) if err != nil { return nil, fmt.Errorf("解析渠道模型能力配置失败:%w", err) } normalized, err := NormalizeModelCapabilityConfigForModel(capability, string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config) if err != nil { return nil, err } return normalized, nil } func NormalizeModelCapabilityConfig(capability string, protocol string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) { return NormalizeModelCapabilityConfigForModel(capability, protocol, "", input) } func NormalizeModelCapabilityConfigForModel(capability string, protocol string, modelName string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) { if capability != "text" && capability != "image" && capability != "video" { return nil, nil } if capability == "text" { if input == nil || input.Text == nil { return nil, BadAuthRequest("请配置文本模型能力参数") } text := *input.Text if text.Streaming == nil { streaming := true text.Streaming = &streaming } if text.ContextWindowTokens == 0 { text.ContextWindowTokens = 128000 } if text.MaxOutputTokens == 0 { text.MaxOutputTokens = 16384 } value := &ModelCapabilityConfig{Version: 1, Text: &text} if err := validateTextCapabilityConfig(value.Text); err != nil { return nil, err } return value, nil } if capability == "image" { if input == nil || input.Image == nil { return nil, BadAuthRequest("请配置图片模型能力参数") } value := &ModelCapabilityConfig{Version: 1, Image: input.Image} if err := validateImageCapabilityConfig(value.Image); err != nil { return nil, err } return value, nil } if input == nil || input.Video == nil { return nil, BadAuthRequest("请配置视频模型能力参数") } video := applyModelSpecificVideoCapability(input.Video, protocol, modelName) if strings.TrimSpace(protocol) == "autodl-comfyui" { var err error video, err = normalizeAutoDLVideoScreenSpec(video, modelName) if err != nil { return nil, err } } value := &ModelCapabilityConfig{Version: 1, Video: video} if err := validateVideoCapabilityConfig(value.Video); err != nil { return nil, err } return value, nil } func applyModelSpecificVideoCapability(profile *VideoCapabilityConfig, protocol string, modelName string) *VideoCapabilityConfig { if profile == nil || model.ChannelInterfaceType(strings.TrimSpace(protocol)) != model.ChannelInterfaceAgnesVideo { return profile } normalizedModel := strings.ToLower(strings.TrimSpace(modelName)) if normalizedModel != "agnes-video-2.5" && normalizedModel != "agnes-video-2.5-flash" { return profile } value := *profile value.References = profile.References flash := normalizedModel == "agnes-video-2.5-flash" value.References.MaxImages = 9 value.References.MaxVideos = 3 value.References.MaxAudios = 3 value.References.MaxVideoBytes = 200 * 1024 * 1024 value.References.MaxVideoDuration = 15 value.References.MaxAudioBytes = 15 * 1024 * 1024 value.References.MaxAudioDuration = 15 if flash { value.References.MaxImages = 5 value.References.MaxVideos = 0 value.References.MaxVideoBytes = 0 value.References.MaxVideoDuration = 0 } value.Duration = VideoDurationConfig{Selection: "range", Min: 4, Max: 12, Step: 1, Default: 5} value.Ratios = []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} value.DefaultRatio = "16:9" value.Resolutions = []string{"720P", "960P", "2K"} if flash { value.Resolutions = []string{"720P"} } value.DefaultResolution = "720P" value.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false} value.Watermark = VideoBooleanConfig{Supported: false, Default: false} value.Operations = []string{"text_to_video", "image_to_video", "reference_to_video", "audio_to_video"} value.DefaultOperation = "text_to_video" return &value } // CapabilitySpecFromModelCapabilityConfig 将渠道模型的真实供应能力投影为路由能力规格。 // 渠道模型能力参数是唯一事实来源,前台模型供应线路直接引用该规格。 func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capability string) (CapabilitySpec, error) { spec := CapabilitySpec{Version: 1, Capability: capability, Inputs: map[string]InputConstraint{}, Options: map[string]OptionConstraint{}} // 音频模型当前没有可编辑的渠道能力 JSON,使用空能力规格表示“无额外路由约束”。 if capability == "audio" { return spec, nil } if config == nil { switch capability { case "text": return spec, BadAuthRequest("渠道文本模型尚未配置能力参数") case "image": return spec, BadAuthRequest("渠道图片模型尚未配置能力参数") case "video": return spec, BadAuthRequest("渠道视频模型尚未配置能力参数") default: return spec, BadAuthRequest("渠道模型尚未配置能力参数") } } switch capability { case "text": if config.Text == nil { return spec, BadAuthRequest("渠道文本模型尚未配置能力参数") } addInputConstraint(spec.Inputs, "image", 0, config.Text.References.MaxImages) addInputConstraint(spec.Inputs, "video", 0, config.Text.References.MaxVideos) case "image": if config.Image == nil { return spec, BadAuthRequest("渠道图片模型尚未配置能力参数") } image := config.Image addInputConstraint(spec.Inputs, "image", 0, image.References.MaxImages) if image.References.MaskSupported { addInputConstraint(spec.Inputs, "mask", 0, 1) } if image.Size.Parameter != "none" { spec.Options["size"] = imageSizeOptionConstraint(image.Size) spec.ImageSize = capabilityImageSizeFromConfig(image.Size) } if image.Quality.Supported { spec.Options["quality"] = anyValues(image.Quality.Values) } if image.TransparentBackground.Supported { spec.Options["transparentBackground"] = boolValues(true) } else { spec.Options["transparentBackground"] = boolValues(false) } spec.Options["count"] = numericRange(1, float64(image.MaxOutputs), 1) case "video": if config.Video == nil { return spec, BadAuthRequest("渠道视频模型尚未配置能力参数") } video := config.Video spec.Operations = append([]string(nil), video.Operations...) addInputConstraint(spec.Inputs, "image", video.References.MinImages, video.References.MaxImages) addInputConstraint(spec.Inputs, "video", 0, video.References.MaxVideos) addInputConstraint(spec.Inputs, "audio", 0, video.References.MaxAudios) if video.Duration.Selection == "enum" { values := make([]any, 0, len(video.Duration.Values)) for _, value := range video.Duration.Values { values = append(values, value) } spec.Options["videoSeconds"] = OptionConstraint{Values: values} } else { spec.Options["videoSeconds"] = numericRange(float64(video.Duration.Min), float64(video.Duration.Max), float64(video.Duration.Step)) } if len(video.Ratios) > 0 { spec.Options["size"] = anyValues(video.Ratios) } if len(video.Resolutions) > 0 { spec.Options["vquality"] = anyValues(video.Resolutions) } if video.GenerateAudio.Supported { spec.Options["videoGenerateAudio"] = boolValues(true) } else { spec.Options["videoGenerateAudio"] = boolValues(false) } if video.Watermark.Supported { spec.Options["videoWatermark"] = boolValues(true) } else { spec.Options["videoWatermark"] = boolValues(false) } default: return spec, BadAuthRequest("未知模型能力类型") } return spec, nil } // imageSizeOptionConstraint 保留可见的标准尺寸/比例,同时用 * 表示允许自定义。 // * 不能替代标准值,否则管理端只能看到一个没有业务含义的通配符。 func imageSizeOptionConstraint(size ImageSizeConfig) OptionConstraint { values := make([]string, 0, len(size.Values)+1) seen := make(map[string]struct{}, len(size.Values)+1) for _, value := range size.Values { value = strings.TrimSpace(value) if value == "" || value == "*" { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} values = append(values, value) } if size.AllowCustom { if len(values) == 0 { for _, value := range legacyImageSizeValues() { seen[value] = struct{}{} values = append(values, value) } } values = append(values, "*") } return anyValues(values) } func capabilityImageSizeFromConfig(size ImageSizeConfig) *CapabilityImageSize { if size.Parameter == "" || size.Parameter == "none" { return nil } result := &CapabilityImageSize{Parameter: size.Parameter, AllowCustom: size.AllowCustom} for _, preset := range size.Presets { tier := strings.ToLower(strings.TrimSpace(preset.Tier)) ratio := strings.TrimSpace(preset.Ratio) value := strings.TrimSpace(preset.Size) if tier == "" || ratio == "" || value == "" { continue } result.Presets = append(result.Presets, CapabilityImageSizePreset{ Size: value, Tier: tier, Ratio: ratio, Width: preset.Width, Height: preset.Height, }) } return result } func addInputConstraint(inputs map[string]InputConstraint, name string, min int, max int) { if min <= 0 && max <= 0 { return } inputs[name] = InputConstraint{Min: min, Max: max} }