diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 7daea9bdd..e2d033e3c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -90,6 +90,7 @@ func main() { // 登录态模型目录代理:避免浏览器直连各上游时分别处理 CORS。 handler.RegisterChannelModelRoutes(api, svc) handler.RegisterLogicalModelRoutes(api, svc) + handler.RegisterModelCatalogRoutes(api, svc) handler.RegisterSystemProxyRoutes(api, svc) handler.RegisterCustomRelayRoutes(api, svc) handler.RegisterTaskRoutes(api, svc) diff --git a/backend/internal/handler/model_catalog.go b/backend/internal/handler/model_catalog.go new file mode 100644 index 000000000..5494c6921 --- /dev/null +++ b/backend/internal/handler/model_catalog.go @@ -0,0 +1,80 @@ +package handler + +import ( + "errors" + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +// RegisterModelCatalogRoutes 注册统一模型目录路由 +func RegisterModelCatalogRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/model-catalog", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + catalog, err := svc.ModelCatalog(nil) + if err != nil { + failService(c, err) + return + } + ok(c, catalog) + }) + + r.POST("/model-catalog/available", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + var intent service.ModelRequestIntent + if err := c.ShouldBindJSON(&intent); err != nil { + fail(c, http.StatusBadRequest, errors.New("模型能力请求格式错误")) + return + } + catalog, err := svc.ModelCatalog(&intent) + if err != nil { + failService(c, err) + return + } + ok(c, catalog) + }) + + r.POST("/model-catalog/quote", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + var req struct { + ModelID string `json:"modelId"` + Intent service.ModelRequestIntent `json:"intent"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, errors.New("模型报价请求格式错误")) + return + } + + // 根据 frontendModelsEnabled 决定使用哪种报价方式 + frontendEnabled, err := svc.FeatureEnabled(service.FeatureFrontendModels) + if err != nil { + failService(c, err) + return + } + + if frontendEnabled { + // 使用前台模型报价 + quote, err := svc.QuoteLogicalModel(req.ModelID, req.Intent) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"quote": quote}) + } else { + // 使用系统渠道模型报价 + // TODO: 实现系统渠道模型报价逻辑 + fail(c, http.StatusNotImplemented, errors.New("系统渠道模型报价功能尚未实现")) + } + }) +} diff --git a/backend/internal/model/models_logical_model.go b/backend/internal/model/models_logical_model.go index 415cfcd4e..f29494b6d 100644 --- a/backend/internal/model/models_logical_model.go +++ b/backend/internal/model/models_logical_model.go @@ -80,3 +80,26 @@ type RouteAttempt struct { StartedAt time.Time `json:"startedAt"` CompletedAt *time.Time `json:"completedAt"` } + +// LogicalModelPriceSKU 表示前台模型在统一定价模式下的独立售价 SKU。 +// 当 pricePolicy = unified 时,用户价格由此 SKU 决定,而非渠道价格。 +// SelectorJSON 统一使用结构化意图选择器,支持复杂的能力组合定价。 +type LogicalModelPriceSKU struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + LogicalModelRevisionID string `json:"logicalModelRevisionId" gorm:"size:36;index;uniqueIndex:idx_logical_sku_active,priority:1,where:deleted_at IS NULL"` + Code string `json:"code" gorm:"size:80"` + Name string `json:"name" gorm:"size:160"` + SelectorKey string `json:"selectorKey" gorm:"size:500;not null;default:{};uniqueIndex:idx_logical_sku_active,priority:2,where:deleted_at IS NULL"` + SelectorJSON string `json:"-" gorm:"type:text;not null;default:{}"` + BillingMode string `json:"billingMode" gorm:"size:32"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + Priority int `json:"priority" gorm:"index"` + Enabled bool `json:"enabled" gorm:"index"` + Version int `json:"version"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt *time.Time `json:"-" gorm:"index"` +} diff --git a/backend/internal/service/errors.go b/backend/internal/service/errors.go index da15f3fee..1a7d77bf7 100644 --- a/backend/internal/service/errors.go +++ b/backend/internal/service/errors.go @@ -1,5 +1,7 @@ package service +import "fmt" + // AppError 是 service 层对外公开的结构化错误。 // Message 必须可安全展示给用户,Cause 仅用于保留内部诊断链路,不得直接写入 HTTP 响应。 type AppError struct { @@ -9,7 +11,6 @@ type AppError struct { Retryable bool Cause error } - func (e *AppError) Error() string { if e == nil { return "" @@ -31,3 +32,124 @@ func NewAppError(status int, message string) *AppError { func WrapAppError(status int, message string, cause error) *AppError { return &AppError{Status: status, Message: message, Cause: cause} } + +// ModelErrorCode 定义模型相关的错误码 +type ModelErrorCode string + +const ( + // ErrCodeModelCapabilityNotSupported 当前模型能力不支持请求 + ErrCodeModelCapabilityNotSupported ModelErrorCode = "model_capability_not_supported" + // ErrCodeModelPriceNotConfigured 当前模型未配置该能力组合价格 + ErrCodeModelPriceNotConfigured ModelErrorCode = "model_price_not_configured" + // ErrCodeModelRouteUnavailable 没有可用供应线路 + ErrCodeModelRouteUnavailable ModelErrorCode = "model_route_unavailable" + // ErrCodeProviderRequestFailed 供应商异常、响应格式错误或上游失败 + ErrCodeProviderRequestFailed ModelErrorCode = "provider_request_failed" + // ErrCodeModelCatalogMismatch 模型目录已更新,请重新选择 + ErrCodeModelCatalogMismatch ModelErrorCode = "model_catalog_mismatch" + // ErrCodeInvalidModelSelection 无效的模型选择 + ErrCodeInvalidModelSelection ModelErrorCode = "invalid_model_selection" +) + +// ModelError 模型相关的错误,包含错误码和详细信息 +// 继承 AppError 以保持兼容性 +type ModelError struct { + *AppError + ErrorCode ModelErrorCode + Details map[string]any +} + +func (e *ModelError) Error() string { + if e.AppError != nil { + return e.AppError.Error() + } + return string(e.ErrorCode) +} + +// NewModelError 创建模型错误 +func NewModelError(code ModelErrorCode, message string) *ModelError { + return &ModelError{ + AppError: NewAppError(400, message), + ErrorCode: code, + Details: make(map[string]any), + } +} + +// WithDetails 添加错误详情 +func (e *ModelError) WithDetails(details map[string]any) *ModelError { + e.Details = details + return e +} + +// ModelCapabilityNotSupported 当前模型能力不支持请求 +func ModelCapabilityNotSupported(message string) error { + if message == "" { + message = "当前模型不支持该能力" + } + return NewModelError(ErrCodeModelCapabilityNotSupported, message) +} + +// ModelPriceNotConfigured 当前模型未配置价格 +func ModelPriceNotConfigured(message string) error { + if message == "" { + message = "当前模型未配置该能力组合价格" + } + return NewModelError(ErrCodeModelPriceNotConfigured, message) +} + +// ModelRouteUnavailable 没有可用供应线路 +func ModelRouteUnavailable(message string) error { + if message == "" { + message = "没有可用供应线路" + } + return NewModelError(ErrCodeModelRouteUnavailable, message) +} + +// ProviderRequestFailed 供应商请求失败 +func ProviderRequestFailed(message string) error { + if message == "" { + message = "模型服务返回失败,请检查请求内容或渠道配置" + } + return NewModelError(ErrCodeProviderRequestFailed, message) +} + +// ModelCatalogMismatch 模型目录已更新 +func ModelCatalogMismatch(message string) error { + if message == "" { + message = "模型目录已更新,请重新选择" + } + return NewModelError(ErrCodeModelCatalogMismatch, message) +} + +// InvalidModelSelection 无效的模型选择 +func InvalidModelSelection(message string) error { + if message == "" { + message = "无效的模型选择" + } + return NewModelError(ErrCodeInvalidModelSelection, message) +} + +// IsModelError 判断是否为模型错误 +func IsModelError(err error) bool { + _, ok := err.(*ModelError) + return ok +} + +// GetModelErrorCode 获取模型错误码 +func GetModelErrorCode(err error) ModelErrorCode { + if modelErr, ok := err.(*ModelError); ok { + return modelErr.ErrorCode + } + return "" +} + +// FormatModelError 格式化模型错误消息 +func FormatModelError(err error) string { + if modelErr, ok := err.(*ModelError); ok { + if len(modelErr.Details) > 0 { + return fmt.Sprintf("%s (错误码: %s)", modelErr.Message, modelErr.ErrorCode) + } + return modelErr.Message + } + return err.Error() +} diff --git a/backend/internal/service/feature_availability.go b/backend/internal/service/feature_availability.go index 889898b65..a384a2b05 100644 --- a/backend/internal/service/feature_availability.go +++ b/backend/internal/service/feature_availability.go @@ -14,10 +14,11 @@ import ( const featureAvailabilitySettingKey = "feature_availability" const ( - FeatureShortDrama = "shortDrama" - FeatureTaskCenter = "taskCenter" - FeatureCredits = "credits" - FeatureCustomChannels = "customChannels" + FeatureShortDrama = "shortDrama" + FeatureTaskCenter = "taskCenter" + FeatureCredits = "credits" + FeatureCustomChannels = "customChannels" + FeatureFrontendModels = "frontendModels" ) type FeatureAvailability struct { @@ -25,6 +26,7 @@ type FeatureAvailability struct { TaskCenterEnabled bool `json:"taskCenterEnabled"` CreditsEnabled bool `json:"creditsEnabled"` CustomChannelsEnabled bool `json:"customChannelsEnabled"` + FrontendModelsEnabled bool `json:"frontendModelsEnabled"` } type PublicFeatureAvailability struct { @@ -37,7 +39,7 @@ type PublicFeatureAvailability struct { func defaultFeatureAvailability() FeatureAvailability { // 缺少配置代表尚未由运维接管,默认保持现有功能全部开放。 - return FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true, CustomChannelsEnabled: true} + return FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true, CustomChannelsEnabled: true, FrontendModelsEnabled: true} } func (s *Service) FeatureAvailability() (*PublicFeatureAvailability, error) { @@ -94,6 +96,8 @@ func (s *Service) FeatureEnabled(feature string) (bool, error) { return value.CreditsEnabled, nil case FeatureCustomChannels: return value.CustomChannelsEnabled, nil + case FeatureFrontendModels: + return value.FrontendModelsEnabled, nil default: return false, errors.New("未知功能开放配置") } @@ -116,6 +120,8 @@ func (s *Service) RequireFeature(feature string) error { return Forbidden("积分功能暂未开放") case FeatureCustomChannels: return Forbidden("自定义渠道暂未开放") + case FeatureFrontendModels: + return Forbidden("前台模型目录暂未开放") default: return Forbidden("该功能暂未开放") } diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index d96b9e5ac..9d21f937a 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -41,7 +41,6 @@ type LogicalModelRequest struct { // SourceChannelModelID 仅供系统渠道同步流程使用,前台模型不再拥有独立的能力和价格真相。 SourceChannelModelID string `json:"-"` } - type LogicalRouteRequest struct { ChannelModelID string `json:"channelModelId"` Enabled bool `json:"enabled"` @@ -58,6 +57,9 @@ type PublicLogicalModel struct { Capability string `json:"capability"` SortOrder int `json:"sortOrder"` PricePolicy string `json:"pricePolicy"` + PricingMode string `json:"pricingMode"` + DisplayPrice *int64 `json:"displayPrice,omitempty"` + PriceLabel string `json:"priceLabel"` BillingMode string `json:"billingMode"` UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"` @@ -180,7 +182,23 @@ func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogical profiles = append(profiles, route.CapabilitySpec) } } - return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, PriceTiers: publicLogicalModelPriceTiers(cached), LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON), CapabilitySpec: productSpec, CapabilityProfiles: profiles, DefaultOptions: cached.Defaults, Available: available} + + priceTiers := publicLogicalModelPriceTiers(cached) + pricingMode, displayPrice, priceLabel := computeModelPriceDisplay(item, priceTiers) + + return PublicLogicalModel{ + ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, + Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, + PricePolicy: item.PricePolicy, PricingMode: pricingMode, DisplayPrice: displayPrice, + PriceLabel: priceLabel, BillingMode: item.BillingMode, + UnitPriceMicrocredits: item.UnitPriceMicrocredits, + InputPriceMicrocredits: item.InputPriceMicrocredits, + OutputPriceMicrocredits: item.OutputPriceMicrocredits, + CachedPriceMicrocredits: item.CachedPriceMicrocredits, + PriceTiers: priceTiers, LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON), + CapabilitySpec: productSpec, CapabilityProfiles: profiles, + DefaultOptions: cached.Defaults, Available: available, + } } func publicLogicalModelPriceTiers(cached cachedLogicalModel) []PublicLogicalModelPriceTier { @@ -1060,3 +1078,57 @@ func boolValues(supportsTrue bool) OptionConstraint { func numericRange(minimum float64, maximum float64, step float64) OptionConstraint { return OptionConstraint{Min: &minimum, Max: &maximum, Step: &step} } + +// computeModelPriceDisplay 计算模型的价格展示信息 +// 返回:pricingMode, displayPrice, priceLabel +func computeModelPriceDisplay(model model.LogicalModel, priceTiers []PublicLogicalModelPriceTier) (string, *int64, string) { + if model.PricePolicy == "channel" { + // 跟随渠道价格 + if len(priceTiers) == 0 { + return "provider", nil, "未配置" + } + // 检查是否所有价格档都相同 + if len(priceTiers) == 1 { + tier := priceTiers[0] + price := getTierDisplayPrice(tier) + if price > 0 { + return "provider", &price, "" + } + } + // 多个价格档或价格为0,显示"按渠道规格计费" + return "provider", nil, "按渠道规格计费" + } + + // 统一定价模式 + if model.BillingMode == "fixed_request" && model.UnitPriceMicrocredits > 0 { + price := model.UnitPriceMicrocredits + return "unified", &price, "" + } + if model.BillingMode == "per_second" && model.UnitPriceMicrocredits > 0 { + price := model.UnitPriceMicrocredits + return "unified", &price, "按秒" + } + if model.BillingMode == "token" { + // Token 计费显示输入/输出价格 + if model.InputPriceMicrocredits > 0 || model.OutputPriceMicrocredits > 0 { + return "unified", nil, "按 Token" + } + } + + return "unified", nil, "未配置" +} + +// getTierDisplayPrice 获取价格档的展示价格 +func getTierDisplayPrice(tier PublicLogicalModelPriceTier) int64 { + if tier.BillingMode == "fixed_request" || tier.BillingMode == "per_second" { + return tier.UnitPriceMicrocredits + } + // Token 计费返回输出价格(如果有) + if tier.OutputTokenPriceMicrocredits > 0 { + return tier.OutputTokenPriceMicrocredits + } + if tier.InputTokenPriceMicrocredits > 0 { + return tier.InputTokenPriceMicrocredits + } + return 0 +} diff --git a/backend/internal/service/model_catalog.go b/backend/internal/service/model_catalog.go new file mode 100644 index 000000000..6dc7e0760 --- /dev/null +++ b/backend/internal/service/model_catalog.go @@ -0,0 +1,247 @@ +package service + +import ( + "encoding/json" + "infinite-canvas/backend/internal/model" +) + +// ModelCatalogSource 表示模型目录来源 +type ModelCatalogSource string + +const ( + // ModelCatalogSourceFrontend 前台模型虚拟渠道 + ModelCatalogSourceFrontend ModelCatalogSource = "frontend" + // ModelCatalogSourceSystem 脱敏的系统渠道模型 + ModelCatalogSourceSystem ModelCatalogSource = "system" +) + +// ModelCatalogResponse 统一模型目录响应 +type ModelCatalogResponse struct { + Source ModelCatalogSource `json:"source"` + Models []PublicLogicalModel `json:"models,omitempty"` + Channels []PublicChannelCatalog `json:"channels,omitempty"` +} + +// PublicChannelCatalog 公开的渠道目录信息(脱敏) +type PublicChannelCatalog struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Models []PublicChannelModel `json:"models"` +} + +// PublicChannelModel 公开的渠道模型信息(脱敏) +type PublicChannelModel struct { + ID string `json:"id"` + ModelKey string `json:"modelKey"` + DisplayName string `json:"displayName"` + Capability string `json:"capability"` + CapabilityConfig map[string]any `json:"capabilityConfig,omitempty"` + PriceTiers []PublicChannelModelPriceTier `json:"priceTiers"` + PricingMode string `json:"pricingMode"` + DisplayPrice *int64 `json:"displayPrice,omitempty"` + PriceLabel string `json:"priceLabel"` + Available bool `json:"available"` +} + +// PublicChannelModelPriceTier 公开的渠道模型价格档(脱敏) +type PublicChannelModelPriceTier struct { + ID string `json:"id"` + Selector map[string]string `json:"selector,omitempty"` + Resolution string `json:"resolution"` + VideoSeconds int `json:"videoSeconds"` + BillingMode string `json:"billingMode"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` +} + +// ModelCatalog 返回统一的模型目录 +// 根据 frontendModelsEnabled 开关返回前台模型或系统渠道模型 +func (s *Service) ModelCatalog(intent *ModelRequestIntent) (*ModelCatalogResponse, error) { + frontendEnabled, err := s.FeatureEnabled(FeatureFrontendModels) + if err != nil { + return nil, err + } + + if frontendEnabled { + // 返回前台模型目录 + models, err := s.PublicLogicalModels(intent) + if err != nil { + return nil, err + } + return &ModelCatalogResponse{ + Source: ModelCatalogSourceFrontend, + Models: models, + }, nil + } + + // 返回脱敏的系统渠道模型目录 + channels, err := s.publicSystemChannelCatalog(intent) + if err != nil { + return nil, err + } + return &ModelCatalogResponse{ + Source: ModelCatalogSourceSystem, + Channels: channels, + }, nil +} + +// publicSystemChannelCatalog 返回脱敏后的系统渠道模型目录 +// 只包含普通用户可见的信息,不包含密钥、Base URL、供应商信息等 +func (s *Service) publicSystemChannelCatalog(intent *ModelRequestIntent) ([]PublicChannelCatalog, error) { + channels, err := s.repo.SystemChannels(true) + if err != nil { + return nil, err + } + + result := make([]PublicChannelCatalog, 0, len(channels)) + for _, channel := range channels { + if !channel.Enabled { + continue + } + + channelModels, err := s.repo.ChannelModels(channel.ID, false) + if err != nil { + return nil, err + } + + publicModels := make([]PublicChannelModel, 0, len(channelModels)) + for _, cm := range channelModels { + if !cm.Enabled { + continue + } + + // 如果提供了意图,进行能力过滤 + if intent != nil && !s.channelModelMatchesIntent(&cm, intent) { + continue + } + + publicModel := s.sanitizeChannelModel(&cm) + publicModels = append(publicModels, publicModel) + } + + if len(publicModels) > 0 { + result = append(result, PublicChannelCatalog{ + ID: channel.ID, + Name: channel.Name, + DisplayName: channel.Name, + Models: publicModels, + }) + } + } + + return result, nil +} + +// sanitizeChannelModel 脱敏渠道模型,只保留用户可见的信息 +func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) PublicChannelModel { + // 价格档已经在 cm.PriceTiers 中加载 + priceTiers := cm.PriceTiers + + // 转换为公开的价格档 + publicTiers := make([]PublicChannelModelPriceTier, 0, len(priceTiers)) + for _, tier := range priceTiers { + if !tier.Enabled || !ValidatePriceTierPrice(&tier) { + continue + } + publicTiers = append(publicTiers, PublicChannelModelPriceTier{ + ID: tier.ID, + Selector: model.DecodeSKUSelector(tier.SelectorJSON), + Resolution: tier.Resolution, + VideoSeconds: tier.VideoSeconds, + BillingMode: tier.BillingMode, + UnitPriceMicrocredits: tier.UnitPriceMicrocredits, + InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, + OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, + CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits, + }) + } + + // 计算价格展示 + pricingMode, displayPrice, priceLabel := computeChannelModelPriceDisplay(cm, publicTiers) + + // 解析能力配置 + var capabilityConfig map[string]any + if cm.CapabilityConfigJSON != "" { + config, _ := DecodeModelCapabilityConfig(cm.CapabilityConfigJSON) + if config != nil { + normalized, _ := NormalizeModelCapabilityConfig(cm.Capability, string(cm.Protocol), config) + if normalized != nil { + // 转换为 map[string]any + capabilityConfig = modelCapabilityConfigToMap(normalized) + } + } + } + + return PublicChannelModel{ + ID: cm.ID, + ModelKey: cm.ModelKey, + DisplayName: cm.DisplayName, + Capability: cm.Capability, + CapabilityConfig: capabilityConfig, + PriceTiers: publicTiers, + PricingMode: pricingMode, + DisplayPrice: displayPrice, + PriceLabel: priceLabel, + Available: len(publicTiers) > 0 || (len(priceTiers) == 0 && HasValidPrice(cm)), + } +} + +// computeChannelModelPriceDisplay 计算渠道模型的价格展示信息 +func computeChannelModelPriceDisplay(cm *model.ChannelModel, priceTiers []PublicChannelModelPriceTier) (string, *int64, string) { + if len(priceTiers) == 0 { + return "provider", nil, "未配置" + } + + if len(priceTiers) == 1 { + tier := priceTiers[0] + price := getChannelTierDisplayPrice(tier) + if price > 0 { + return "provider", &price, "" + } + } + + // 多个价格档,显示"按渠道规格计费" + return "provider", nil, "按渠道规格计费" +} + +// getChannelTierDisplayPrice 获取渠道价格档的展示价格 +func getChannelTierDisplayPrice(tier PublicChannelModelPriceTier) int64 { + if tier.BillingMode == "fixed_request" || tier.BillingMode == "per_second" { + return tier.UnitPriceMicrocredits + } + if tier.OutputTokenPriceMicrocredits > 0 { + return tier.OutputTokenPriceMicrocredits + } + if tier.InputTokenPriceMicrocredits > 0 { + return tier.InputTokenPriceMicrocredits + } + return 0 +} + +// channelModelMatchesIntent 检查渠道模型是否匹配意图 +func (s *Service) channelModelMatchesIntent(cm *model.ChannelModel, intent *ModelRequestIntent) bool { + if intent.Capability != "" && cm.Capability != intent.Capability { + return false + } + // 这里可以添加更多的能力匹配逻辑 + return true +} + +// modelCapabilityConfigToMap 将 ModelCapabilityConfig 转换为 map[string]any +func modelCapabilityConfigToMap(config *ModelCapabilityConfig) map[string]any { + if config == nil { + return nil + } + encoded, err := json.Marshal(config) + if err != nil { + return nil + } + var result map[string]any + if err := json.Unmarshal(encoded, &result); err != nil { + return nil + } + return result +} diff --git a/backend/internal/service/price_validation.go b/backend/internal/service/price_validation.go new file mode 100644 index 000000000..57e1aae91 --- /dev/null +++ b/backend/internal/service/price_validation.go @@ -0,0 +1,79 @@ +package service + +import "infinite-canvas/backend/internal/model" + +// ValidateChannelModelPrice 校验渠道模型价格配置的有效性 +// 根据 billingMode 检查必要的价格字段是否已配置 +func ValidateChannelModelPrice(billingMode string, capability string, protocol model.ChannelInterfaceType, unitPrice, inputPrice, outputPrice, cachedPrice int64) bool { + switch billingMode { + case "fixed_request": + // 固定价格模式:必须配置单价且大于 0 + return unitPrice > 0 + case "per_second": + // 按秒计费:必须配置单价且大于 0 + return unitPrice > 0 + case "token": + // Token 计费:根据协议要求校验 + if capability == "video" && (protocol == model.ChannelInterfaceVolcengineArkVideo || protocol == model.ChannelInterfaceVolcengineJiMengVideo) { + // 火山方舟视频必须配置输出价格 + return outputPrice > 0 + } + // 文本模型:至少配置一项价格 + return inputPrice > 0 || outputPrice > 0 || cachedPrice > 0 + default: + return false + } +} + +// ValidatePriceTierPrice 校验价格档的价格配置有效性 +func ValidatePriceTierPrice(tier *model.ChannelModelPriceTier) bool { + if tier == nil { + return false + } + return ValidateChannelModelPrice(tier.BillingMode, "", "", tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits) +} + +// ComputePriceConfigured 根据价格内容计算 PriceConfigured 标志 +// 不再允许手动设置 PriceConfigured,必须由价格字段派生 +func ComputePriceConfigured(billingMode string, capability string, protocol model.ChannelInterfaceType, unitPrice, inputPrice, outputPrice, cachedPrice int64) bool { + return ValidateChannelModelPrice(billingMode, capability, protocol, unitPrice, inputPrice, outputPrice, cachedPrice) +} + +// ComputeTierPriceConfigured 计算价格档的 PriceConfigured 标志 +func ComputeTierPriceConfigured(tier *model.ChannelModelPriceTier) bool { + if tier == nil { + return false + } + return ComputePriceConfigured(tier.BillingMode, "", "", tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits) +} + +// HasValidPrice 检查渠道模型是否有有效价格 +// 对于有价格档的模型,检查是否至少有一个启用且价格有效的价格档 +func HasValidPrice(channelModel *model.ChannelModel) bool { + if channelModel == nil { + return false + } + + // 如果有价格档,检查价格档 + if len(channelModel.PriceTiers) > 0 { + for _, tier := range channelModel.PriceTiers { + if tier.Enabled && ValidatePriceTierPrice(&tier) { + return true + } + } + return false + } + + // 否则检查模型级别的价格 + return ValidateChannelModelPrice(channelModel.BillingMode, channelModel.Capability, channelModel.Protocol, channelModel.UnitPriceMicrocredits, channelModel.InputTokenPriceMicrocredits, channelModel.OutputTokenPriceMicrocredits, channelModel.CachedTokenPriceMicrocredits) +} + +// ValidateLogicalModelPrice 校验前台模型的价格配置 +func ValidateLogicalModelPrice(pricePolicy string, billingMode string, unitPrice, inputPrice, outputPrice, cachedPrice int64) bool { + if pricePolicy == "channel" { + // 跟随渠道价格,不需要校验前台价格 + return true + } + // 统一定价模式,按 billingMode 校验 + return ValidateChannelModelPrice(billingMode, "", "", unitPrice, inputPrice, outputPrice, cachedPrice) +} diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go index 447fae39a..de43c520f 100644 --- a/backend/internal/service/task_creation.go +++ b/backend/internal/service/task_creation.go @@ -25,16 +25,38 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, if err != nil { return nil, err } + + // 根据 frontendModelsEnabled 开关强制分流模型选择 + frontendEnabled, err := s.FeatureEnabled(FeatureFrontendModels) + if err != nil { + return nil, err + } + var routed *RoutedModel logicalModelID := strings.TrimSpace(req.LogicalModelID) - if logicalModelID != "" { + + if frontendEnabled { + // 前台模型模式:必须有 logicalModelId + if logicalModelID == "" { + return nil, InvalidModelSelection("前台模型模式下必须指定 logicalModelId") + } intent := ModelRequestIntentFromTaskInput(normalizedInput, taskType, req.Operation) routed, err = s.ResolveLogicalModel(logicalModelID, intent) if err != nil { return nil, err } normalizedInput = applyRoutedProviderSelection(normalizedInput, routed) + } else { + // 系统渠道模型模式:禁止 logicalModelId + if logicalModelID != "" { + return nil, ModelCatalogMismatch("模型目录已更新,请重新选择") + } + // 必须校验 channelId + model 属于启用的系统渠道模型 + if err := s.validateSystemChannelModelSelection(normalizedInput); err != nil { + return nil, err + } } + if strings.HasPrefix(taskType, "video_") && !hasExecutableProviderVideoConfig(normalizedInput) { if mode, _ := normalizedInput["mode"].(string); mode != "video" { return nil, errors.New("视频任务必须使用 video 模式") @@ -236,6 +258,49 @@ func (s *Service) requireCustomChannelsForTaskInput(input map[string]any) error return s.RequireFeature(FeatureCustomChannels) } +// validateSystemChannelModelSelection 校验系统渠道模型选择的有效性 +func (s *Service) validateSystemChannelModelSelection(input map[string]any) error { + config, ok := input["config"].(map[string]any) + if !ok { + return InvalidModelSelection("缺少模型配置") + } + + channelID, _ := config["channelId"].(string) + modelKey, _ := config["model"].(string) + + channelID = strings.TrimSpace(channelID) + modelKey = strings.TrimSpace(modelKey) + + if channelID == "" || modelKey == "" { + return InvalidModelSelection("必须指定系统渠道和模型") + } + + // 验证渠道存在且启用 + channel, err := s.repo.SystemChannel(channelID) + if err != nil { + return InvalidModelSelection("指定的渠道不存在") + } + if !channel.Enabled || channel.Scope != model.ChannelScopeSystem { + return InvalidModelSelection("指定的渠道不可用") + } + + // 验证渠道模型存在且启用 + channelModel, err := s.repo.ChannelModelByKey(channelID, modelKey) + if err != nil { + return InvalidModelSelection("指定的模型不存在") + } + if !channelModel.Enabled { + return InvalidModelSelection("指定的模型已停用") + } + + // 验证价格配置 + if !HasValidPrice(channelModel) { + return ModelPriceNotConfigured("指定的模型未配置有效价格") + } + + return nil +} + func taskInputUsesCustomChannel(input map[string]any) bool { config, ok := input["config"].(map[string]any) if !ok { diff --git a/web/src/components/auth/require-feature.tsx b/web/src/components/auth/require-feature.tsx index 58d93166a..a973ad9cc 100644 --- a/web/src/components/auth/require-feature.tsx +++ b/web/src/components/auth/require-feature.tsx @@ -7,12 +7,13 @@ import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/co import { refreshFeatureAvailability } from "@/lib/user-session"; import { useUserStore } from "@/stores/use-user-store"; -type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled"; +type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "frontendModelsEnabled"; const featureNames: Record = { shortDramaEnabled: "短剧创作", taskCenterEnabled: "任务中心", creditsEnabled: "积分中心", + frontendModelsEnabled: "前台模型", }; let featureAvailabilityCheckedOnce = false; @@ -43,9 +44,14 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi if (checking) return ; if (error) return navigate("/create", { replace: true })} />; if (!features[feature]) { + // 管理员页面返回到管理后台首页,用户页面返回到创作台 + const isAdminFeature = feature === "frontendModelsEnabled"; + const backPath = isAdminFeature ? "/admin" : "/create"; + const backLabel = isAdminFeature ? "返回管理后台" : "返回创作台"; + return ( - navigate("/create", { replace: true })}>返回创作台} /> + navigate(backPath, { replace: true })}>{backLabel}} /> ); } diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 62174fc35..69748b478 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -1,11 +1,11 @@ import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth"; -import { listLogicalModels, type CapabilitySpec, type OptionConstraint, type PublicLogicalModel } from "@/services/api/logical-models"; +import { getModelCatalog, listLogicalModels, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog, type PublicLogicalModel } from "@/services/api/logical-models"; import { localForageStorage } from "@/lib/localforage-storage"; import { appQueryClient } from "@/lib/query-client"; import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope"; import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store"; -import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelChannel } from "@/stores/use-config-store"; +import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store"; import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities"; import { useUserStore } from "@/stores/use-user-store"; import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "@/services/user-data-sync"; @@ -41,9 +41,17 @@ export async function applyUserSession(payload: AuthSessionPayload) { if (!persistedAssets) useAssetStore.setState({ assets: [] }); if (!persistedConfig) { // 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。 + // 使用统一模型目录接口 + const catalog = await getModelCatalog(); + let channels: ModelChannel[] = []; + if (catalog.source === "frontend" && catalog.models) { + channels = managedModelChannels(catalog.models); + } else if (catalog.source === "system" && catalog.channels) { + channels = systemChannelModelChannels(catalog.channels); + } const initialSystemConfig = { ...defaultConfig, - channels: managedModelChannels(payload.logicalModels || []), + channels, imageModels: undefined, videoModels: undefined, textModels: undefined, @@ -51,7 +59,13 @@ export async function applyUserSession(payload: AuthSessionPayload) { }; useConfigStore.getState().replaceConfig(normalizeConfigSnapshot({ config: initialSystemConfig }).config); } else { - useConfigStore.getState().mergeSystemChannels(managedModelChannels(payload.logicalModels || [])); + // 已有配置时也需要合并最新的系统渠道 + const catalog = await getModelCatalog(); + if (catalog.source === "frontend" && catalog.models) { + useConfigStore.getState().mergeSystemChannels(managedModelChannels(catalog.models)); + } else if (catalog.source === "system" && catalog.channels) { + useConfigStore.getState().mergeSystemChannels(systemChannelModelChannels(catalog.channels)); + } } installRemoteUserDataAutoSync(); if (payload.user?.id) { @@ -64,9 +78,16 @@ export async function applyUserSession(payload: AuthSessionPayload) { } export async function refreshSystemChannels() { - // 创作端只刷新公开前台模型;供应渠道目录仅管理员页面可见。 - const logicalPayload = await listLogicalModels(); - useConfigStore.getState().mergeSystemChannels(managedModelChannels(logicalPayload.models || [])); + // 使用统一模型目录接口,根据 frontendModelsEnabled 自动返回前台模型或系统渠道模型 + const catalog = await getModelCatalog(); + + if (catalog.source === "frontend" && catalog.models) { + // 前台模型模式 + useConfigStore.getState().mergeSystemChannels(managedModelChannels(catalog.models)); + } else if (catalog.source === "system" && catalog.channels) { + // 系统渠道模式 + useConfigStore.getState().mergeSystemChannels(systemChannelModelChannels(catalog.channels)); + } } function managedModelChannels(models: PublicLogicalModel[]) { @@ -107,6 +128,62 @@ function managedModelChannels(models: PublicLogicalModel[]) { return [managed]; } +// 系统渠道模型转换为前端配置格式 +function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] { + return channels.map((channel) => { + const availableModels = channel.models.filter((m) => m.available); + return { + id: channel.id, + name: channel.displayName, + baseUrl: "/api", + apiKey: "system", + apiFormat: "openai", + scope: "system" as const, + enabled: true, + models: availableModels.map((m) => m.modelKey), + modelAliases: {}, + modelCosts: availableModels.map((model) => { + // 标量价格仅用于旧配置兼容;创作端按完整 SKU 档位展示和匹配价格。 + const firstTier = model.priceTiers?.[0]; + const unitPrice = firstTier?.unitPriceMicrocredits || 0; + const inputPrice = firstTier?.inputTokenPriceMicrocredits || 0; + const outputPrice = firstTier?.outputTokenPriceMicrocredits || 0; + const cachedPrice = firstTier?.cachedTokenPriceMicrocredits || 0; + const billingMode = firstTier?.billingMode || "fixed_request"; + const logicalPriceTiers = (model.priceTiers || []).map((tier) => ({ + selector: tier.selector || {}, + resolution: tier.resolution || "*", + videoSeconds: tier.videoSeconds || 0, + billingMode: tier.billingMode as "fixed_request" | "per_second" | "token", + unitPriceMicrocredits: tier.unitPriceMicrocredits || 0, + inputTokenPriceMicrocredits: tier.inputTokenPriceMicrocredits || 0, + outputTokenPriceMicrocredits: tier.outputTokenPriceMicrocredits || 0, + cachedTokenPriceMicrocredits: tier.cachedTokenPriceMicrocredits || 0, + })); + + return { + model: model.modelKey, + displayName: model.displayName, + description: "", + icon: "", + capability: model.capability as ModelCapability, + pricePolicy: "channel" as const, + billingMode: billingMode as any, + unitPriceMicrocredits: unitPrice, + inputTokenPriceMicrocredits: inputPrice, + outputTokenPriceMicrocredits: outputPrice, + cachedTokenPriceMicrocredits: cachedPrice, + capabilityConfig: (model.capabilityConfig as ModelCapabilityConfig | undefined) || defaultModelCapabilityConfig(), + channelModelId: model.id, + channelId: channel.id, + modelKey: model.modelKey, + logicalPriceTiers, + }; + }), + }; + }); +} + function projectLogicalCapability(spec: CapabilitySpec, defaults: Record): ModelCapabilityConfig { const projected = defaultModelCapabilityConfig(); if (spec.capability === "image" && projected.image) { diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index dbccd9134..8b9a09580 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -32,12 +32,14 @@ import { WORKSPACE_SIDEBAR_STORAGE_KEY } from "@/components/layout/workspace-sid import { getAdminAntThemeConfig } from "@/lib/app-theme"; import { cn } from "@/lib/utils"; import { useThemeStore } from "@/stores/use-theme-store"; +import { useUserStore } from "@/stores/use-user-store"; type AdminNavigationItem = { path: string; label: string; description: string; icon: ReactNode; + requireFeature?: "frontendModelsEnabled"; }; const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = [ @@ -50,7 +52,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = items: [ { path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: }, { path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: }, - { path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: }, + { path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: , requireFeature: "frontendModelsEnabled" }, { path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: }, ], }, @@ -160,62 +162,70 @@ export function AdminPageFrame({ title, actions, back, scroll = false, children } function MobileAdminNavigation() { + const features = useUserStore((state) => state.features); + const visibleItems = adminNavigation.flatMap((group) => group.items).filter((item) => !item.requireFeature || features[item.requireFeature]); + return ( ); } function AdminNavigation({ collapsed }: { collapsed: boolean }) { + const features = useUserStore((state) => state.features); + return ( ); } diff --git a/web/src/pages/admin/components/feature-availability-panel.tsx b/web/src/pages/admin/components/feature-availability-panel.tsx index e89de7bc5..9d285cb53 100644 --- a/web/src/pages/admin/components/feature-availability-panel.tsx +++ b/web/src/pages/admin/components/feature-availability-panel.tsx @@ -1,20 +1,24 @@ import { useEffect, useState, type ReactNode } from "react"; import { App, Switch } from "antd"; -import { Clapperboard, Coins, ListChecks, RadioTower, ToggleLeft } from "lucide-react"; +import { Clapperboard, Coins, ListChecks, RadioTower, ToggleLeft, Sparkles, Settings } from "lucide-react"; import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/services/api/auth"; import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store"; import { SettingsSectionCard } from "./admin-ui"; -type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled"; +type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled"; -const featureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [ +const userFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [ { key: "shortDramaEnabled", title: "短剧创作", menu: "/projects", description: "关闭后隐藏短剧入口,并拦截项目列表、详情和项目 API。已有项目数据不会删除。", icon: }, { key: "taskCenterEnabled", title: "任务", menu: "/tasks", description: "关闭后仅隐藏并拦截任务中心页面;生成任务仍会创建、执行、记录和恢复。", icon: }, { key: "creditsEnabled", title: "积分中心", menu: "/wallet", description: "关闭后隐藏用户积分入口,新创建的任务和系统渠道请求不再冻结或消费积分。", icon: }, { key: "customChannelsEnabled", title: "自定义渠道", menu: "/settings?section=channels", description: "关闭后隐藏用户自定义渠道入口,并拦截模型目录拉取、渠道中转和新的生成任务。已有渠道配置不会删除。", icon: }, ]; +const adminFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [ + { key: "frontendModelsEnabled", title: "前台模型", menu: "/admin/models", description: "关闭后用户直接使用系统渠道中的模型;后台仍保留前台模型配置入口,方便重新开启。已有配置不会删除。", icon: }, +]; + export default function FeatureAvailabilityPanel() { const { message, modal } = App.useApp(); const setGlobalFeatures = useUserStore((state) => state.setFeatures); @@ -43,10 +47,11 @@ export default function FeatureAvailabilityPanel() { taskCenterEnabled: next.taskCenterEnabled, creditsEnabled: next.creditsEnabled, customChannelsEnabled: next.customChannelsEnabled, + frontendModelsEnabled: next.frontendModelsEnabled, }); setFeatures(result.features); setGlobalFeatures(result.features); - message.success(`${featureRows.find((item) => item.key === key)?.title || "功能"}已${enabled ? "开放" : "关闭"}`); + message.success(`${allFeatureRows.find((item) => item.key === key)?.title || "功能"}已${enabled ? "开放" : "关闭"}`); } catch (error) { message.error(error instanceof Error ? error.message : "保存功能开放配置失败"); } finally { @@ -55,30 +60,67 @@ export default function FeatureAvailabilityPanel() { }; const toggle = (key: FeatureKey, enabled: boolean) => { - if (key !== "creditsEnabled" || enabled) { - void save(key, enabled); + // 积分关闭需要二次确认 + if (key === "creditsEnabled" && !enabled) { + modal.confirm({ + title: "关闭用户积分功能?", + content: "保存后新创建的任务和系统渠道请求将不再扣减积分;已经冻结的计费订单仍按原规则结算,已有余额和流水继续保留。", + okText: "确认关闭", + cancelText: "取消", + okButtonProps: { danger: true }, + onOk: () => save(key, false), + }); return; } - modal.confirm({ - title: "关闭用户积分功能?", - content: "保存后新创建的任务和系统渠道请求将不再扣减积分;已经冻结的计费订单仍按原规则结算,已有余额和流水继续保留。", - okText: "确认关闭", - cancelText: "取消", - okButtonProps: { danger: true }, - onOk: () => save(key, false), - }); + // 前台模型关闭需要二次确认 + if (key === "frontendModelsEnabled" && !enabled) { + modal.confirm({ + title: "关闭前台模型功能?", + content: "关闭后用户将直接使用系统渠道中配置的模型;管理后台仍保留前台模型配置入口,重新开启后即可继续使用。", + okText: "确认关闭", + cancelText: "取消", + okButtonProps: { danger: true }, + onOk: () => save(key, false), + }); + return; + } + void save(key, enabled); }; - const enabledCount = features ? featureRows.filter((item) => features[item.key]).length : 0; + const allFeatureRows = [...userFeatureRows, ...adminFeatureRows]; + const userEnabledCount = features ? userFeatureRows.filter((item) => features[item.key]).length : 0; + const adminEnabledCount = features ? adminFeatureRows.filter((item) => features[item.key]).length : 0; + return ( -
+
} title="用户功能开放" - status={{ label: features ? `${enabledCount}/${featureRows.length} 已开放` : "读取中", color: enabledCount === featureRows.length ? "success" : "default" }} + status={{ label: features ? `${userEnabledCount}/${userFeatureRows.length} 已开放` : "读取中", color: userEnabledCount === userFeatureRows.length ? "success" : "default" }} >
- {featureRows.map((item) => ( + {userFeatureRows.map((item) => ( +
+ {item.icon} +
+
+

{item.title}

+ {item.menu} +
+
+ toggle(item.key, checked)} aria-label={`开放${item.title}`} /> +
+ ))} +
+
+ + } + title="管理后台功能" + status={{ label: features ? `${adminEnabledCount}/${adminFeatureRows.length} 已开放` : "读取中", color: adminEnabledCount === adminFeatureRows.length ? "success" : "default" }} + > +
+ {adminFeatureRows.map((item) => (
{item.icon}
diff --git a/web/src/pages/auth/login.tsx b/web/src/pages/auth/login.tsx index 4be3406e6..163739b46 100644 --- a/web/src/pages/auth/login.tsx +++ b/web/src/pages/auth/login.tsx @@ -5,6 +5,7 @@ import { useNavigate, useSearchParams } from "react-router"; import { applyUserSession } from "@/lib/user-session"; import { getAuthSession, getAuthSettings, linuxDOLoginURL, login } from "@/services/api/auth"; +import { useUserStore } from "@/stores/use-user-store"; import { LinuxDOIcon } from "./auth-scene"; export default function LoginPage() { @@ -16,6 +17,15 @@ export default function LoginPage() { const [submitting, setSubmitting] = useState(false); const [linuxdoEnabled, setLinuxdoEnabled] = useState(false); const next = safeNext(params.get("next")); + const user = useUserStore((state) => state.user); + const hydrated = useUserStore((state) => state.hydrated); + + // 如果已登录,直接跳转 + useEffect(() => { + if (hydrated && user) { + navigate(next, { replace: true }); + } + }, [hydrated, user, next, navigate]); useEffect(() => { void getAuthSettings().then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled)).catch(() => undefined); diff --git a/web/src/router.tsx b/web/src/router.tsx index ae0ec093a..cf76bc264 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -142,7 +142,7 @@ export const router = createBrowserRouter([ { index: true, element: deferred() }, { path: "users", element: deferred() }, { path: "channels", element: deferred() }, - { path: "models", element: deferred() }, + { path: "models", element: {deferred()} }, { path: "prompt-templates", element: deferred() }, { path: "storyboard-prompts", element: }, { path: "announcements", element: deferred() }, diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index f2ded001c..0290a479d 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -370,7 +370,7 @@ export function getAdminFeatureAvailability() { return request<{ features: FeatureAvailability }>(api.get("/admin/settings/features")); } -export function updateAdminFeatureAvailability(features: Pick) { +export function updateAdminFeatureAvailability(features: Pick) { return request<{ features: FeatureAvailability }>(api.patch("/admin/settings/features", features)); } diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index 2f167c4ca..0bae72771 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -121,6 +121,56 @@ export type LogicalModelQuote = { estimated: boolean; }; +export type ModelCatalogSource = "frontend" | "system"; + +export type PublicChannelCatalog = { + id: string; + name: string; + displayName: string; + models: PublicChannelModel[]; +}; + +export type PublicChannelModel = { + id: string; + modelKey: string; + displayName: string; + capability: string; + capabilityConfig?: Record; + priceTiers: PublicChannelModelPriceTier[]; + pricingMode: string; + displayPrice?: number; + priceLabel: string; + available: boolean; +}; + +export type PublicChannelModelPriceTier = { + id: string; + selector?: Record; + resolution: string; + videoSeconds: number; + billingMode: string; + unitPriceMicrocredits: number; + inputTokenPriceMicrocredits: number; + outputTokenPriceMicrocredits: number; + cachedTokenPriceMicrocredits: number; +}; + +export type ModelCatalogResponse = { + source: ModelCatalogSource; + models?: PublicLogicalModel[]; + channels?: PublicChannelCatalog[]; +}; + +// 统一模型目录接口 - 根据 frontendModelsEnabled 开关返回前台模型或系统渠道模型 +export function getModelCatalog() { + return request(apiClient.get("/model-catalog")); +} + +export function getAvailableModelCatalog(intent: ModelRequestIntent) { + return request(apiClient.post("/model-catalog/available", intent)); +} + +// 旧接口,保持兼容 export function listLogicalModels() { return request<{ models: PublicLogicalModel[] }>(apiClient.get("/models")); } diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index d1b881d39..fb8540774 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -475,7 +475,20 @@ export function modelOptionsFromChannels(channels: ModelChannel[]) { export function hasSystemModelPrice(channel: ModelChannel, model: string) { if (channel.scope !== "system") return true; - return channel.modelCosts?.some((item) => item.model === model && Number.isFinite(item.unitPriceMicrocredits) && item.unitPriceMicrocredits >= 0) === true; + const positive = (value: number | undefined) => typeof value === "number" && Number.isFinite(value) && value > 0; + return channel.modelCosts?.some((item) => { + if (item.model !== model) return false; + const tiers = item.logicalPriceTiers || []; + if (tiers.length) { + return tiers.some((tier) => tier.billingMode === "token" + ? [tier.inputTokenPriceMicrocredits, tier.outputTokenPriceMicrocredits, tier.cachedTokenPriceMicrocredits].some(positive) + : positive(tier.unitPriceMicrocredits)); + } + if (item.billingMode === "token") { + return [item.inputTokenPriceMicrocredits, item.outputTokenPriceMicrocredits, item.cachedTokenPriceMicrocredits].some(positive); + } + return positive(item.unitPriceMicrocredits); + }) === true; } export function normalizeModelOptionValue(value: unknown, channels: ModelChannel[]) { diff --git a/web/src/stores/use-user-store.ts b/web/src/stores/use-user-store.ts index 182b5e906..e416b2d04 100644 --- a/web/src/stores/use-user-store.ts +++ b/web/src/stores/use-user-store.ts @@ -29,6 +29,7 @@ export type FeatureAvailability = { taskCenterEnabled: boolean; creditsEnabled: boolean; customChannelsEnabled: boolean; + frontendModelsEnabled: boolean; desktopLocalChannelsEnabled: boolean; configured?: boolean; updatedBy?: string; @@ -40,6 +41,7 @@ export const defaultFeatureAvailability: FeatureAvailability = { taskCenterEnabled: true, creditsEnabled: true, customChannelsEnabled: true, + frontendModelsEnabled: true, desktopLocalChannelsEnabled: false, };