diff --git a/common/utils.go b/common/utils.go index d9db67d0..17aecd95 100644 --- a/common/utils.go +++ b/common/utils.go @@ -13,6 +13,7 @@ import ( "math/big" "math/rand" "net" + "net/url" "os" "os/exec" "runtime" @@ -284,3 +285,20 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64 } return strconv.ParseFloat(durationStr, 64) } + +// BuildURL concatenates base and endpoint, returns the complete url string +func BuildURL(base string, endpoint string) string { + u, err := url.Parse(base) + if err != nil { + return base + endpoint + } + end := endpoint + if end == "" { + end = "/" + } + ref, err := url.Parse(end) + if err != nil { + return base + endpoint + } + return u.ResolveReference(ref).String() +} diff --git a/controller/ratio_config.go b/controller/ratio_config.go new file mode 100644 index 00000000..6ddc3d9e --- /dev/null +++ b/controller/ratio_config.go @@ -0,0 +1,24 @@ +package controller + +import ( + "net/http" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +func GetRatioConfig(c *gin.Context) { + if !ratio_setting.IsExposeRatioEnabled() { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "倍率配置接口未启用", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": ratio_setting.GetExposedData(), + }) +} \ No newline at end of file diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go new file mode 100644 index 00000000..f749f384 --- /dev/null +++ b/controller/ratio_sync.go @@ -0,0 +1,322 @@ +package controller + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "sync" + "time" + + "one-api/common" + "one-api/dto" + "one-api/model" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +const ( + defaultTimeoutSeconds = 10 + defaultEndpoint = "/api/ratio_config" + maxConcurrentFetches = 8 +) + +var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} + +type upstreamResult struct { + Name string `json:"name"` + Data map[string]any `json:"data,omitempty"` + Err string `json:"err,omitempty"` +} + +func FetchUpstreamRatios(c *gin.Context) { + var req dto.UpstreamRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + + if req.Timeout <= 0 { + req.Timeout = defaultTimeoutSeconds + } + + var upstreams []dto.UpstreamDTO + + if len(req.ChannelIDs) > 0 { + intIds := make([]int, 0, len(req.ChannelIDs)) + for _, id64 := range req.ChannelIDs { + intIds = append(intIds, int(id64)) + } + dbChannels, err := model.GetChannelsByIds(intIds) + if err != nil { + common.LogError(c.Request.Context(), "failed to query channels: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"}) + return + } + for _, ch := range dbChannels { + if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") { + upstreams = append(upstreams, dto.UpstreamDTO{ + Name: ch.Name, + BaseURL: strings.TrimRight(base, "/"), + Endpoint: "", + }) + } + } + } + + if len(upstreams) == 0 { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"}) + return + } + + var wg sync.WaitGroup + ch := make(chan upstreamResult, len(upstreams)) + + sem := make(chan struct{}, maxConcurrentFetches) + + client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}} + + for _, chn := range upstreams { + wg.Add(1) + go func(chItem dto.UpstreamDTO) { + defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + + endpoint := chItem.Endpoint + if endpoint == "" { + endpoint = defaultEndpoint + } else if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint + } + fullURL := chItem.BaseURL + endpoint + + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + common.LogWarn(c.Request.Context(), "build request failed: "+err.Error()) + ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + return + } + + resp, err := client.Do(httpReq) + if err != nil { + common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status) + ch <- upstreamResult{Name: chItem.Name, Err: resp.Status} + return + } + var body struct { + Success bool `json:"success"` + Data map[string]any `json:"data"` + Message string `json:"message"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: chItem.Name, Err: err.Error()} + return + } + if !body.Success { + ch <- upstreamResult{Name: chItem.Name, Err: body.Message} + return + } + ch <- upstreamResult{Name: chItem.Name, Data: body.Data} + }(chn) + } + + wg.Wait() + close(ch) + + localData := ratio_setting.GetExposedData() + + var testResults []dto.TestResult + var successfulChannels []struct { + name string + data map[string]any + } + + for r := range ch { + if r.Err != "" { + testResults = append(testResults, dto.TestResult{ + Name: r.Name, + Status: "error", + Error: r.Err, + }) + } else { + testResults = append(testResults, dto.TestResult{ + Name: r.Name, + Status: "success", + }) + successfulChannels = append(successfulChannels, struct { + name string + data map[string]any + }{name: r.Name, data: r.Data}) + } + } + + differences := buildDifferences(localData, successfulChannels) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "differences": differences, + "test_results": testResults, + }, + }) +} + +func buildDifferences(localData map[string]any, successfulChannels []struct { + name string + data map[string]any +}) map[string]map[string]dto.DifferenceItem { + differences := make(map[string]map[string]dto.DifferenceItem) + + allModels := make(map[string]struct{}) + + for _, ratioType := range ratioTypes { + if localRatioAny, ok := localData[ratioType]; ok { + if localRatio, ok := localRatioAny.(map[string]float64); ok { + for modelName := range localRatio { + allModels[modelName] = struct{}{} + } + } + } + } + + for _, channel := range successfulChannels { + for _, ratioType := range ratioTypes { + if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { + for modelName := range upstreamRatio { + allModels[modelName] = struct{}{} + } + } + } + } + + for modelName := range allModels { + for _, ratioType := range ratioTypes { + var localValue interface{} = nil + if localRatioAny, ok := localData[ratioType]; ok { + if localRatio, ok := localRatioAny.(map[string]float64); ok { + if val, exists := localRatio[modelName]; exists { + localValue = val + } + } + } + + upstreamValues := make(map[string]interface{}) + hasUpstreamValue := false + hasDifference := false + + for _, channel := range successfulChannels { + var upstreamValue interface{} = nil + + if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { + if val, exists := upstreamRatio[modelName]; exists { + upstreamValue = val + hasUpstreamValue = true + + if localValue != nil && localValue != val { + hasDifference = true + } else if localValue == val { + upstreamValue = "same" + } + } + } + if upstreamValue == nil && localValue == nil { + upstreamValue = "same" + } + + if localValue == nil && upstreamValue != nil && upstreamValue != "same" { + hasDifference = true + } + + upstreamValues[channel.name] = upstreamValue + } + + shouldInclude := false + + if localValue != nil { + if hasDifference { + shouldInclude = true + } + } else { + if hasUpstreamValue { + shouldInclude = true + } + } + + if shouldInclude { + if differences[modelName] == nil { + differences[modelName] = make(map[string]dto.DifferenceItem) + } + differences[modelName][ratioType] = dto.DifferenceItem{ + Current: localValue, + Upstreams: upstreamValues, + } + } + } + } + + channelHasDiff := make(map[string]bool) + for _, ratioMap := range differences { + for _, item := range ratioMap { + for chName, val := range item.Upstreams { + if val != nil && val != "same" { + channelHasDiff[chName] = true + } + } + } + } + + for modelName, ratioMap := range differences { + for ratioType, item := range ratioMap { + for chName := range item.Upstreams { + if !channelHasDiff[chName] { + delete(item.Upstreams, chName) + } + } + differences[modelName][ratioType] = item + } + } + + return differences +} + +func GetSyncableChannels(c *gin.Context) { + channels, err := model.GetAllChannels(0, 0, true, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var syncableChannels []dto.SyncableChannel + for _, channel := range channels { + if channel.GetBaseURL() != "" { + syncableChannels = append(syncableChannels, dto.SyncableChannel{ + ID: channel.Id, + Name: channel.Name, + BaseURL: channel.GetBaseURL(), + Status: channel.Status, + }) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": syncableChannels, + }) +} \ No newline at end of file diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go new file mode 100644 index 00000000..55a89025 --- /dev/null +++ b/dto/ratio_sync.go @@ -0,0 +1,49 @@ +package dto + +// UpstreamDTO 提交到后端同步倍率的上游渠道信息 +// Endpoint 可以为空,后端会默认使用 /api/ratio_config +// BaseURL 必须以 http/https 开头,不要以 / 结尾 +// 例如: https://api.example.com +// Endpoint: /api/ratio_config +// 提交示例: +// { +// "name": "openai", +// "base_url": "https://api.openai.com", +// "endpoint": "/ratio_config" +// } + +type UpstreamDTO struct { + Name string `json:"name" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + Endpoint string `json:"endpoint"` +} + +type UpstreamRequest struct { + ChannelIDs []int64 `json:"channel_ids"` + Timeout int `json:"timeout"` +} + +// TestResult 上游测试连通性结果 +type TestResult struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// DifferenceItem 差异项 +// Current 为本地值,可能为 nil +// Upstreams 为各渠道的上游值,具体数值 / "same" / nil + +type DifferenceItem struct { + Current interface{} `json:"current"` + Upstreams map[string]interface{} `json:"upstreams"` +} + +// SyncableChannel 可同步的渠道信息(base_url 不为空) + +type SyncableChannel struct { + ID int `json:"id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Status int `json:"status"` +} \ No newline at end of file diff --git a/model/option.go b/model/option.go index ec386b29..ea72e5ee 100644 --- a/model/option.go +++ b/model/option.go @@ -127,6 +127,7 @@ func InitOptionMap() { common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() + common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) // 自动添加所有注册的模型配置 modelConfigs := config.GlobalConfig.ExportAllConfigs() @@ -267,6 +268,8 @@ func updateOptionMap(key string, value string) (err error) { setting.WorkerAllowHttpImageRequestEnabled = boolValue case "DefaultUseAutoGroup": setting.DefaultUseAutoGroup = boolValue + case "ExposeRatioEnabled": + ratio_setting.SetExposeRatioEnabled(boolValue) } } switch key { diff --git a/router/api-router.go b/router/api-router.go index 45930246..badfa7bf 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -36,6 +36,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind) apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin) apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind) + apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) userRoute := apiRouter.Group("/user") { @@ -83,6 +84,12 @@ func SetApiRouter(router *gin.Engine) { optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 } + ratioSyncRoute := apiRouter.Group("/ratio_sync") + ratioSyncRoute.Use(middleware.RootAuth()) + { + ratioSyncRoute.GET("/channels", controller.GetSyncableChannels) + ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios) + } channelRoute := apiRouter.Group("/channel") channelRoute.Use(middleware.AdminAuth()) { diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index aa934b22..51d473a8 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -85,7 +85,11 @@ func UpdateCacheRatioByJSONString(jsonStr string) error { cacheRatioMapMutex.Lock() defer cacheRatioMapMutex.Unlock() cacheRatioMap = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &cacheRatioMap) + err := json.Unmarshal([]byte(jsonStr), &cacheRatioMap) + if err == nil { + InvalidateExposedDataCache() + } + return err } // GetCacheRatio returns the cache ratio for a model @@ -106,3 +110,13 @@ func GetCreateCacheRatio(name string) (float64, bool) { } return ratio, true } + +func GetCacheRatioCopy() map[string]float64 { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(cacheRatioMap)) + for k, v := range cacheRatioMap { + copyMap[k] = v + } + return copyMap +} diff --git a/setting/ratio_setting/expose_ratio.go b/setting/ratio_setting/expose_ratio.go new file mode 100644 index 00000000..8fca0bcb --- /dev/null +++ b/setting/ratio_setting/expose_ratio.go @@ -0,0 +1,17 @@ +package ratio_setting + +import "sync/atomic" + +var exposeRatioEnabled atomic.Bool + +func init() { + exposeRatioEnabled.Store(false) +} + +func SetExposeRatioEnabled(enabled bool) { + exposeRatioEnabled.Store(enabled) +} + +func IsExposeRatioEnabled() bool { + return exposeRatioEnabled.Load() +} \ No newline at end of file diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go new file mode 100644 index 00000000..9e5b6c30 --- /dev/null +++ b/setting/ratio_setting/exposed_cache.go @@ -0,0 +1,55 @@ +package ratio_setting + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +const exposedDataTTL = 30 * time.Second + +type exposedCache struct { + data gin.H + expiresAt time.Time +} + +var ( + exposedData atomic.Value + rebuildMu sync.Mutex +) + +func InvalidateExposedDataCache() { + exposedData.Store((*exposedCache)(nil)) +} + +func cloneGinH(src gin.H) gin.H { + dst := make(gin.H, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func GetExposedData() gin.H { + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + rebuildMu.Lock() + defer rebuildMu.Unlock() + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + newData := gin.H{ + "model_ratio": GetModelRatioCopy(), + "completion_ratio": GetCompletionRatioCopy(), + "cache_ratio": GetCacheRatioCopy(), + "model_price": GetModelPriceCopy(), + } + exposedData.Store(&exposedCache{ + data: newData, + expiresAt: time.Now().Add(exposedDataTTL), + }) + return cloneGinH(newData) +} \ No newline at end of file diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 3102dfe9..1eaf25b1 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -317,7 +317,11 @@ func UpdateModelPriceByJSONString(jsonStr string) error { modelPriceMapMutex.Lock() defer modelPriceMapMutex.Unlock() modelPriceMap = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &modelPriceMap) + err := json.Unmarshal([]byte(jsonStr), &modelPriceMap) + if err == nil { + InvalidateExposedDataCache() + } + return err } // GetModelPrice 返回模型的价格,如果模型不存在则返回-1,false @@ -345,7 +349,11 @@ func UpdateModelRatioByJSONString(jsonStr string) error { modelRatioMapMutex.Lock() defer modelRatioMapMutex.Unlock() modelRatioMap = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &modelRatioMap) + err := json.Unmarshal([]byte(jsonStr), &modelRatioMap) + if err == nil { + InvalidateExposedDataCache() + } + return err } // 处理带有思考预算的模型名称,方便统一定价 @@ -405,7 +413,11 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error { CompletionRatioMutex.Lock() defer CompletionRatioMutex.Unlock() CompletionRatio = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &CompletionRatio) + err := json.Unmarshal([]byte(jsonStr), &CompletionRatio) + if err == nil { + InvalidateExposedDataCache() + } + return err } func GetCompletionRatio(name string) float64 { @@ -609,3 +621,33 @@ func GetImageRatio(name string) (float64, bool) { } return ratio, true } + +func GetModelRatioCopy() map[string]float64 { + modelRatioMapMutex.RLock() + defer modelRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(modelRatioMap)) + for k, v := range modelRatioMap { + copyMap[k] = v + } + return copyMap +} + +func GetModelPriceCopy() map[string]float64 { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + copyMap := make(map[string]float64, len(modelPriceMap)) + for k, v := range modelPriceMap { + copyMap[k] = v + } + return copyMap +} + +func GetCompletionRatioCopy() map[string]float64 { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + copyMap := make(map[string]float64, len(CompletionRatio)) + for k, v := range CompletionRatio { + copyMap[k] = v + } + return copyMap +} diff --git a/web/src/components/settings/ChannelSelectorModal.js b/web/src/components/settings/ChannelSelectorModal.js new file mode 100644 index 00000000..573329b3 --- /dev/null +++ b/web/src/components/settings/ChannelSelectorModal.js @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { + Modal, + Transfer, + Input, + Space, + Checkbox, + Avatar, + Highlight, +} from '@douyinfe/semi-ui'; +import { IconClose } from '@douyinfe/semi-icons'; + +const CHANNEL_STATUS_CONFIG = { + 1: { color: 'green', text: '启用' }, + 2: { color: 'red', text: '禁用' }, + 3: { color: 'amber', text: '自禁' }, + default: { color: 'grey', text: '未知' } +}; + +const getChannelStatusConfig = (status) => { + return CHANNEL_STATUS_CONFIG[status] || CHANNEL_STATUS_CONFIG.default; +}; + +export default function ChannelSelectorModal({ + t, + visible, + onCancel, + onOk, + allChannels = [], + selectedChannelIds = [], + setSelectedChannelIds, + channelEndpoints, + updateChannelEndpoint, +}) { + const [searchText, setSearchText] = useState(''); + + const ChannelInfo = ({ item, showEndpoint = false, isSelected = false }) => { + const channelId = item.key || item.value; + const currentEndpoint = channelEndpoints[channelId]; + const baseUrl = item._originalData?.base_url || ''; + const status = item._originalData?.status || 0; + const statusConfig = getChannelStatusConfig(status); + + return ( + <> + + {statusConfig.text} + +
+
+ {isSelected ? ( + item.label + ) : ( + + )} +
+
+ + {isSelected ? ( + baseUrl + ) : ( + + )} + + {showEndpoint && ( + updateChannelEndpoint(channelId, value)} + placeholder="/api/ratio_config" + className="flex-1 text-xs" + style={{ fontSize: '12px' }} + /> + )} + {isSelected && !showEndpoint && ( + + {currentEndpoint} + + )} +
+
+ + ); + }; + + const renderSourceItem = (item) => { + return ( +
+ + + +
+ ); + }; + + const renderSelectedItem = (item) => { + return ( +
+ + +
+ ); + }; + + const channelFilter = (input, item) => { + const searchLower = input.toLowerCase(); + return item.label.toLowerCase().includes(searchLower) || + (item._originalData?.base_url || '').toLowerCase().includes(searchLower); + }; + + return ( + {t('选择同步渠道')}} + width={1000} + > + + + + + ); +} \ No newline at end of file diff --git a/web/src/components/settings/RatioSetting.js b/web/src/components/settings/RatioSetting.js index bf97282c..1d87c6de 100644 --- a/web/src/components/settings/RatioSetting.js +++ b/web/src/components/settings/RatioSetting.js @@ -6,6 +6,7 @@ import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings.js' import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings.js'; import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor.js'; import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor.js'; +import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync.js'; import { API, showError } from '../../helpers'; @@ -21,6 +22,7 @@ const RatioSetting = () => { GroupGroupRatio: '', AutoGroups: '', DefaultUseAutoGroup: false, + ExposeRatioEnabled: false, UserUsableGroups: '', }); @@ -48,7 +50,7 @@ const RatioSetting = () => { // 如果后端返回的不是合法 JSON,直接展示 } } - if (['DefaultUseAutoGroup'].includes(item.key)) { + if (['DefaultUseAutoGroup', 'ExposeRatioEnabled'].includes(item.key)) { newInputs[item.key] = item.value === 'true' ? true : false; } else { newInputs[item.key] = item.value; @@ -78,10 +80,6 @@ const RatioSetting = () => { return ( - {/* 分组倍率设置 */} - - - {/* 模型倍率设置以及可视化编辑器 */} @@ -100,8 +98,18 @@ const RatioSetting = () => { refresh={onRefresh} /> + + + + {/* 分组倍率设置 */} + + + ); }; diff --git a/web/src/constants/common.constant.js b/web/src/constants/common.constant.js index 1a37d5f6..9ce83432 100644 --- a/web/src/constants/common.constant.js +++ b/web/src/constants/common.constant.js @@ -1 +1,3 @@ export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend! + +export const DEFAULT_ENDPOINT = '/api/ratio_config'; \ No newline at end of file diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index fc80f9c1..ab793364 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1665,5 +1665,28 @@ "确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?", "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "This will delete all used, disabled, and expired redemption codes, this operation cannot be undone.", "选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)", - "请输入备注(仅管理员可见)": "Please enter a remark (only visible to administrators)" + "请输入备注(仅管理员可见)": "Please enter a remark (only visible to administrators)", + "上游倍率同步": "Upstream ratio synchronization", + "获取渠道失败:": "Failed to get channels: ", + "请至少选择一个渠道": "Please select at least one channel", + "获取倍率失败:": "Failed to get ratios: ", + "后端请求失败": "Backend request failed", + "部分渠道测试失败:": "Some channels failed to test: ", + "已与上游倍率完全一致,无需同步": "The upstream ratio is completely consistent, no synchronization is required", + "请求后端接口失败:": "Failed to request the backend interface: ", + "同步成功": "Synchronization successful", + "部分保存失败": "Some settings failed to save", + "保存失败": "Save failed", + "选择同步渠道": "Select synchronization channel", + "应用同步": "Apply synchronization", + "倍率类型": "Ratio type", + "当前值": "Current value", + "上游值": "Upstream value", + "差异": "Difference", + "搜索渠道名称或地址": "Search channel name or address", + "缓存倍率": "Cache ratio", + "暂无差异化倍率显示": "No differential ratio display", + "请先选择同步渠道": "Please select the synchronization channel first", + "与本地相同": "Same as local", + "未找到匹配的模型": "No matching model found" } \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css index c1254fcc..ff7294ad 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -432,4 +432,72 @@ code { .semi-table-tbody>.semi-table-row { border-bottom: 1px solid rgba(0, 0, 0, 0.1); } +} + +/* ==================== 同步倍率 - 渠道选择器 ==================== */ + +.components-transfer-source-item, +.components-transfer-selected-item { + display: flex; + align-items: center; + padding: 8px; +} + +.semi-transfer-left-list, +.semi-transfer-right-list { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.semi-transfer-left-list::-webkit-scrollbar, +.semi-transfer-right-list::-webkit-scrollbar { + display: none; +} + +.components-transfer-source-item .semi-checkbox, +.components-transfer-selected-item .semi-checkbox { + display: flex; + align-items: center; + width: 100%; +} + +.components-transfer-source-item .semi-avatar, +.components-transfer-selected-item .semi-avatar { + margin-right: 12px; + flex-shrink: 0; +} + +.components-transfer-source-item .info, +.components-transfer-selected-item .info { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; +} + +.components-transfer-source-item .name, +.components-transfer-selected-item .name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.components-transfer-source-item .email, +.components-transfer-selected-item .email { + font-size: 12px; + color: var(--semi-color-text-2); + display: flex; + align-items: center; +} + +.components-transfer-selected-item .semi-icon-close { + margin-left: 8px; + cursor: pointer; + color: var(--semi-color-text-2); +} + +.components-transfer-selected-item .semi-icon-close:hover { + color: var(--semi-color-text-0); } \ No newline at end of file diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index 15c02abf..0fd18d16 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -1112,7 +1112,6 @@ const Detail = (props) => { @@ -1389,7 +1388,6 @@ const Detail = (props) => { ) : ( + + + + setInputs({ ...inputs, ExposeRatioEnabled: value }) + } + /> + + diff --git a/web/src/pages/Setting/Ratio/UpstreamRatioSync.js b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js new file mode 100644 index 00000000..f83e0cdc --- /dev/null +++ b/web/src/pages/Setting/Ratio/UpstreamRatioSync.js @@ -0,0 +1,503 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { + Button, + Table, + Tag, + Empty, + Checkbox, + Form, + Input, +} from '@douyinfe/semi-ui'; +import { IconSearch } from '@douyinfe/semi-icons'; +import { + RefreshCcw, + CheckSquare, +} from 'lucide-react'; +import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers'; +import { DEFAULT_ENDPOINT } from '../../../constants'; +import { useTranslation } from 'react-i18next'; +import { + IllustrationNoResult, + IllustrationNoResultDark +} from '@douyinfe/semi-illustrations'; +import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal'; + +export default function UpstreamRatioSync(props) { + const { t } = useTranslation(); + const [modalVisible, setModalVisible] = useState(false); + const [loading, setLoading] = useState(false); + const [syncLoading, setSyncLoading] = useState(false); + + // 渠道选择相关 + const [allChannels, setAllChannels] = useState([]); + const [selectedChannelIds, setSelectedChannelIds] = useState([]); + + // 渠道端点配置 + const [channelEndpoints, setChannelEndpoints] = useState({}); // { channelId: endpoint } + + // 差异数据和测试结果 + const [differences, setDifferences] = useState({}); + const [resolutions, setResolutions] = useState({}); + + // 是否已经执行过同步 + const [hasSynced, setHasSynced] = useState(false); + + // 分页相关状态 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + // 搜索相关状态 + const [searchKeyword, setSearchKeyword] = useState(''); + + const fetchAllChannels = async () => { + setLoading(true); + try { + const res = await API.get('/api/ratio_sync/channels'); + + if (res.data.success) { + const channels = res.data.data || []; + + const transferData = channels.map(channel => ({ + key: channel.id, + label: channel.name, + value: channel.id, + disabled: false, + _originalData: channel, + })); + + setAllChannels(transferData); + + const initialEndpoints = {}; + transferData.forEach(channel => { + initialEndpoints[channel.key] = DEFAULT_ENDPOINT; + }); + setChannelEndpoints(initialEndpoints); + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('获取渠道失败:') + error.message); + } finally { + setLoading(false); + } + }; + + const confirmChannelSelection = () => { + const selected = allChannels + .filter(ch => selectedChannelIds.includes(ch.value)) + .map(ch => ch._originalData); + + if (selected.length === 0) { + showWarning(t('请至少选择一个渠道')); + return; + } + + setModalVisible(false); + fetchRatiosFromChannels(selected); + }; + + const fetchRatiosFromChannels = async (channelList) => { + setSyncLoading(true); + + const payload = { + channel_ids: channelList.map(ch => parseInt(ch.id)), + timeout: 10, + }; + + try { + const res = await API.post('/api/ratio_sync/fetch', payload); + + if (!res.data.success) { + showError(res.data.message || t('后端请求失败')); + setSyncLoading(false); + return; + } + + const { differences = {}, test_results = [] } = res.data.data; + + const errorResults = test_results.filter(r => r.status === 'error'); + if (errorResults.length > 0) { + showWarning(t('部分渠道测试失败:') + errorResults.map(r => `${r.name}: ${r.error}`).join(', ')); + } + + setDifferences(differences); + setResolutions({}); + setHasSynced(true); + + if (Object.keys(differences).length === 0) { + showSuccess(t('已与上游倍率完全一致,无需同步')); + } + } catch (e) { + showError(t('请求后端接口失败:') + e.message); + } finally { + setSyncLoading(false); + } + }; + + const selectValue = (model, ratioType, value) => { + setResolutions(prev => ({ + ...prev, + [model]: { + ...prev[model], + [ratioType]: value, + }, + })); + }; + + const applySync = async () => { + const currentRatios = { + ModelRatio: JSON.parse(props.options.ModelRatio || '{}'), + CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'), + CacheRatio: JSON.parse(props.options.CacheRatio || '{}'), + ModelPrice: JSON.parse(props.options.ModelPrice || '{}'), + }; + + Object.entries(resolutions).forEach(([model, ratios]) => { + Object.entries(ratios).forEach(([ratioType, value]) => { + const optionKey = ratioType + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + currentRatios[optionKey][model] = parseFloat(value); + }); + }); + + setLoading(true); + try { + const updates = Object.entries(currentRatios).map(([key, value]) => + API.put('/api/option/', { + key, + value: JSON.stringify(value, null, 2), + }) + ); + + const results = await Promise.all(updates); + + if (results.every(res => res.data.success)) { + showSuccess(t('同步成功')); + props.refresh(); + + setDifferences(prevDifferences => { + const newDifferences = { ...prevDifferences }; + + Object.entries(resolutions).forEach(([model, ratios]) => { + Object.keys(ratios).forEach(ratioType => { + if (newDifferences[model] && newDifferences[model][ratioType]) { + delete newDifferences[model][ratioType]; + + if (Object.keys(newDifferences[model]).length === 0) { + delete newDifferences[model]; + } + } + }); + }); + + return newDifferences; + }); + + setResolutions({}); + } else { + showError(t('部分保存失败')); + } + } catch (error) { + showError(t('保存失败')); + } finally { + setLoading(false); + } + }; + + const getCurrentPageData = (dataSource) => { + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + return dataSource.slice(startIndex, endIndex); + }; + + const renderHeader = () => ( +
+
+
+ + + {(() => { + const hasSelections = Object.keys(resolutions).length > 0; + + return ( + + ); + })()} + + } + placeholder={t('搜索模型名称')} + value={searchKeyword} + onChange={setSearchKeyword} + className="!rounded-full w-full md:w-64 mt-2" + showClear + /> +
+
+
+ ); + + const renderDifferenceTable = () => { + const dataSource = useMemo(() => { + const tmp = []; + + Object.entries(differences).forEach(([model, ratioTypes]) => { + Object.entries(ratioTypes).forEach(([ratioType, diff]) => { + tmp.push({ + key: `${model}_${ratioType}`, + model, + ratioType, + current: diff.current, + upstreams: diff.upstreams, + }); + }); + }); + + return tmp; + }, [differences]); + + const filteredDataSource = useMemo(() => { + if (!searchKeyword.trim()) { + return dataSource; + } + + const keyword = searchKeyword.toLowerCase().trim(); + return dataSource.filter(item => + item.model.toLowerCase().includes(keyword) + ); + }, [dataSource, searchKeyword]); + + const upstreamNames = useMemo(() => { + const set = new Set(); + filteredDataSource.forEach((row) => { + Object.keys(row.upstreams || {}).forEach((name) => set.add(name)); + }); + return Array.from(set); + }, [filteredDataSource]); + + if (filteredDataSource.length === 0) { + return ( + } + darkModeImage={} + description={ + searchKeyword.trim() + ? t('未找到匹配的模型') + : (Object.keys(differences).length === 0 ? + (hasSynced ? t('暂无差异化倍率显示') : t('请先选择同步渠道')) + : t('请先选择同步渠道')) + } + style={{ padding: 30 }} + /> + ); + } + + const columns = [ + { + title: t('模型'), + dataIndex: 'model', + fixed: 'left', + }, + { + title: t('倍率类型'), + dataIndex: 'ratioType', + render: (text) => { + const typeMap = { + model_ratio: t('模型倍率'), + completion_ratio: t('补全倍率'), + cache_ratio: t('缓存倍率'), + model_price: t('固定价格'), + }; + return {typeMap[text] || text}; + }, + }, + { + title: t('当前值'), + dataIndex: 'current', + render: (text) => ( + + {text !== null && text !== undefined ? text : t('未设置')} + + ), + }, + ...upstreamNames.map((upName) => { + const channelStats = (() => { + let selectableCount = 0; + let selectedCount = 0; + + filteredDataSource.forEach((row) => { + const upstreamVal = row.upstreams?.[upName]; + if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') { + selectableCount++; + const isSelected = resolutions[row.model]?.[row.ratioType] === upstreamVal; + if (isSelected) { + selectedCount++; + } + } + }); + + return { + selectableCount, + selectedCount, + allSelected: selectableCount > 0 && selectedCount === selectableCount, + partiallySelected: selectedCount > 0 && selectedCount < selectableCount, + hasSelectableItems: selectableCount > 0 + }; + })(); + + const handleBulkSelect = (checked) => { + setResolutions((prev) => { + const newRes = { ...prev }; + + filteredDataSource.forEach((row) => { + const upstreamVal = row.upstreams?.[upName]; + if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') { + if (checked) { + if (!newRes[row.model]) newRes[row.model] = {}; + newRes[row.model][row.ratioType] = upstreamVal; + } else { + if (newRes[row.model]) { + delete newRes[row.model][row.ratioType]; + if (Object.keys(newRes[row.model]).length === 0) { + delete newRes[row.model]; + } + } + } + } + }); + + return newRes; + }); + }; + + return { + title: channelStats.hasSelectableItems ? ( + handleBulkSelect(e.target.checked)} + > + {upName} + + ) : ( + {upName} + ), + dataIndex: upName, + render: (_, record) => { + const upstreamVal = record.upstreams?.[upName]; + + if (upstreamVal === null || upstreamVal === undefined) { + return {t('未设置')}; + } + + if (upstreamVal === 'same') { + return {t('与本地相同')}; + } + + const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal; + + return ( + { + const isChecked = e.target.checked; + if (isChecked) { + selectValue(record.model, record.ratioType, upstreamVal); + } else { + setResolutions((prev) => { + const newRes = { ...prev }; + if (newRes[record.model]) { + delete newRes[record.model][record.ratioType]; + if (Object.keys(newRes[record.model]).length === 0) { + delete newRes[record.model]; + } + } + return newRes; + }); + } + }} + > + {upstreamVal} + + ); + }, + }; + }), + ]; + + return ( + t('第 {{start}} - {{end}} 条,共 {{total}} 条', { + start: page.currentStart, + end: page.currentEnd, + total: filteredDataSource.length, + }), + pageSizeOptions: ['5', '10', '20', '50'], + onChange: (page, size) => { + setCurrentPage(page); + setPageSize(size); + }, + onShowSizeChange: (current, size) => { + setCurrentPage(1); + setPageSize(size); + } + }} + scroll={{ x: 'max-content' }} + size='middle' + loading={loading || syncLoading} + className="rounded-xl overflow-hidden" + /> + ); + }; + + const updateChannelEndpoint = useCallback((channelId, endpoint) => { + setChannelEndpoints(prev => ({ ...prev, [channelId]: endpoint })); + }, []); + + return ( + <> + + {renderDifferenceTable()} + + + setModalVisible(false)} + onOk={confirmChannelSelection} + allChannels={allChannels} + selectedChannelIds={selectedChannelIds} + setSelectedChannelIds={setSelectedChannelIds} + channelEndpoints={channelEndpoints} + updateChannelEndpoint={updateChannelEndpoint} + /> + + ); +} \ No newline at end of file