diff --git a/assets/prototypes/business-channel-architecture.drawio b/assets/prototypes/business-channel-architecture.drawio new file mode 100644 index 000000000..315275514 --- /dev/null +++ b/assets/prototypes/business-channel-architecture.drawio @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 1a90d6c29..ed47eab1b 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -237,6 +237,9 @@ type ChannelModel struct { PriceConfigured bool `json:"priceConfigured" gorm:"index"` Enabled bool `json:"enabled" gorm:"index"` PriceVersion int64 `json:"priceVersion"` + CapabilityConfigJSON string `json:"-" gorm:"type:text"` + CapabilityVersion int64 `json:"capabilityVersion"` + CapabilityConfig map[string]any `json:"capabilityConfig,omitempty" gorm:"-"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 66cfbc271..9477461a4 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -123,6 +123,7 @@ type PublicChannelModelPrice struct { InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig,omitempty"` } func (s *Service) RequireAdmin(user *model.User) error { @@ -749,7 +750,8 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model } models = append(models, item.ModelKey) if item.Enabled && item.PriceConfigured { - modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits}) + capabilityConfig, _ := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig}) } } if len(models) == 0 { diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 60e9b0c1c..0d99fc58d 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -14,17 +14,18 @@ import ( ) type ChannelModelRequest struct { - ModelKey string `json:"modelKey"` - DisplayName string `json:"displayName"` - Capability string `json:"capability"` - Protocol string `json:"protocol"` - BillingMode string `json:"billingMode"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` - OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` - CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` - PriceConfigured bool `json:"priceConfigured"` - Enabled *bool `json:"enabled"` + ModelKey string `json:"modelKey"` + DisplayName string `json:"displayName"` + Capability string `json:"capability"` + Protocol string `json:"protocol"` + BillingMode string `json:"billingMode"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + PriceConfigured bool `json:"priceConfigured"` + Enabled *bool `json:"enabled"` + CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"` } // AdminChannelModelFetchResult 是管理员从上游拉目录后的汇总:models 为去重后的标识,added 为本次新建条数。 @@ -63,7 +64,20 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod if _, err := s.repo.AdminSystemChannel(channelID); err != nil { return nil, err } - return s.ensureChannelModels(channelID, true) + items, err := s.ensureChannelModels(channelID, true) + if err != nil { + return nil, err + } + for index := range items { + if strings.TrimSpace(items[index].CapabilityConfigJSON) == "" { + continue + } + var config map[string]any + if json.Unmarshal([]byte(items[index].CapabilityConfigJSON), &config) == nil { + items[index].CapabilityConfig = config + } + } + return items, nil } func (s *Service) SystemChannelModel(channelID string, modelKey string) (*model.ChannelModel, error) { @@ -123,6 +137,11 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if err != nil { return nil, err } + if capability == "video" { + if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil { + return nil, err + } + } billingMode := strings.TrimSpace(req.BillingMode) if billingMode == "" { billingMode = "fixed_request" @@ -174,6 +193,23 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id item.OutputTokenPriceMicrocredits = req.OutputTokenPriceMicrocredits item.CachedTokenPriceMicrocredits = req.CachedTokenPriceMicrocredits item.PriceConfigured = req.PriceConfigured + if capability == "video" { + capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig) + if normalizeErr != nil { + return nil, normalizeErr + } + encoded, encodeErr := json.Marshal(capabilityConfig) + if encodeErr != nil { + return nil, encodeErr + } + if item.CapabilityConfigJSON != string(encoded) { + item.CapabilityVersion++ + } + item.CapabilityConfigJSON = string(encoded) + } else { + item.CapabilityConfigJSON = "" + item.CapabilityVersion = 0 + } if req.Enabled != nil { item.Enabled = *req.Enabled } @@ -198,6 +234,11 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, if err != nil { return nil, err } + if capability == "video" { + if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil { + return nil, err + } + } if strings.TrimSpace(channel.BaseURL) == "" || strings.TrimSpace(channel.APIKey) == "" { return nil, BadAuthRequest("请先在渠道中配置 Base URL 和 API Key") } diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go new file mode 100644 index 000000000..c429b19f3 --- /dev/null +++ b/backend/internal/service/model_capability.go @@ -0,0 +1,332 @@ +package service + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "infinite-canvas/backend/internal/model" +) + +// ModelCapabilityConfig 是模型能力声明,不包含供应商字段名;协议适配器负责把统一参数映射到上游请求。 +type ModelCapabilityConfig struct { + Version int `json:"version"` + Video *VideoCapabilityConfig `json:"video,omitempty"` +} + +type VideoCapabilityConfig struct { + References VideoReferenceConfig `json:"references"` + Duration VideoDurationConfig `json:"duration"` + Ratios []string `json:"ratios"` + DefaultRatio string `json:"defaultRatio"` + Resolutions []string `json:"resolutions"` + DefaultResolution string `json:"defaultResolution"` + GenerateAudio VideoBooleanConfig `json:"generateAudio"` + Watermark VideoBooleanConfig `json:"watermark"` + Operations []string `json:"operations"` + DefaultOperation string `json:"defaultOperation"` +} + +type VideoReferenceConfig struct { + PromptMaxChars int `json:"promptMaxChars"` + 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"` +} + +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 DefaultModelCapabilityConfig(protocol string) *ModelCapabilityConfig { + video := &VideoCapabilityConfig{ + References: VideoReferenceConfig{PromptMaxChars: 1000, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0}, + Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6}, + Ratios: []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}, + DefaultRatio: "16:9", + Resolutions: []string{"480p", "720p", "1080p", "2160p"}, + DefaultResolution: "720p", + GenerateAudio: VideoBooleanConfig{Supported: false, Default: false}, + Watermark: VideoBooleanConfig{Supported: false, Default: false}, + Operations: []string{"text_to_video", "image_to_video"}, + DefaultOperation: "text_to_video", + } + switch model.ChannelInterfaceType(protocol) { + case model.ChannelInterfaceVolcengineJiMengVideo: + video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{5, 10}, Default: 5} + video.Resolutions = []string{"720p"} + case model.ChannelInterfaceGeminiVeo: + video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 6, 8}, Default: 6} + video.Resolutions = []string{"720p", "1080p"} + case model.ChannelInterfaceVolcengineArkVideo: + video.References.MaxVideos, video.References.MaxAudios = 3, 3 + video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024 + video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 + video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} + video.Watermark = VideoBooleanConfig{Supported: true, Default: false} + case model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2: + video.References.MaxVideos, video.References.MaxAudios = 3, 3 + video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024 + video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 + video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} + case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo: + video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false} + } + return &ModelCapabilityConfig{Version: 1, Video: video} +} + +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 +} + +func NormalizeModelCapabilityConfig(capability string, protocol string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) { + if capability != "video" { + return nil, nil + } + if input == nil || input.Video == nil { + return nil, BadAuthRequest("请配置视频模型能力参数") + } + value := &ModelCapabilityConfig{Version: 1, Video: input.Video} + if err := validateVideoCapabilityConfig(value.Video); err != nil { + return nil, err + } + return value, nil +} + +func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error { + if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { + return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") + } + for name, number := range map[string]int{"最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} { + if number < 0 || number > 100 { + return BadAuthRequest(name + "必须在 0-100 之间") + } + } + if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 || value.References.MaxAudioBytes < 0 || value.References.MaxVideoDuration < 0 || value.References.MaxAudioDuration < 0 { + return BadAuthRequest("引用素材限制不能小于 0") + } + if err := validateVideoDuration(value.Duration); err != nil { + return err + } + if len(value.Ratios) == 0 || strings.TrimSpace(value.DefaultRatio) == "" || !containsCapabilityString(value.Ratios, value.DefaultRatio) { + return BadAuthRequest("请至少配置一个画面比例,并选择默认比例") + } + if len(value.Resolutions) == 0 || strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) { + return BadAuthRequest("请至少配置一个输出分辨率,并选择默认分辨率") + } + if len(value.Operations) == 0 || strings.TrimSpace(value.DefaultOperation) == "" || !containsCapabilityString(value.Operations, value.DefaultOperation) { + return BadAuthRequest("请至少配置一个生成模式,并选择默认模式") + } + return nil +} + +func validateVideoDuration(value VideoDurationConfig) error { + switch value.Selection { + case "range": + if value.Min < 1 || value.Max < value.Min || value.Max > 3600 || value.Step < 1 || value.Default < value.Min || value.Default > value.Max || (value.Default-value.Min)%value.Step != 0 { + return BadAuthRequest("视频时长范围或默认值无效") + } + case "enum": + if len(value.Values) == 0 || len(value.Values) > 100 { + return BadAuthRequest("视频固定时长至少需要一个选项") + } + values := append([]int(nil), value.Values...) + sort.Ints(values) + for index, item := range values { + if item < 1 || item > 3600 || (index > 0 && values[index-1] == item) { + return BadAuthRequest("视频固定时长选项无效或重复") + } + } + if !containsInt(values, value.Default) { + return BadAuthRequest("视频默认时长必须属于固定时长选项") + } + default: + return BadAuthRequest("视频时长选择方式仅支持范围或固定值") + } + return nil +} + +func (s *Service) ValidateTaskCapability(input map[string]any) error { + encoded, err := json.Marshal(input) + if err != nil { + return BadAuthRequest("任务输入格式无效") + } + var taskInput canvasGenerationInput + if err := json.Unmarshal(encoded, &taskInput); err != nil || taskInput.Mode != "video" { + return nil + } + channelID := strings.TrimSpace(taskInput.Config.ChannelID) + if channelID == "" { + channelID = systemChannelIDFromBaseURL(taskInput.Config.BaseURL) + } + if channelID == "" { + if taskInput.Config.CapabilityConfig == nil || taskInput.Config.CapabilityConfig.Video == nil { + return nil + } + return validateVideoTask(taskInput.Config.CapabilityConfig.Video, taskInput) + } + item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(taskInput.Config.Model), "models/")) + if err != nil { + return BadAuthRequest("当前系统渠道模型未配置或已停用") + } + profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + if err != nil || profile == nil || profile.Video == nil { + return BadAuthRequest("当前视频模型尚未配置能力参数") + } + return validateVideoTask(profile.Video, taskInput) +} + +func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInput) error { + if utf8.RuneCountInString(input.Prompt) > profile.References.PromptMaxChars { + return BadAuthRequest(fmt.Sprintf("提示词超过当前模型限制(最多 %d 字)", profile.References.PromptMaxChars)) + } + if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios { + return BadAuthRequest("参考素材数量超过当前模型限制") + } + for _, media := range input.ReferenceImages { + if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes { + return BadAuthRequest("参考图片文件超过当前模型大小限制") + } + } + for _, media := range input.ReferenceVideos { + if profile.References.MaxVideoBytes > 0 && media.Bytes > profile.References.MaxVideoBytes { + return BadAuthRequest("参考视频文件超过当前模型大小限制") + } + if profile.References.MaxVideoDuration > 0 && media.DurationMs > int64(profile.References.MaxVideoDuration)*1000 { + return BadAuthRequest("参考视频时长超过当前模型限制") + } + } + for _, media := range input.ReferenceAudios { + if profile.References.MaxAudioBytes > 0 && media.Bytes > profile.References.MaxAudioBytes { + return BadAuthRequest("参考音频文件超过当前模型大小限制") + } + if profile.References.MaxAudioDuration > 0 && media.DurationMs > int64(profile.References.MaxAudioDuration)*1000 { + return BadAuthRequest("参考音频时长超过当前模型限制") + } + } + seconds, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds)) + if err != nil || !videoDurationAllowed(profile.Duration, seconds) { + return BadAuthRequest("视频时长不在当前模型支持范围内") + } + if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) { + return BadAuthRequest("画面比例不在当前模型支持范围内") + } + if input.Config.VQuality != "" && !containsCapabilityString(profile.Resolutions, normalizeResolution(input.Config.VQuality)) { + return BadAuthRequest("输出分辨率不在当前模型支持范围内") + } + operation := metadataString(input.Metadata, "videoEditOperation") + if operation == "" { + if len(input.ReferenceImages) > 0 { + operation = "image_to_video" + } else { + operation = profile.DefaultOperation + } + } + if !containsCapabilityString(profile.Operations, operation) { + return BadAuthRequest("当前视频模型不支持该生成模式") + } + return nil +} + +func videoDurationAllowed(value VideoDurationConfig, seconds int) bool { + if value.Selection == "enum" { + return containsInt(value.Values, seconds) + } + return seconds >= value.Min && seconds <= value.Max && value.Step > 0 && (seconds-value.Min)%value.Step == 0 +} + +func videoRatioAllowed(options []string, value string) bool { + value = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(value, "×", "x"))) + if containsCapabilityString(options, value) { + return true + } + parts := strings.Split(value, "x") + if len(parts) != 2 { + return false + } + width, widthErr := strconv.ParseFloat(parts[0], 64) + height, heightErr := strconv.ParseFloat(parts[1], 64) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return false + } + actual := width / height + for _, option := range options { + candidate := ratioValue(option) + if candidate > 0 && absFloat(candidate-actual)/candidate < 0.01 { + return true + } + } + return false +} + +func ratioValue(value string) float64 { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) != 2 { + return 0 + } + width, widthErr := strconv.ParseFloat(parts[0], 64) + height, heightErr := strconv.ParseFloat(parts[1], 64) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return 0 + } + return width / height +} + +func normalizeResolution(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.TrimSuffix(value, "p") + if value == "4k" { + return "2160p" + } + return value + "p" +} + +func containsCapabilityString(values []string, target string) bool { + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) { + return true + } + } + return false +} + +func containsInt(values []int, target int) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func absFloat(value float64) float64 { + if value < 0 { + return -value + } + return value +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index cdcc4ed3f..84d1766fa 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -31,30 +31,32 @@ type canvasGenerationInput struct { ReferenceAudios []providerMedia `json:"referenceAudios"` Mask *providerMedia `json:"mask"` Metadata map[string]interface{} `json:"metadata"` + VideoCapability *VideoCapabilityConfig `json:"-"` } type providerConfig struct { - ChannelID string `json:"channelId"` - APIFormat string `json:"apiFormat"` - InterfaceType string `json:"interfaceType"` - BaseURL string `json:"baseUrl"` - APIKey string `json:"apiKey"` - SecretKey string `json:"secretKey"` - Headers []OutboundHeader `json:"headers"` - Model string `json:"model"` - Size string `json:"size"` - Quality string `json:"quality"` - TransparentBackground string `json:"transparentBackground"` - Count string `json:"count"` - VideoSeconds string `json:"videoSeconds"` - VQuality string `json:"vquality"` - VideoGenerateAudio string `json:"videoGenerateAudio"` - VideoWatermark string `json:"videoWatermark"` - AudioVoice string `json:"audioVoice"` - AudioFormat string `json:"audioFormat"` - AudioSpeed string `json:"audioSpeed"` - AudioInstructions string `json:"audioInstructions"` - SystemPrompt string `json:"systemPrompt"` + ChannelID string `json:"channelId"` + APIFormat string `json:"apiFormat"` + InterfaceType string `json:"interfaceType"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + SecretKey string `json:"secretKey"` + Headers []OutboundHeader `json:"headers"` + Model string `json:"model"` + Size string `json:"size"` + Quality string `json:"quality"` + TransparentBackground string `json:"transparentBackground"` + Count string `json:"count"` + VideoSeconds string `json:"videoSeconds"` + VQuality string `json:"vquality"` + VideoGenerateAudio string `json:"videoGenerateAudio"` + VideoWatermark string `json:"videoWatermark"` + AudioVoice string `json:"audioVoice"` + AudioFormat string `json:"audioFormat"` + AudioSpeed string `json:"audioSpeed"` + AudioInstructions string `json:"audioInstructions"` + SystemPrompt string `json:"systemPrompt"` + CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"` } const providerHTTPTimeout = 5 * time.Minute @@ -69,6 +71,10 @@ type providerMedia struct { URL string `json:"url"` StorageKey string `json:"storageKey"` MimeType string `json:"mimeType"` + Bytes int64 `json:"bytes"` + Width int `json:"width"` + Height int `json:"height"` + DurationMs int64 `json:"durationMs"` } type imageResponse struct { @@ -183,12 +189,22 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string if isVolcengineJiMengProtocol(input.Config.InterfaceType) && strings.TrimSpace(input.Config.SecretKey) == "" { return nil, errors.New("即梦官方 API 缺少 Secret Key") } + if input.Mode == "video" { + if err := s.validateResolvedVideoCapability(&input); err != nil { + return nil, err + } + } if resumedProviderRequestID(ctx) == "" { requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2" || input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) if err := s.hydrateGenerationMedia(userID, &input, requirePublicURL); err != nil { return nil, err } } + if input.Mode == "video" && input.VideoCapability != nil { + if err := validateVideoTask(input.VideoCapability, input); err != nil { + return nil, err + } + } switch input.Mode { case "image": return runImageTask(ctx, input) @@ -207,6 +223,27 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } } +func (s *Service) validateResolvedVideoCapability(input *canvasGenerationInput) error { + channelID := strings.TrimSpace(input.Config.ChannelID) + if channelID == "" { + if input.Config.CapabilityConfig == nil || input.Config.CapabilityConfig.Video == nil { + return nil + } + input.VideoCapability = input.Config.CapabilityConfig.Video + return validateVideoTask(input.VideoCapability, *input) + } + item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(input.Config.Model), "models/")) + if err != nil { + return errors.New("当前系统渠道模型未配置或已停用") + } + profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + if err != nil || profile == nil || profile.Video == nil { + return errors.New("当前视频模型尚未配置能力参数") + } + input.VideoCapability = profile.Video + return validateVideoTask(profile.Video, *input) +} + func metadataStringValues(value any) map[string]string { values := map[string]string{} raw, ok := value.(map[string]interface{}) @@ -257,6 +294,10 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requ media.URL = signedURL media.DataURL = "" media.MimeType = firstNonEmpty(media.MimeType, resource.MimeType) + media.Bytes = resource.Size + media.Width = resource.Width + media.Height = resource.Height + media.DurationMs = resource.DurationMs return nil } if strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") { @@ -282,6 +323,10 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requ mimeType := normalizedMediaMimeType(firstNonEmpty(media.MimeType, resource.MimeType), data) media.DataURL = dataURL(mimeType, data) media.MimeType = mimeType + media.Bytes = int64(len(data)) + media.Width = resource.Width + media.Height = resource.Height + media.DurationMs = resource.DurationMs return nil } @@ -1385,12 +1430,14 @@ func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{ ratio := normalizeNewAPIChannel2Ratio(input.Config.Size, modelName) resolution := normalizeNewAPIChannel2Resolution(input.Config.VQuality, modelName) body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": strings.TrimSpace(input.Prompt), - "seconds": strconv.Itoa(seconds), - "aspect_ratio": ratio, - "resolution": resolution, - "generate_audio": parseBool(input.Config.VideoGenerateAudio, true), + "model": input.Config.Model, + "prompt": strings.TrimSpace(input.Prompt), + "seconds": strconv.Itoa(seconds), + "aspect_ratio": ratio, + "resolution": resolution, + } + if videoCapabilitySupportsAudio(input) { + body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true) } if len(images) > 0 { body["image_urls"] = images @@ -1557,16 +1604,19 @@ func newAPIChannel1VideoBody(input canvasGenerationInput) (map[string]interface{ } media = append(media, map[string]string{"type": "reference_voice", "url": url}) } + parameters := map[string]interface{}{ + "resolution": normalizeNewAPIChannel1Resolution(input.Config.VQuality), + "ratio": normalizeNewAPIChannel1Ratio(input.Config.Size), + "prompt_extend": false, + "duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds), + } + if videoCapabilitySupportsWatermark(input) { + parameters["watermark"] = parseBool(input.Config.VideoWatermark, false) + } body := map[string]interface{}{ - "model": input.Config.Model, - "input": map[string]interface{}{"prompt": strings.TrimSpace(input.Prompt)}, - "parameters": map[string]interface{}{ - "resolution": normalizeNewAPIChannel1Resolution(input.Config.VQuality), - "ratio": normalizeNewAPIChannel1Ratio(input.Config.Size), - "prompt_extend": false, - "watermark": parseBool(input.Config.VideoWatermark, false), - "duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds), - }, + "model": input.Config.Model, + "input": map[string]interface{}{"prompt": strings.TrimSpace(input.Prompt)}, + "parameters": parameters, } if len(media) > 0 { body["input"].(map[string]interface{})["media"] = media @@ -1758,13 +1808,17 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn } } body := map[string]interface{}{ - "model": input.Config.Model, - "content": content, - "ratio": normalizeSeedanceRatio(input.Config.Size), - "resolution": normalizeSeedanceResolution(input.Config.VQuality, input.Config.Model), - "duration": normalizeSeedanceDuration(input.Config.VideoSeconds), - "generate_audio": parseBool(input.Config.VideoGenerateAudio, true), - "watermark": parseBool(input.Config.VideoWatermark, false), + "model": input.Config.Model, + "content": content, + "ratio": normalizeSeedanceRatio(input.Config.Size), + "resolution": normalizeSeedanceResolution(input.Config.VQuality, input.Config.Model), + "duration": normalizeSeedanceDuration(input.Config.VideoSeconds), + } + if videoCapabilitySupportsAudio(input) { + body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true) + } + if videoCapabilitySupportsWatermark(input) { + body["watermark"] = parseBool(input.Config.VideoWatermark, false) } if err := postJSON(ctx, input.Config, "/contents/generations/tasks", body, &created); err != nil { return nil, err @@ -2297,6 +2351,15 @@ func shouldSendNewAPIVideoImages(input canvasGenerationInput) bool { return strings.TrimSpace(operation) != "text_to_video" } +// 本地测试 helper 没有能力配置时保留历史协议字段;真实系统任务会携带已解析的模型能力。 +func videoCapabilitySupportsAudio(input canvasGenerationInput) bool { + return input.VideoCapability == nil || input.VideoCapability.GenerateAudio.Supported +} + +func videoCapabilitySupportsWatermark(input canvasGenerationInput) bool { + return input.VideoCapability == nil || input.VideoCapability.Watermark.Supported +} + func newAPIVideoPromptText(input canvasGenerationInput) string { return strings.TrimSpace(input.Prompt) } @@ -2306,11 +2369,13 @@ func seedanceVideosBody(input canvasGenerationInput) (map[string]interface{}, er return nil, errors.New("Seedance 参考视频或参考音频需要同时连接至少 1 张主参考图") } body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": seedanceVideosPromptText(input), - "aspect_ratio": normalizeSeedanceVideosRatio(input.Config.Size), - "duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds), - "generate_audio": parseBool(input.Config.VideoGenerateAudio, true), + "model": input.Config.Model, + "prompt": seedanceVideosPromptText(input), + "aspect_ratio": normalizeSeedanceVideosRatio(input.Config.Size), + "duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds), + } + if videoCapabilitySupportsAudio(input) { + body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true) } imageURLs := make([]string, 0, len(input.ReferenceImages)) for _, image := range input.ReferenceImages { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index c3483042b..46775e709 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -320,6 +320,9 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, if err != nil { return nil, err } + if err := s.ValidateTaskCapability(normalizedInput); err != nil { + return nil, err + } if containsInlineMediaDataURL(normalizedInput) { return nil, BadAuthRequest("任务输入不能包含内嵌媒体,请先上传到资源存储") } diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 0947c14e5..6f33d1d89 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -7,6 +7,7 @@ import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resol import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; +import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationAllowed } from "@/lib/model-capabilities"; import { navigateToSettings } from "@/lib/settings-navigation"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -45,7 +46,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC const mode = node.metadata?.generationMode || "image"; const simpleMode = workspaceMode === "simple"; const config = buildNodeConfig(globalConfig, node, mode); - const operationOptions = node.metadata?.videoEditOperation === "concat" ? [...videoOperationOptions, { label: "合并成片", value: "concat" as const }] : videoOperationOptions; + const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined; + const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions; const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const priceChannel = resolveModelChannel(config, config.model); const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 }); @@ -53,7 +55,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }; const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount); const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim()); - const canGenerate = hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput); + const capabilityError = videoProfile ? videoCapabilityError(videoProfile, config.videoSeconds, node.metadata?.composerContent ?? node.metadata?.prompt ?? "", inputSummary, node.metadata?.videoEditOperation) : ""; + const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError; return (
event.stopPropagation()}> @@ -153,6 +156,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
)} + {capabilityError ?
{capabilityError}
: null} + ) : null} {showStatusTrack ? (
- {resourceLabel ? : null} + {resourceLabel && data.type !== CanvasNodeType.Image ? : null} {hasMediaContent && !readOnly ? : null} {isBatchRoot ? onToggleBatch?.(data.id)} /> : null} {isBatchChild && !readOnly ? onSetBatchPrimary?.(data)} /> : null} diff --git a/web/src/components/canvas/canvas-version-compare-modal.tsx b/web/src/components/canvas/canvas-version-compare-modal.tsx index 8d9d1ecb2..548570328 100644 --- a/web/src/components/canvas/canvas-version-compare-modal.tsx +++ b/web/src/components/canvas/canvas-version-compare-modal.tsx @@ -12,10 +12,10 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
{versions.map((node) => ( -
+
-
{node.metadata?.versionLabel || "-"}{node.title}
- {node.metadata?.versionPrimary ? 主版本 : null} +
{node.metadata?.versionLabel || "-"}{node.title}
+ {node.metadata?.versionPrimary ? 主版本 : null}
+ ); + })} +
+ ); +} + +export function ProtocolCardPicker({ capability, value, onChange, density = "comfortable" }: { capability?: ModelCapabilityChoice; value?: ModelProtocol; onChange?: (value: ModelProtocol) => void; density?: PickerDensity }) { + const protocols = MODEL_PROTOCOLS.filter((item) => item.capability === capability); + return ( +
+ {protocols.map((protocol) => { + const selected = value === protocol.value; + return ( + + ); + })} +
+ ); +} + +export function ModelCapabilityProtocolModal({ value, onChange }: { value: { capability: ModelCapabilityChoice; protocol: ModelProtocol }; onChange: (value: { capability: ModelCapabilityChoice; protocol: ModelProtocol }) => void }) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(value); + + useEffect(() => { + if (!open) setDraft(value); + }, [open, value]); + + const updateCapability = (capability: ModelCapabilityChoice) => { + const protocol = draft.protocol && MODEL_PROTOCOLS.some((item) => item.value === draft.protocol && item.capability === capability) + ? draft.protocol + : MODEL_PROTOCOLS.find((item) => item.capability === capability)?.value || "chat-completion"; + setDraft({ capability, protocol }); + }; + + return ( + <> + + setOpen(false)} + okText="应用配置" + cancelText="取消" + onOk={() => { onChange(draft); setOpen(false); }} + > +
+
+
模型能力
+ +
+
+
请求协议
+ setDraft((current) => ({ ...current, protocol }))} /> +
+
+
+ + ); +} + +function ProtocolBrandMark({ protocol, compact = false }: { protocol: ModelProtocolDefinition; compact?: boolean }) { + const iconSize = compact ? "size-6" : "size-8"; + if (protocol.value === "chat-completion") return ; + if (protocol.value.startsWith("volcengine-jimeng-")) return ; + if (protocol.value.startsWith("volcengine-")) return ; + if (protocol.value.startsWith("newapi-channel-")) return ; + const brand = protocol.value === "gemini-veo" ? "gemini" : protocol.value === "xai-video" ? "grok" : "openai"; + return ; +} + +function BrandIconRow({ models, compact = false, className }: { models: string[]; compact?: boolean; className?: string }) { + return ( + + ); +} + +function modelBrandLabel(model: string) { + const labels: Record = { openai: "OpenAI", deepseek: "DeepSeek", glm: "智谱 GLM", gemini: "Google Gemini", grok: "xAI Grok" }; + return labels[model] || model; +} + +function capabilityLabel(value: ModelCapabilityChoice) { + return { text: "文本", image: "图片", video: "视频", audio: "音频" }[value]; +} diff --git a/web/src/components/video-settings-panel.tsx b/web/src/components/video-settings-panel.tsx index 82680e243..b179a4dd6 100644 --- a/web/src/components/video-settings-panel.tsx +++ b/web/src/components/video-settings-panel.tsx @@ -2,13 +2,12 @@ import { type ReactNode } from "react"; import { Switch } from "antd"; import { ImageSettingsTheme } from "@/components/image-settings-panel"; -import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video"; +import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceRatioOptions } from "@/lib/seedance-video"; import { type CanvasTheme } from "@/lib/canvas-theme"; -import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN, VIDEO_DURATION_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; +import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN } from "@/lib/video-generation-options"; +import { modelCapabilityConfigFor, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities"; import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; -const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: `${value}P` })); - const sizeOptions = [ { value: "1280x720", label: "横屏", width: 1280, height: 720 }, { value: "720x1280", label: "竖屏", width: 720, height: 1280 }, @@ -18,9 +17,6 @@ const sizeOptions = [ { value: "auto", label: "auto", width: 0, height: 0 }, ]; -const defaultSecondOptions = VIDEO_DURATION_OPTIONS; -const jiMengRatioOptions = ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"]; - type VideoSettingsPanelProps = { config: AiConfig; onConfigChange: (key: "vquality" | "size" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark", value: string) => void; @@ -30,18 +26,23 @@ type VideoSettingsPanelProps = { }; export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[292px] space-y-3" }: VideoSettingsPanelProps) { + const profile = modelCapabilityConfigFor(config, config.model).video!; if (resolveModelRequestConfig(config, config.model).interfaceType === "volcengine-jimeng-video") { - return ; + return ; } if (isSeedanceVideoConfig(config)) { - return ; + return ; } const seconds = normalizeVideoDuration(config.videoSeconds); - const secondOptions = defaultSecondOptions; + const secondOptions = videoDurationOptions(profile); + const durationColumns = secondOptions.length + (profile.duration.selection === "range" ? 1 : 0); const size = normalizeVideoSizeValue(config.size); const dimensions = readSizeDimensions(size); const resolution = normalizeVideoResolutionValue(config.vquality); + const configuredResolutions = profile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: value.toUpperCase() })); + const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default); + const watermark = boolConfig(config.videoWatermark, profile.watermark.default); const updateDimension = (key: "width" | "height", value: number | null) => { const next = Math.max(1, Math.floor(value || dimensions[key] || 720)); onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`); @@ -53,7 +54,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = {showTitle ?
视频设置
: null}
- {resolutionOptions.map((item) => ( + {configuredResolutions.map((item) => ( onConfigChange("vquality", item.value)}> {item.label} @@ -67,37 +68,38 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = updateDimension("height", value)} />
- {sizeOptions.map((item) => ( + {profile.ratios.map((value) => ( ))}
-
+
{secondOptions.map((value) => ( onConfigChange("videoSeconds", String(value))}> {value}s ))} - onConfigChange("videoSeconds", String(value))} /> + {profile.duration.selection === "range" ? onConfigChange("videoSeconds", String(value))} /> : null}
+ {profile.generateAudio.supported || profile.watermark.supported ?
{profile.generateAudio.supported ? onConfigChange("videoGenerateAudio", String(checked))} /> : null}{profile.watermark.supported ? onConfigChange("videoWatermark", String(checked))} /> : null}
: null}
); } -function JiMengVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) { +function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) { const seconds = normalizeVideoDuration(config.videoSeconds); return ( @@ -105,12 +107,12 @@ function JiMengVideoSettingsPanel({ config, onConfigChange, theme, showTitle, cl {showTitle ?
视频设置
: null}
- {jiMengRatioOptions.map((value) => onConfigChange("size", value)}>{value})} + {profile.ratios.map((value) => onConfigChange("size", value)}>{value})}
- {[5, 10].map((value) => onConfigChange("videoSeconds", String(value))}>{value}s)} + {videoDurationOptions(profile).map((value) => onConfigChange("videoSeconds", String(value))}>{value}s)}
@@ -118,13 +120,15 @@ function JiMengVideoSettingsPanel({ config, onConfigChange, theme, showTitle, cl ); } -function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) { +function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) { const model = modelOptionName(config.model || config.videoModel); const resolution = normalizeSeedanceResolution(config.vquality, model); const ratio = normalizeSeedanceRatio(config.size); const duration = normalizeSeedanceDuration(config.videoSeconds); - const generateAudio = boolConfig(config.videoGenerateAudio, true); - const watermark = boolConfig(config.videoWatermark, false); + const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default); + const watermark = boolConfig(config.videoWatermark, profile.watermark.default); + const durationOptions = videoDurationOptions(profile); + const durationColumns = durationOptions.length + (profile.duration.selection === "range" ? 1 : 0); return ( @@ -132,7 +136,8 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, {showTitle ?
视频设置
: null}
- {seedanceResolutionOptions.map((item) => { + {profile.resolutions.map((value) => { + const item = { value, label: value.toUpperCase() }; const disabled = item.value === "1080p" && isSeedanceFastModel(model); return ( onConfigChange("vquality", item.value)}> @@ -145,7 +150,9 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
- {seedanceRatioOptions.map((item) => ( + {profile.ratios.map((value) => { + const item = { value, label: value }; + return ( - ))} + ); + })}
-
- {seedanceDurationOptions.map((value) => ( +
+ {durationOptions.map((value) => ( onConfigChange("videoSeconds", String(value))}> {value}s ))} - onConfigChange("videoSeconds", String(value))} /> + {profile.duration.selection === "range" ? onConfigChange("videoSeconds", String(value))} /> : null}
- onConfigChange("videoGenerateAudio", String(checked))} /> - onConfigChange("videoWatermark", String(checked))} /> + {profile.generateAudio.supported ? onConfigChange("videoGenerateAudio", String(checked))} /> : null} + {profile.watermark.supported ? onConfigChange("videoWatermark", String(checked))} /> : null}
@@ -239,9 +247,9 @@ function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: ); } -function DurationInput({ value, min, theme, onChange }: { value: number; min: number; theme: CanvasTheme; onChange: (value: number) => void }) { +function DurationInput({ value, min, max, theme, onChange }: { value: number; min: number; max?: number; theme: CanvasTheme; onChange: (value: number) => void }) { const commit = (input: HTMLInputElement) => { - const next = Math.max(min, Math.floor(Number(input.value) || value || min)); + const next = Math.min(max || Number.POSITIVE_INFINITY, Math.max(min, Math.floor(Number(input.value) || value || min))); input.value = String(next); onChange(next); }; @@ -253,6 +261,7 @@ function DurationInput({ value, min, theme, onChange }: { value: number; min: nu type="number" inputMode="numeric" min={min} + max={max} defaultValue={value} aria-label="视频时长(秒)" className="min-w-0 flex-1 bg-transparent pl-2 text-right outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" @@ -303,3 +312,9 @@ function readSizeDimensions(size: string) { const match = size.match(/^(\d+)x(\d+)$/); return { width: Number(match?.[1]) || 1280, height: Number(match?.[2]) || 720 }; } + +function normalizeRatioValue(value: string) { + const match = String(value || "").match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/); + if (!match) return value; + return `${match[1]}:${match[2]}`; +} diff --git a/web/src/lib/app-theme.ts b/web/src/lib/app-theme.ts index 96df0ff74..190a32da5 100644 --- a/web/src/lib/app-theme.ts +++ b/web/src/lib/app-theme.ts @@ -33,16 +33,17 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig { const elevatedBackground = dark ? "rgba(31, 31, 32, 0.96)" : "rgba(255, 255, 255, 0.96)"; const subtleBackground = dark ? "rgba(255, 255, 255, 0.055)" : "rgba(17, 17, 17, 0.035)"; const interactiveBorder = dark ? "rgba(255, 255, 255, 0.18)" : "rgba(17, 17, 17, 0.18)"; - const focusShadow = dark ? "0 0 0 3px rgba(255, 255, 255, 0.12)" : "0 0 0 3px rgba(17, 17, 17, 0.08)"; + // 黑白主题使用边框表达焦点,避免输入控件周围出现蓝紫色光圈。 + const focusShadow = "none"; // 暗色界面的黑白主按钮与选择控件分色,避免开关轨道、勾选符号和滑块融成一片。 const selectionControl = dark ? { - active: "#4657c5", + active: "#ffffff", border: "rgba(255, 255, 255, 0.3)", disabledBackground: "rgba(255, 255, 255, 0.06)", - focus: "rgba(116, 133, 238, 0.28)", - hover: "#7485ee", - primary: "#5b6ee1", + focus: "transparent", + hover: "#e5e5e5", + primary: "#f5f5f5", surface: "rgba(255, 255, 255, 0.035)", } : null; @@ -180,7 +181,7 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig { multipleItemHeight: 24, activeBorderColor: interactiveBorder, hoverBorderColor: interactiveBorder, - activeOutlineColor: dark ? "rgba(255, 255, 255, 0.12)" : "rgba(17, 17, 17, 0.08)", + activeOutlineColor: "transparent", optionActiveBg: color.selectActiveBg, optionSelectedBg: color.selectSelectedBg, optionSelectedColor: color.selectText, diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts new file mode 100644 index 000000000..572cc9f70 --- /dev/null +++ b/web/src/lib/model-capabilities.ts @@ -0,0 +1,121 @@ +import type { ModelProtocol } from "@/lib/model-protocols"; + +export type ModelCapabilityConfig = { + version: number; + video?: VideoCapabilityConfig; +}; + +export type VideoCapabilityConfig = { + references: { + promptMaxChars: number; + maxImages: number; + maxImageBytes: number; + maxVideos: number; + maxVideoBytes: number; + maxVideoDurationSeconds: number; + maxAudios: number; + maxAudioBytes: number; + maxAudioDurationSeconds: number; + }; + duration: { + selection: "range" | "enum"; + min?: number; + max?: number; + step?: number; + values?: number[]; + default: number; + }; + ratios: string[]; + defaultRatio: string; + resolutions: string[]; + defaultResolution: string; + generateAudio: { supported: boolean; default: boolean }; + watermark: { supported: boolean; default: boolean }; + operations: string[]; + defaultOperation: string; +}; + +export function defaultModelCapabilityConfig(protocol?: ModelProtocol): ModelCapabilityConfig { + const video: VideoCapabilityConfig = { + references: { + promptMaxChars: 1000, + maxImages: 9, + maxImageBytes: 30 * 1024 * 1024, + maxVideos: 0, + maxVideoBytes: 0, + maxVideoDurationSeconds: 0, + maxAudios: 0, + maxAudioBytes: 0, + maxAudioDurationSeconds: 0, + }, + duration: { selection: "range", min: 1, max: 15, step: 1, default: 6 }, + ratios: ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"], + defaultRatio: "16:9", + resolutions: ["480p", "720p", "1080p", "2160p"], + defaultResolution: "720p", + generateAudio: { supported: false, default: false }, + watermark: { supported: false, default: false }, + operations: ["text_to_video", "image_to_video"], + defaultOperation: "text_to_video", + }; + if (protocol === "volcengine-jimeng-video") video.duration = { selection: "enum", values: [5, 10], default: 5 }; + if (protocol === "gemini-veo") { + video.duration = { selection: "enum", values: [4, 6, 8], default: 6 }; + video.resolutions = ["720p", "1080p"]; + } + if (protocol === "volcengine-ark-video" || protocol === "newapi-channel-1" || protocol === "newapi-channel-2") { + video.references.maxVideos = 3; + video.references.maxAudios = 3; + video.references.maxVideoBytes = 200 * 1024 * 1024; + video.references.maxAudioBytes = 15 * 1024 * 1024; + video.references.maxVideoDurationSeconds = 15; + video.references.maxAudioDurationSeconds = 15; + video.generateAudio = { supported: true, default: true }; + } + if (protocol === "volcengine-ark-video") video.watermark = { supported: true, default: false }; + return { version: 1, video }; +} + +export function modelCapabilityConfigFor(config: { channels: Array<{ id: string; models: string[]; modelCosts?: Array<{ model: string; capabilityConfig?: ModelCapabilityConfig; protocol?: ModelProtocol }> }> }, model: string) { + const separator = model.indexOf("::"); + const channelId = separator >= 0 ? model.slice(0, separator) : ""; + const modelName = separator >= 0 ? model.slice(separator + 2) : model; + const channel = config.channels.find((item) => item.id === channelId) || config.channels.find((item) => item.models.includes(modelName)); + const cost = channel?.modelCosts?.find((item) => item.model === modelName); + return cost?.capabilityConfig || defaultModelCapabilityConfig(cost?.protocol); +} + +export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { seconds?: string; ratio?: string; resolution?: string }) { + const duration = profile.duration.selection === "enum" + ? (profile.duration.values || []).includes(Number(value.seconds)) ? Number(value.seconds) : profile.duration.default + : normalizeRangeDuration(profile, Number(value.seconds)); + const ratio = profile.ratios.includes(value.ratio || "") ? value.ratio! : profile.defaultRatio; + const resolution = profile.resolutions.includes(value.resolution || "") ? value.resolution! : profile.defaultResolution; + return { seconds: String(duration), ratio, resolution }; +} + +function normalizeRangeDuration(profile: VideoCapabilityConfig, value: number) { + const min = profile.duration.min || 1; + const max = profile.duration.max || min; + const step = profile.duration.step || 1; + const candidate = Number.isFinite(value) ? Math.floor(value) : profile.duration.default; + const clamped = Math.min(max, Math.max(min, candidate)); + const maxStep = Math.max(0, Math.floor((max - min) / step)); + return min + Math.min(maxStep, Math.max(0, Math.round((clamped - min) / step))) * step; +} + +export function videoDurationOptions(profile: VideoCapabilityConfig) { + if (profile.duration.selection === "enum") return profile.duration.values || []; + const min = profile.duration.min || 1; + const max = profile.duration.max || min; + const step = profile.duration.step || 1; + return Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, index) => min + index * step); +} + +export function videoDurationAllowed(profile: VideoCapabilityConfig, value: number) { + if (profile.duration.selection === "enum") return (profile.duration.values || []).includes(value); + const min = profile.duration.min || 1; + const max = profile.duration.max || min; + const step = profile.duration.step || 1; + return value >= min && value <= max && (value - min) % step === 0; +} diff --git a/web/src/lib/model-connection-test.ts b/web/src/lib/model-connection-test.ts index 9910c34ad..e005f4387 100644 --- a/web/src/lib/model-connection-test.ts +++ b/web/src/lib/model-connection-test.ts @@ -21,6 +21,7 @@ export async function testChannelModelConnection(channel: ModelChannel, model: s protocol: testProtocol, billingMode: modelCost?.billingMode || "fixed_request", unitPriceMicrocredits: modelCost?.unitPriceMicrocredits || 0, + capabilityConfig: modelCost?.capabilityConfig, }, ...(channel.modelCosts || []).filter((item) => item.model !== model), ], diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index ee124c60b..0e4670c94 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -164,7 +164,7 @@ export default function ChannelsPage() { {loading && channels.length === 0 ? : } onClick={() => openDrawer()}>新增系统渠道} /> }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 820 }} />} - void save()}>保存}> + void save()}>保存}>
diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index d1ff779d6..5e90f4810 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -125,7 +125,7 @@ export function AdminPageFrame({ title, description, actions, back, children }: function MobileAdminNavigation() { return ( -