فهرست منبع

Merge pull request #1267 from t0ng7u/feature/upstream-ratio-sync

🔄 feat(ratio-sync): introduce upstream ratio synchronisation feature #1220
Calcium-Ion 6 ماه پیش
والد
کامیت
03468e05e4

+ 18 - 0
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()
+}

+ 24 - 0
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(),
+    })
+} 

+ 322 - 0
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,
+    })
+} 

+ 49 - 0
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"`
+} 

+ 3 - 0
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 {

+ 7 - 0
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())
 		{

+ 15 - 1
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
+}

+ 17 - 0
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()
+} 

+ 55 - 0
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)
+} 

+ 45 - 3
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
+}

+ 143 - 0
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 (
+      <>
+        <Avatar color={statusConfig.color} size="small">
+          {statusConfig.text}
+        </Avatar>
+        <div className="info">
+          <div className="name">
+            {isSelected ? (
+              item.label
+            ) : (
+              <Highlight sourceString={item.label} searchWords={[searchText]} />
+            )}
+          </div>
+          <div className="email" style={showEndpoint ? { display: 'flex', alignItems: 'center', gap: '4px' } : {}}>
+            <span className="text-xs text-gray-500 truncate max-w-[200px]" title={baseUrl}>
+              {isSelected ? (
+                baseUrl
+              ) : (
+                <Highlight sourceString={baseUrl} searchWords={[searchText]} />
+              )}
+            </span>
+            {showEndpoint && (
+              <Input
+                size="small"
+                value={currentEndpoint}
+                onChange={(value) => updateChannelEndpoint(channelId, value)}
+                placeholder="/api/ratio_config"
+                className="flex-1 text-xs"
+                style={{ fontSize: '12px' }}
+              />
+            )}
+            {isSelected && !showEndpoint && (
+              <span className="text-xs text-gray-700 font-mono bg-gray-100 px-2 py-1 rounded ml-2">
+                {currentEndpoint}
+              </span>
+            )}
+          </div>
+        </div>
+      </>
+    );
+  };
+
+  const renderSourceItem = (item) => {
+    return (
+      <div className="components-transfer-source-item" key={item.key}>
+        <Checkbox
+          onChange={item.onChange}
+          checked={item.checked}
+          style={{ height: 52, alignItems: 'center' }}
+        >
+          <ChannelInfo item={item} showEndpoint={true} />
+        </Checkbox>
+      </div>
+    );
+  };
+
+  const renderSelectedItem = (item) => {
+    return (
+      <div className="components-transfer-selected-item" key={item.key}>
+        <ChannelInfo item={item} isSelected={true} />
+        <IconClose style={{ cursor: 'pointer' }} onClick={item.onRemove} />
+      </div>
+    );
+  };
+
+  const channelFilter = (input, item) => {
+    const searchLower = input.toLowerCase();
+    return item.label.toLowerCase().includes(searchLower) ||
+      (item._originalData?.base_url || '').toLowerCase().includes(searchLower);
+  };
+
+  return (
+    <Modal
+      visible={visible}
+      onCancel={onCancel}
+      onOk={onOk}
+      title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
+      width={1000}
+    >
+      <Space vertical style={{ width: '100%' }}>
+        <Transfer
+          style={{ width: '100%' }}
+          dataSource={allChannels}
+          value={selectedChannelIds}
+          onChange={setSelectedChannelIds}
+          renderSourceItem={renderSourceItem}
+          renderSelectedItem={renderSelectedItem}
+          filter={channelFilter}
+          inputProps={{ placeholder: t('搜索渠道名称或地址') }}
+          onSearch={setSearchText}
+          emptyContent={{
+            left: t('暂无渠道'),
+            right: t('暂无选择'),
+            search: t('无搜索结果'),
+          }}
+        />
+      </Space>
+    </Modal>
+  );
+} 

+ 13 - 5
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 (
     <Spin spinning={loading} size='large'>
-      {/* 分组倍率设置 */}
-      <Card style={{ marginTop: '10px' }}>
-        <GroupRatioSettings options={inputs} refresh={onRefresh} />
-      </Card>
       {/* 模型倍率设置以及可视化编辑器 */}
       <Card style={{ marginTop: '10px' }}>
         <Tabs type='line'>
@@ -100,8 +98,18 @@ const RatioSetting = () => {
               refresh={onRefresh}
             />
           </Tabs.TabPane>
+          <Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
+            <UpstreamRatioSync
+              options={inputs}
+              refresh={onRefresh}
+            />
+          </Tabs.TabPane>
         </Tabs>
       </Card>
+      {/* 分组倍率设置 */}
+      <Card style={{ marginTop: '10px' }}>
+        <GroupRatioSettings options={inputs} refresh={onRefresh} />
+      </Card>
     </Spin>
   );
 };

+ 2 - 0
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';

+ 24 - 1
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"
 }

+ 68 - 0
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);
 }

+ 0 - 2
web/src/pages/Detail/index.js

@@ -1112,7 +1112,6 @@ const Detail = (props) => {
                   </div>
                   <Tabs
                     type="button"
-                    preventScroll={true}
                     activeKey={activeChartTab}
                     onChange={setActiveChartTab}
                   >
@@ -1389,7 +1388,6 @@ const Detail = (props) => {
                         ) : (
                           <Tabs
                             type="card"
-                            preventScroll={true}
                             collapsible
                             activeKey={activeUptimeTab}
                             onChange={setActiveUptimeTab}

+ 12 - 0
web/src/pages/Setting/Ratio/ModelRatioSettings.js

@@ -25,6 +25,7 @@ export default function ModelRatioSettings(props) {
     ModelRatio: '',
     CacheRatio: '',
     CompletionRatio: '',
+    ExposeRatioEnabled: false,
   });
   const refForm = useRef();
   const [inputsRow, setInputsRow] = useState(inputs);
@@ -206,6 +207,17 @@ export default function ModelRatioSettings(props) {
               />
             </Col>
           </Row>
+          <Row gutter={16}>
+            <Col span={16}>
+              <Form.Switch
+                label={t('暴露倍率接口')}
+                field={'ExposeRatioEnabled'}
+                onChange={(value) =>
+                  setInputs({ ...inputs, ExposeRatioEnabled: value })
+                }
+              />
+            </Col>
+          </Row>
         </Form.Section>
       </Form>
       <Space>

+ 503 - 0
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 = () => (
+    <div className="flex flex-col w-full">
+      <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
+        <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
+          <Button
+            icon={<RefreshCcw size={14} />}
+            className="!rounded-full w-full md:w-auto mt-2"
+            onClick={() => {
+              setModalVisible(true);
+              fetchAllChannels();
+            }}
+          >
+            {t('选择同步渠道')}
+          </Button>
+
+          {(() => {
+            const hasSelections = Object.keys(resolutions).length > 0;
+
+            return (
+              <Button
+                icon={<CheckSquare size={14} />}
+                type='secondary'
+                onClick={applySync}
+                disabled={!hasSelections}
+                className="!rounded-full w-full md:w-auto mt-2"
+              >
+                {t('应用同步')}
+              </Button>
+            );
+          })()}
+
+          <Input
+            prefix={<IconSearch size={14} />}
+            placeholder={t('搜索模型名称')}
+            value={searchKeyword}
+            onChange={setSearchKeyword}
+            className="!rounded-full w-full md:w-64 mt-2"
+            showClear
+          />
+        </div>
+      </div>
+    </div>
+  );
+
+  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 (
+        <Empty
+          image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+          darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+          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 <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
+        },
+      },
+      {
+        title: t('当前值'),
+        dataIndex: 'current',
+        render: (text) => (
+          <Tag color={text !== null && text !== undefined ? 'blue' : 'default'} shape="circle">
+            {text !== null && text !== undefined ? text : t('未设置')}
+          </Tag>
+        ),
+      },
+      ...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 ? (
+            <Checkbox
+              checked={channelStats.allSelected}
+              indeterminate={channelStats.partiallySelected}
+              onChange={(e) => handleBulkSelect(e.target.checked)}
+            >
+              {upName}
+            </Checkbox>
+          ) : (
+            <span>{upName}</span>
+          ),
+          dataIndex: upName,
+          render: (_, record) => {
+            const upstreamVal = record.upstreams?.[upName];
+
+            if (upstreamVal === null || upstreamVal === undefined) {
+              return <Tag color="default" shape="circle">{t('未设置')}</Tag>;
+            }
+
+            if (upstreamVal === 'same') {
+              return <Tag color="blue" shape="circle">{t('与本地相同')}</Tag>;
+            }
+
+            const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal;
+
+            return (
+              <Checkbox
+                checked={isSelected}
+                onChange={(e) => {
+                  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}
+              </Checkbox>
+            );
+          },
+        };
+      }),
+    ];
+
+    return (
+      <Table
+        columns={columns}
+        dataSource={getCurrentPageData(filteredDataSource)}
+        pagination={{
+          currentPage: currentPage,
+          pageSize: pageSize,
+          total: filteredDataSource.length,
+          showSizeChanger: true,
+          showQuickJumper: true,
+          formatPageText: (page) => 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 (
+    <>
+      <Form.Section text={renderHeader()}>
+        {renderDifferenceTable()}
+      </Form.Section>
+
+      <ChannelSelectorModal
+        t={t}
+        visible={modalVisible}
+        onCancel={() => setModalVisible(false)}
+        onOk={confirmChannelSelection}
+        allChannels={allChannels}
+        selectedChannelIds={selectedChannelIds}
+        setSelectedChannelIds={setSelectedChannelIds}
+        channelEndpoints={channelEndpoints}
+        updateChannelEndpoint={updateChannelEndpoint}
+      />
+    </>
+  );
+}