mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-29 23:10:35 +00:00
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
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
// 根据启用状态注入可选内容
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
2
main.go
2
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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
}
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
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,
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
if (!imageEnabled) {
|
||||
Toast.warning({
|
||||
content: t('请先在设置中启用图片功能'),
|
||||
duration: 3,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('Failed to read image file:', reader.error);
|
||||
|
||||
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('粘贴图片失败'),
|
||||
duration: 2,
|
||||
});
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -18,8 +18,22 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -68,4 +68,3 @@ export default function HttpStatusCodeRulesInput(props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
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>
|
||||
|
||||
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
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>
|
||||
|
||||
@@ -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'>
|
||||
<Space wrap spacing='tight'>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() =>
|
||||
setCodexOAuthModalVisible(true)
|
||||
}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('Codex 授权')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleRefreshCodexCredential}
|
||||
loading={codexCredentialRefreshing}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('刷新凭证')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => formatJsonField('key')}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('格式化')}
|
||||
</Button>
|
||||
{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='primary'
|
||||
theme='outline'
|
||||
onClick={() =>
|
||||
setCodexOAuthModalVisible(true)
|
||||
type={
|
||||
!useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
disabled={isIonetLocked}
|
||||
onClick={() => {
|
||||
setUseManualInput(false);
|
||||
// 切换到文件上传模式时清空手动输入的密钥
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key', '');
|
||||
}
|
||||
handleInputChange('key', '');
|
||||
}}
|
||||
>
|
||||
{t('Codex 授权')}
|
||||
{t('文件上传')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleRefreshCodexCredential}
|
||||
loading={codexCredentialRefreshing}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('刷新凭证')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => formatJsonField('key')}
|
||||
disabled={isIonetLocked}
|
||||
type={
|
||||
useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
onClick={() => {
|
||||
setUseManualInput(true);
|
||||
// 切换到手动输入模式时清空文件上传相关状态
|
||||
setVertexKeys([]);
|
||||
setVertexFileList([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(
|
||||
'vertex_files',
|
||||
[],
|
||||
);
|
||||
}
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
vertex_files: [],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t('格式化')}
|
||||
{t('手动输入')}
|
||||
</Button>
|
||||
{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', '');
|
||||
}
|
||||
handleInputChange('key', '');
|
||||
}}
|
||||
>
|
||||
{t('文件上传')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type={useManualInput ? 'primary' : 'tertiary'}
|
||||
onClick={() => {
|
||||
setUseManualInput(true);
|
||||
// 切换到手动输入模式时清空文件上传相关状态
|
||||
setVertexKeys([]);
|
||||
setVertexFileList([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(
|
||||
'vertex_files',
|
||||
[],
|
||||
);
|
||||
}
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
vertex_files: [],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t('手动输入')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{batch && (
|
||||
<Banner
|
||||
|
||||
@@ -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}' +
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
// 分类模型:新获取的模型和已有模型
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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%' }}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
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>
|
||||
) : (
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 support@quantumnous.com
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 support@quantumnous.com
|
||||
*/
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
// 只在没有保存的消息时才更新默认消息
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -18,7 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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('兑换码管理'),
|
||||
|
||||
@@ -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 support@quantumnous.com
|
||||
*/
|
||||
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({
|
||||
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);
|
||||
|
||||
// Parse products
|
||||
try {
|
||||
const parsedProducts = JSON.parse(currentInputs.CreemProducts);
|
||||
setProducts(parsedProducts);
|
||||
} catch (e) {
|
||||
setProducts([]);
|
||||
}
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitCreemSetting = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [];
|
||||
|
||||
if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
|
||||
options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
|
||||
}
|
||||
|
||||
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 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 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 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 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);
|
||||
const saveProduct = () => {
|
||||
if (
|
||||
!productForm.name ||
|
||||
!productForm.productId ||
|
||||
productForm.price <= 0 ||
|
||||
productForm.quota <= 0 ||
|
||||
!productForm.currency
|
||||
) {
|
||||
showError(t('请填写完整的产品信息'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse products
|
||||
try {
|
||||
const parsedProducts = JSON.parse(currentInputs.CreemProducts);
|
||||
setProducts(parsedProducts);
|
||||
} catch (e) {
|
||||
setProducts([]);
|
||||
}
|
||||
}
|
||||
}, [props.options]);
|
||||
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 });
|
||||
}
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
setProducts(newProducts);
|
||||
closeProductModal();
|
||||
};
|
||||
|
||||
const submitCreemSetting = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [];
|
||||
const deleteProduct = (productId) => {
|
||||
const newProducts = products.filter((p) => p.productId !== productId);
|
||||
setProducts(newProducts);
|
||||
};
|
||||
|
||||
if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
|
||||
options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
|
||||
}
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
|
||||
options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret });
|
||||
}
|
||||
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')} />
|
||||
|
||||
// Save test mode setting
|
||||
options.push({ key: 'CreemTestMode', value: inputs.CreemTestMode ? 'true' : 'false' });
|
||||
<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>
|
||||
|
||||
// Save products as JSON string
|
||||
options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
|
||||
<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>
|
||||
|
||||
// 发送请求
|
||||
const requestQueue = options.map(opt =>
|
||||
API.put('/api/option/', {
|
||||
key: opt.key,
|
||||
value: opt.value,
|
||||
})
|
||||
);
|
||||
|
||||
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 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 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 });
|
||||
}
|
||||
|
||||
setProducts(newProducts);
|
||||
closeProductModal();
|
||||
};
|
||||
|
||||
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)}
|
||||
/>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={products}
|
||||
pagination={false}
|
||||
empty={
|
||||
<div className='text-center py-8'>
|
||||
<Text type='tertiary'>{t('暂无产品配置')}</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
<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
|
||||
>
|
||||
<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'
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user