fix(*): 模型目录 - 修复前台开关、系统模型价格与访问控制
This commit is contained in:
@@ -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)
|
||||
|
||||
80
backend/internal/handler/model_catalog.go
Normal file
80
backend/internal/handler/model_catalog.go
Normal file
@@ -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("系统渠道模型报价功能尚未实现"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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("该功能暂未开放")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
247
backend/internal/service/model_catalog.go
Normal file
247
backend/internal/service/model_catalog.go
Normal file
@@ -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
|
||||
}
|
||||
79
backend/internal/service/price_validation.go
Normal file
79
backend/internal/service/price_validation.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<FeatureKey, string> = {
|
||||
shortDramaEnabled: "短剧创作",
|
||||
taskCenterEnabled: "任务中心",
|
||||
creditsEnabled: "积分中心",
|
||||
frontendModelsEnabled: "前台模型",
|
||||
};
|
||||
|
||||
let featureAvailabilityCheckedOnce = false;
|
||||
@@ -43,9 +44,14 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
|
||||
if (checking) return <WorkspacePage><WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} /></WorkspacePage>;
|
||||
if (error) return <WorkspacePage><WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/create", { replace: true })} /></WorkspacePage>;
|
||||
if (!features[feature]) {
|
||||
// 管理员页面返回到管理后台首页,用户页面返回到创作台
|
||||
const isAdminFeature = feature === "frontendModelsEnabled";
|
||||
const backPath = isAdminFeature ? "/admin" : "/create";
|
||||
const backLabel = isAdminFeature ? "返回管理后台" : "返回创作台";
|
||||
|
||||
return (
|
||||
<WorkspacePage>
|
||||
<WorkspaceState icon="empty" title={`${featureNames[feature]}暂未开放`} description="当前功能已由平台管理员关闭。" action={<Button type="primary" onClick={() => navigate("/create", { replace: true })}>返回创作台</Button>} />
|
||||
<WorkspaceState icon="empty" title={`${featureNames[feature]}暂未开放`} description="当前功能已由平台管理员关闭。" action={<Button type="primary" onClick={() => navigate(backPath, { replace: true })}>{backLabel}</Button>} />
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): ModelCapabilityConfig {
|
||||
const projected = defaultModelCapabilityConfig();
|
||||
if (spec.capability === "image" && projected.image) {
|
||||
|
||||
@@ -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: <UsersRound className="size-4" /> },
|
||||
{ path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: <RadioTower className="size-4" /> },
|
||||
{ path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: <Layers3 className="size-4" /> },
|
||||
{ path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: <Layers3 className="size-4" />, requireFeature: "frontendModelsEnabled" },
|
||||
{ path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: <MessageSquareText className="size-4" /> },
|
||||
],
|
||||
},
|
||||
@@ -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 (
|
||||
<nav className="app-workspace-navigation hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 px-3 py-2 lg:hidden" aria-label="管理后台分区">
|
||||
{adminNavigation
|
||||
.flatMap((group) => group.items)
|
||||
.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === "/admin"}
|
||||
className={({ isActive }) =>
|
||||
cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-surface-hover hover:text-foreground")
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{visibleItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === "/admin"}
|
||||
className={({ isActive }) =>
|
||||
cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-surface-hover hover:text-foreground")
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<AppChangelogButton className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground [&_svg]:size-4" />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminNavigation({ collapsed }: { collapsed: boolean }) {
|
||||
const features = useUserStore((state) => state.features);
|
||||
|
||||
return (
|
||||
<nav className="thin-scrollbar flex-1 overflow-y-auto px-2 py-2" aria-label="管理后台菜单">
|
||||
{adminNavigation.map((group) => (
|
||||
<div key={group.label} className="mb-3">
|
||||
{!collapsed ? (
|
||||
<div className="admin-nav-group-label mb-1 px-2.5 text-[var(--fs-tiny)] font-medium text-foreground/38">
|
||||
<span>{group.label}</span>
|
||||
{adminNavigation.map((group) => {
|
||||
const visibleItems = group.items.filter((item) => !item.requireFeature || features[item.requireFeature]);
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={group.label} className="mb-3">
|
||||
{!collapsed ? (
|
||||
<div className="admin-nav-group-label mb-1 px-2.5 text-[var(--fs-tiny)] font-medium text-foreground/38">
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto mb-1.5 h-px w-7 bg-border/80" />
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{visibleItems.map((item) => (
|
||||
<Tooltip key={item.path} mouseEnterDelay={0.1} title={collapsed ? item.label : undefined} placement="right">
|
||||
<NavLink
|
||||
to={item.path}
|
||||
end={item.path === "/admin"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"app-workspace-nav-link flex h-8 items-center rounded-md text-[var(--fs-body)] transition-colors",
|
||||
collapsed ? "justify-center px-0" : "gap-2.5 px-2.5",
|
||||
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed ? <span className="truncate">{item.label}</span> : null}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto mb-1.5 h-px w-7 bg-border/80" />
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<Tooltip key={item.path} mouseEnterDelay={0.1} title={collapsed ? item.label : undefined} placement="right">
|
||||
<NavLink
|
||||
to={item.path}
|
||||
end={item.path === "/admin"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"app-workspace-nav-link flex h-8 items-center rounded-md text-[var(--fs-body)] transition-colors",
|
||||
collapsed ? "justify-center px-0" : "gap-2.5 px-2.5",
|
||||
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed ? <span className="truncate">{item.label}</span> : null}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: <Clapperboard className="size-4" /> },
|
||||
{ key: "taskCenterEnabled", title: "任务", menu: "/tasks", description: "关闭后仅隐藏并拦截任务中心页面;生成任务仍会创建、执行、记录和恢复。", icon: <ListChecks className="size-4" /> },
|
||||
{ key: "creditsEnabled", title: "积分中心", menu: "/wallet", description: "关闭后隐藏用户积分入口,新创建的任务和系统渠道请求不再冻结或消费积分。", icon: <Coins className="size-4" /> },
|
||||
{ key: "customChannelsEnabled", title: "自定义渠道", menu: "/settings?section=channels", description: "关闭后隐藏用户自定义渠道入口,并拦截模型目录拉取、渠道中转和新的生成任务。已有渠道配置不会删除。", icon: <RadioTower className="size-4" /> },
|
||||
];
|
||||
|
||||
const adminFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [
|
||||
{ key: "frontendModelsEnabled", title: "前台模型", menu: "/admin/models", description: "关闭后用户直接使用系统渠道中的模型;后台仍保留前台模型配置入口,方便重新开启。已有配置不会删除。", icon: <Sparkles className="size-4" /> },
|
||||
];
|
||||
|
||||
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 (
|
||||
<div className="pt-4">
|
||||
<div className="space-y-6 pt-4">
|
||||
<SettingsSectionCard
|
||||
icon={<ToggleLeft className="size-4" />}
|
||||
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" }}
|
||||
>
|
||||
<div className="divide-y divide-border/75">
|
||||
{featureRows.map((item) => (
|
||||
{userFeatureRows.map((item) => (
|
||||
<div key={item.key} className="flex min-h-16 items-center gap-3 px-4 py-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/35 text-foreground/65">{item.icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-medium">{item.title}</h3>
|
||||
<span className="rounded border border-border/70 px-1.5 py-0.5 text-[var(--fs-micro)] text-foreground/45">{item.menu}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={features?.[item.key] === true} loading={!features || saving === item.key} disabled={Boolean(saving && saving !== item.key)} onChange={(checked) => toggle(item.key, checked)} aria-label={`开放${item.title}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<SettingsSectionCard
|
||||
icon={<Settings className="size-4" />}
|
||||
title="管理后台功能"
|
||||
status={{ label: features ? `${adminEnabledCount}/${adminFeatureRows.length} 已开放` : "读取中", color: adminEnabledCount === adminFeatureRows.length ? "success" : "default" }}
|
||||
>
|
||||
<div className="divide-y divide-border/75">
|
||||
{adminFeatureRows.map((item) => (
|
||||
<div key={item.key} className="flex min-h-16 items-center gap-3 px-4 py-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/35 text-foreground/65">{item.icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -142,7 +142,7 @@ export const router = createBrowserRouter([
|
||||
{ index: true, element: deferred(<AnalyticsPage />) },
|
||||
{ path: "users", element: deferred(<UsersPage />) },
|
||||
{ path: "channels", element: deferred(<ChannelsPage />) },
|
||||
{ path: "models", element: deferred(<LogicalModelsPage />) },
|
||||
{ path: "models", element: <RequireFeature feature="frontendModelsEnabled">{deferred(<LogicalModelsPage />)}</RequireFeature> },
|
||||
{ path: "prompt-templates", element: deferred(<StoryboardPromptsPage />) },
|
||||
{ path: "storyboard-prompts", element: <Navigate to="/admin/prompt-templates" replace /> },
|
||||
{ path: "announcements", element: deferred(<AnnouncementsPage />) },
|
||||
|
||||
@@ -370,7 +370,7 @@ export function getAdminFeatureAvailability() {
|
||||
return request<{ features: FeatureAvailability }>(api.get("/admin/settings/features"));
|
||||
}
|
||||
|
||||
export function updateAdminFeatureAvailability(features: Pick<FeatureAvailability, "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled">) {
|
||||
export function updateAdminFeatureAvailability(features: Pick<FeatureAvailability, "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled">) {
|
||||
return request<{ features: FeatureAvailability }>(api.patch("/admin/settings/features", features));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, any>;
|
||||
priceTiers: PublicChannelModelPriceTier[];
|
||||
pricingMode: string;
|
||||
displayPrice?: number;
|
||||
priceLabel: string;
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
export type PublicChannelModelPriceTier = {
|
||||
id: string;
|
||||
selector?: Record<string, string>;
|
||||
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<ModelCatalogResponse>(apiClient.get("/model-catalog"));
|
||||
}
|
||||
|
||||
export function getAvailableModelCatalog(intent: ModelRequestIntent) {
|
||||
return request<ModelCatalogResponse>(apiClient.post("/model-catalog/available", intent));
|
||||
}
|
||||
|
||||
// 旧接口,保持兼容
|
||||
export function listLogicalModels() {
|
||||
return request<{ models: PublicLogicalModel[] }>(apiClient.get("/models"));
|
||||
}
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user