Просмотр исходного кода

fix: channel affinity (#2799)

* fix: channel affinity log styles

* fix: Issue with incorrect data storage when switching key sources

* feat: support not retrying after a single rule configuration fails

* fix: render channel affinity tooltip as multiline content

* feat: channel affinity cache hit

* fix: prevent ChannelAffinityUsageCacheModal infinite loading and hide data before fetch

* chore: format backend with gofmt and frontend with prettier/eslint autofix
Seefs 1 неделя назад
Родитель
Сommit
540cf6c991
61 измененных файлов с 1998 добавлено и 990 удалено
  1. 28 0
      controller/channel_affinity_cache.go
  2. 1 1
      controller/misc.go
  3. 5 6
      controller/performance.go
  4. 6 0
      controller/relay.go
  5. 1 1
      main.go
  6. 1 1
      relay/channel/ali/adaptor.go
  7. 5 0
      relay/compatible_handler.go
  8. 1 0
      router/api-router.go
  9. 297 1
      service/channel_affinity.go
  10. 2 0
      setting/operation_setting/channel_affinity_setting.go
  11. 55 66
      web/i18next.config.js
  12. 28 10
      web/src/components/auth/LoginForm.jsx
  13. 25 15
      web/src/components/common/DocumentRenderer/index.jsx
  14. 5 7
      web/src/components/layout/components/SkeletonWrapper.jsx
  15. 1 2
      web/src/components/playground/CodeViewer.jsx
  16. 49 46
      web/src/components/playground/CustomInputRender.jsx
  17. 6 2
      web/src/components/playground/CustomRequestEditor.jsx
  18. 1 4
      web/src/components/playground/DebugPanel.jsx
  19. 75 27
      web/src/components/playground/SSEViewer.jsx
  20. 0 1
      web/src/components/settings/HttpStatusCodeRulesInput.jsx
  21. 2 2
      web/src/components/settings/ModelDeploymentSetting.jsx
  22. 2 1
      web/src/components/settings/OperationSetting.jsx
  23. 7 3
      web/src/components/settings/OtherSetting.jsx
  24. 1 3
      web/src/components/settings/RatioSetting.jsx
  25. 8 4
      web/src/components/settings/SystemSetting.jsx
  26. 3 1
      web/src/components/table/channels/ChannelsColumnDefs.jsx
  27. 26 5
      web/src/components/table/channels/modals/CodexOAuthModal.jsx
  28. 20 3
      web/src/components/table/channels/modals/CodexUsageModal.jsx
  29. 155 146
      web/src/components/table/channels/modals/EditChannelModal.jsx
  30. 6 4
      web/src/components/table/channels/modals/EditTagModal.jsx
  31. 3 1
      web/src/components/table/channels/modals/ModelSelectModal.jsx
  32. 6 6
      web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx
  33. 13 19
      web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx
  34. 9 2
      web/src/components/table/tokens/modals/EditTokenModal.jsx
  35. 62 35
      web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
  36. 10 1
      web/src/components/table/usage-logs/UsageLogsTable.jsx
  37. 2 0
      web/src/components/table/usage-logs/index.jsx
  38. 200 0
      web/src/components/table/usage-logs/modals/ChannelAffinityUsageCacheModal.jsx
  39. 8 2
      web/src/components/topup/RechargeCard.jsx
  40. 2 1
      web/src/components/topup/index.jsx
  41. 1 3
      web/src/helpers/api.js
  42. 13 5
      web/src/helpers/dashboard.jsx
  43. 24 2
      web/src/helpers/statusCodeRules.js
  44. 11 3
      web/src/helpers/utils.jsx
  45. 21 5
      web/src/hooks/model-deployments/useModelDeploymentSettings.js
  46. 6 3
      web/src/hooks/playground/useApiRequest.jsx
  47. 14 7
      web/src/hooks/playground/usePlaygroundState.js
  48. 33 4
      web/src/hooks/usage-logs/useUsageLogsData.jsx
  49. 86 83
      web/src/pages/Playground/index.jsx
  50. 3 3
      web/src/pages/PrivacyPolicy/index.jsx
  51. 13 15
      web/src/pages/Setting/Model/SettingGlobalModel.jsx
  52. 1 2
      web/src/pages/Setting/Model/SettingGrokModel.jsx
  53. 35 22
      web/src/pages/Setting/Model/SettingModelDeployment.jsx
  54. 26 1
      web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx
  55. 3 1
      web/src/pages/Setting/Operation/SettingsCreditLimit.jsx
  56. 3 8
      web/src/pages/Setting/Operation/SettingsMonitoring.jsx
  57. 5 1
      web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
  58. 389 352
      web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx
  59. 167 37
      web/src/pages/Setting/Performance/SettingsPerformance.jsx
  60. 4 1
      web/src/pages/Setting/Ratio/GroupRatioSettings.jsx
  61. 3 3
      web/src/pages/UserAgreement/index.jsx

+ 28 - 0
controller/channel_affinity_cache.go

@@ -58,3 +58,31 @@ func ClearChannelAffinityCache(c *gin.Context) {
 		},
 	})
 }
+
+func GetChannelAffinityUsageCacheStats(c *gin.Context) {
+	ruleName := strings.TrimSpace(c.Query("rule_name"))
+	usingGroup := strings.TrimSpace(c.Query("using_group"))
+	keyFp := strings.TrimSpace(c.Query("key_fp"))
+
+	if ruleName == "" {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "missing param: rule_name",
+		})
+		return
+	}
+	if keyFp == "" {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "missing param: key_fp",
+		})
+		return
+	}
+
+	stats := service.GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp)
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    stats,
+	})
+}

+ 1 - 1
controller/misc.go

@@ -115,7 +115,7 @@ func GetStatus(c *gin.Context) {
 		"user_agreement_enabled":      legalSetting.UserAgreement != "",
 		"privacy_policy_enabled":      legalSetting.PrivacyPolicy != "",
 		"checkin_enabled":             operation_setting.GetCheckinSetting().Enabled,
-		"_qn":                          "new-api",
+		"_qn":                         "new-api",
 	}
 
 	// 根据启用状态注入可选内容

+ 5 - 6
controller/performance.go

@@ -91,11 +91,11 @@ func GetPerformanceStats(c *gin.Context) {
 	// 获取配置信息
 	diskConfig := common.GetDiskCacheConfig()
 	config := PerformanceConfig{
-		DiskCacheEnabled:      diskConfig.Enabled,
-		DiskCacheThresholdMB:  diskConfig.ThresholdMB,
-		DiskCacheMaxSizeMB:    diskConfig.MaxSizeMB,
-		DiskCachePath:         diskConfig.Path,
-		IsRunningInContainer:  common.IsRunningInContainer(),
+		DiskCacheEnabled:     diskConfig.Enabled,
+		DiskCacheThresholdMB: diskConfig.ThresholdMB,
+		DiskCacheMaxSizeMB:   diskConfig.MaxSizeMB,
+		DiskCachePath:        diskConfig.Path,
+		IsRunningInContainer: common.IsRunningInContainer(),
 	}
 
 	// 获取磁盘空间信息
@@ -199,4 +199,3 @@ func getDiskCacheInfo() DiskCacheInfo {
 
 	return info
 }
-

+ 6 - 0
controller/relay.go

@@ -311,6 +311,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
 	if openaiErr == nil {
 		return false
 	}
+	if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
+		return false
+	}
 	if types.IsChannelError(openaiErr) {
 		return true
 	}
@@ -514,6 +517,9 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError,
 	if taskErr == nil {
 		return false
 	}
+	if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
+		return false
+	}
 	if retryTimes <= 0 {
 		return false
 	}

+ 1 - 1
main.go

@@ -19,8 +19,8 @@ import (
 	"github.com/QuantumNous/new-api/model"
 	"github.com/QuantumNous/new-api/router"
 	"github.com/QuantumNous/new-api/service"
+	_ "github.com/QuantumNous/new-api/setting/performance_setting"
 	"github.com/QuantumNous/new-api/setting/ratio_setting"
-	_ "github.com/QuantumNous/new-api/setting/performance_setting" // 注册性能设置
 
 	"github.com/bytedance/gopkg/util/gopool"
 	"github.com/gin-contrib/sessions"

+ 1 - 1
relay/channel/ali/adaptor.go

@@ -13,8 +13,8 @@ import (
 	"github.com/QuantumNous/new-api/relay/channel/openai"
 	relaycommon "github.com/QuantumNous/new-api/relay/common"
 	"github.com/QuantumNous/new-api/relay/constant"
-	"github.com/QuantumNous/new-api/setting/model_setting"
 	"github.com/QuantumNous/new-api/service"
+	"github.com/QuantumNous/new-api/setting/model_setting"
 	"github.com/QuantumNous/new-api/types"
 
 	"github.com/gin-gonic/gin"

+ 5 - 0
relay/compatible_handler.go

@@ -219,6 +219,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
 }
 
 func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) {
+	originUsage := usage
 	if usage == nil {
 		usage = &dto.Usage{
 			PromptTokens:     relayInfo.GetEstimatePromptTokens(),
@@ -228,6 +229,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
 		extraContent = append(extraContent, "上游无计费信息")
 	}
 
+	if originUsage != nil {
+		service.ObserveChannelAffinityUsageCacheFromContext(ctx, usage)
+	}
+
 	adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
 
 	useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()

+ 1 - 0
router/api-router.go

@@ -220,6 +220,7 @@ func SetApiRouter(router *gin.Engine) {
 		logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)
 		logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
 		logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
+		logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats)
 		logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
 		logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
 		logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs)

+ 297 - 1
service/channel_affinity.go

@@ -2,6 +2,7 @@ package service
 
 import (
 	"fmt"
+	"hash/fnv"
 	"regexp"
 	"strconv"
 	"strings"
@@ -9,6 +10,7 @@ import (
 	"time"
 
 	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/dto"
 	"github.com/QuantumNous/new-api/pkg/cachex"
 	"github.com/QuantumNous/new-api/setting/operation_setting"
 	"github.com/gin-gonic/gin"
@@ -21,14 +23,19 @@ const (
 	ginKeyChannelAffinityTTLSeconds = "channel_affinity_ttl_seconds"
 	ginKeyChannelAffinityMeta       = "channel_affinity_meta"
 	ginKeyChannelAffinityLogInfo    = "channel_affinity_log_info"
+	ginKeyChannelAffinitySkipRetry  = "channel_affinity_skip_retry_on_failure"
 
-	channelAffinityCacheNamespace = "new-api:channel_affinity:v1"
+	channelAffinityCacheNamespace           = "new-api:channel_affinity:v1"
+	channelAffinityUsageCacheStatsNamespace = "new-api:channel_affinity_usage_cache_stats:v1"
 )
 
 var (
 	channelAffinityCacheOnce sync.Once
 	channelAffinityCache     *cachex.HybridCache[int]
 
+	channelAffinityUsageCacheStatsOnce  sync.Once
+	channelAffinityUsageCacheStatsCache *cachex.HybridCache[ChannelAffinityUsageCacheCounters]
+
 	channelAffinityRegexCache sync.Map // map[string]*regexp.Regexp
 )
 
@@ -36,15 +43,24 @@ type channelAffinityMeta struct {
 	CacheKey       string
 	TTLSeconds     int
 	RuleName       string
+	SkipRetry      bool
 	KeySourceType  string
 	KeySourceKey   string
 	KeySourcePath  string
+	KeyHint        string
 	KeyFingerprint string
 	UsingGroup     string
 	ModelName      string
 	RequestPath    string
 }
 
+type ChannelAffinityStatsContext struct {
+	RuleName       string
+	UsingGroup     string
+	KeyFingerprint string
+	TTLSeconds     int64
+}
+
 type ChannelAffinityCacheStats struct {
 	Enabled       bool           `json:"enabled"`
 	Total         int            `json:"total"`
@@ -338,6 +354,32 @@ func getChannelAffinityMeta(c *gin.Context) (channelAffinityMeta, bool) {
 	return meta, true
 }
 
+func GetChannelAffinityStatsContext(c *gin.Context) (ChannelAffinityStatsContext, bool) {
+	if c == nil {
+		return ChannelAffinityStatsContext{}, false
+	}
+	meta, ok := getChannelAffinityMeta(c)
+	if !ok {
+		return ChannelAffinityStatsContext{}, false
+	}
+	ruleName := strings.TrimSpace(meta.RuleName)
+	keyFp := strings.TrimSpace(meta.KeyFingerprint)
+	usingGroup := strings.TrimSpace(meta.UsingGroup)
+	if ruleName == "" || keyFp == "" {
+		return ChannelAffinityStatsContext{}, false
+	}
+	ttlSeconds := int64(meta.TTLSeconds)
+	if ttlSeconds <= 0 {
+		return ChannelAffinityStatsContext{}, false
+	}
+	return ChannelAffinityStatsContext{
+		RuleName:       ruleName,
+		UsingGroup:     usingGroup,
+		KeyFingerprint: keyFp,
+		TTLSeconds:     ttlSeconds,
+	}, true
+}
+
 func affinityFingerprint(s string) string {
 	if s == "" {
 		return ""
@@ -349,6 +391,19 @@ func affinityFingerprint(s string) string {
 	return hex
 }
 
+func buildChannelAffinityKeyHint(s string) string {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return ""
+	}
+	s = strings.ReplaceAll(s, "\n", " ")
+	s = strings.ReplaceAll(s, "\r", " ")
+	if len(s) <= 12 {
+		return s
+	}
+	return s[:4] + "..." + s[len(s)-4:]
+}
+
 func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) {
 	setting := operation_setting.GetChannelAffinitySetting()
 	if setting == nil || !setting.Enabled {
@@ -399,9 +454,11 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
 			CacheKey:       cacheKeyFull,
 			TTLSeconds:     ttlSeconds,
 			RuleName:       rule.Name,
+			SkipRetry:      rule.SkipRetryOnFailure,
 			KeySourceType:  strings.TrimSpace(usedSource.Type),
 			KeySourceKey:   strings.TrimSpace(usedSource.Key),
 			KeySourcePath:  strings.TrimSpace(usedSource.Path),
+			KeyHint:        buildChannelAffinityKeyHint(affinityValue),
 			KeyFingerprint: affinityFingerprint(affinityValue),
 			UsingGroup:     usingGroup,
 			ModelName:      modelName,
@@ -422,6 +479,21 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
 	return 0, false
 }
 
+func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool {
+	if c == nil {
+		return false
+	}
+	v, ok := c.Get(ginKeyChannelAffinitySkipRetry)
+	if !ok {
+		return false
+	}
+	b, ok := v.(bool)
+	if !ok {
+		return false
+	}
+	return b
+}
+
 func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) {
 	if c == nil || channelID <= 0 {
 		return
@@ -430,6 +502,7 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int
 	if !ok {
 		return
 	}
+	c.Set(ginKeyChannelAffinitySkipRetry, meta.SkipRetry)
 	info := map[string]interface{}{
 		"reason":         meta.RuleName,
 		"rule_name":      meta.RuleName,
@@ -441,6 +514,7 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int
 		"key_source":     meta.KeySourceType,
 		"key_key":        meta.KeySourceKey,
 		"key_path":       meta.KeySourcePath,
+		"key_hint":       meta.KeyHint,
 		"key_fp":         meta.KeyFingerprint,
 	}
 	c.Set(ginKeyChannelAffinityLogInfo, info)
@@ -485,3 +559,225 @@ func RecordChannelAffinity(c *gin.Context, channelID int) {
 		common.SysError(fmt.Sprintf("channel affinity cache set failed: key=%s, err=%v", cacheKey, err))
 	}
 }
+
+type ChannelAffinityUsageCacheStats struct {
+	RuleName       string `json:"rule_name"`
+	UsingGroup     string `json:"using_group"`
+	KeyFingerprint string `json:"key_fp"`
+
+	Hit           int64 `json:"hit"`
+	Total         int64 `json:"total"`
+	WindowSeconds int64 `json:"window_seconds"`
+
+	PromptTokens         int64 `json:"prompt_tokens"`
+	CompletionTokens     int64 `json:"completion_tokens"`
+	TotalTokens          int64 `json:"total_tokens"`
+	CachedTokens         int64 `json:"cached_tokens"`
+	PromptCacheHitTokens int64 `json:"prompt_cache_hit_tokens"`
+	LastSeenAt           int64 `json:"last_seen_at"`
+}
+
+type ChannelAffinityUsageCacheCounters struct {
+	Hit           int64 `json:"hit"`
+	Total         int64 `json:"total"`
+	WindowSeconds int64 `json:"window_seconds"`
+
+	PromptTokens         int64 `json:"prompt_tokens"`
+	CompletionTokens     int64 `json:"completion_tokens"`
+	TotalTokens          int64 `json:"total_tokens"`
+	CachedTokens         int64 `json:"cached_tokens"`
+	PromptCacheHitTokens int64 `json:"prompt_cache_hit_tokens"`
+	LastSeenAt           int64 `json:"last_seen_at"`
+}
+
+var channelAffinityUsageCacheStatsLocks [64]sync.Mutex
+
+func ObserveChannelAffinityUsageCacheFromContext(c *gin.Context, usage *dto.Usage) {
+	statsCtx, ok := GetChannelAffinityStatsContext(c)
+	if !ok {
+		return
+	}
+	observeChannelAffinityUsageCache(statsCtx, usage)
+}
+
+func GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp string) ChannelAffinityUsageCacheStats {
+	ruleName = strings.TrimSpace(ruleName)
+	usingGroup = strings.TrimSpace(usingGroup)
+	keyFp = strings.TrimSpace(keyFp)
+
+	entryKey := channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFp)
+	if entryKey == "" {
+		return ChannelAffinityUsageCacheStats{
+			RuleName:       ruleName,
+			UsingGroup:     usingGroup,
+			KeyFingerprint: keyFp,
+		}
+	}
+
+	cache := getChannelAffinityUsageCacheStatsCache()
+	v, found, err := cache.Get(entryKey)
+	if err != nil || !found {
+		return ChannelAffinityUsageCacheStats{
+			RuleName:       ruleName,
+			UsingGroup:     usingGroup,
+			KeyFingerprint: keyFp,
+		}
+	}
+	return ChannelAffinityUsageCacheStats{
+		RuleName:             ruleName,
+		UsingGroup:           usingGroup,
+		KeyFingerprint:       keyFp,
+		Hit:                  v.Hit,
+		Total:                v.Total,
+		WindowSeconds:        v.WindowSeconds,
+		PromptTokens:         v.PromptTokens,
+		CompletionTokens:     v.CompletionTokens,
+		TotalTokens:          v.TotalTokens,
+		CachedTokens:         v.CachedTokens,
+		PromptCacheHitTokens: v.PromptCacheHitTokens,
+		LastSeenAt:           v.LastSeenAt,
+	}
+}
+
+func observeChannelAffinityUsageCache(statsCtx ChannelAffinityStatsContext, usage *dto.Usage) {
+	entryKey := channelAffinityUsageCacheEntryKey(statsCtx.RuleName, statsCtx.UsingGroup, statsCtx.KeyFingerprint)
+	if entryKey == "" {
+		return
+	}
+
+	windowSeconds := statsCtx.TTLSeconds
+	if windowSeconds <= 0 {
+		return
+	}
+
+	cache := getChannelAffinityUsageCacheStatsCache()
+	ttl := time.Duration(windowSeconds) * time.Second
+
+	lock := channelAffinityUsageCacheStatsLock(entryKey)
+	lock.Lock()
+	defer lock.Unlock()
+
+	prev, found, err := cache.Get(entryKey)
+	if err != nil {
+		return
+	}
+	next := prev
+	if !found {
+		next = ChannelAffinityUsageCacheCounters{}
+	}
+	next.Total++
+	hit, cachedTokens, promptCacheHitTokens := usageCacheSignals(usage)
+	if hit {
+		next.Hit++
+	}
+	next.WindowSeconds = windowSeconds
+	next.LastSeenAt = time.Now().Unix()
+	next.CachedTokens += cachedTokens
+	next.PromptCacheHitTokens += promptCacheHitTokens
+	next.PromptTokens += int64(usagePromptTokens(usage))
+	next.CompletionTokens += int64(usageCompletionTokens(usage))
+	next.TotalTokens += int64(usageTotalTokens(usage))
+	_ = cache.SetWithTTL(entryKey, next, ttl)
+}
+
+func channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFp string) string {
+	ruleName = strings.TrimSpace(ruleName)
+	usingGroup = strings.TrimSpace(usingGroup)
+	keyFp = strings.TrimSpace(keyFp)
+	if ruleName == "" || keyFp == "" {
+		return ""
+	}
+	return ruleName + "\n" + usingGroup + "\n" + keyFp
+}
+
+func usageCacheSignals(usage *dto.Usage) (hit bool, cachedTokens int64, promptCacheHitTokens int64) {
+	if usage == nil {
+		return false, 0, 0
+	}
+
+	cached := int64(0)
+	if usage.PromptTokensDetails.CachedTokens > 0 {
+		cached = int64(usage.PromptTokensDetails.CachedTokens)
+	} else if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
+		cached = int64(usage.InputTokensDetails.CachedTokens)
+	}
+	pcht := int64(0)
+	if usage.PromptCacheHitTokens > 0 {
+		pcht = int64(usage.PromptCacheHitTokens)
+	}
+	return cached > 0 || pcht > 0, cached, pcht
+}
+
+func usagePromptTokens(usage *dto.Usage) int {
+	if usage == nil {
+		return 0
+	}
+	if usage.PromptTokens > 0 {
+		return usage.PromptTokens
+	}
+	return usage.InputTokens
+}
+
+func usageCompletionTokens(usage *dto.Usage) int {
+	if usage == nil {
+		return 0
+	}
+	if usage.CompletionTokens > 0 {
+		return usage.CompletionTokens
+	}
+	return usage.OutputTokens
+}
+
+func usageTotalTokens(usage *dto.Usage) int {
+	if usage == nil {
+		return 0
+	}
+	if usage.TotalTokens > 0 {
+		return usage.TotalTokens
+	}
+	pt := usagePromptTokens(usage)
+	ct := usageCompletionTokens(usage)
+	if pt > 0 || ct > 0 {
+		return pt + ct
+	}
+	return 0
+}
+
+func getChannelAffinityUsageCacheStatsCache() *cachex.HybridCache[ChannelAffinityUsageCacheCounters] {
+	channelAffinityUsageCacheStatsOnce.Do(func() {
+		setting := operation_setting.GetChannelAffinitySetting()
+		capacity := 100_000
+		defaultTTLSeconds := 3600
+		if setting != nil {
+			if setting.MaxEntries > 0 {
+				capacity = setting.MaxEntries
+			}
+			if setting.DefaultTTLSeconds > 0 {
+				defaultTTLSeconds = setting.DefaultTTLSeconds
+			}
+		}
+
+		channelAffinityUsageCacheStatsCache = cachex.NewHybridCache[ChannelAffinityUsageCacheCounters](cachex.HybridCacheConfig[ChannelAffinityUsageCacheCounters]{
+			Namespace: cachex.Namespace(channelAffinityUsageCacheStatsNamespace),
+			Redis:     common.RDB,
+			RedisEnabled: func() bool {
+				return common.RedisEnabled && common.RDB != nil
+			},
+			RedisCodec: cachex.JSONCodec[ChannelAffinityUsageCacheCounters]{},
+			Memory: func() *hot.HotCache[string, ChannelAffinityUsageCacheCounters] {
+				return hot.NewHotCache[string, ChannelAffinityUsageCacheCounters](hot.LRU, capacity).
+					WithTTL(time.Duration(defaultTTLSeconds) * time.Second).
+					WithJanitor().
+					Build()
+			},
+		})
+	})
+	return channelAffinityUsageCacheStatsCache
+}
+
+func channelAffinityUsageCacheStatsLock(key string) *sync.Mutex {
+	h := fnv.New32a()
+	_, _ = h.Write([]byte(key))
+	idx := h.Sum32() % uint32(len(channelAffinityUsageCacheStatsLocks))
+	return &channelAffinityUsageCacheStatsLocks[idx]
+}

+ 2 - 0
setting/operation_setting/channel_affinity_setting.go

@@ -18,6 +18,8 @@ type ChannelAffinityRule struct {
 	ValueRegex string `json:"value_regex"`
 	TTLSeconds int    `json:"ttl_seconds"`
 
+	SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
+
 	IncludeUsingGroup bool `json:"include_using_group"`
 	IncludeRuleName   bool `json:"include_rule_name"`
 }

+ 55 - 66
web/i18next.config.js

@@ -21,77 +21,66 @@ import { defineConfig } from 'i18next-cli';
 
 /** @type {import('i18next-cli').I18nextToolkitConfig} */
 export default defineConfig({
-  locales: [
-    "zh",
-    "en",
-    "fr",
-    "ru",
-    "ja",
-    "vi"
-  ],
+  locales: ['zh', 'en', 'fr', 'ru', 'ja', 'vi'],
   extract: {
-    input: [
-      "src/**/*.{js,jsx,ts,tsx}"
-    ],
-    ignore: [
-      "src/i18n/**/*"
-    ],
-    output: "src/i18n/locales/{{language}}.json",
+    input: ['src/**/*.{js,jsx,ts,tsx}'],
+    ignore: ['src/i18n/**/*'],
+    output: 'src/i18n/locales/{{language}}.json',
     ignoredAttributes: [
-      "accept",
-      "align",
-      "aria-label",
-      "autoComplete",
-      "className",
-      "clipRule",
-      "color",
-      "crossOrigin",
-      "data-index",
-      "data-name",
-      "data-testid",
-      "data-type",
-      "defaultActiveKey",
-      "direction",
-      "editorType",
-      "field",
-      "fill",
-      "fillRule",
-      "height",
-      "hoverStyle",
-      "htmlType",
-      "id",
-      "itemKey",
-      "key",
-      "keyPrefix",
-      "layout",
-      "margin",
-      "maxHeight",
-      "mode",
-      "name",
-      "overflow",
-      "placement",
-      "position",
-      "rel",
-      "role",
-      "rowKey",
-      "searchPosition",
-      "selectedStyle",
-      "shape",
-      "size",
-      "style",
-      "theme",
-      "trigger",
-      "uploadTrigger",
-      "validateStatus",
-      "value",
-      "viewBox",
-      "width"
+      'accept',
+      'align',
+      'aria-label',
+      'autoComplete',
+      'className',
+      'clipRule',
+      'color',
+      'crossOrigin',
+      'data-index',
+      'data-name',
+      'data-testid',
+      'data-type',
+      'defaultActiveKey',
+      'direction',
+      'editorType',
+      'field',
+      'fill',
+      'fillRule',
+      'height',
+      'hoverStyle',
+      'htmlType',
+      'id',
+      'itemKey',
+      'key',
+      'keyPrefix',
+      'layout',
+      'margin',
+      'maxHeight',
+      'mode',
+      'name',
+      'overflow',
+      'placement',
+      'position',
+      'rel',
+      'role',
+      'rowKey',
+      'searchPosition',
+      'selectedStyle',
+      'shape',
+      'size',
+      'style',
+      'theme',
+      'trigger',
+      'uploadTrigger',
+      'validateStatus',
+      'value',
+      'viewBox',
+      'width',
     ],
     sort: true,
     disablePlurals: false,
     removeUnusedKeys: false,
     nsSeparator: false,
     keySeparator: false,
-    mergeNamespaces: true
-  }
-});
+    mergeNamespaces: true,
+  },
+});

+ 28 - 10
web/src/components/auth/LoginForm.jsx

@@ -39,7 +39,15 @@ import {
   isPasskeySupported,
 } from '../../helpers';
 import Turnstile from 'react-turnstile';
-import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Card,
+  Checkbox,
+  Divider,
+  Form,
+  Icon,
+  Modal,
+} from '@douyinfe/semi-ui';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import TelegramLoginButton from 'react-telegram-login';
@@ -55,7 +63,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
 import LinuxDoIcon from '../common/logo/LinuxDoIcon';
 import TwoFAVerification from './TwoFAVerification';
 import { useTranslation } from 'react-i18next';
-import { SiDiscord }from 'react-icons/si';
+import { SiDiscord } from 'react-icons/si';
 
 const LoginForm = () => {
   let navigate = useNavigate();
@@ -126,7 +134,7 @@ const LoginForm = () => {
       setTurnstileEnabled(true);
       setTurnstileSiteKey(status.turnstile_site_key);
     }
-    
+
     // 从 status 获取用户协议和隐私政策的启用状态
     setHasUserAgreement(status?.user_agreement_enabled || false);
     setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
@@ -514,7 +522,15 @@ const LoginForm = () => {
                     theme='outline'
                     className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
                     type='tertiary'
-                    icon={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
+                    icon={
+                      <SiDiscord
+                        style={{
+                          color: '#5865F2',
+                          width: '20px',
+                          height: '20px',
+                        }}
+                      />
+                    }
                     onClick={handleDiscordClick}
                     loading={discordLoading}
                   >
@@ -626,11 +642,11 @@ const LoginForm = () => {
                             {t('隐私政策')}
                           </a>
                         </>
-                        )}
-                      </Text>
-                    </Checkbox>
-                  </div>
-                )}
+                      )}
+                    </Text>
+                  </Checkbox>
+                </div>
+              )}
 
               {!status.self_use_mode_enabled && (
                 <div className='mt-6 text-center text-sm'>
@@ -746,7 +762,9 @@ const LoginForm = () => {
                     htmlType='submit'
                     onClick={handleSubmit}
                     loading={loginLoading}
-                    disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
+                    disabled={
+                      (hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
+                    }
                   >
                     {t('继续')}
                   </Button>

+ 25 - 15
web/src/components/common/DocumentRenderer/index.jsx

@@ -41,7 +41,7 @@ const isUrl = (content) => {
 // 检查是否为 HTML 内容
 const isHtmlContent = (content) => {
   if (!content || typeof content !== 'string') return false;
-  
+
   // 检查是否包含HTML标签
   const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
   return htmlTagRegex.test(content);
@@ -52,16 +52,16 @@ const sanitizeHtml = (html) => {
   // 创建一个临时元素来解析HTML
   const tempDiv = document.createElement('div');
   tempDiv.innerHTML = html;
-  
+
   // 提取样式
   const styles = Array.from(tempDiv.querySelectorAll('style'))
-    .map(style => style.innerHTML)
+    .map((style) => style.innerHTML)
     .join('\n');
-  
+
   // 提取body内容,如果没有body标签则使用全部内容
   const bodyContent = tempDiv.querySelector('body');
   const content = bodyContent ? bodyContent.innerHTML : html;
-  
+
   return { content, styles };
 };
 
@@ -129,7 +129,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
   // 处理HTML样式注入
   useEffect(() => {
     const styleId = `document-renderer-styles-${cacheKey}`;
-    
+
     if (htmlStyles) {
       let styleEl = document.getElementById(styleId);
       if (!styleEl) {
@@ -165,8 +165,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
       <div className='flex justify-center items-center min-h-screen bg-gray-50'>
         <Empty
           title={t('管理员未设置' + title + '内容')}
-          image={<IllustrationConstruction style={{ width: 150, height: 150 }} />}
-          darkModeImage={<IllustrationConstructionDark style={{ width: 150, height: 150 }} />}
+          image={
+            <IllustrationConstruction style={{ width: 150, height: 150 }} />
+          }
+          darkModeImage={
+            <IllustrationConstructionDark style={{ width: 150, height: 150 }} />
+          }
           className='p-8'
         />
       </div>
@@ -179,7 +183,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
       <div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
         <Card className='max-w-md w-full'>
           <div className='text-center'>
-            <Title heading={4} className='mb-4'>{title}</Title>
+            <Title heading={4} className='mb-4'>
+              {title}
+            </Title>
             <p className='text-gray-600 mb-4'>
               {t('管理员设置了外部链接,点击下方按钮访问')}
             </p>
@@ -202,20 +208,22 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
   // 如果是 HTML 内容,直接渲染
   if (isHtmlContent(content)) {
     const { content: htmlContent, styles } = sanitizeHtml(content);
-    
+
     // 设置样式(如果有的话)
     useEffect(() => {
       if (styles && styles !== htmlStyles) {
         setHtmlStyles(styles);
       }
     }, [content, styles, htmlStyles]);
-    
+
     return (
       <div className='min-h-screen bg-gray-50'>
         <div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
           <div className='bg-white rounded-lg shadow-sm p-8'>
-            <Title heading={2} className='text-center mb-8'>{title}</Title>
-            <div 
+            <Title heading={2} className='text-center mb-8'>
+              {title}
+            </Title>
+            <div
               className='prose prose-lg max-w-none'
               dangerouslySetInnerHTML={{ __html: htmlContent }}
             />
@@ -230,7 +238,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
     <div className='min-h-screen bg-gray-50'>
       <div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
         <div className='bg-white rounded-lg shadow-sm p-8'>
-          <Title heading={2} className='text-center mb-8'>{title}</Title>
+          <Title heading={2} className='text-center mb-8'>
+            {title}
+          </Title>
           <div className='prose prose-lg max-w-none'>
             <MarkdownRenderer content={content} />
           </div>
@@ -240,4 +250,4 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
   );
 };
 
-export default DocumentRenderer;
+export default DocumentRenderer;

+ 5 - 7
web/src/components/layout/components/SkeletonWrapper.jsx

@@ -136,9 +136,7 @@ const SkeletonWrapper = ({
           loading={true}
           active
           placeholder={
-            <Skeleton.Title
-              style={{ width, height, borderRadius: 9999 }}
-            />
+            <Skeleton.Title style={{ width, height, borderRadius: 9999 }} />
           }
         />
       </div>
@@ -186,7 +184,9 @@ const SkeletonWrapper = ({
           loading={true}
           active
           placeholder={
-            <Skeleton.Title style={{ width: width || 60, height: height || 12 }} />
+            <Skeleton.Title
+              style={{ width: width || 60, height: height || 12 }}
+            />
           }
         />
       </div>
@@ -221,9 +221,7 @@ const SkeletonWrapper = ({
         loading={true}
         active
         placeholder={
-          <Skeleton.Title
-            style={{ width: labelWidth, height: TEXT_HEIGHT }}
-          />
+          <Skeleton.Title style={{ width: labelWidth, height: TEXT_HEIGHT }} />
         }
       />
     );

+ 1 - 2
web/src/components/playground/CodeViewer.jsx

@@ -115,8 +115,7 @@ const linkifyHtml = (html) => {
       if (part.startsWith('<')) return part;
       return part.replace(
         linkRegex,
-        (url) =>
-          `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
+        (url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
       );
     })
     .join('');

+ 49 - 46
web/src/components/playground/CustomInputRender.jsx

@@ -30,64 +30,67 @@ const CustomInputRender = (props) => {
     detailProps;
   const containerRef = useRef(null);
 
-  const handlePaste = useCallback(async (e) => {
-    const items = e.clipboardData?.items;
-    if (!items) return;
-
-    for (let i = 0; i < items.length; i++) {
-      const item = items[i];
-      
-      if (item.type.indexOf('image') !== -1) {
-        e.preventDefault();
-        const file = item.getAsFile();
-        
-        if (file) {
-          try {
-            if (!imageEnabled) {
-              Toast.warning({
-                content: t('请先在设置中启用图片功能'),
-                duration: 3,
-              });
-              return;
-            }
+  const handlePaste = useCallback(
+    async (e) => {
+      const items = e.clipboardData?.items;
+      if (!items) return;
 
-            const reader = new FileReader();
-            reader.onload = (event) => {
-              const base64 = event.target.result;
-              
-              if (onPasteImage) {
-                onPasteImage(base64);
-                Toast.success({
-                  content: t('图片已添加'),
-                  duration: 2,
+      for (let i = 0; i < items.length; i++) {
+        const item = items[i];
+
+        if (item.type.indexOf('image') !== -1) {
+          e.preventDefault();
+          const file = item.getAsFile();
+
+          if (file) {
+            try {
+              if (!imageEnabled) {
+                Toast.warning({
+                  content: t('请先在设置中启用图片功能'),
+                  duration: 3,
                 });
-              } else {
+                return;
+              }
+
+              const reader = new FileReader();
+              reader.onload = (event) => {
+                const base64 = event.target.result;
+
+                if (onPasteImage) {
+                  onPasteImage(base64);
+                  Toast.success({
+                    content: t('图片已添加'),
+                    duration: 2,
+                  });
+                } else {
+                  Toast.error({
+                    content: t('无法添加图片'),
+                    duration: 2,
+                  });
+                }
+              };
+              reader.onerror = () => {
+                console.error('Failed to read image file:', reader.error);
                 Toast.error({
-                  content: t('无法添加图片'),
+                  content: t('粘贴图片失败'),
                   duration: 2,
                 });
-              }
-            };
-            reader.onerror = () => {
-              console.error('Failed to read image file:', reader.error);
+              };
+              reader.readAsDataURL(file);
+            } catch (error) {
+              console.error('Failed to paste image:', error);
               Toast.error({
                 content: t('粘贴图片失败'),
                 duration: 2,
               });
-            };
-            reader.readAsDataURL(file);
-          } catch (error) {
-            console.error('Failed to paste image:', error);
-            Toast.error({
-              content: t('粘贴图片失败'),
-              duration: 2,
-            });
+            }
           }
+          break;
         }
-        break;
       }
-    }
-  }, [onPasteImage, imageEnabled, t]);
+    },
+    [onPasteImage, imageEnabled, t],
+  );
 
   useEffect(() => {
     const container = containerRef.current;

+ 6 - 2
web/src/components/playground/CustomRequestEditor.jsx

@@ -140,7 +140,9 @@ const CustomRequestEditor = ({
           {/* 提示信息 */}
           <Banner
             type='warning'
-            description={t('启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。')}
+            description={t(
+              '启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。',
+            )}
             icon={<AlertTriangle size={16} />}
             className='!rounded-lg'
             closeIcon={null}
@@ -201,7 +203,9 @@ const CustomRequestEditor = ({
             )}
 
             <Typography.Text className='text-xs text-gray-500 mt-2 block'>
-              {t('请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。')}
+              {t(
+                '请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。',
+              )}
             </Typography.Text>
           </div>
         </>

+ 1 - 4
web/src/components/playground/DebugPanel.jsx

@@ -191,10 +191,7 @@ const DebugPanel = ({
             itemKey='response'
           >
             {debugData.sseMessages && debugData.sseMessages.length > 0 ? (
-              <SSEViewer
-                sseData={debugData.sseMessages}
-                title='response'
-              />
+              <SSEViewer sseData={debugData.sseMessages} title='response' />
             ) : (
               <CodeViewer
                 content={debugData.response}

+ 75 - 27
web/src/components/playground/SSEViewer.jsx

@@ -18,8 +18,22 @@ For commercial licensing, please contact [email protected]
 */
 
 import React, { useState, useMemo, useCallback } from 'react';
-import { Button, Tooltip, Toast, Collapse, Badge, Typography } from '@douyinfe/semi-ui';
-import { Copy, ChevronDown, ChevronUp, Zap, CheckCircle, XCircle } from 'lucide-react';
+import {
+  Button,
+  Tooltip,
+  Toast,
+  Collapse,
+  Badge,
+  Typography,
+} from '@douyinfe/semi-ui';
+import {
+  Copy,
+  ChevronDown,
+  ChevronUp,
+  Zap,
+  CheckCircle,
+  XCircle,
+} from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { copy } from '../../helpers';
 
@@ -67,19 +81,19 @@ const SSEViewer = ({ sseData }) => {
 
   const stats = useMemo(() => {
     const total = parsedSSEData.length;
-    const errors = parsedSSEData.filter(item => item.error).length;
-    const done = parsedSSEData.filter(item => item.isDone).length;
+    const errors = parsedSSEData.filter((item) => item.error).length;
+    const done = parsedSSEData.filter((item) => item.isDone).length;
     const valid = total - errors - done;
 
     return { total, errors, done, valid };
   }, [parsedSSEData]);
 
   const handleToggleAll = useCallback(() => {
-    setExpandedKeys(prev => {
+    setExpandedKeys((prev) => {
       if (prev.length === parsedSSEData.length) {
         return [];
       } else {
-        return parsedSSEData.map(item => item.key);
+        return parsedSSEData.map((item) => item.key);
       }
     });
   }, [parsedSSEData]);
@@ -87,7 +101,9 @@ const SSEViewer = ({ sseData }) => {
   const handleCopyAll = useCallback(async () => {
     try {
       const allData = parsedSSEData
-        .map(item => (item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw))
+        .map((item) =>
+          item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw,
+        )
         .join('\n\n');
 
       await copy(allData);
@@ -100,15 +116,20 @@ const SSEViewer = ({ sseData }) => {
     }
   }, [parsedSSEData, t]);
 
-  const handleCopySingle = useCallback(async (item) => {
-    try {
-      const textToCopy = item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw;
-      await copy(textToCopy);
-      Toast.success(t('已复制'));
-    } catch (err) {
-      Toast.error(t('复制失败'));
-    }
-  }, [t]);
+  const handleCopySingle = useCallback(
+    async (item) => {
+      try {
+        const textToCopy = item.parsed
+          ? JSON.stringify(item.parsed, null, 2)
+          : item.raw;
+        await copy(textToCopy);
+        Toast.success(t('已复制'));
+      } catch (err) {
+        Toast.error(t('复制失败'));
+      }
+    },
+    [t],
+  );
 
   const renderSSEItem = (item) => {
     if (item.isDone) {
@@ -158,18 +179,24 @@ const SSEViewer = ({ sseData }) => {
         {item.parsed?.choices?.[0] && (
           <div className='flex flex-wrap gap-2 text-xs'>
             {item.parsed.choices[0].delta?.content && (
-              <Badge count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`} type='primary' />
+              <Badge
+                count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`}
+                type='primary'
+              />
             )}
             {item.parsed.choices[0].delta?.reasoning_content && (
               <Badge count={t('有 Reasoning')} type='warning' />
             )}
             {item.parsed.choices[0].finish_reason && (
-              <Badge count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`} type='success' />
+              <Badge
+                count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`}
+                type='success'
+              />
             )}
             {item.parsed.usage && (
-              <Badge 
-                count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`} 
-                type='tertiary' 
+              <Badge
+                count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
+                type='tertiary'
               />
             )}
           </div>
@@ -194,7 +221,9 @@ const SSEViewer = ({ sseData }) => {
           <Zap size={16} className='text-blue-500' />
           <Typography.Text strong>{t('SSE数据流')}</Typography.Text>
           <Badge count={stats.total} type='primary' />
-          {stats.errors > 0 && <Badge count={`${stats.errors} ${t('错误')}`} type='danger' />}
+          {stats.errors > 0 && (
+            <Badge count={`${stats.errors} ${t('错误')}`} type='danger' />
+          )}
         </div>
 
         <div className='flex items-center gap-2'>
@@ -208,14 +237,28 @@ const SSEViewer = ({ sseData }) => {
               {copied ? t('已复制') : t('复制全部')}
             </Button>
           </Tooltip>
-          <Tooltip content={expandedKeys.length === parsedSSEData.length ? t('全部收起') : t('全部展开')}>
+          <Tooltip
+            content={
+              expandedKeys.length === parsedSSEData.length
+                ? t('全部收起')
+                : t('全部展开')
+            }
+          >
             <Button
-              icon={expandedKeys.length === parsedSSEData.length ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
+              icon={
+                expandedKeys.length === parsedSSEData.length ? (
+                  <ChevronUp size={14} />
+                ) : (
+                  <ChevronDown size={14} />
+                )
+              }
               size='small'
               onClick={handleToggleAll}
               theme='borderless'
             >
-              {expandedKeys.length === parsedSSEData.length ? t('收起') : t('展开')}
+              {expandedKeys.length === parsedSSEData.length
+                ? t('收起')
+                : t('展开')}
             </Button>
           </Tooltip>
         </div>
@@ -242,11 +285,16 @@ const SSEViewer = ({ sseData }) => {
                   ) : (
                     <>
                       <span className='text-gray-600'>
-                        {item.parsed?.id || item.parsed?.object || t('SSE 事件')}
+                        {item.parsed?.id ||
+                          item.parsed?.object ||
+                          t('SSE 事件')}
                       </span>
                       {item.parsed?.choices?.[0]?.delta && (
                         <span className='text-xs text-gray-400'>
-                          • {Object.keys(item.parsed.choices[0].delta).filter(k => item.parsed.choices[0].delta[k]).join(', ')}
+                          •{' '}
+                          {Object.keys(item.parsed.choices[0].delta)
+                            .filter((k) => item.parsed.choices[0].delta[k])
+                            .join(', ')}
                         </span>
                       )}
                     </>

+ 0 - 1
web/src/components/settings/HttpStatusCodeRulesInput.jsx

@@ -68,4 +68,3 @@ export default function HttpStatusCodeRulesInput(props) {
     </>
   );
 }
-

+ 2 - 2
web/src/components/settings/ModelDeploymentSetting.jsx

@@ -40,7 +40,7 @@ const ModelDeploymentSetting = () => {
         'model_deployment.ionet.api_key': '',
         'model_deployment.ionet.enabled': false,
       };
-      
+
       data.forEach((item) => {
         if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
           newInputs[item.key] = toBoolean(item.value);
@@ -82,4 +82,4 @@ const ModelDeploymentSetting = () => {
   );
 };
 
-export default ModelDeploymentSetting;
+export default ModelDeploymentSetting;

+ 2 - 1
web/src/components/settings/OperationSetting.jsx

@@ -71,7 +71,8 @@ const OperationSetting = () => {
     AutomaticEnableChannelEnabled: false,
     AutomaticDisableKeywords: '',
     AutomaticDisableStatusCodes: '401',
-    AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
+    AutomaticRetryStatusCodes:
+      '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
     'monitor_setting.auto_test_channel_enabled': false,
     'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
     'checkin_setting.enabled': false,

+ 7 - 3
web/src/components/settings/OtherSetting.jsx

@@ -378,13 +378,15 @@ const OtherSetting = () => {
               <Form.TextArea
                 label={t('用户协议')}
                 placeholder={t(
-              '在此输入用户协议内容,支持 Markdown & HTML 代码',
+                  '在此输入用户协议内容,支持 Markdown & HTML 代码',
                 )}
                 field={LEGAL_USER_AGREEMENT_KEY}
                 onChange={handleInputChange}
                 style={{ fontFamily: 'JetBrains Mono, Consolas' }}
                 autosize={{ minRows: 6, maxRows: 12 }}
-                helpText={t('填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议')}
+                helpText={t(
+                  '填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议',
+                )}
               />
               <Button
                 onClick={submitUserAgreement}
@@ -401,7 +403,9 @@ const OtherSetting = () => {
                 onChange={handleInputChange}
                 style={{ fontFamily: 'JetBrains Mono, Consolas' }}
                 autosize={{ minRows: 6, maxRows: 12 }}
-                helpText={t('填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策')}
+                helpText={t(
+                  '填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策',
+                )}
               />
               <Button
                 onClick={submitPrivacyPolicy}

+ 1 - 3
web/src/components/settings/RatioSetting.jsx

@@ -57,9 +57,7 @@ const RatioSetting = () => {
     if (success) {
       let newInputs = {};
       data.forEach((item) => {
-        if (
-          item.value.startsWith('{') || item.value.startsWith('[')
-        ) {
+        if (item.value.startsWith('{') || item.value.startsWith('[')) {
           try {
             item.value = JSON.stringify(JSON.parse(item.value), null, 2);
           } catch (e) {

+ 8 - 4
web/src/components/settings/SystemSetting.jsx

@@ -481,10 +481,14 @@ const SystemSetting = () => {
     const options = [];
 
     if (originInputs['discord.client_id'] !== inputs['discord.client_id']) {
-      options.push({ key: 'discord.client_id', value: inputs['discord.client_id'] });
+      options.push({
+        key: 'discord.client_id',
+        value: inputs['discord.client_id'],
+      });
     }
     if (
-      originInputs['discord.client_secret'] !== inputs['discord.client_secret'] &&
+      originInputs['discord.client_secret'] !==
+        inputs['discord.client_secret'] &&
       inputs['discord.client_secret'] !== ''
     ) {
       options.push({
@@ -745,8 +749,8 @@ const SystemSetting = () => {
                       rel='noreferrer'
                     >
                       new-api-worker
-                    </a>
-                    {' '}{t('或其兼容new-api-worker格式的其他版本')}
+                    </a>{' '}
+                    {t('或其兼容new-api-worker格式的其他版本')}
                   </Text>
                   <Row
                     gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}

+ 3 - 1
web/src/components/table/channels/ChannelsColumnDefs.jsx

@@ -109,7 +109,9 @@ const renderType = (type, record = {}, t) => {
       <Tooltip
         content={
           <div className='max-w-xs'>
-            <div className='text-xs text-gray-600'>{t('来源于 IO.NET 部署')}</div>
+            <div className='text-xs text-gray-600'>
+              {t('来源于 IO.NET 部署')}
+            </div>
             {ionetMeta?.deployment_id && (
               <div className='text-xs text-gray-500 mt-1'>
                 {t('部署 ID')}: {ionetMeta.deployment_id}

+ 26 - 5
web/src/components/table/channels/modals/CodexOAuthModal.jsx

@@ -19,7 +19,14 @@ For commercial licensing, please contact [email protected]
 
 import React, { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Modal, Button, Space, Typography, Input, Banner } from '@douyinfe/semi-ui';
+import {
+  Modal,
+  Button,
+  Space,
+  Typography,
+  Input,
+  Banner,
+} from '@douyinfe/semi-ui';
 import { API, copy, showError, showSuccess } from '../../../../helpers';
 
 const { Text } = Typography;
@@ -33,14 +40,21 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
   const startOAuth = async () => {
     setLoading(true);
     try {
-      const res = await API.post('/api/channel/codex/oauth/start', {}, { skipErrorHandler: true });
+      const res = await API.post(
+        '/api/channel/codex/oauth/start',
+        {},
+        { skipErrorHandler: true },
+      );
       if (!res?.data?.success) {
         console.error('Codex OAuth start failed:', res?.data?.message);
         throw new Error(t('启动授权失败'));
       }
       const url = res?.data?.data?.authorize_url || '';
       if (!url) {
-        console.error('Codex OAuth start response missing authorize_url:', res?.data);
+        console.error(
+          'Codex OAuth start response missing authorize_url:',
+          res?.data,
+        );
         throw new Error(t('响应缺少授权链接'));
       }
       setAuthorizeUrl(url);
@@ -106,7 +120,12 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
           <Button theme='borderless' onClick={onCancel} disabled={loading}>
             {t('取消')}
           </Button>
-          <Button theme='solid' type='primary' onClick={completeOAuth} loading={loading}>
+          <Button
+            theme='solid'
+            type='primary'
+            onClick={completeOAuth}
+            loading={loading}
+          >
             {t('生成并填入')}
           </Button>
         </Space>
@@ -141,7 +160,9 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
         />
 
         <Text type='tertiary' size='small'>
-          {t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。')}
+          {t(
+            '说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。',
+          )}
         </Text>
       </Space>
     </Modal>

+ 20 - 3
web/src/components/table/channels/modals/CodexUsageModal.jsx

@@ -18,7 +18,14 @@ For commercial licensing, please contact [email protected]
 */
 
 import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { Modal, Button, Progress, Tag, Typography, Spin } from '@douyinfe/semi-ui';
+import {
+  Modal,
+  Button,
+  Progress,
+  Tag,
+  Typography,
+  Spin,
+} from '@douyinfe/semi-ui';
 import { API, showError } from '../../../../helpers';
 
 const { Text } = Typography;
@@ -134,7 +141,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
         </Text>
         <div className='flex items-center gap-2'>
           {statusTag}
-          <Button size='small' type='tertiary' theme='borderless' onClick={onRefresh}>
+          <Button
+            size='small'
+            type='tertiary'
+            theme='borderless'
+            onClick={onRefresh}
+          >
             {tt('刷新')}
           </Button>
         </div>
@@ -243,7 +255,12 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
       <div className='flex flex-col gap-3'>
         <Text type='danger'>{tt('获取用量失败')}</Text>
         <div className='flex justify-end'>
-          <Button size='small' type='primary' theme='outline' onClick={fetchUsage}>
+          <Button
+            size='small'
+            type='primary'
+            theme='outline'
+            onClick={fetchUsage}
+          >
             {tt('刷新')}
           </Button>
         </div>

+ 155 - 146
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -2000,171 +2000,180 @@ const EditChannelModal = (props) => {
                           autoComplete='new-password'
                           onChange={(value) => handleInputChange('key', value)}
                           disabled={isIonetLocked}
-                        extraText={
-                          <div className='flex items-center gap-2 flex-wrap'>
-                            {isEdit &&
-                              isMultiKeyChannel &&
-                              keyMode === 'append' && (
-                                <Text type='warning' size='small'>
-                                  {t(
-                                    '追加模式:新密钥将添加到现有密钥列表的末尾',
-                                  )}
-                                </Text>
+                          extraText={
+                            <div className='flex items-center gap-2 flex-wrap'>
+                              {isEdit &&
+                                isMultiKeyChannel &&
+                                keyMode === 'append' && (
+                                  <Text type='warning' size='small'>
+                                    {t(
+                                      '追加模式:新密钥将添加到现有密钥列表的末尾',
+                                    )}
+                                  </Text>
+                                )}
+                              {isEdit && (
+                                <Button
+                                  size='small'
+                                  type='primary'
+                                  theme='outline'
+                                  onClick={handleShow2FAModal}
+                                >
+                                  {t('查看密钥')}
+                                </Button>
                               )}
-                            {isEdit && (
-                              <Button
-                                size='small'
-                                type='primary'
-                                theme='outline'
-                                onClick={handleShow2FAModal}
-                              >
-                                {t('查看密钥')}
-                              </Button>
-                            )}
-                            {batchExtra}
-                          </div>
-                        }
-                        showClear
-                      />
-                    )
-                  ) : (
-                    <>
-                      {inputs.type === 57 ? (
-                        <>
-                          <Form.TextArea
-                            field='key'
-                            label={
-                              isEdit
-                                ? t('密钥(编辑模式下,保存的密钥不会显示)')
-                                : t('密钥')
-                            }
-                            placeholder={t(
-                              '请输入 JSON 格式的 OAuth 凭据,例如:\n{\n  "access_token": "...",\n  "account_id": "..." \n}',
-                            )}
-                            rules={
-                              isEdit
-                                ? []
-                                : [{ required: true, message: t('请输入密钥') }]
-                            }
-                            autoComplete='new-password'
-                            onChange={(value) => handleInputChange('key', value)}
-                            disabled={isIonetLocked}
-                            extraText={
-                              <div className='flex flex-col gap-2'>
-                                <Text type='tertiary' size='small'>
-                                  {t(
-                                    '仅支持 JSON 对象,必须包含 access_token 与 account_id',
-                                  )}
-                                </Text>
+                              {batchExtra}
+                            </div>
+                          }
+                          showClear
+                        />
+                      )
+                    ) : (
+                      <>
+                        {inputs.type === 57 ? (
+                          <>
+                            <Form.TextArea
+                              field='key'
+                              label={
+                                isEdit
+                                  ? t('密钥(编辑模式下,保存的密钥不会显示)')
+                                  : t('密钥')
+                              }
+                              placeholder={t(
+                                '请输入 JSON 格式的 OAuth 凭据,例如:\n{\n  "access_token": "...",\n  "account_id": "..." \n}',
+                              )}
+                              rules={
+                                isEdit
+                                  ? []
+                                  : [
+                                      {
+                                        required: true,
+                                        message: t('请输入密钥'),
+                                      },
+                                    ]
+                              }
+                              autoComplete='new-password'
+                              onChange={(value) =>
+                                handleInputChange('key', value)
+                              }
+                              disabled={isIonetLocked}
+                              extraText={
+                                <div className='flex flex-col gap-2'>
+                                  <Text type='tertiary' size='small'>
+                                    {t(
+                                      '仅支持 JSON 对象,必须包含 access_token 与 account_id',
+                                    )}
+                                  </Text>
 
-                                <Space wrap spacing='tight'>
-                                  <Button
-                                    size='small'
-                                    type='primary'
-                                    theme='outline'
-                                    onClick={() =>
-                                      setCodexOAuthModalVisible(true)
-                                    }
-                                    disabled={isIonetLocked}
-                                  >
-                                    {t('Codex 授权')}
-                                  </Button>
-                                  {isEdit && (
+                                  <Space wrap spacing='tight'>
                                     <Button
                                       size='small'
                                       type='primary'
                                       theme='outline'
-                                      onClick={handleRefreshCodexCredential}
-                                      loading={codexCredentialRefreshing}
+                                      onClick={() =>
+                                        setCodexOAuthModalVisible(true)
+                                      }
                                       disabled={isIonetLocked}
                                     >
-                                      {t('刷新凭证')}
+                                      {t('Codex 授权')}
                                     </Button>
-                                  )}
-                                  <Button
-                                    size='small'
-                                    type='primary'
-                                    theme='outline'
-                                    onClick={() => formatJsonField('key')}
-                                    disabled={isIonetLocked}
-                                  >
-                                    {t('格式化')}
-                                  </Button>
-                                  {isEdit && (
+                                    {isEdit && (
+                                      <Button
+                                        size='small'
+                                        type='primary'
+                                        theme='outline'
+                                        onClick={handleRefreshCodexCredential}
+                                        loading={codexCredentialRefreshing}
+                                        disabled={isIonetLocked}
+                                      >
+                                        {t('刷新凭证')}
+                                      </Button>
+                                    )}
                                     <Button
                                       size='small'
                                       type='primary'
                                       theme='outline'
-                                      onClick={handleShow2FAModal}
+                                      onClick={() => formatJsonField('key')}
                                       disabled={isIonetLocked}
                                     >
-                                      {t('查看密钥')}
+                                      {t('格式化')}
                                     </Button>
-                                  )}
-                                  {batchExtra}
-                                </Space>
-                              </div>
-                            }
-                            autosize
-                            showClear
-                          />
+                                    {isEdit && (
+                                      <Button
+                                        size='small'
+                                        type='primary'
+                                        theme='outline'
+                                        onClick={handleShow2FAModal}
+                                        disabled={isIonetLocked}
+                                      >
+                                        {t('查看密钥')}
+                                      </Button>
+                                    )}
+                                    {batchExtra}
+                                  </Space>
+                                </div>
+                              }
+                              autosize
+                              showClear
+                            />
 
-                          <CodexOAuthModal
-                            visible={codexOAuthModalVisible}
-                            onCancel={() => setCodexOAuthModalVisible(false)}
-                            onSuccess={handleCodexOAuthGenerated}
-                          />
-                        </>
-                      ) : inputs.type === 41 &&
-                        (inputs.vertex_key_type || 'json') === 'json' ? (
-                        <>
-                          {!batch && (
-                            <div className='flex items-center justify-between mb-3'>
-                              <Text className='text-sm font-medium'>
-                                {t('密钥输入方式')}
-                              </Text>
-                              <Space>
-                                <Button
-                                  size='small'
-                                  type={
-                                    !useManualInput ? 'primary' : 'tertiary'
-                                  }
-                                  onClick={() => {
-                                    setUseManualInput(false);
-                                    // 切换到文件上传模式时清空手动输入的密钥
-                                    if (formApiRef.current) {
-                                      formApiRef.current.setValue('key', '');
+                            <CodexOAuthModal
+                              visible={codexOAuthModalVisible}
+                              onCancel={() => setCodexOAuthModalVisible(false)}
+                              onSuccess={handleCodexOAuthGenerated}
+                            />
+                          </>
+                        ) : inputs.type === 41 &&
+                          (inputs.vertex_key_type || 'json') === 'json' ? (
+                          <>
+                            {!batch && (
+                              <div className='flex items-center justify-between mb-3'>
+                                <Text className='text-sm font-medium'>
+                                  {t('密钥输入方式')}
+                                </Text>
+                                <Space>
+                                  <Button
+                                    size='small'
+                                    type={
+                                      !useManualInput ? 'primary' : 'tertiary'
                                     }
-                                    handleInputChange('key', '');
-                                  }}
-                                >
-                                  {t('文件上传')}
-                                </Button>
-                                <Button
-                                  size='small'
-                                  type={useManualInput ? 'primary' : 'tertiary'}
-                                  onClick={() => {
-                                    setUseManualInput(true);
-                                    // 切换到手动输入模式时清空文件上传相关状态
-                                    setVertexKeys([]);
-                                    setVertexFileList([]);
-                                    if (formApiRef.current) {
-                                      formApiRef.current.setValue(
-                                        'vertex_files',
-                                        [],
-                                      );
+                                    onClick={() => {
+                                      setUseManualInput(false);
+                                      // 切换到文件上传模式时清空手动输入的密钥
+                                      if (formApiRef.current) {
+                                        formApiRef.current.setValue('key', '');
+                                      }
+                                      handleInputChange('key', '');
+                                    }}
+                                  >
+                                    {t('文件上传')}
+                                  </Button>
+                                  <Button
+                                    size='small'
+                                    type={
+                                      useManualInput ? 'primary' : 'tertiary'
                                     }
-                                    setInputs((prev) => ({
-                                      ...prev,
-                                      vertex_files: [],
-                                    }));
-                                  }}
-                                >
-                                  {t('手动输入')}
-                                </Button>
-                              </Space>
-                            </div>
-                          )}
+                                    onClick={() => {
+                                      setUseManualInput(true);
+                                      // 切换到手动输入模式时清空文件上传相关状态
+                                      setVertexKeys([]);
+                                      setVertexFileList([]);
+                                      if (formApiRef.current) {
+                                        formApiRef.current.setValue(
+                                          'vertex_files',
+                                          [],
+                                        );
+                                      }
+                                      setInputs((prev) => ({
+                                        ...prev,
+                                        vertex_files: [],
+                                      }));
+                                    }}
+                                  >
+                                    {t('手动输入')}
+                                  </Button>
+                                </Space>
+                              </div>
+                            )}
 
                             {batch && (
                               <Banner

+ 6 - 4
web/src/components/table/channels/modals/EditTagModal.jsx

@@ -533,7 +533,11 @@ const EditTagModal = (props) => {
               <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
                 {/* Header: Advanced Settings */}
                 <div className='flex items-center mb-2'>
-                  <Avatar size='small' color='orange' className='mr-2 shadow-md'>
+                  <Avatar
+                    size='small'
+                    color='orange'
+                    className='mr-2 shadow-md'
+                  >
                     <IconSetting size={16} />
                   </Avatar>
                   <div>
@@ -549,9 +553,7 @@ const EditTagModal = (props) => {
                     field='param_override'
                     label={t('参数覆盖')}
                     placeholder={
-                      t(
-                        '此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
-                      ) +
+                      t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
                       '\n' +
                       t('旧格式(直接覆盖):') +
                       '\n{\n  "temperature": 0,\n  "max_tokens": 1000\n}' +

+ 3 - 1
web/src/components/table/channels/modals/ModelSelectModal.jsx

@@ -104,7 +104,9 @@ const ModelSelectModal = ({
   }, [normalizedRedirectModels, normalizedSelectedSet]);
 
   const filteredModels = models.filter((m) =>
-    String(m || '').toLowerCase().includes(keyword.toLowerCase()),
+    String(m || '')
+      .toLowerCase()
+      .includes(keyword.toLowerCase()),
   );
 
   // 分类模型:新获取的模型和已有模型

+ 6 - 6
web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx

@@ -30,7 +30,7 @@ const ConfirmationDialog = ({
   type = 'danger',
   deployment,
   t,
-  loading = false
+  loading = false,
 }) => {
   const [confirmText, setConfirmText] = useState('');
 
@@ -66,17 +66,17 @@ const ConfirmationDialog = ({
       okButtonProps={{
         disabled: !isConfirmed,
         type: type === 'danger' ? 'danger' : 'primary',
-        loading
+        loading,
       }}
       width={480}
     >
-      <div className="space-y-4">
-        <Text type="danger" strong>
+      <div className='space-y-4'>
+        <Text type='danger' strong>
           {t('此操作具有风险,请确认要继续执行')}。
         </Text>
         <Text>
           {t('请输入部署名称以完成二次确认')}:
-          <Text code className="ml-1">
+          <Text code className='ml-1'>
             {requiredText || t('未知部署')}
           </Text>
         </Text>
@@ -87,7 +87,7 @@ const ConfirmationDialog = ({
           autoFocus
         />
         {!isConfirmed && confirmText && (
-          <Text type="danger" size="small">
+          <Text type='danger' size='small'>
             {t('部署名称不匹配,请检查后重新输入')}
           </Text>
         )}

+ 13 - 19
web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx

@@ -130,9 +130,7 @@ const ExtendDurationModal = ({
       ? details.locations
           .map((location) =>
             Number(
-              location?.id ??
-                location?.location_id ??
-                location?.locationId,
+              location?.id ?? location?.location_id ?? location?.locationId,
             ),
           )
           .filter((id) => Number.isInteger(id) && id > 0)
@@ -181,9 +179,7 @@ const ExtendDurationModal = ({
       } else {
         const message = response.data.message || '';
         setPriceEstimation(null);
-        setPriceError(
-          t('价格计算失败') + (message ? `: ${message}` : ''),
-        );
+        setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
       }
     } catch (error) {
       if (costRequestIdRef.current !== requestId) {
@@ -192,9 +188,7 @@ const ExtendDurationModal = ({
 
       const message = error?.response?.data?.message || error.message || '';
       setPriceEstimation(null);
-      setPriceError(
-        t('价格计算失败') + (message ? `: ${message}` : ''),
-      );
+      setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
     } finally {
       if (costRequestIdRef.current === requestId) {
         setCostLoading(false);
@@ -269,11 +263,8 @@ const ExtendDurationModal = ({
   const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
 
   const priceData = priceEstimation || {};
-  const breakdown =
-    priceData.price_breakdown || priceData.PriceBreakdown || {};
-  const currencyLabel = (
-    priceData.currency || priceData.Currency || 'USDC'
-  )
+  const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {};
+  const currencyLabel = (priceData.currency || priceData.Currency || 'USDC')
     .toString()
     .toUpperCase();
 
@@ -316,7 +307,10 @@ const ExtendDurationModal = ({
       confirmLoading={loading}
       okButtonProps={{
         disabled:
-          !deployment?.id || detailsLoading || !durationHours || durationHours < 1,
+          !deployment?.id ||
+          detailsLoading ||
+          !durationHours ||
+          durationHours < 1,
       }}
       width={600}
       className='extend-duration-modal'
@@ -357,9 +351,7 @@ const ExtendDurationModal = ({
               <p>
                 {t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
               </p>
-              <p>
-                {t('延长操作一旦确认无法撤销,费用将立即扣除。')}
-              </p>
+              <p>{t('延长操作一旦确认无法撤销,费用将立即扣除。')}</p>
             </div>
           }
         />
@@ -370,7 +362,9 @@ const ExtendDurationModal = ({
           onValueChange={(values) => {
             if (values.duration_hours !== undefined) {
               const numericValue = Number(values.duration_hours);
-              setDurationHours(Number.isFinite(numericValue) ? numericValue : 0);
+              setDurationHours(
+                Number.isFinite(numericValue) ? numericValue : 0,
+              );
             }
           }}
         >

+ 9 - 2
web/src/components/table/tokens/modals/EditTokenModal.jsx

@@ -378,7 +378,12 @@ const EditTokenModal = (props) => {
                       />
                     )}
                   </Col>
-                  <Col span={24} style={{ display: values.group === 'auto' ? 'block' : 'none' }}>
+                  <Col
+                    span={24}
+                    style={{
+                      display: values.group === 'auto' ? 'block' : 'none',
+                    }}
+                  >
                     <Form.Switch
                       field='cross_group_retry'
                       label={t('跨分组重试')}
@@ -561,7 +566,9 @@ const EditTokenModal = (props) => {
                       placeholder={t('允许的IP,一行一个,不填写则不限制')}
                       autosize
                       rows={1}
-                      extraText={t('请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用')}
+                      extraText={t(
+                        '请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用',
+                      )}
                       showClear
                       style={{ width: '100%' }}
                     />

+ 62 - 35
web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx

@@ -20,6 +20,7 @@ For commercial licensing, please contact [email protected]
 import React from 'react';
 import {
   Avatar,
+  Button,
   Space,
   Tag,
   Tooltip,
@@ -71,6 +72,34 @@ function formatRatio(ratio) {
   return String(ratio);
 }
 
+function buildChannelAffinityTooltip(affinity, t) {
+  if (!affinity) {
+    return null;
+  }
+
+  const keySource = affinity.key_source || '-';
+  const keyPath = affinity.key_path || affinity.key_key || '-';
+  const keyHint = affinity.key_hint || '';
+  const keyFp = affinity.key_fp ? `#${affinity.key_fp}` : '';
+  const keyText = `${keySource}:${keyPath}${keyFp}`;
+
+  const lines = [
+    t('渠道亲和性'),
+    `${t('规则')}:${affinity.rule_name || '-'}`,
+    `${t('分组')}:${affinity.selected_group || '-'}`,
+    `${t('Key')}:${keyText}`,
+    ...(keyHint ? [`${t('Key 摘要')}:${keyHint}`] : []),
+  ];
+
+  return (
+    <div style={{ lineHeight: 1.6, display: 'flex', flexDirection: 'column' }}>
+      {lines.map((line, i) => (
+        <div key={i}>{line}</div>
+      ))}
+    </div>
+  );
+}
+
 // Render functions
 function renderType(type, t) {
   switch (type) {
@@ -250,6 +279,7 @@ export const getLogsColumns = ({
   COLUMN_KEYS,
   copyText,
   showUserInfoFunc,
+  openChannelAffinityUsageCacheModal,
   isAdminUser,
 }) => {
   return [
@@ -532,42 +562,39 @@ export const getLogsColumns = ({
         return isAdminUser ? (
           <Space>
             <div>{content}</div>
-	            {affinity ? (
-	              <Tooltip
-	                content={
-	                  <div style={{ lineHeight: 1.6 }}>
-	                    <Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
-	                    <div>
-	                      <Typography.Text type='secondary'>
-	                        {t('规则')}:{affinity.rule_name || '-'}
-	                      </Typography.Text>
-	                    </div>
-	                    <div>
-	                      <Typography.Text type='secondary'>
-	                        {t('分组')}:{affinity.selected_group || '-'}
-	                      </Typography.Text>
-	                    </div>
-	                    <div>
-	                      <Typography.Text type='secondary'>
-	                        {t('Key')}:
-	                        {(affinity.key_source || '-') +
-	                          ':' +
-	                          (affinity.key_path || affinity.key_key || '-') +
-                          (affinity.key_fp ? `#${affinity.key_fp}` : '')}
-                      </Typography.Text>
+            {affinity ? (
+              <Tooltip
+                content={
+                  <div>
+                    {buildChannelAffinityTooltip(affinity, t)}
+                    <div style={{ marginTop: 6 }}>
+                      <Button
+                        theme='borderless'
+                        size='small'
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          openChannelAffinityUsageCacheModal?.(affinity);
+                        }}
+                      >
+                        {t('查看详情')}
+                      </Button>
                     </div>
-	                  </div>
-	                }
-	              >
-	                <span>
-	                  <Tag className='channel-affinity-tag' color='cyan' shape='circle'>
-	                    <span className='channel-affinity-tag-content'>
-	                      <IconStarStroked style={{ fontSize: 13 }} />
-	                      {t('优选')}
-	                    </span>
-	                  </Tag>
-	                </span>
-	              </Tooltip>
+                  </div>
+                }
+              >
+                <span>
+                  <Tag
+                    className='channel-affinity-tag'
+                    color='cyan'
+                    shape='circle'
+                  >
+                    <span className='channel-affinity-tag-content'>
+                      <IconStarStroked style={{ fontSize: 13 }} />
+                      {t('优选')}
+                    </span>
+                  </Tag>
+                </span>
+              </Tooltip>
             ) : null}
           </Space>
         ) : (

+ 10 - 1
web/src/components/table/usage-logs/UsageLogsTable.jsx

@@ -40,6 +40,7 @@ const LogsTable = (logsData) => {
     handlePageSizeChange,
     copyText,
     showUserInfoFunc,
+    openChannelAffinityUsageCacheModal,
     hasExpandableRows,
     isAdminUser,
     t,
@@ -53,9 +54,17 @@ const LogsTable = (logsData) => {
       COLUMN_KEYS,
       copyText,
       showUserInfoFunc,
+      openChannelAffinityUsageCacheModal,
       isAdminUser,
     });
-  }, [t, COLUMN_KEYS, copyText, showUserInfoFunc, isAdminUser]);
+  }, [
+    t,
+    COLUMN_KEYS,
+    copyText,
+    showUserInfoFunc,
+    openChannelAffinityUsageCacheModal,
+    isAdminUser,
+  ]);
 
   // Filter columns based on visibility settings
   const getVisibleColumns = () => {

+ 2 - 0
web/src/components/table/usage-logs/index.jsx

@@ -24,6 +24,7 @@ import LogsActions from './UsageLogsActions';
 import LogsFilters from './UsageLogsFilters';
 import ColumnSelectorModal from './modals/ColumnSelectorModal';
 import UserInfoModal from './modals/UserInfoModal';
+import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
 import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
 import { useIsMobile } from '../../../hooks/common/useIsMobile';
 import { createCardProPagination } from '../../../helpers/utils';
@@ -37,6 +38,7 @@ const LogsPage = () => {
       {/* Modals */}
       <ColumnSelectorModal {...logsData} />
       <UserInfoModal {...logsData} />
+      <ChannelAffinityUsageCacheModal {...logsData} />
 
       {/* Main Content */}
       <CardPro

+ 200 - 0
web/src/components/table/usage-logs/modals/ChannelAffinityUsageCacheModal.jsx

@@ -0,0 +1,200 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { Modal, Descriptions, Spin, Typography } from '@douyinfe/semi-ui';
+import { API, showError, timestamp2string } from '../../../../helpers';
+
+const { Text } = Typography;
+
+function formatRate(hit, total) {
+  if (!total || total <= 0) return '-';
+  const r = (Number(hit || 0) / Number(total || 0)) * 100;
+  if (!Number.isFinite(r)) return '-';
+  return `${r.toFixed(2)}%`;
+}
+
+function formatTokenRate(n, d) {
+  const nn = Number(n || 0);
+  const dd = Number(d || 0);
+  if (!dd || dd <= 0) return '-';
+  const r = (nn / dd) * 100;
+  if (!Number.isFinite(r)) return '-';
+  return `${r.toFixed(2)}%`;
+}
+
+const ChannelAffinityUsageCacheModal = ({
+  t,
+  showChannelAffinityUsageCacheModal,
+  setShowChannelAffinityUsageCacheModal,
+  channelAffinityUsageCacheTarget,
+}) => {
+  const [loading, setLoading] = useState(false);
+  const [stats, setStats] = useState(null);
+  const requestSeqRef = useRef(0);
+
+  const params = useMemo(() => {
+    const x = channelAffinityUsageCacheTarget || {};
+    return {
+      rule_name: (x.rule_name || '').trim(),
+      using_group: (x.using_group || '').trim(),
+      key_hint: (x.key_hint || '').trim(),
+      key_fp: (x.key_fp || '').trim(),
+    };
+  }, [channelAffinityUsageCacheTarget]);
+
+  useEffect(() => {
+    if (!showChannelAffinityUsageCacheModal) {
+      requestSeqRef.current += 1; // invalidate inflight request
+      setLoading(false);
+      setStats(null);
+      return;
+    }
+    if (!params.rule_name || !params.key_fp) {
+      setLoading(false);
+      setStats(null);
+      return;
+    }
+
+    const reqSeq = (requestSeqRef.current += 1);
+    setStats(null);
+    setLoading(true);
+    (async () => {
+      try {
+        const res = await API.get('/api/log/channel_affinity_usage_cache', {
+          params,
+          disableDuplicate: true,
+        });
+        if (reqSeq !== requestSeqRef.current) return;
+        const { success, message, data } = res.data || {};
+        if (!success) {
+          setStats(null);
+          showError(t(message || '请求失败'));
+          return;
+        }
+        setStats(data || {});
+      } catch (e) {
+        if (reqSeq !== requestSeqRef.current) return;
+        setStats(null);
+        showError(t('请求失败'));
+      } finally {
+        if (reqSeq !== requestSeqRef.current) return;
+        setLoading(false);
+      }
+    })();
+  }, [
+    showChannelAffinityUsageCacheModal,
+    params.rule_name,
+    params.using_group,
+    params.key_hint,
+    params.key_fp,
+    t,
+  ]);
+
+  const rows = useMemo(() => {
+    const s = stats || {};
+    const hit = Number(s.hit || 0);
+    const total = Number(s.total || 0);
+    const windowSeconds = Number(s.window_seconds || 0);
+    const lastSeenAt = Number(s.last_seen_at || 0);
+    const promptTokens = Number(s.prompt_tokens || 0);
+    const completionTokens = Number(s.completion_tokens || 0);
+    const totalTokens = Number(s.total_tokens || 0);
+    const cachedTokens = Number(s.cached_tokens || 0);
+    const promptCacheHitTokens = Number(s.prompt_cache_hit_tokens || 0);
+
+    return [
+      { key: t('规则'), value: s.rule_name || params.rule_name || '-' },
+      { key: t('分组'), value: s.using_group || params.using_group || '-' },
+      {
+        key: t('Key 摘要'),
+        value: params.key_hint || '-',
+      },
+      {
+        key: t('Key 指纹'),
+        value: s.key_fp || params.key_fp || '-',
+      },
+      { key: t('TTL(秒)'), value: windowSeconds > 0 ? windowSeconds : '-' },
+      {
+        key: t('命中率'),
+        value: `${hit}/${total} (${formatRate(hit, total)})`,
+      },
+      {
+        key: t('Prompt tokens'),
+        value: promptTokens,
+      },
+      {
+        key: t('Cached tokens'),
+        value: `${cachedTokens} (${formatTokenRate(cachedTokens, promptTokens)})`,
+      },
+      {
+        key: t('Prompt cache hit tokens'),
+        value: promptCacheHitTokens,
+      },
+      {
+        key: t('Completion tokens'),
+        value: completionTokens,
+      },
+      {
+        key: t('Total tokens'),
+        value: totalTokens,
+      },
+      {
+        key: t('最近一次'),
+        value: lastSeenAt > 0 ? timestamp2string(lastSeenAt) : '-',
+      },
+    ];
+  }, [stats, params, t]);
+
+  return (
+    <Modal
+      title={t('渠道亲和性:上游缓存命中')}
+      visible={showChannelAffinityUsageCacheModal}
+      onCancel={() => setShowChannelAffinityUsageCacheModal(false)}
+      footer={null}
+      centered
+      closable
+      maskClosable
+      width={640}
+    >
+      <div style={{ padding: 16 }}>
+        <div style={{ marginBottom: 12 }}>
+          <Text type='tertiary' size='small'>
+            {t(
+              '命中判定:usage 中存在 cached tokens(例如 cached_tokens/prompt_cache_hit_tokens)即视为命中。',
+            )}
+          </Text>
+        </div>
+        <Spin spinning={loading} tip={t('加载中...')}>
+          {stats ? (
+            <Descriptions data={rows} />
+          ) : (
+            <div style={{ padding: '24px 0' }}>
+              <Text type='tertiary' size='small'>
+                {loading ? t('加载中...') : t('暂无数据')}
+              </Text>
+            </div>
+          )}
+        </Spin>
+      </div>
+    </Modal>
+  );
+};
+
+export default ChannelAffinityUsageCacheModal;

+ 8 - 2
web/src/components/topup/RechargeCard.jsx

@@ -87,7 +87,12 @@ const RechargeCard = ({
   const onlineFormApiRef = useRef(null);
   const redeemFormApiRef = useRef(null);
   const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
-  console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts);
+  console.log(
+    ' enabled screem ?',
+    enableCreemTopUp,
+    ' products ?',
+    creemProducts,
+  );
   return (
     <Card className='!rounded-2xl shadow-sm border-0'>
       {/* 卡片头部 */}
@@ -503,7 +508,8 @@ const RechargeCard = ({
                             {t('充值额度')}: {product.quota}
                           </div>
                           <div className='text-lg font-semibold text-blue-600'>
-                            {product.currency === 'EUR' ? '€' : '$'}{product.price}
+                            {product.currency === 'EUR' ? '€' : '$'}
+                            {product.price}
                           </div>
                         </Card>
                       ))}

+ 2 - 1
web/src/components/topup/index.jsx

@@ -651,7 +651,8 @@ const TopUp = () => {
               {t('产品名称')}:{selectedCreemProduct.name}
             </p>
             <p>
-              {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price}
+              {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}
+              {selectedCreemProduct.price}
             </p>
             <p>
               {t('充值额度')}:{selectedCreemProduct.quota}

+ 1 - 3
web/src/helpers/api.js

@@ -236,9 +236,7 @@ async function prepareOAuthState(options = {}) {
   if (shouldLogout) {
     try {
       await API.get('/api/user/logout', { skipErrorHandler: true });
-    } catch (err) {
-
-    }
+    } catch (err) {}
     localStorage.removeItem('user');
     updateAPI();
   }

+ 13 - 5
web/src/helpers/dashboard.jsx

@@ -261,7 +261,7 @@ export const processRawData = (
   };
 
   // 检查数据是否跨年
-  const showYear = isDataCrossYear(data.map(item => item.created_at));
+  const showYear = isDataCrossYear(data.map((item) => item.created_at));
 
   data.forEach((item) => {
     result.uniqueModels.add(item.model_name);
@@ -269,7 +269,11 @@ export const processRawData = (
     result.totalQuota += item.quota;
     result.totalTimes += item.count;
 
-    const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear);
+    const timeKey = timestamp2string1(
+      item.created_at,
+      dataExportDefaultTime,
+      showYear,
+    );
     if (!result.timePoints.includes(timeKey)) {
       result.timePoints.push(timeKey);
     }
@@ -328,10 +332,14 @@ export const aggregateDataByTimeAndModel = (data, dataExportDefaultTime) => {
   const aggregatedData = new Map();
 
   // 检查数据是否跨年
-  const showYear = isDataCrossYear(data.map(item => item.created_at));
+  const showYear = isDataCrossYear(data.map((item) => item.created_at));
 
   data.forEach((item) => {
-    const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear);
+    const timeKey = timestamp2string1(
+      item.created_at,
+      dataExportDefaultTime,
+      showYear,
+    );
     const modelKey = item.model_name;
     const key = `${timeKey}-${modelKey}`;
 
@@ -372,7 +380,7 @@ export const generateChartTimePoints = (
     );
     const showYear = isDataCrossYear(generatedTimestamps);
 
-    chartTimePoints = generatedTimestamps.map(ts =>
+    chartTimePoints = generatedTimestamps.map((ts) =>
       timestamp2string1(ts, dataExportDefaultTime, showYear),
     );
   }

+ 24 - 2
web/src/helpers/statusCodeRules.js

@@ -1,3 +1,21 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
 export function parseHttpStatusCodeRules(input) {
   const raw = (input ?? '').toString().trim();
   if (raw.length === 0) {
@@ -35,7 +53,9 @@ export function parseHttpStatusCodeRules(input) {
   }
 
   const merged = mergeRanges(ranges);
-  const tokens = merged.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`));
+  const tokens = merged.map((r) =>
+    r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`,
+  );
   const normalized = tokens.join(',');
 
   return {
@@ -78,7 +98,9 @@ function isNumber(s) {
 function mergeRanges(ranges) {
   if (!Array.isArray(ranges) || ranges.length === 0) return [];
 
-  const sorted = [...ranges].sort((a, b) => (a.start !== b.start ? a.start - b.start : a.end - b.end));
+  const sorted = [...ranges].sort((a, b) =>
+    a.start !== b.start ? a.start - b.start : a.end - b.end,
+  );
   const merged = [sorted[0]];
 
   for (let i = 1; i < sorted.length; i += 1) {

+ 11 - 3
web/src/helpers/utils.jsx

@@ -217,7 +217,11 @@ export function timestamp2string(timestamp) {
   );
 }
 
-export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', showYear = false) {
+export function timestamp2string1(
+  timestamp,
+  dataExportDefaultTime = 'hour',
+  showYear = false,
+) {
   let date = new Date(timestamp * 1000);
   let year = date.getFullYear();
   let month = (date.getMonth() + 1).toString();
@@ -248,7 +252,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho
       nextDay = '0' + nextDay;
     }
     // 周视图结束日期也仅在跨年时显示年份
-    let nextStr = showYear ? nextWeekYear + '-' + nextMonth + '-' + nextDay : nextMonth + '-' + nextDay;
+    let nextStr = showYear
+      ? nextWeekYear + '-' + nextMonth + '-' + nextDay
+      : nextMonth + '-' + nextDay;
     str += ' - ' + nextStr;
   }
   return str;
@@ -257,7 +263,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho
 // 检查时间戳数组是否跨年
 export function isDataCrossYear(timestamps) {
   if (!timestamps || timestamps.length === 0) return false;
-  const years = new Set(timestamps.map(ts => new Date(ts * 1000).getFullYear()));
+  const years = new Set(
+    timestamps.map((ts) => new Date(ts * 1000).getFullYear()),
+  );
   return years.size > 1;
 }
 

+ 21 - 5
web/src/hooks/model-deployments/useModelDeploymentSettings.js

@@ -55,13 +55,20 @@ export const useModelDeploymentSettings = () => {
 
   const isIoNetEnabled = settings['model_deployment.ionet.enabled'];
 
-  const buildConnectionError = (rawMessage, fallbackMessage = 'Connection failed') => {
+  const buildConnectionError = (
+    rawMessage,
+    fallbackMessage = 'Connection failed',
+  ) => {
     const message = (rawMessage || fallbackMessage).trim();
     const normalized = message.toLowerCase();
     if (normalized.includes('expired') || normalized.includes('expire')) {
       return { type: 'expired', message };
     }
-    if (normalized.includes('invalid') || normalized.includes('unauthorized') || normalized.includes('api key')) {
+    if (
+      normalized.includes('invalid') ||
+      normalized.includes('unauthorized') ||
+      normalized.includes('api key')
+    ) {
       return { type: 'invalid', message };
     }
     if (normalized.includes('network') || normalized.includes('timeout')) {
@@ -85,7 +92,11 @@ export const useModelDeploymentSettings = () => {
       }
 
       const message = response?.data?.message || 'Connection failed';
-      setConnectionState({ loading: false, ok: false, error: buildConnectionError(message) });
+      setConnectionState({
+        loading: false,
+        ok: false,
+        error: buildConnectionError(message),
+      });
     } catch (error) {
       if (error?.code === 'ERR_NETWORK') {
         setConnectionState({
@@ -95,8 +106,13 @@ export const useModelDeploymentSettings = () => {
         });
         return;
       }
-      const rawMessage = error?.response?.data?.message || error?.message || 'Unknown error';
-      setConnectionState({ loading: false, ok: false, error: buildConnectionError(rawMessage, 'Connection failed') });
+      const rawMessage =
+        error?.response?.data?.message || error?.message || 'Unknown error';
+      setConnectionState({
+        loading: false,
+        ok: false,
+        error: buildConnectionError(rawMessage, 'Connection failed'),
+      });
     }
   }, []);
 

+ 6 - 3
web/src/hooks/playground/useApiRequest.jsx

@@ -231,7 +231,10 @@ export const useApiRequest = (
         if (data.choices?.[0]) {
           const choice = data.choices[0];
           let content = choice.message?.content || '';
-          let reasoningContent = choice.message?.reasoning_content || choice.message?.reasoning || '';
+          let reasoningContent =
+            choice.message?.reasoning_content ||
+            choice.message?.reasoning ||
+            '';
 
           const processed = processThinkTags(content, reasoningContent);
 
@@ -318,8 +321,8 @@ export const useApiRequest = (
           isStreamComplete = true; // 标记流正常完成
           source.close();
           sseSourceRef.current = null;
-          setDebugData((prev) => ({ 
-            ...prev, 
+          setDebugData((prev) => ({
+            ...prev,
             response: responseData,
             sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记
             isStreaming: false,

+ 14 - 7
web/src/hooks/playground/usePlaygroundState.js

@@ -36,18 +36,23 @@ import { processIncompleteThinkTags } from '../../helpers';
 
 export const usePlaygroundState = () => {
   const { t } = useTranslation();
-  
+
   // 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
   const [savedConfig] = useState(() => loadConfig());
   const [initialMessages] = useState(() => {
     const loaded = loadMessages();
     // 检查是否是旧的中文默认消息,如果是则清除
-    if (loaded && loaded.length === 2 && loaded[0].id === '2' && loaded[1].id === '3') {
-      const hasOldChinese = 
-        loaded[0].content === '你好' || 
+    if (
+      loaded &&
+      loaded.length === 2 &&
+      loaded[0].id === '2' &&
+      loaded[1].id === '3'
+    ) {
+      const hasOldChinese =
+        loaded[0].content === '你好' ||
         loaded[1].content === '你好,请问有什么可以帮助您的吗?' ||
         loaded[1].content === '你好!很高兴见到你。有什么我可以帮助你的吗?';
-      
+
       if (hasOldChinese) {
         // 清除旧的默认消息
         localStorage.removeItem('playground_messages');
@@ -81,8 +86,10 @@ export const usePlaygroundState = () => {
   const [status, setStatus] = useState({});
 
   // 消息相关状态 - 使用加载的消息或默认消息初始化
-  const [message, setMessage] = useState(() => initialMessages || getDefaultMessages(t));
-  
+  const [message, setMessage] = useState(
+    () => initialMessages || getDefaultMessages(t),
+  );
+
   // 当语言改变时,如果是默认消息则更新
   useEffect(() => {
     // 只在没有保存的消息时才更新默认消息

+ 33 - 4
web/src/hooks/usage-logs/useUsageLogsData.jsx

@@ -112,6 +112,14 @@ export const useLogsData = () => {
   const [showUserInfo, setShowUserInfoModal] = useState(false);
   const [userInfoData, setUserInfoData] = useState(null);
 
+  // Channel affinity usage cache stats modal state (admin only)
+  const [
+    showChannelAffinityUsageCacheModal,
+    setShowChannelAffinityUsageCacheModal,
+  ] = useState(false);
+  const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
+    useState(null);
+
   // Load saved column preferences from localStorage
   useEffect(() => {
     const savedColumns = localStorage.getItem(STORAGE_KEY);
@@ -304,6 +312,17 @@ export const useLogsData = () => {
     }
   };
 
+  const openChannelAffinityUsageCacheModal = (affinity) => {
+    const a = affinity || {};
+    setChannelAffinityUsageCacheTarget({
+      rule_name: a.rule_name || a.reason || '',
+      using_group: a.using_group || '',
+      key_hint: a.key_hint || '',
+      key_fp: a.key_fp || '',
+    });
+    setShowChannelAffinityUsageCacheModal(true);
+  };
+
   // Format logs data
   const setLogsFormat = (logs) => {
     const requestConversionDisplayValue = (conversionChain) => {
@@ -372,9 +391,13 @@ export const useLogsData = () => {
                 other.cache_ratio || 1.0,
                 other.cache_creation_ratio || 1.0,
                 other.cache_creation_tokens_5m || 0,
-                other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
+                other.cache_creation_ratio_5m ||
+                  other.cache_creation_ratio ||
+                  1.0,
                 other.cache_creation_tokens_1h || 0,
-                other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
+                other.cache_creation_ratio_1h ||
+                  other.cache_creation_ratio ||
+                  1.0,
               )
             : renderLogContent(
                 other?.model_ratio,
@@ -524,8 +547,8 @@ export const useLogsData = () => {
           localCountMode = t('上游返回');
         }
         expandDataLocal.push({
-            key: t('计费模式'),
-            value: localCountMode,
+          key: t('计费模式'),
+          value: localCountMode,
         });
       }
       expandDatesLocal[logs[i].key] = expandDataLocal;
@@ -680,6 +703,12 @@ export const useLogsData = () => {
     userInfoData,
     showUserInfoFunc,
 
+    // Channel affinity usage cache stats modal
+    showChannelAffinityUsageCacheModal,
+    setShowChannelAffinityUsageCacheModal,
+    channelAffinityUsageCacheTarget,
+    openChannelAffinityUsageCacheModal,
+
     // Functions
     loadLogs,
     handlePageChange,

+ 86 - 83
web/src/pages/Playground/index.jsx

@@ -438,14 +438,17 @@ const Playground = () => {
   }, [setMessage, saveMessagesImmediately]);
 
   // 处理粘贴图片
-  const handlePasteImage = useCallback((base64Data) => {
-    if (!inputs.imageEnabled) {
-      return;
-    }
-    // 添加图片到 imageUrls 数组
-    const newUrls = [...(inputs.imageUrls || []), base64Data];
-    handleInputChange('imageUrls', newUrls);
-  }, [inputs.imageEnabled, inputs.imageUrls, handleInputChange]);
+  const handlePasteImage = useCallback(
+    (base64Data) => {
+      if (!inputs.imageEnabled) {
+        return;
+      }
+      // 添加图片到 imageUrls 数组
+      const newUrls = [...(inputs.imageUrls || []), base64Data];
+      handleInputChange('imageUrls', newUrls);
+    },
+    [inputs.imageEnabled, inputs.imageUrls, handleInputChange],
+  );
 
   // Playground Context 值
   const playgroundContextValue = {
@@ -457,10 +460,10 @@ const Playground = () => {
   return (
     <PlaygroundProvider value={playgroundContextValue}>
       <div className='h-full'>
-      <Layout className='h-full bg-transparent flex flex-col md:flex-row'>
-        {(showSettings || !isMobile) && (
-          <Layout.Sider
-            className={`
+        <Layout className='h-full bg-transparent flex flex-col md:flex-row'>
+          {(showSettings || !isMobile) && (
+            <Layout.Sider
+              className={`
               bg-transparent border-r-0 flex-shrink-0 overflow-auto mt-[60px]
               ${
                 isMobile
@@ -468,93 +471,93 @@ const Playground = () => {
                   : 'relative z-[1] w-80 h-[calc(100vh-66px)]'
               }
             `}
-            width={isMobile ? '100%' : 320}
-          >
-            <OptimizedSettingsPanel
-              inputs={inputs}
-              parameterEnabled={parameterEnabled}
-              models={models}
-              groups={groups}
-              styleState={styleState}
-              showSettings={showSettings}
-              showDebugPanel={showDebugPanel}
-              customRequestMode={customRequestMode}
-              customRequestBody={customRequestBody}
-              onInputChange={handleInputChange}
-              onParameterToggle={handleParameterToggle}
-              onCloseSettings={() => setShowSettings(false)}
-              onConfigImport={handleConfigImport}
-              onConfigReset={handleConfigReset}
-              onCustomRequestModeChange={setCustomRequestMode}
-              onCustomRequestBodyChange={setCustomRequestBody}
-              previewPayload={previewPayload}
-              messages={message}
-            />
-          </Layout.Sider>
-        )}
-
-        <Layout.Content className='relative flex-1 overflow-hidden'>
-          <div className='overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)] mt-[60px]'>
-            <div className='flex-1 flex flex-col'>
-              <ChatArea
-                chatRef={chatRef}
-                message={message}
+              width={isMobile ? '100%' : 320}
+            >
+              <OptimizedSettingsPanel
                 inputs={inputs}
+                parameterEnabled={parameterEnabled}
+                models={models}
+                groups={groups}
                 styleState={styleState}
+                showSettings={showSettings}
                 showDebugPanel={showDebugPanel}
-                roleInfo={roleInfo}
-                onMessageSend={onMessageSend}
-                onMessageCopy={messageActions.handleMessageCopy}
-                onMessageReset={messageActions.handleMessageReset}
-                onMessageDelete={messageActions.handleMessageDelete}
-                onStopGenerator={onStopGenerator}
-                onClearMessages={handleClearMessages}
-                onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
-                renderCustomChatContent={renderCustomChatContent}
-                renderChatBoxAction={renderChatBoxAction}
+                customRequestMode={customRequestMode}
+                customRequestBody={customRequestBody}
+                onInputChange={handleInputChange}
+                onParameterToggle={handleParameterToggle}
+                onCloseSettings={() => setShowSettings(false)}
+                onConfigImport={handleConfigImport}
+                onConfigReset={handleConfigReset}
+                onCustomRequestModeChange={setCustomRequestMode}
+                onCustomRequestBodyChange={setCustomRequestBody}
+                previewPayload={previewPayload}
+                messages={message}
               />
+            </Layout.Sider>
+          )}
+
+          <Layout.Content className='relative flex-1 overflow-hidden'>
+            <div className='overflow-hidden flex flex-col lg:flex-row h-[calc(100vh-66px)] mt-[60px]'>
+              <div className='flex-1 flex flex-col'>
+                <ChatArea
+                  chatRef={chatRef}
+                  message={message}
+                  inputs={inputs}
+                  styleState={styleState}
+                  showDebugPanel={showDebugPanel}
+                  roleInfo={roleInfo}
+                  onMessageSend={onMessageSend}
+                  onMessageCopy={messageActions.handleMessageCopy}
+                  onMessageReset={messageActions.handleMessageReset}
+                  onMessageDelete={messageActions.handleMessageDelete}
+                  onStopGenerator={onStopGenerator}
+                  onClearMessages={handleClearMessages}
+                  onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
+                  renderCustomChatContent={renderCustomChatContent}
+                  renderChatBoxAction={renderChatBoxAction}
+                />
+              </div>
+
+              {/* 调试面板 - 桌面端 */}
+              {showDebugPanel && !isMobile && (
+                <div className='w-96 flex-shrink-0 h-full'>
+                  <OptimizedDebugPanel
+                    debugData={debugData}
+                    activeDebugTab={activeDebugTab}
+                    onActiveDebugTabChange={setActiveDebugTab}
+                    styleState={styleState}
+                    customRequestMode={customRequestMode}
+                  />
+                </div>
+              )}
             </div>
 
-            {/* 调试面板 - 桌面端 */}
-            {showDebugPanel && !isMobile && (
-              <div className='w-96 flex-shrink-0 h-full'>
+            {/* 调试面板 - 移动端覆盖层 */}
+            {showDebugPanel && isMobile && (
+              <div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
                 <OptimizedDebugPanel
                   debugData={debugData}
                   activeDebugTab={activeDebugTab}
                   onActiveDebugTabChange={setActiveDebugTab}
                   styleState={styleState}
+                  showDebugPanel={showDebugPanel}
+                  onCloseDebugPanel={() => setShowDebugPanel(false)}
                   customRequestMode={customRequestMode}
                 />
               </div>
             )}
-          </div>
-
-          {/* 调试面板 - 移动端覆盖层 */}
-          {showDebugPanel && isMobile && (
-            <div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
-              <OptimizedDebugPanel
-                debugData={debugData}
-                activeDebugTab={activeDebugTab}
-                onActiveDebugTabChange={setActiveDebugTab}
-                styleState={styleState}
-                showDebugPanel={showDebugPanel}
-                onCloseDebugPanel={() => setShowDebugPanel(false)}
-                customRequestMode={customRequestMode}
-              />
-            </div>
-          )}
 
-          {/* 浮动按钮 */}
-          <FloatingButtons
-            styleState={styleState}
-            showSettings={showSettings}
-            showDebugPanel={showDebugPanel}
-            onToggleSettings={() => setShowSettings(!showSettings)}
-            onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
-          />
-        </Layout.Content>
-      </Layout>
-    </div>
+            {/* 浮动按钮 */}
+            <FloatingButtons
+              styleState={styleState}
+              showSettings={showSettings}
+              showDebugPanel={showDebugPanel}
+              onToggleSettings={() => setShowSettings(!showSettings)}
+              onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
+            />
+          </Layout.Content>
+        </Layout>
+      </div>
     </PlaygroundProvider>
   );
 };

+ 3 - 3
web/src/pages/PrivacyPolicy/index.jsx

@@ -26,12 +26,12 @@ const PrivacyPolicy = () => {
 
   return (
     <DocumentRenderer
-      apiEndpoint="/api/privacy-policy"
+      apiEndpoint='/api/privacy-policy'
       title={t('隐私政策')}
-      cacheKey="privacy_policy"
+      cacheKey='privacy_policy'
       emptyMessage={t('加载隐私政策内容失败...')}
     />
   );
 };
 
-export default PrivacyPolicy;
+export default PrivacyPolicy;

+ 13 - 15
web/src/pages/Setting/Model/SettingGlobalModel.jsx

@@ -199,9 +199,9 @@ export default function SettingGlobalModel(props) {
                       'global.pass_through_request_enabled': value,
                     })
                   }
-                  extraText={
-                    t('开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启')
-                  }
+                  extraText={t(
+                    '开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启',
+                  )}
                 />
               </Col>
             </Row>
@@ -210,11 +210,7 @@ export default function SettingGlobalModel(props) {
                 <Form.TextArea
                   label={t('禁用思考处理的模型列表')}
                   field={'global.thinking_model_blacklist'}
-                  placeholder={
-                    t('例如:') +
-                    '\n' +
-                    thinkingExample
-                  }
+                  placeholder={t('例如:') + '\n' + thinkingExample}
                   rows={4}
                   rules={[
                     {
@@ -270,12 +266,12 @@ export default function SettingGlobalModel(props) {
 
               <Row style={{ marginTop: 10 }}>
                 <Col span={24}>
-	                  <Form.TextArea
-	                    label={t('参数配置')}
-	                    field={chatCompletionsToResponsesPolicyKey}
-	                    placeholder={
-	                      t('例如(指定渠道):') +
-	                      '\n' +
+                  <Form.TextArea
+                    label={t('参数配置')}
+                    field={chatCompletionsToResponsesPolicyKey}
+                    placeholder={
+                      t('例如(指定渠道):') +
+                      '\n' +
                       chatCompletionsToResponsesPolicyExample +
                       '\n\n' +
                       t('例如(全渠道):') +
@@ -370,7 +366,9 @@ export default function SettingGlobalModel(props) {
                 <Col span={24}>
                   <Banner
                     type='warning'
-                    description={t('警告:启用保活后,如果已经写入保活数据后渠道出错,系统无法重试,如果必须开启,推荐设置尽可能大的Ping间隔')}
+                    description={t(
+                      '警告:启用保活后,如果已经写入保活数据后渠道出错,系统无法重试,如果必须开启,推荐设置尽可能大的Ping间隔',
+                    )}
                   />
                 </Col>
               </Row>

+ 1 - 2
web/src/pages/Setting/Model/SettingGrokModel.jsx

@@ -49,8 +49,7 @@ export default function SettingGrokModel(props) {
       .validate()
       .then(() => {
         const updateArray = compareObjects(inputs, inputsRow);
-        if (!updateArray.length)
-          return showWarning(t('你似乎并没有修改什么'));
+        if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
 
         const requestQueue = updateArray.map((item) => {
           const value = String(inputs[item.key]);

+ 35 - 22
web/src/pages/Setting/Model/SettingModelDeployment.jsx

@@ -18,7 +18,15 @@ For commercial licensing, please contact [email protected]
 */
 
 import React, { useEffect, useState, useRef } from 'react';
-import { Button, Col, Form, Row, Spin, Card, Typography } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Col,
+  Form,
+  Row,
+  Spin,
+  Card,
+  Typography,
+} from '@douyinfe/semi-ui';
 import {
   compareObjects,
   API,
@@ -88,9 +96,7 @@ export default function SettingModelDeployment(props) {
         showError(t('网络连接失败,请检查网络设置或稍后重试'));
       } else {
         const rawMessage =
-          error?.response?.data?.message ||
-          error?.message ||
-          '';
+          error?.response?.data?.message || error?.message || '';
         const localizedMessage = rawMessage
           ? getLocalizedMessage(rawMessage)
           : t('未知错误');
@@ -104,7 +110,7 @@ export default function SettingModelDeployment(props) {
   function onSubmit() {
     const updateArray = compareObjects(inputs, inputsRow);
     if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
-    
+
     const requestQueue = updateArray.map((item) => {
       let value = String(inputs[item.key]);
       return API.put('/api/option/', {
@@ -112,7 +118,7 @@ export default function SettingModelDeployment(props) {
         value,
       });
     });
-    
+
     setLoading(true);
     Promise.all(requestQueue)
       .then((res) => {
@@ -141,7 +147,7 @@ export default function SettingModelDeployment(props) {
         'model_deployment.ionet.api_key': '',
         'model_deployment.ionet.enabled': false,
       };
-      
+
       const currentInputs = {};
       for (let key in defaultInputs) {
         if (props.options.hasOwnProperty(key)) {
@@ -150,7 +156,7 @@ export default function SettingModelDeployment(props) {
           currentInputs[key] = defaultInputs[key];
         }
       }
-      
+
       setInputs(currentInputs);
       setInputsRow(structuredClone(currentInputs));
       refForm.current?.setValues(currentInputs);
@@ -165,9 +171,11 @@ export default function SettingModelDeployment(props) {
           getFormApi={(formAPI) => (refForm.current = formAPI)}
           style={{ marginBottom: 15 }}
         >
-          <Form.Section 
+          <Form.Section
             text={
-              <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+              <div
+                style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
+              >
                 <span>{t('模型部署设置')}</span>
               </div>
             }
@@ -186,7 +194,9 @@ export default function SettingModelDeployment(props) {
 
             <Card
               title={
-                <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+                <div
+                  style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
+                >
                   <Cloud size={18} />
                   <span>io.net</span>
                 </div>
@@ -226,18 +236,16 @@ export default function SettingModelDeployment(props) {
                       }
                       disabled={!inputs['model_deployment.ionet.enabled']}
                       extraText={t('请使用 Project 为 io.cloud 的密钥')}
-                      mode="password"
+                      mode='password'
                     />
                     <div style={{ display: 'flex', gap: '12px' }}>
                       <Button
-                        type="outline"
-                        size="small"
+                        type='outline'
+                        size='small'
                         icon={<Zap size={16} />}
                         onClick={testApiKey}
                         loading={testing}
-                        disabled={
-                          !inputs['model_deployment.ionet.enabled']
-                        }
+                        disabled={!inputs['model_deployment.ionet.enabled']}
                         style={{
                           height: '32px',
                           fontSize: '13px',
@@ -271,7 +279,10 @@ export default function SettingModelDeployment(props) {
                     }}
                   >
                     <div>
-                      <Text strong style={{ display: 'block', marginBottom: '8px' }}>
+                      <Text
+                        strong
+                        style={{ display: 'block', marginBottom: '8px' }}
+                      >
                         {t('获取 io.net API Key')}
                       </Text>
                       <ul
@@ -287,14 +298,16 @@ export default function SettingModelDeployment(props) {
                         }}
                       >
                         <li>{t('访问 io.net 控制台的 API Keys 页面')}</li>
-                        <li>{t('创建或选择密钥时,将 Project 设置为 io.cloud')}</li>
+                        <li>
+                          {t('创建或选择密钥时,将 Project 设置为 io.cloud')}
+                        </li>
                         <li>{t('复制生成的密钥并粘贴到此处')}</li>
                       </ul>
                     </div>
                     <Button
                       icon={<ArrowUpRight size={16} />}
-                      type="primary"
-                      theme="solid"
+                      type='primary'
+                      theme='solid'
                       style={{ width: '100%' }}
                       onClick={() =>
                         window.open('https://ai.io.net/ai/api-keys', '_blank')
@@ -308,7 +321,7 @@ export default function SettingModelDeployment(props) {
             </Card>
 
             <Row>
-              <Button size='default' type="primary" onClick={onSubmit}>
+              <Button size='default' type='primary' onClick={onSubmit}>
                 {t('保存设置')}
               </Button>
             </Row>

+ 26 - 1
web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx

@@ -73,6 +73,7 @@ const RULE_TEMPLATES = {
     key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
     value_regex: '',
     ttl_seconds: 0,
+    skip_retry_on_failure: false,
     include_using_group: true,
     include_rule_name: true,
   },
@@ -83,6 +84,7 @@ const RULE_TEMPLATES = {
     key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
     value_regex: '',
     ttl_seconds: 0,
+    skip_retry_on_failure: false,
     include_using_group: true,
     include_rule_name: true,
   },
@@ -112,6 +114,7 @@ const RULES_JSON_PLACEHOLDER = `[
     ],
     "value_regex": "^[-0-9A-Za-z._:]{1,128}$",
     "ttl_seconds": 600,
+    "skip_retry_on_failure": false,
     "include_using_group": true,
     "include_rule_name": true
   }
@@ -153,7 +156,12 @@ const normalizeKeySource = (src) => {
   const type = (src?.type || '').trim();
   const key = (src?.key || '').trim();
   const path = (src?.path || '').trim();
-  return { type, key, path };
+
+  if (type === 'gjson') {
+    return { type, key: '', path };
+  }
+
+  return { type, key, path: '' };
 };
 
 const makeUniqueName = (existingNames, baseName) => {
@@ -229,6 +237,7 @@ export default function SettingsChannelAffinity(props) {
       user_agent_include_text: (r.user_agent_include || []).join('\n'),
       value_regex: r.value_regex || '',
       ttl_seconds: Number(r.ttl_seconds || 0),
+      skip_retry_on_failure: !!r.skip_retry_on_failure,
       include_using_group: r.include_using_group ?? true,
       include_rule_name: r.include_rule_name ?? true,
     };
@@ -523,6 +532,7 @@ export default function SettingsChannelAffinity(props) {
       key_sources: [{ type: 'gjson', path: '' }],
       value_regex: '',
       ttl_seconds: 0,
+      skip_retry_on_failure: false,
       include_using_group: true,
       include_rule_name: true,
     };
@@ -583,6 +593,9 @@ export default function SettingsChannelAffinity(props) {
         ttl_seconds: Number(values.ttl_seconds || 0),
         include_using_group: !!values.include_using_group,
         include_rule_name: !!values.include_rule_name,
+        ...(values.skip_retry_on_failure
+          ? { skip_retry_on_failure: true }
+          : {}),
         ...(userAgentInclude.length > 0
           ? { user_agent_include: userAgentInclude }
           : {}),
@@ -1041,6 +1054,18 @@ export default function SettingsChannelAffinity(props) {
                   </Text>
                 </Col>
               </Row>
+
+              <Row gutter={16}>
+                <Col xs={24} sm={12}>
+                  <Form.Switch
+                    field='skip_retry_on_failure'
+                    label={t('失败后不重试')}
+                  />
+                  <Text type='tertiary' size='small'>
+                    {t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')}
+                  </Text>
+                </Col>
+              </Row>
             </Collapse.Panel>
           </Collapse>
 

+ 3 - 1
web/src/pages/Setting/Operation/SettingsCreditLimit.jsx

@@ -172,7 +172,9 @@ export default function SettingsCreditLimit(props) {
                 <Form.Switch
                   label={t('对免费模型启用预消耗')}
                   field={'quota_setting.enable_free_model_pre_consume'}
-                  extraText={t('开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度')}
+                  extraText={t(
+                    '开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度',
+                  )}
                   onChange={(value) =>
                     setInputs({
                       ...inputs,

+ 3 - 8
web/src/pages/Setting/Operation/SettingsMonitoring.jsx

@@ -18,13 +18,7 @@ For commercial licensing, please contact [email protected]
 */
 
 import React, { useEffect, useState, useRef } from 'react';
-import {
-  Button,
-  Col,
-  Form,
-  Row,
-  Spin,
-} from '@douyinfe/semi-ui';
+import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
 import {
   compareObjects,
   API,
@@ -46,7 +40,8 @@ export default function SettingsMonitoring(props) {
     AutomaticEnableChannelEnabled: false,
     AutomaticDisableKeywords: '',
     AutomaticDisableStatusCodes: '401',
-    AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
+    AutomaticRetryStatusCodes:
+      '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
     'monitor_setting.auto_test_channel_enabled': false,
     'monitor_setting.auto_test_channel_minutes': 10,
   });

+ 5 - 1
web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx

@@ -252,7 +252,11 @@ export default function SettingsSidebarModulesAdmin(props) {
       modules: [
         { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
         { key: 'models', title: t('模型管理'), description: t('AI模型配置') },
-        { key: 'deployment', title: t('模型部署'), description: t('模型部署管理') },
+        {
+          key: 'deployment',
+          title: t('模型部署'),
+          description: t('模型部署管理'),
+        },
         {
           key: 'redemption',
           title: t('兑换码管理'),

+ 389 - 352
web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx

@@ -1,385 +1,422 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
 import React, { useEffect, useState, useRef } from 'react';
 import {
-    Banner,
-    Button,
-    Form,
-    Row,
-    Col,
-    Typography,
-    Spin,
-    Table,
-    Modal,
-    Input,
-    InputNumber,
-    Select,
+  Banner,
+  Button,
+  Form,
+  Row,
+  Col,
+  Typography,
+  Spin,
+  Table,
+  Modal,
+  Input,
+  InputNumber,
+  Select,
 } from '@douyinfe/semi-ui';
 const { Text } = Typography;
-import {
-    API,
-    showError,
-    showSuccess,
-} from '../../../helpers';
+import { API, showError, showSuccess } from '../../../helpers';
 import { useTranslation } from 'react-i18next';
 import { Plus, Trash2 } from 'lucide-react';
 
 export default function SettingsPaymentGatewayCreem(props) {
-    const { t } = useTranslation();
-    const [loading, setLoading] = useState(false);
-    const [inputs, setInputs] = useState({
-        CreemApiKey: '',
-        CreemWebhookSecret: '',
-        CreemProducts: '[]',
-        CreemTestMode: false,
-    });
-    const [originInputs, setOriginInputs] = useState({});
-    const [products, setProducts] = useState([]);
-    const [showProductModal, setShowProductModal] = useState(false);
-    const [editingProduct, setEditingProduct] = useState(null);
-    const [productForm, setProductForm] = useState({
-        name: '',
-        productId: '',
-        price: 0,
-        quota: 0,
-        currency: 'USD',
-    });
-    const formApiRef = useRef(null);
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [inputs, setInputs] = useState({
+    CreemApiKey: '',
+    CreemWebhookSecret: '',
+    CreemProducts: '[]',
+    CreemTestMode: false,
+  });
+  const [originInputs, setOriginInputs] = useState({});
+  const [products, setProducts] = useState([]);
+  const [showProductModal, setShowProductModal] = useState(false);
+  const [editingProduct, setEditingProduct] = useState(null);
+  const [productForm, setProductForm] = useState({
+    name: '',
+    productId: '',
+    price: 0,
+    quota: 0,
+    currency: 'USD',
+  });
+  const formApiRef = useRef(null);
 
-    useEffect(() => {
-        if (props.options && formApiRef.current) {
-            const currentInputs = {
-                CreemApiKey: props.options.CreemApiKey || '',
-                CreemWebhookSecret: props.options.CreemWebhookSecret || '',
-                CreemProducts: props.options.CreemProducts || '[]',
-                CreemTestMode: props.options.CreemTestMode === 'true',
-            };
-            setInputs(currentInputs);
-            setOriginInputs({ ...currentInputs });
-            formApiRef.current.setValues(currentInputs);
+  useEffect(() => {
+    if (props.options && formApiRef.current) {
+      const currentInputs = {
+        CreemApiKey: props.options.CreemApiKey || '',
+        CreemWebhookSecret: props.options.CreemWebhookSecret || '',
+        CreemProducts: props.options.CreemProducts || '[]',
+        CreemTestMode: props.options.CreemTestMode === 'true',
+      };
+      setInputs(currentInputs);
+      setOriginInputs({ ...currentInputs });
+      formApiRef.current.setValues(currentInputs);
 
-            // Parse products
-            try {
-                const parsedProducts = JSON.parse(currentInputs.CreemProducts);
-                setProducts(parsedProducts);
-            } catch (e) {
-                setProducts([]);
-            }
-        }
-    }, [props.options]);
+      // Parse products
+      try {
+        const parsedProducts = JSON.parse(currentInputs.CreemProducts);
+        setProducts(parsedProducts);
+      } catch (e) {
+        setProducts([]);
+      }
+    }
+  }, [props.options]);
 
-    const handleFormChange = (values) => {
-        setInputs(values);
-    };
+  const handleFormChange = (values) => {
+    setInputs(values);
+  };
 
-    const submitCreemSetting = async () => {
-        setLoading(true);
-        try {
-            const options = [];
+  const submitCreemSetting = async () => {
+    setLoading(true);
+    try {
+      const options = [];
 
-            if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
-                options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
-            }
+      if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
+        options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
+      }
 
-            if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
-                options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret });
-            }
+      if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
+        options.push({
+          key: 'CreemWebhookSecret',
+          value: inputs.CreemWebhookSecret,
+        });
+      }
 
-            // Save test mode setting
-            options.push({ key: 'CreemTestMode', value: inputs.CreemTestMode ? 'true' : 'false' });
+      // Save test mode setting
+      options.push({
+        key: 'CreemTestMode',
+        value: inputs.CreemTestMode ? 'true' : 'false',
+      });
 
-            // Save products as JSON string
-            options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
+      // Save products as JSON string
+      options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
 
-            // 发送请求
-            const requestQueue = options.map(opt =>
-                API.put('/api/option/', {
-                    key: opt.key,
-                    value: opt.value,
-                })
-            );
+      // 发送请求
+      const requestQueue = options.map((opt) =>
+        API.put('/api/option/', {
+          key: opt.key,
+          value: opt.value,
+        }),
+      );
 
-            const results = await Promise.all(requestQueue);
+      const results = await Promise.all(requestQueue);
 
-            // 检查所有请求是否成功
-            const errorResults = results.filter(res => !res.data.success);
-            if (errorResults.length > 0) {
-                errorResults.forEach(res => {
-                    showError(res.data.message);
-                });
-            } else {
-                showSuccess(t('更新成功'));
-                // 更新本地存储的原始值
-                setOriginInputs({ ...inputs });
-                props.refresh?.();
-            }
-        } catch (error) {
-            showError(t('更新失败'));
-        }
-        setLoading(false);
-    };
+      // 检查所有请求是否成功
+      const errorResults = results.filter((res) => !res.data.success);
+      if (errorResults.length > 0) {
+        errorResults.forEach((res) => {
+          showError(res.data.message);
+        });
+      } else {
+        showSuccess(t('更新成功'));
+        // 更新本地存储的原始值
+        setOriginInputs({ ...inputs });
+        props.refresh?.();
+      }
+    } catch (error) {
+      showError(t('更新失败'));
+    }
+    setLoading(false);
+  };
 
-    const openProductModal = (product = null) => {
-        if (product) {
-            setEditingProduct(product);
-            setProductForm({ ...product });
-        } else {
-            setEditingProduct(null);
-            setProductForm({
-                name: '',
-                productId: '',
-                price: 0,
-                quota: 0,
-                currency: 'USD',
-            });
-        }
-        setShowProductModal(true);
-    };
+  const openProductModal = (product = null) => {
+    if (product) {
+      setEditingProduct(product);
+      setProductForm({ ...product });
+    } else {
+      setEditingProduct(null);
+      setProductForm({
+        name: '',
+        productId: '',
+        price: 0,
+        quota: 0,
+        currency: 'USD',
+      });
+    }
+    setShowProductModal(true);
+  };
 
-    const closeProductModal = () => {
-        setShowProductModal(false);
-        setEditingProduct(null);
-        setProductForm({
-            name: '',
-            productId: '',
-            price: 0,
-            quota: 0,
-            currency: 'USD',
-        });
-    };
+  const closeProductModal = () => {
+    setShowProductModal(false);
+    setEditingProduct(null);
+    setProductForm({
+      name: '',
+      productId: '',
+      price: 0,
+      quota: 0,
+      currency: 'USD',
+    });
+  };
 
-    const saveProduct = () => {
-        if (!productForm.name || !productForm.productId || productForm.price <= 0 || productForm.quota <= 0 || !productForm.currency) {
-            showError(t('请填写完整的产品信息'));
-            return;
-        }
+  const saveProduct = () => {
+    if (
+      !productForm.name ||
+      !productForm.productId ||
+      productForm.price <= 0 ||
+      productForm.quota <= 0 ||
+      !productForm.currency
+    ) {
+      showError(t('请填写完整的产品信息'));
+      return;
+    }
 
-        let newProducts = [...products];
-        if (editingProduct) {
-            // 编辑现有产品
-            const index = newProducts.findIndex(p => p.productId === editingProduct.productId);
-            if (index !== -1) {
-                newProducts[index] = { ...productForm };
-            }
-        } else {
-            // 添加新产品
-            if (newProducts.find(p => p.productId === productForm.productId)) {
-                showError(t('产品ID已存在'));
-                return;
-            }
-            newProducts.push({ ...productForm });
-        }
+    let newProducts = [...products];
+    if (editingProduct) {
+      // 编辑现有产品
+      const index = newProducts.findIndex(
+        (p) => p.productId === editingProduct.productId,
+      );
+      if (index !== -1) {
+        newProducts[index] = { ...productForm };
+      }
+    } else {
+      // 添加新产品
+      if (newProducts.find((p) => p.productId === productForm.productId)) {
+        showError(t('产品ID已存在'));
+        return;
+      }
+      newProducts.push({ ...productForm });
+    }
 
-        setProducts(newProducts);
-        closeProductModal();
-    };
+    setProducts(newProducts);
+    closeProductModal();
+  };
 
-    const deleteProduct = (productId) => {
-        const newProducts = products.filter(p => p.productId !== productId);
-        setProducts(newProducts);
-    };
+  const deleteProduct = (productId) => {
+    const newProducts = products.filter((p) => p.productId !== productId);
+    setProducts(newProducts);
+  };
 
-    const columns = [
-        {
-            title: t('产品名称'),
-            dataIndex: 'name',
-            key: 'name',
-        },
-        {
-            title: t('产品ID'),
-            dataIndex: 'productId',
-            key: 'productId',
-        },
-        {
-            title: t('展示价格'),
-            dataIndex: 'price',
-            key: 'price',
-            render: (price, record) => `${record.currency === 'EUR' ? '€' : '$'}${price}`,
-        },
-        {
-            title: t('充值额度'),
-            dataIndex: 'quota',
-            key: 'quota',
-        },
-        {
-            title: t('操作'),
-            key: 'action',
-            render: (_, record) => (
-                <div className='flex gap-2'>
-                    <Button
-                        type='tertiary'
-                        size='small'
-                        onClick={() => openProductModal(record)}
-                    >
-                        {t('编辑')}
-                    </Button>
-                    <Button
-                        type='danger'
-                        theme='borderless'
-                        size='small'
-                        icon={<Trash2 size={14} />}
-                        onClick={() => deleteProduct(record.productId)}
-                    />
-                </div>
-            ),
-        },
-    ];
+  const columns = [
+    {
+      title: t('产品名称'),
+      dataIndex: 'name',
+      key: 'name',
+    },
+    {
+      title: t('产品ID'),
+      dataIndex: 'productId',
+      key: 'productId',
+    },
+    {
+      title: t('展示价格'),
+      dataIndex: 'price',
+      key: 'price',
+      render: (price, record) =>
+        `${record.currency === 'EUR' ? '€' : '$'}${price}`,
+    },
+    {
+      title: t('充值额度'),
+      dataIndex: 'quota',
+      key: 'quota',
+    },
+    {
+      title: t('操作'),
+      key: 'action',
+      render: (_, record) => (
+        <div className='flex gap-2'>
+          <Button
+            type='tertiary'
+            size='small'
+            onClick={() => openProductModal(record)}
+          >
+            {t('编辑')}
+          </Button>
+          <Button
+            type='danger'
+            theme='borderless'
+            size='small'
+            icon={<Trash2 size={14} />}
+            onClick={() => deleteProduct(record.productId)}
+          />
+        </div>
+      ),
+    },
+  ];
 
-    return (
-        <Spin spinning={loading}>
-            <Form
-                initValues={inputs}
-                onValueChange={handleFormChange}
-                getFormApi={(api) => (formApiRef.current = api)}
-            >
-                <Form.Section text={t('Creem 设置')}>
-                    <Text>
-                        {t('Creem 介绍')}
-                        <a
-                            href='https://creem.io'
-                            target='_blank'
-                            rel='noreferrer'
-                        >Creem Official Site</a>
-                        <br />
-                    </Text>
-                    <Banner
-                        type='info'
-                        description={t('Creem Setting Tips')}
-                    />
+  return (
+    <Spin spinning={loading}>
+      <Form
+        initValues={inputs}
+        onValueChange={handleFormChange}
+        getFormApi={(api) => (formApiRef.current = api)}
+      >
+        <Form.Section text={t('Creem 设置')}>
+          <Text>
+            {t('Creem 介绍')}
+            <a href='https://creem.io' target='_blank' rel='noreferrer'>
+              Creem Official Site
+            </a>
+            <br />
+          </Text>
+          <Banner type='info' description={t('Creem Setting Tips')} />
 
-                    <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
-                        <Col xs={24} sm={24} md={8} lg={8} xl={8}>
-                            <Form.Input
-                                field='CreemApiKey'
-                                label={t('API 密钥')}
-                                placeholder={t('Creem API 密钥,敏感信息不显示')}
-                                type='password'
-                            />
-                        </Col>
-                        <Col xs={24} sm={24} md={8} lg={8} xl={8}>
-                            <Form.Input
-                                field='CreemWebhookSecret'
-                                label={t('Webhook 密钥')}
-                                placeholder={t('用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示')}
-                                type='password'
-                            />
-                        </Col>
-                        <Col xs={24} sm={24} md={8} lg={8} xl={8}>
-                            <Form.Switch
-                                field='CreemTestMode'
-                                label={t('测试模式')}
-                                extraText={t('启用后将使用 Creem Test Mode')}
-                            />
-                        </Col>
-                    </Row>
+          <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
+            <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+              <Form.Input
+                field='CreemApiKey'
+                label={t('API 密钥')}
+                placeholder={t('Creem API 密钥,敏感信息不显示')}
+                type='password'
+              />
+            </Col>
+            <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+              <Form.Input
+                field='CreemWebhookSecret'
+                label={t('Webhook 密钥')}
+                placeholder={t(
+                  '用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示',
+                )}
+                type='password'
+              />
+            </Col>
+            <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+              <Form.Switch
+                field='CreemTestMode'
+                label={t('测试模式')}
+                extraText={t('启用后将使用 Creem Test Mode')}
+              />
+            </Col>
+          </Row>
 
-                    <div style={{ marginTop: 24 }}>
-                        <div className='flex justify-between items-center mb-4'>
-                            <Text strong>{t('产品配置')}</Text>
-                            <Button
-                                type='primary'
-                                icon={<Plus size={16} />}
-                                onClick={() => openProductModal()}
-                            >
-                                {t('添加产品')}
-                            </Button>
-                        </div>
+          <div style={{ marginTop: 24 }}>
+            <div className='flex justify-between items-center mb-4'>
+              <Text strong>{t('产品配置')}</Text>
+              <Button
+                type='primary'
+                icon={<Plus size={16} />}
+                onClick={() => openProductModal()}
+              >
+                {t('添加产品')}
+              </Button>
+            </div>
 
-                        <Table
-                            columns={columns}
-                            dataSource={products}
-                            pagination={false}
-                            empty={
-                                <div className='text-center py-8'>
-                                    <Text type='tertiary'>{t('暂无产品配置')}</Text>
-                                </div>
-                            }
-                        />
-                    </div>
+            <Table
+              columns={columns}
+              dataSource={products}
+              pagination={false}
+              empty={
+                <div className='text-center py-8'>
+                  <Text type='tertiary'>{t('暂无产品配置')}</Text>
+                </div>
+              }
+            />
+          </div>
 
-                    <Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
-                        {t('更新 Creem 设置')}
-                    </Button>
-                </Form.Section>
-            </Form>
+          <Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
+            {t('更新 Creem 设置')}
+          </Button>
+        </Form.Section>
+      </Form>
 
-            {/* 产品配置模态框 */}
-            <Modal
-                title={editingProduct ? t('编辑产品') : t('添加产品')}
-                visible={showProductModal}
-                onOk={saveProduct}
-                onCancel={closeProductModal}
-                maskClosable={false}
-                size='small'
-                centered
+      {/* 产品配置模态框 */}
+      <Modal
+        title={editingProduct ? t('编辑产品') : t('添加产品')}
+        visible={showProductModal}
+        onOk={saveProduct}
+        onCancel={closeProductModal}
+        maskClosable={false}
+        size='small'
+        centered
+      >
+        <div className='space-y-4'>
+          <div>
+            <Text strong className='block mb-2'>
+              {t('产品名称')}
+            </Text>
+            <Input
+              value={productForm.name}
+              onChange={(value) =>
+                setProductForm({ ...productForm, name: value })
+              }
+              placeholder={t('例如:基础套餐')}
+              size='large'
+            />
+          </div>
+          <div>
+            <Text strong className='block mb-2'>
+              {t('产品ID')}
+            </Text>
+            <Input
+              value={productForm.productId}
+              onChange={(value) =>
+                setProductForm({ ...productForm, productId: value })
+              }
+              placeholder={t('例如:prod_6I8rBerHpPxyoiU9WK4kot')}
+              size='large'
+              disabled={!!editingProduct}
+            />
+          </div>
+          <div>
+            <Text strong className='block mb-2'>
+              {t('货币')}
+            </Text>
+            <Select
+              value={productForm.currency}
+              onChange={(value) =>
+                setProductForm({ ...productForm, currency: value })
+              }
+              size='large'
+              className='w-full'
             >
-                <div className='space-y-4'>
-                    <div>
-                        <Text strong className='block mb-2'>
-                            {t('产品名称')}
-                        </Text>
-                        <Input
-                            value={productForm.name}
-                            onChange={(value) => setProductForm({ ...productForm, name: value })}
-                            placeholder={t('例如:基础套餐')}
-                            size='large'
-                        />
-                    </div>
-                    <div>
-                        <Text strong className='block mb-2'>
-                            {t('产品ID')}
-                        </Text>
-                        <Input
-                            value={productForm.productId}
-                            onChange={(value) => setProductForm({ ...productForm, productId: value })}
-                            placeholder={t('例如:prod_6I8rBerHpPxyoiU9WK4kot')}
-                            size='large'
-                            disabled={!!editingProduct}
-                        />
-                    </div>
-                    <div>
-                        <Text strong className='block mb-2'>
-                            {t('货币')}
-                        </Text>
-                        <Select
-                            value={productForm.currency}
-                            onChange={(value) => setProductForm({ ...productForm, currency: value })}
-                            size='large'
-                            className='w-full'
-                        >
-                            <Select.Option value='USD'>{t('USD (美元)')}</Select.Option>
-                            <Select.Option value='EUR'>{t('EUR (欧元)')}</Select.Option>
-                        </Select>
-                    </div>
-                    <div>
-                        <Text strong className='block mb-2'>
-                            {t('价格')} ({productForm.currency === 'EUR' ? t('欧元') : t('美元')})
-                        </Text>
-                        <InputNumber
-                            value={productForm.price}
-                            onChange={(value) => setProductForm({ ...productForm, price: value })}
-                            placeholder={t('例如:4.99')}
-                            min={0.01}
-                            precision={2}
-                            size='large'
-                            className='w-full'
-                            defaultValue={4.49}
-                        />
-                    </div>
-                    <div>
-                        <Text strong className='block mb-2'>
-                            {t('充值额度')}
-                        </Text>
-                        <InputNumber
-                            value={productForm.quota}
-                            onChange={(value) => setProductForm({ ...productForm, quota: value })}
-                            placeholder={t('例如:100000')}
-                            min={1}
-                            precision={0}
-                            size='large'
-                            className='w-full'
-                        />
-                    </div>
-                </div>
-            </Modal>
-        </Spin>
-    );
-}
+              <Select.Option value='USD'>{t('USD (美元)')}</Select.Option>
+              <Select.Option value='EUR'>{t('EUR (欧元)')}</Select.Option>
+            </Select>
+          </div>
+          <div>
+            <Text strong className='block mb-2'>
+              {t('价格')} (
+              {productForm.currency === 'EUR' ? t('欧元') : t('美元')})
+            </Text>
+            <InputNumber
+              value={productForm.price}
+              onChange={(value) =>
+                setProductForm({ ...productForm, price: value })
+              }
+              placeholder={t('例如:4.99')}
+              min={0.01}
+              precision={2}
+              size='large'
+              className='w-full'
+              defaultValue={4.49}
+            />
+          </div>
+          <div>
+            <Text strong className='block mb-2'>
+              {t('充值额度')}
+            </Text>
+            <InputNumber
+              value={productForm.quota}
+              onChange={(value) =>
+                setProductForm({ ...productForm, quota: value })
+              }
+              placeholder={t('例如:100000')}
+              min={1}
+              precision={0}
+              size='large'
+              className='w-full'
+            />
+          </div>
+        </div>
+      </Modal>
+    </Spin>
+  );
+}

+ 167 - 37
web/src/pages/Setting/Performance/SettingsPerformance.jsx

@@ -168,7 +168,8 @@ export default function SettingsPerformance(props) {
     for (let key in props.options) {
       if (Object.keys(inputs).includes(key)) {
         if (typeof inputs[key] === 'boolean') {
-          currentInputs[key] = props.options[key] === 'true' || props.options[key] === true;
+          currentInputs[key] =
+            props.options[key] === 'true' || props.options[key] === true;
         } else if (typeof inputs[key] === 'number') {
           currentInputs[key] = parseInt(props.options[key]) || inputs[key];
         } else {
@@ -184,9 +185,14 @@ export default function SettingsPerformance(props) {
     fetchStats();
   }, [props.options]);
 
-  const diskCacheUsagePercent = stats?.cache_stats?.disk_cache_max_bytes > 0
-    ? (stats.cache_stats.current_disk_usage_bytes / stats.cache_stats.disk_cache_max_bytes * 100).toFixed(1)
-    : 0;
+  const diskCacheUsagePercent =
+    stats?.cache_stats?.disk_cache_max_bytes > 0
+      ? (
+          (stats.cache_stats.current_disk_usage_bytes /
+            stats.cache_stats.disk_cache_max_bytes) *
+          100
+        ).toFixed(1)
+      : 0;
 
   return (
     <>
@@ -199,7 +205,9 @@ export default function SettingsPerformance(props) {
           <Form.Section text={t('磁盘缓存设置(磁盘换内存)')}>
             <Banner
               type='info'
-              description={t('启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。')}
+              description={t(
+                '启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。',
+              )}
               style={{ marginBottom: 16 }}
             />
             <Row gutter={16}>
@@ -211,7 +219,9 @@ export default function SettingsPerformance(props) {
                   size='default'
                   checkedText='|'
                   uncheckedText='〇'
-                  onChange={handleFieldChange('performance_setting.disk_cache_enabled')}
+                  onChange={handleFieldChange(
+                    'performance_setting.disk_cache_enabled',
+                  )}
                 />
               </Col>
               <Col xs={24} sm={12} md={8} lg={8} xl={8}>
@@ -221,7 +231,9 @@ export default function SettingsPerformance(props) {
                   extraText={t('请求体超过此大小时使用磁盘缓存')}
                   min={1}
                   max={1024}
-                  onChange={handleFieldChange('performance_setting.disk_cache_threshold_mb')}
+                  onChange={handleFieldChange(
+                    'performance_setting.disk_cache_threshold_mb',
+                  )}
                   disabled={!inputs['performance_setting.disk_cache_enabled']}
                 />
               </Col>
@@ -239,7 +251,9 @@ export default function SettingsPerformance(props) {
                   }
                   min={100}
                   max={102400}
-                  onChange={handleFieldChange('performance_setting.disk_cache_max_size_mb')}
+                  onChange={handleFieldChange(
+                    'performance_setting.disk_cache_max_size_mb',
+                  )}
                   disabled={!inputs['performance_setting.disk_cache_enabled']}
                 />
               </Col>
@@ -251,7 +265,9 @@ export default function SettingsPerformance(props) {
                     label={t('缓存目录')}
                     extraText={t('留空使用系统临时目录')}
                     placeholder={t('例如 /var/cache/new-api')}
-                    onChange={handleFieldChange('performance_setting.disk_cache_path')}
+                    onChange={handleFieldChange(
+                      'performance_setting.disk_cache_path',
+                    )}
                     showClear
                     disabled={!inputs['performance_setting.disk_cache_enabled']}
                   />
@@ -290,38 +306,98 @@ export default function SettingsPerformance(props) {
           {stats && (
             <>
               {/* 缓存使用情况 */}
-              <Row gutter={16} style={{ marginBottom: 16, display: 'flex', alignItems: 'stretch' }}>
+              <Row
+                gutter={16}
+                style={{
+                  marginBottom: 16,
+                  display: 'flex',
+                  alignItems: 'stretch',
+                }}
+              >
                 <Col xs={24} md={12} style={{ display: 'flex' }}>
-                  <div style={{ padding: 16, background: 'var(--semi-color-fill-0)', borderRadius: 8, flex: 1, display: 'flex', flexDirection: 'column' }}>
-                    <Text strong style={{ marginBottom: 8, display: 'block' }}>{t('请求体磁盘缓存')}</Text>
+                  <div
+                    style={{
+                      padding: 16,
+                      background: 'var(--semi-color-fill-0)',
+                      borderRadius: 8,
+                      flex: 1,
+                      display: 'flex',
+                      flexDirection: 'column',
+                    }}
+                  >
+                    <Text strong style={{ marginBottom: 8, display: 'block' }}>
+                      {t('请求体磁盘缓存')}
+                    </Text>
                     <Progress
                       percent={parseFloat(diskCacheUsagePercent)}
                       showInfo
                       style={{ marginBottom: 8 }}
-                      stroke={parseFloat(diskCacheUsagePercent) > 80 ? 'var(--semi-color-danger)' : 'var(--semi-color-primary)'}
+                      stroke={
+                        parseFloat(diskCacheUsagePercent) > 80
+                          ? 'var(--semi-color-danger)'
+                          : 'var(--semi-color-primary)'
+                      }
                     />
-                    <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
+                    <div
+                      style={{
+                        display: 'flex',
+                        justifyContent: 'space-between',
+                        marginBottom: 8,
+                      }}
+                    >
                       <Text type='tertiary'>
-                        {formatBytes(stats.cache_stats.current_disk_usage_bytes)} / {formatBytes(stats.cache_stats.disk_cache_max_bytes)}
+                        {formatBytes(
+                          stats.cache_stats.current_disk_usage_bytes,
+                        )}{' '}
+                        / {formatBytes(stats.cache_stats.disk_cache_max_bytes)}
                       </Text>
                       <Text type='tertiary'>
                         {t('活跃文件')}: {stats.cache_stats.active_disk_files}
                       </Text>
                     </div>
                     <div style={{ marginTop: 'auto' }}>
-                      <Tag color='blue'>{t('磁盘命中')}: {stats.cache_stats.disk_cache_hits}</Tag>
+                      <Tag color='blue'>
+                        {t('磁盘命中')}: {stats.cache_stats.disk_cache_hits}
+                      </Tag>
                     </div>
                   </div>
                 </Col>
                 <Col xs={24} md={12} style={{ display: 'flex' }}>
-                  <div style={{ padding: 16, background: 'var(--semi-color-fill-0)', borderRadius: 8, flex: 1, display: 'flex', flexDirection: 'column' }}>
-                    <Text strong style={{ marginBottom: 8, display: 'block' }}>{t('请求体内存缓存')}</Text>
-                    <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
-                      <Text>{t('当前缓存大小')}: {formatBytes(stats.cache_stats.current_memory_usage_bytes)}</Text>
-                      <Text>{t('活跃缓存数')}: {stats.cache_stats.active_memory_buffers}</Text>
+                  <div
+                    style={{
+                      padding: 16,
+                      background: 'var(--semi-color-fill-0)',
+                      borderRadius: 8,
+                      flex: 1,
+                      display: 'flex',
+                      flexDirection: 'column',
+                    }}
+                  >
+                    <Text strong style={{ marginBottom: 8, display: 'block' }}>
+                      {t('请求体内存缓存')}
+                    </Text>
+                    <div
+                      style={{
+                        display: 'flex',
+                        justifyContent: 'space-between',
+                        marginBottom: 8,
+                      }}
+                    >
+                      <Text>
+                        {t('当前缓存大小')}:{' '}
+                        {formatBytes(
+                          stats.cache_stats.current_memory_usage_bytes,
+                        )}
+                      </Text>
+                      <Text>
+                        {t('活跃缓存数')}:{' '}
+                        {stats.cache_stats.active_memory_buffers}
+                      </Text>
                     </div>
                     <div style={{ marginTop: 'auto' }}>
-                      <Tag color='green'>{t('内存命中')}: {stats.cache_stats.memory_cache_hits}</Tag>
+                      <Tag color='green'>
+                        {t('内存命中')}: {stats.cache_stats.memory_cache_hits}
+                      </Tag>
                     </div>
                   </div>
                 </Col>
@@ -331,20 +407,56 @@ export default function SettingsPerformance(props) {
               {stats.disk_space_info?.total > 0 && (
                 <Row gutter={16} style={{ marginBottom: 16 }}>
                   <Col span={24}>
-                    <div style={{ padding: 16, background: 'var(--semi-color-fill-0)', borderRadius: 8 }}>
-                      <Text strong style={{ marginBottom: 8, display: 'block' }}>{t('缓存目录磁盘空间')}</Text>
+                    <div
+                      style={{
+                        padding: 16,
+                        background: 'var(--semi-color-fill-0)',
+                        borderRadius: 8,
+                      }}
+                    >
+                      <Text
+                        strong
+                        style={{ marginBottom: 8, display: 'block' }}
+                      >
+                        {t('缓存目录磁盘空间')}
+                      </Text>
                       <Progress
-                        percent={parseFloat(stats.disk_space_info.used_percent.toFixed(1))}
+                        percent={parseFloat(
+                          stats.disk_space_info.used_percent.toFixed(1),
+                        )}
                         showInfo
                         style={{ marginBottom: 8 }}
-                        stroke={stats.disk_space_info.used_percent > 90 ? 'var(--semi-color-danger)' : stats.disk_space_info.used_percent > 70 ? 'var(--semi-color-warning)' : 'var(--semi-color-primary)'}
+                        stroke={
+                          stats.disk_space_info.used_percent > 90
+                            ? 'var(--semi-color-danger)'
+                            : stats.disk_space_info.used_percent > 70
+                              ? 'var(--semi-color-warning)'
+                              : 'var(--semi-color-primary)'
+                        }
                       />
-                      <div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
-                        <Text type='tertiary'>{t('已用')}: {formatBytes(stats.disk_space_info.used)}</Text>
-                        <Text type='tertiary'>{t('可用')}: {formatBytes(stats.disk_space_info.free)}</Text>
-                        <Text type='tertiary'>{t('总计')}: {formatBytes(stats.disk_space_info.total)}</Text>
+                      <div
+                        style={{
+                          display: 'flex',
+                          justifyContent: 'space-between',
+                          flexWrap: 'wrap',
+                          gap: 8,
+                        }}
+                      >
+                        <Text type='tertiary'>
+                          {t('已用')}: {formatBytes(stats.disk_space_info.used)}
+                        </Text>
+                        <Text type='tertiary'>
+                          {t('可用')}: {formatBytes(stats.disk_space_info.free)}
+                        </Text>
+                        <Text type='tertiary'>
+                          {t('总计')}:{' '}
+                          {formatBytes(stats.disk_space_info.total)}
+                        </Text>
                       </div>
-                      {stats.disk_space_info.free < inputs['performance_setting.disk_cache_max_size_mb'] * 1024 * 1024 && (
+                      {stats.disk_space_info.free <
+                        inputs['performance_setting.disk_cache_max_size_mb'] *
+                          1024 *
+                          1024 && (
                         <Banner
                           type='warning'
                           description={t('磁盘可用空间小于缓存最大总量设置')}
@@ -361,14 +473,32 @@ export default function SettingsPerformance(props) {
                 <Col span={24}>
                   <Descriptions
                     data={[
-                      { key: t('已分配内存'), value: formatBytes(stats.memory_stats.alloc) },
-                      { key: t('总分配内存'), value: formatBytes(stats.memory_stats.total_alloc) },
-                      { key: t('系统内存'), value: formatBytes(stats.memory_stats.sys) },
+                      {
+                        key: t('已分配内存'),
+                        value: formatBytes(stats.memory_stats.alloc),
+                      },
+                      {
+                        key: t('总分配内存'),
+                        value: formatBytes(stats.memory_stats.total_alloc),
+                      },
+                      {
+                        key: t('系统内存'),
+                        value: formatBytes(stats.memory_stats.sys),
+                      },
                       { key: t('GC 次数'), value: stats.memory_stats.num_gc },
-                      { key: t('Goroutine 数'), value: stats.memory_stats.num_goroutine },
+                      {
+                        key: t('Goroutine 数'),
+                        value: stats.memory_stats.num_goroutine,
+                      },
                       { key: t('缓存目录'), value: stats.disk_cache_info.path },
-                      { key: t('目录文件数'), value: stats.disk_cache_info.file_count },
-                      { key: t('目录总大小'), value: formatBytes(stats.disk_cache_info.total_size) },
+                      {
+                        key: t('目录文件数'),
+                        value: stats.disk_cache_info.file_count,
+                      },
+                      {
+                        key: t('目录总大小'),
+                        value: formatBytes(stats.disk_cache_info.total_size),
+                      },
                     ]}
                   />
                 </Col>

+ 4 - 1
web/src/pages/Setting/Ratio/GroupRatioSettings.jsx

@@ -205,7 +205,10 @@ export default function GroupRatioSettings(props) {
                 },
               ]}
               onChange={(value) =>
-                setInputs({ ...inputs, 'group_ratio_setting.group_special_usable_group': value })
+                setInputs({
+                  ...inputs,
+                  'group_ratio_setting.group_special_usable_group': value,
+                })
               }
             />
           </Col>

+ 3 - 3
web/src/pages/UserAgreement/index.jsx

@@ -26,12 +26,12 @@ const UserAgreement = () => {
 
   return (
     <DocumentRenderer
-      apiEndpoint="/api/user-agreement"
+      apiEndpoint='/api/user-agreement'
       title={t('用户协议')}
-      cacheKey="user_agreement"
+      cacheKey='user_agreement'
       emptyMessage={t('加载用户协议内容失败...')}
     />
   );
 };
 
-export default UserAgreement;
+export default UserAgreement;