mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 04:40:59 +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 != "",
|
"user_agreement_enabled": legalSetting.UserAgreement != "",
|
||||||
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
|
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
|
||||||
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
|
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
|
||||||
"_qn": "new-api",
|
"_qn": "new-api",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据启用状态注入可选内容
|
// 根据启用状态注入可选内容
|
||||||
|
|||||||
@@ -91,11 +91,11 @@ func GetPerformanceStats(c *gin.Context) {
|
|||||||
// 获取配置信息
|
// 获取配置信息
|
||||||
diskConfig := common.GetDiskCacheConfig()
|
diskConfig := common.GetDiskCacheConfig()
|
||||||
config := PerformanceConfig{
|
config := PerformanceConfig{
|
||||||
DiskCacheEnabled: diskConfig.Enabled,
|
DiskCacheEnabled: diskConfig.Enabled,
|
||||||
DiskCacheThresholdMB: diskConfig.ThresholdMB,
|
DiskCacheThresholdMB: diskConfig.ThresholdMB,
|
||||||
DiskCacheMaxSizeMB: diskConfig.MaxSizeMB,
|
DiskCacheMaxSizeMB: diskConfig.MaxSizeMB,
|
||||||
DiskCachePath: diskConfig.Path,
|
DiskCachePath: diskConfig.Path,
|
||||||
IsRunningInContainer: common.IsRunningInContainer(),
|
IsRunningInContainer: common.IsRunningInContainer(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取磁盘空间信息
|
// 获取磁盘空间信息
|
||||||
@@ -199,4 +199,3 @@ func getDiskCacheInfo() DiskCacheInfo {
|
|||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
|||||||
if openaiErr == nil {
|
if openaiErr == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if types.IsChannelError(openaiErr) {
|
if types.IsChannelError(openaiErr) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -514,6 +517,9 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError,
|
|||||||
if taskErr == nil {
|
if taskErr == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if retryTimes <= 0 {
|
if retryTimes <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -19,8 +19,8 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/model"
|
"github.com/QuantumNous/new-api/model"
|
||||||
"github.com/QuantumNous/new-api/router"
|
"github.com/QuantumNous/new-api/router"
|
||||||
"github.com/QuantumNous/new-api/service"
|
"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/ratio_setting"
|
||||||
_ "github.com/QuantumNous/new-api/setting/performance_setting" // 注册性能设置
|
|
||||||
|
|
||||||
"github.com/bytedance/gopkg/util/gopool"
|
"github.com/bytedance/gopkg/util/gopool"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
"github.com/QuantumNous/new-api/relay/constant"
|
"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/service"
|
||||||
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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) {
|
func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) {
|
||||||
|
originUsage := usage
|
||||||
if usage == nil {
|
if usage == nil {
|
||||||
usage = &dto.Usage{
|
usage = &dto.Usage{
|
||||||
PromptTokens: relayInfo.GetEstimatePromptTokens(),
|
PromptTokens: relayInfo.GetEstimatePromptTokens(),
|
||||||
@@ -228,6 +229,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
|||||||
extraContent = append(extraContent, "上游无计费信息")
|
extraContent = append(extraContent, "上游无计费信息")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if originUsage != nil {
|
||||||
|
service.ObserveChannelAffinityUsageCacheFromContext(ctx, usage)
|
||||||
|
}
|
||||||
|
|
||||||
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
|
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
|
||||||
|
|
||||||
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)
|
logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)
|
||||||
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
|
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
|
||||||
logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
|
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("/search", middleware.AdminAuth(), controller.SearchAllLogs)
|
||||||
logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
|
logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
|
||||||
logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs)
|
logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/pkg/cachex"
|
"github.com/QuantumNous/new-api/pkg/cachex"
|
||||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -21,14 +23,19 @@ const (
|
|||||||
ginKeyChannelAffinityTTLSeconds = "channel_affinity_ttl_seconds"
|
ginKeyChannelAffinityTTLSeconds = "channel_affinity_ttl_seconds"
|
||||||
ginKeyChannelAffinityMeta = "channel_affinity_meta"
|
ginKeyChannelAffinityMeta = "channel_affinity_meta"
|
||||||
ginKeyChannelAffinityLogInfo = "channel_affinity_log_info"
|
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 (
|
var (
|
||||||
channelAffinityCacheOnce sync.Once
|
channelAffinityCacheOnce sync.Once
|
||||||
channelAffinityCache *cachex.HybridCache[int]
|
channelAffinityCache *cachex.HybridCache[int]
|
||||||
|
|
||||||
|
channelAffinityUsageCacheStatsOnce sync.Once
|
||||||
|
channelAffinityUsageCacheStatsCache *cachex.HybridCache[ChannelAffinityUsageCacheCounters]
|
||||||
|
|
||||||
channelAffinityRegexCache sync.Map // map[string]*regexp.Regexp
|
channelAffinityRegexCache sync.Map // map[string]*regexp.Regexp
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,15 +43,24 @@ type channelAffinityMeta struct {
|
|||||||
CacheKey string
|
CacheKey string
|
||||||
TTLSeconds int
|
TTLSeconds int
|
||||||
RuleName string
|
RuleName string
|
||||||
|
SkipRetry bool
|
||||||
KeySourceType string
|
KeySourceType string
|
||||||
KeySourceKey string
|
KeySourceKey string
|
||||||
KeySourcePath string
|
KeySourcePath string
|
||||||
|
KeyHint string
|
||||||
KeyFingerprint string
|
KeyFingerprint string
|
||||||
UsingGroup string
|
UsingGroup string
|
||||||
ModelName string
|
ModelName string
|
||||||
RequestPath string
|
RequestPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChannelAffinityStatsContext struct {
|
||||||
|
RuleName string
|
||||||
|
UsingGroup string
|
||||||
|
KeyFingerprint string
|
||||||
|
TTLSeconds int64
|
||||||
|
}
|
||||||
|
|
||||||
type ChannelAffinityCacheStats struct {
|
type ChannelAffinityCacheStats struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
@@ -338,6 +354,32 @@ func getChannelAffinityMeta(c *gin.Context) (channelAffinityMeta, bool) {
|
|||||||
return meta, true
|
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 {
|
func affinityFingerprint(s string) string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -349,6 +391,19 @@ func affinityFingerprint(s string) string {
|
|||||||
return hex
|
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) {
|
func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) {
|
||||||
setting := operation_setting.GetChannelAffinitySetting()
|
setting := operation_setting.GetChannelAffinitySetting()
|
||||||
if setting == nil || !setting.Enabled {
|
if setting == nil || !setting.Enabled {
|
||||||
@@ -399,9 +454,11 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
|
|||||||
CacheKey: cacheKeyFull,
|
CacheKey: cacheKeyFull,
|
||||||
TTLSeconds: ttlSeconds,
|
TTLSeconds: ttlSeconds,
|
||||||
RuleName: rule.Name,
|
RuleName: rule.Name,
|
||||||
|
SkipRetry: rule.SkipRetryOnFailure,
|
||||||
KeySourceType: strings.TrimSpace(usedSource.Type),
|
KeySourceType: strings.TrimSpace(usedSource.Type),
|
||||||
KeySourceKey: strings.TrimSpace(usedSource.Key),
|
KeySourceKey: strings.TrimSpace(usedSource.Key),
|
||||||
KeySourcePath: strings.TrimSpace(usedSource.Path),
|
KeySourcePath: strings.TrimSpace(usedSource.Path),
|
||||||
|
KeyHint: buildChannelAffinityKeyHint(affinityValue),
|
||||||
KeyFingerprint: affinityFingerprint(affinityValue),
|
KeyFingerprint: affinityFingerprint(affinityValue),
|
||||||
UsingGroup: usingGroup,
|
UsingGroup: usingGroup,
|
||||||
ModelName: modelName,
|
ModelName: modelName,
|
||||||
@@ -422,6 +479,21 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
|
|||||||
return 0, false
|
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) {
|
func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) {
|
||||||
if c == nil || channelID <= 0 {
|
if c == nil || channelID <= 0 {
|
||||||
return
|
return
|
||||||
@@ -430,6 +502,7 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.Set(ginKeyChannelAffinitySkipRetry, meta.SkipRetry)
|
||||||
info := map[string]interface{}{
|
info := map[string]interface{}{
|
||||||
"reason": meta.RuleName,
|
"reason": meta.RuleName,
|
||||||
"rule_name": meta.RuleName,
|
"rule_name": meta.RuleName,
|
||||||
@@ -441,6 +514,7 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int
|
|||||||
"key_source": meta.KeySourceType,
|
"key_source": meta.KeySourceType,
|
||||||
"key_key": meta.KeySourceKey,
|
"key_key": meta.KeySourceKey,
|
||||||
"key_path": meta.KeySourcePath,
|
"key_path": meta.KeySourcePath,
|
||||||
|
"key_hint": meta.KeyHint,
|
||||||
"key_fp": meta.KeyFingerprint,
|
"key_fp": meta.KeyFingerprint,
|
||||||
}
|
}
|
||||||
c.Set(ginKeyChannelAffinityLogInfo, info)
|
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))
|
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"`
|
ValueRegex string `json:"value_regex"`
|
||||||
TTLSeconds int `json:"ttl_seconds"`
|
TTLSeconds int `json:"ttl_seconds"`
|
||||||
|
|
||||||
|
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
|
||||||
|
|
||||||
IncludeUsingGroup bool `json:"include_using_group"`
|
IncludeUsingGroup bool `json:"include_using_group"`
|
||||||
IncludeRuleName bool `json:"include_rule_name"`
|
IncludeRuleName bool `json:"include_rule_name"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,77 +21,66 @@ import { defineConfig } from 'i18next-cli';
|
|||||||
|
|
||||||
/** @type {import('i18next-cli').I18nextToolkitConfig} */
|
/** @type {import('i18next-cli').I18nextToolkitConfig} */
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
locales: [
|
locales: ['zh', 'en', 'fr', 'ru', 'ja', 'vi'],
|
||||||
"zh",
|
|
||||||
"en",
|
|
||||||
"fr",
|
|
||||||
"ru",
|
|
||||||
"ja",
|
|
||||||
"vi"
|
|
||||||
],
|
|
||||||
extract: {
|
extract: {
|
||||||
input: [
|
input: ['src/**/*.{js,jsx,ts,tsx}'],
|
||||||
"src/**/*.{js,jsx,ts,tsx}"
|
ignore: ['src/i18n/**/*'],
|
||||||
],
|
output: 'src/i18n/locales/{{language}}.json',
|
||||||
ignore: [
|
|
||||||
"src/i18n/**/*"
|
|
||||||
],
|
|
||||||
output: "src/i18n/locales/{{language}}.json",
|
|
||||||
ignoredAttributes: [
|
ignoredAttributes: [
|
||||||
"accept",
|
'accept',
|
||||||
"align",
|
'align',
|
||||||
"aria-label",
|
'aria-label',
|
||||||
"autoComplete",
|
'autoComplete',
|
||||||
"className",
|
'className',
|
||||||
"clipRule",
|
'clipRule',
|
||||||
"color",
|
'color',
|
||||||
"crossOrigin",
|
'crossOrigin',
|
||||||
"data-index",
|
'data-index',
|
||||||
"data-name",
|
'data-name',
|
||||||
"data-testid",
|
'data-testid',
|
||||||
"data-type",
|
'data-type',
|
||||||
"defaultActiveKey",
|
'defaultActiveKey',
|
||||||
"direction",
|
'direction',
|
||||||
"editorType",
|
'editorType',
|
||||||
"field",
|
'field',
|
||||||
"fill",
|
'fill',
|
||||||
"fillRule",
|
'fillRule',
|
||||||
"height",
|
'height',
|
||||||
"hoverStyle",
|
'hoverStyle',
|
||||||
"htmlType",
|
'htmlType',
|
||||||
"id",
|
'id',
|
||||||
"itemKey",
|
'itemKey',
|
||||||
"key",
|
'key',
|
||||||
"keyPrefix",
|
'keyPrefix',
|
||||||
"layout",
|
'layout',
|
||||||
"margin",
|
'margin',
|
||||||
"maxHeight",
|
'maxHeight',
|
||||||
"mode",
|
'mode',
|
||||||
"name",
|
'name',
|
||||||
"overflow",
|
'overflow',
|
||||||
"placement",
|
'placement',
|
||||||
"position",
|
'position',
|
||||||
"rel",
|
'rel',
|
||||||
"role",
|
'role',
|
||||||
"rowKey",
|
'rowKey',
|
||||||
"searchPosition",
|
'searchPosition',
|
||||||
"selectedStyle",
|
'selectedStyle',
|
||||||
"shape",
|
'shape',
|
||||||
"size",
|
'size',
|
||||||
"style",
|
'style',
|
||||||
"theme",
|
'theme',
|
||||||
"trigger",
|
'trigger',
|
||||||
"uploadTrigger",
|
'uploadTrigger',
|
||||||
"validateStatus",
|
'validateStatus',
|
||||||
"value",
|
'value',
|
||||||
"viewBox",
|
'viewBox',
|
||||||
"width"
|
'width',
|
||||||
],
|
],
|
||||||
sort: true,
|
sort: true,
|
||||||
disablePlurals: false,
|
disablePlurals: false,
|
||||||
removeUnusedKeys: false,
|
removeUnusedKeys: false,
|
||||||
nsSeparator: false,
|
nsSeparator: false,
|
||||||
keySeparator: false,
|
keySeparator: false,
|
||||||
mergeNamespaces: true
|
mergeNamespaces: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,15 @@ import {
|
|||||||
isPasskeySupported,
|
isPasskeySupported,
|
||||||
} from '../../helpers';
|
} from '../../helpers';
|
||||||
import Turnstile from 'react-turnstile';
|
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 Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||||
import TelegramLoginButton from 'react-telegram-login';
|
import TelegramLoginButton from 'react-telegram-login';
|
||||||
@@ -55,7 +63,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
|
|||||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||||
import TwoFAVerification from './TwoFAVerification';
|
import TwoFAVerification from './TwoFAVerification';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SiDiscord }from 'react-icons/si';
|
import { SiDiscord } from 'react-icons/si';
|
||||||
|
|
||||||
const LoginForm = () => {
|
const LoginForm = () => {
|
||||||
let navigate = useNavigate();
|
let navigate = useNavigate();
|
||||||
@@ -126,7 +134,7 @@ const LoginForm = () => {
|
|||||||
setTurnstileEnabled(true);
|
setTurnstileEnabled(true);
|
||||||
setTurnstileSiteKey(status.turnstile_site_key);
|
setTurnstileSiteKey(status.turnstile_site_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 status 获取用户协议和隐私政策的启用状态
|
// 从 status 获取用户协议和隐私政策的启用状态
|
||||||
setHasUserAgreement(status?.user_agreement_enabled || false);
|
setHasUserAgreement(status?.user_agreement_enabled || false);
|
||||||
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
||||||
@@ -514,7 +522,15 @@ const LoginForm = () => {
|
|||||||
theme='outline'
|
theme='outline'
|
||||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||||
type='tertiary'
|
type='tertiary'
|
||||||
icon={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
|
icon={
|
||||||
|
<SiDiscord
|
||||||
|
style={{
|
||||||
|
color: '#5865F2',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onClick={handleDiscordClick}
|
onClick={handleDiscordClick}
|
||||||
loading={discordLoading}
|
loading={discordLoading}
|
||||||
>
|
>
|
||||||
@@ -626,11 +642,11 @@ const LoginForm = () => {
|
|||||||
{t('隐私政策')}
|
{t('隐私政策')}
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!status.self_use_mode_enabled && (
|
{!status.self_use_mode_enabled && (
|
||||||
<div className='mt-6 text-center text-sm'>
|
<div className='mt-6 text-center text-sm'>
|
||||||
@@ -746,7 +762,9 @@ const LoginForm = () => {
|
|||||||
htmlType='submit'
|
htmlType='submit'
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
loading={loginLoading}
|
loading={loginLoading}
|
||||||
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
|
disabled={
|
||||||
|
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t('继续')}
|
{t('继续')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const isUrl = (content) => {
|
|||||||
// 检查是否为 HTML 内容
|
// 检查是否为 HTML 内容
|
||||||
const isHtmlContent = (content) => {
|
const isHtmlContent = (content) => {
|
||||||
if (!content || typeof content !== 'string') return false;
|
if (!content || typeof content !== 'string') return false;
|
||||||
|
|
||||||
// 检查是否包含HTML标签
|
// 检查是否包含HTML标签
|
||||||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
||||||
return htmlTagRegex.test(content);
|
return htmlTagRegex.test(content);
|
||||||
@@ -52,16 +52,16 @@ const sanitizeHtml = (html) => {
|
|||||||
// 创建一个临时元素来解析HTML
|
// 创建一个临时元素来解析HTML
|
||||||
const tempDiv = document.createElement('div');
|
const tempDiv = document.createElement('div');
|
||||||
tempDiv.innerHTML = html;
|
tempDiv.innerHTML = html;
|
||||||
|
|
||||||
// 提取样式
|
// 提取样式
|
||||||
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
||||||
.map(style => style.innerHTML)
|
.map((style) => style.innerHTML)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
// 提取body内容,如果没有body标签则使用全部内容
|
// 提取body内容,如果没有body标签则使用全部内容
|
||||||
const bodyContent = tempDiv.querySelector('body');
|
const bodyContent = tempDiv.querySelector('body');
|
||||||
const content = bodyContent ? bodyContent.innerHTML : html;
|
const content = bodyContent ? bodyContent.innerHTML : html;
|
||||||
|
|
||||||
return { content, styles };
|
return { content, styles };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
|||||||
// 处理HTML样式注入
|
// 处理HTML样式注入
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const styleId = `document-renderer-styles-${cacheKey}`;
|
const styleId = `document-renderer-styles-${cacheKey}`;
|
||||||
|
|
||||||
if (htmlStyles) {
|
if (htmlStyles) {
|
||||||
let styleEl = document.getElementById(styleId);
|
let styleEl = document.getElementById(styleId);
|
||||||
if (!styleEl) {
|
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'>
|
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
|
||||||
<Empty
|
<Empty
|
||||||
title={t('管理员未设置' + title + '内容')}
|
title={t('管理员未设置' + title + '内容')}
|
||||||
image={<IllustrationConstruction style={{ width: 150, height: 150 }} />}
|
image={
|
||||||
darkModeImage={<IllustrationConstructionDark style={{ width: 150, height: 150 }} />}
|
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
||||||
|
}
|
||||||
|
darkModeImage={
|
||||||
|
<IllustrationConstructionDark style={{ width: 150, height: 150 }} />
|
||||||
|
}
|
||||||
className='p-8'
|
className='p-8'
|
||||||
/>
|
/>
|
||||||
</div>
|
</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'>
|
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
|
||||||
<Card className='max-w-md w-full'>
|
<Card className='max-w-md w-full'>
|
||||||
<div className='text-center'>
|
<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'>
|
<p className='text-gray-600 mb-4'>
|
||||||
{t('管理员设置了外部链接,点击下方按钮访问')}
|
{t('管理员设置了外部链接,点击下方按钮访问')}
|
||||||
</p>
|
</p>
|
||||||
@@ -202,20 +208,22 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
|||||||
// 如果是 HTML 内容,直接渲染
|
// 如果是 HTML 内容,直接渲染
|
||||||
if (isHtmlContent(content)) {
|
if (isHtmlContent(content)) {
|
||||||
const { content: htmlContent, styles } = sanitizeHtml(content);
|
const { content: htmlContent, styles } = sanitizeHtml(content);
|
||||||
|
|
||||||
// 设置样式(如果有的话)
|
// 设置样式(如果有的话)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (styles && styles !== htmlStyles) {
|
if (styles && styles !== htmlStyles) {
|
||||||
setHtmlStyles(styles);
|
setHtmlStyles(styles);
|
||||||
}
|
}
|
||||||
}, [content, styles, htmlStyles]);
|
}, [content, styles, htmlStyles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='min-h-screen bg-gray-50'>
|
<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='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'>
|
<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'>
|
||||||
<div
|
{title}
|
||||||
|
</Title>
|
||||||
|
<div
|
||||||
className='prose prose-lg max-w-none'
|
className='prose prose-lg max-w-none'
|
||||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||||
/>
|
/>
|
||||||
@@ -230,7 +238,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
|||||||
<div className='min-h-screen bg-gray-50'>
|
<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='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'>
|
<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'>
|
<div className='prose prose-lg max-w-none'>
|
||||||
<MarkdownRenderer content={content} />
|
<MarkdownRenderer content={content} />
|
||||||
</div>
|
</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}
|
loading={true}
|
||||||
active
|
active
|
||||||
placeholder={
|
placeholder={
|
||||||
<Skeleton.Title
|
<Skeleton.Title style={{ width, height, borderRadius: 9999 }} />
|
||||||
style={{ width, height, borderRadius: 9999 }}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,7 +184,9 @@ const SkeletonWrapper = ({
|
|||||||
loading={true}
|
loading={true}
|
||||||
active
|
active
|
||||||
placeholder={
|
placeholder={
|
||||||
<Skeleton.Title style={{ width: width || 60, height: height || 12 }} />
|
<Skeleton.Title
|
||||||
|
style={{ width: width || 60, height: height || 12 }}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,9 +221,7 @@ const SkeletonWrapper = ({
|
|||||||
loading={true}
|
loading={true}
|
||||||
active
|
active
|
||||||
placeholder={
|
placeholder={
|
||||||
<Skeleton.Title
|
<Skeleton.Title style={{ width: labelWidth, height: TEXT_HEIGHT }} />
|
||||||
style={{ width: labelWidth, height: TEXT_HEIGHT }}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,8 +115,7 @@ const linkifyHtml = (html) => {
|
|||||||
if (part.startsWith('<')) return part;
|
if (part.startsWith('<')) return part;
|
||||||
return part.replace(
|
return part.replace(
|
||||||
linkRegex,
|
linkRegex,
|
||||||
(url) =>
|
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
|
||||||
`<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|||||||
@@ -30,64 +30,67 @@ const CustomInputRender = (props) => {
|
|||||||
detailProps;
|
detailProps;
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
const handlePaste = useCallback(async (e) => {
|
const handlePaste = useCallback(
|
||||||
const items = e.clipboardData?.items;
|
async (e) => {
|
||||||
if (!items) return;
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
|
|
||||||
if (item.type.indexOf('image') !== -1) {
|
|
||||||
e.preventDefault();
|
|
||||||
const file = item.getAsFile();
|
|
||||||
|
|
||||||
if (file) {
|
|
||||||
try {
|
|
||||||
if (!imageEnabled) {
|
|
||||||
Toast.warning({
|
|
||||||
content: t('请先在设置中启用图片功能'),
|
|
||||||
duration: 3,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
if (item.type.indexOf('image') !== -1) {
|
||||||
reader.onload = (event) => {
|
e.preventDefault();
|
||||||
const base64 = event.target.result;
|
const file = item.getAsFile();
|
||||||
|
|
||||||
if (onPasteImage) {
|
if (file) {
|
||||||
onPasteImage(base64);
|
try {
|
||||||
Toast.success({
|
if (!imageEnabled) {
|
||||||
content: t('图片已添加'),
|
Toast.warning({
|
||||||
duration: 2,
|
content: t('请先在设置中启用图片功能'),
|
||||||
});
|
duration: 3,
|
||||||
} else {
|
|
||||||
Toast.error({
|
|
||||||
content: t('无法添加图片'),
|
|
||||||
duration: 2,
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
const reader = new FileReader();
|
||||||
console.error('Failed to read image file:', reader.error);
|
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({
|
Toast.error({
|
||||||
content: t('粘贴图片失败'),
|
content: t('粘贴图片失败'),
|
||||||
duration: 2,
|
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(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|||||||
@@ -140,7 +140,9 @@ const CustomRequestEditor = ({
|
|||||||
{/* 提示信息 */}
|
{/* 提示信息 */}
|
||||||
<Banner
|
<Banner
|
||||||
type='warning'
|
type='warning'
|
||||||
description={t('启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。')}
|
description={t(
|
||||||
|
'启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。',
|
||||||
|
)}
|
||||||
icon={<AlertTriangle size={16} />}
|
icon={<AlertTriangle size={16} />}
|
||||||
className='!rounded-lg'
|
className='!rounded-lg'
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
@@ -201,7 +203,9 @@ const CustomRequestEditor = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Typography.Text className='text-xs text-gray-500 mt-2 block'>
|
<Typography.Text className='text-xs text-gray-500 mt-2 block'>
|
||||||
{t('请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。')}
|
{t(
|
||||||
|
'请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。',
|
||||||
|
)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -191,10 +191,7 @@ const DebugPanel = ({
|
|||||||
itemKey='response'
|
itemKey='response'
|
||||||
>
|
>
|
||||||
{debugData.sseMessages && debugData.sseMessages.length > 0 ? (
|
{debugData.sseMessages && debugData.sseMessages.length > 0 ? (
|
||||||
<SSEViewer
|
<SSEViewer sseData={debugData.sseMessages} title='response' />
|
||||||
sseData={debugData.sseMessages}
|
|
||||||
title='response'
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<CodeViewer
|
<CodeViewer
|
||||||
content={debugData.response}
|
content={debugData.response}
|
||||||
|
|||||||
@@ -18,8 +18,22 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Button, Tooltip, Toast, Collapse, Badge, Typography } from '@douyinfe/semi-ui';
|
import {
|
||||||
import { Copy, ChevronDown, ChevronUp, Zap, CheckCircle, XCircle } from 'lucide-react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { copy } from '../../helpers';
|
import { copy } from '../../helpers';
|
||||||
|
|
||||||
@@ -67,19 +81,19 @@ const SSEViewer = ({ sseData }) => {
|
|||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const total = parsedSSEData.length;
|
const total = parsedSSEData.length;
|
||||||
const errors = parsedSSEData.filter(item => item.error).length;
|
const errors = parsedSSEData.filter((item) => item.error).length;
|
||||||
const done = parsedSSEData.filter(item => item.isDone).length;
|
const done = parsedSSEData.filter((item) => item.isDone).length;
|
||||||
const valid = total - errors - done;
|
const valid = total - errors - done;
|
||||||
|
|
||||||
return { total, errors, done, valid };
|
return { total, errors, done, valid };
|
||||||
}, [parsedSSEData]);
|
}, [parsedSSEData]);
|
||||||
|
|
||||||
const handleToggleAll = useCallback(() => {
|
const handleToggleAll = useCallback(() => {
|
||||||
setExpandedKeys(prev => {
|
setExpandedKeys((prev) => {
|
||||||
if (prev.length === parsedSSEData.length) {
|
if (prev.length === parsedSSEData.length) {
|
||||||
return [];
|
return [];
|
||||||
} else {
|
} else {
|
||||||
return parsedSSEData.map(item => item.key);
|
return parsedSSEData.map((item) => item.key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [parsedSSEData]);
|
}, [parsedSSEData]);
|
||||||
@@ -87,7 +101,9 @@ const SSEViewer = ({ sseData }) => {
|
|||||||
const handleCopyAll = useCallback(async () => {
|
const handleCopyAll = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const allData = parsedSSEData
|
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');
|
.join('\n\n');
|
||||||
|
|
||||||
await copy(allData);
|
await copy(allData);
|
||||||
@@ -100,15 +116,20 @@ const SSEViewer = ({ sseData }) => {
|
|||||||
}
|
}
|
||||||
}, [parsedSSEData, t]);
|
}, [parsedSSEData, t]);
|
||||||
|
|
||||||
const handleCopySingle = useCallback(async (item) => {
|
const handleCopySingle = useCallback(
|
||||||
try {
|
async (item) => {
|
||||||
const textToCopy = item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw;
|
try {
|
||||||
await copy(textToCopy);
|
const textToCopy = item.parsed
|
||||||
Toast.success(t('已复制'));
|
? JSON.stringify(item.parsed, null, 2)
|
||||||
} catch (err) {
|
: item.raw;
|
||||||
Toast.error(t('复制失败'));
|
await copy(textToCopy);
|
||||||
}
|
Toast.success(t('已复制'));
|
||||||
}, [t]);
|
} catch (err) {
|
||||||
|
Toast.error(t('复制失败'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
const renderSSEItem = (item) => {
|
const renderSSEItem = (item) => {
|
||||||
if (item.isDone) {
|
if (item.isDone) {
|
||||||
@@ -158,18 +179,24 @@ const SSEViewer = ({ sseData }) => {
|
|||||||
{item.parsed?.choices?.[0] && (
|
{item.parsed?.choices?.[0] && (
|
||||||
<div className='flex flex-wrap gap-2 text-xs'>
|
<div className='flex flex-wrap gap-2 text-xs'>
|
||||||
{item.parsed.choices[0].delta?.content && (
|
{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 && (
|
{item.parsed.choices[0].delta?.reasoning_content && (
|
||||||
<Badge count={t('有 Reasoning')} type='warning' />
|
<Badge count={t('有 Reasoning')} type='warning' />
|
||||||
)}
|
)}
|
||||||
{item.parsed.choices[0].finish_reason && (
|
{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 && (
|
{item.parsed.usage && (
|
||||||
<Badge
|
<Badge
|
||||||
count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
|
count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
|
||||||
type='tertiary'
|
type='tertiary'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +221,9 @@ const SSEViewer = ({ sseData }) => {
|
|||||||
<Zap size={16} className='text-blue-500' />
|
<Zap size={16} className='text-blue-500' />
|
||||||
<Typography.Text strong>{t('SSE数据流')}</Typography.Text>
|
<Typography.Text strong>{t('SSE数据流')}</Typography.Text>
|
||||||
<Badge count={stats.total} type='primary' />
|
<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>
|
||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
@@ -208,14 +237,28 @@ const SSEViewer = ({ sseData }) => {
|
|||||||
{copied ? t('已复制') : t('复制全部')}
|
{copied ? t('已复制') : t('复制全部')}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content={expandedKeys.length === parsedSSEData.length ? t('全部收起') : t('全部展开')}>
|
<Tooltip
|
||||||
|
content={
|
||||||
|
expandedKeys.length === parsedSSEData.length
|
||||||
|
? t('全部收起')
|
||||||
|
: t('全部展开')
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<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'
|
size='small'
|
||||||
onClick={handleToggleAll}
|
onClick={handleToggleAll}
|
||||||
theme='borderless'
|
theme='borderless'
|
||||||
>
|
>
|
||||||
{expandedKeys.length === parsedSSEData.length ? t('收起') : t('展开')}
|
{expandedKeys.length === parsedSSEData.length
|
||||||
|
? t('收起')
|
||||||
|
: t('展开')}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,11 +285,16 @@ const SSEViewer = ({ sseData }) => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className='text-gray-600'>
|
<span className='text-gray-600'>
|
||||||
{item.parsed?.id || item.parsed?.object || t('SSE 事件')}
|
{item.parsed?.id ||
|
||||||
|
item.parsed?.object ||
|
||||||
|
t('SSE 事件')}
|
||||||
</span>
|
</span>
|
||||||
{item.parsed?.choices?.[0]?.delta && (
|
{item.parsed?.choices?.[0]?.delta && (
|
||||||
<span className='text-xs text-gray-400'>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -68,4 +68,3 @@ export default function HttpStatusCodeRulesInput(props) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const ModelDeploymentSetting = () => {
|
|||||||
'model_deployment.ionet.api_key': '',
|
'model_deployment.ionet.api_key': '',
|
||||||
'model_deployment.ionet.enabled': false,
|
'model_deployment.ionet.enabled': false,
|
||||||
};
|
};
|
||||||
|
|
||||||
data.forEach((item) => {
|
data.forEach((item) => {
|
||||||
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
|
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
|
||||||
newInputs[item.key] = toBoolean(item.value);
|
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,
|
AutomaticEnableChannelEnabled: false,
|
||||||
AutomaticDisableKeywords: '',
|
AutomaticDisableKeywords: '',
|
||||||
AutomaticDisableStatusCodes: '401',
|
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_enabled': false,
|
||||||
'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
|
'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
|
||||||
'checkin_setting.enabled': false,
|
'checkin_setting.enabled': false,
|
||||||
|
|||||||
@@ -378,13 +378,15 @@ const OtherSetting = () => {
|
|||||||
<Form.TextArea
|
<Form.TextArea
|
||||||
label={t('用户协议')}
|
label={t('用户协议')}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
'在此输入用户协议内容,支持 Markdown & HTML 代码',
|
'在此输入用户协议内容,支持 Markdown & HTML 代码',
|
||||||
)}
|
)}
|
||||||
field={LEGAL_USER_AGREEMENT_KEY}
|
field={LEGAL_USER_AGREEMENT_KEY}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||||
autosize={{ minRows: 6, maxRows: 12 }}
|
autosize={{ minRows: 6, maxRows: 12 }}
|
||||||
helpText={t('填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议')}
|
helpText={t(
|
||||||
|
'填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={submitUserAgreement}
|
onClick={submitUserAgreement}
|
||||||
@@ -401,7 +403,9 @@ const OtherSetting = () => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||||
autosize={{ minRows: 6, maxRows: 12 }}
|
autosize={{ minRows: 6, maxRows: 12 }}
|
||||||
helpText={t('填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策')}
|
helpText={t(
|
||||||
|
'填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={submitPrivacyPolicy}
|
onClick={submitPrivacyPolicy}
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ const RatioSetting = () => {
|
|||||||
if (success) {
|
if (success) {
|
||||||
let newInputs = {};
|
let newInputs = {};
|
||||||
data.forEach((item) => {
|
data.forEach((item) => {
|
||||||
if (
|
if (item.value.startsWith('{') || item.value.startsWith('[')) {
|
||||||
item.value.startsWith('{') || item.value.startsWith('[')
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -481,10 +481,14 @@ const SystemSetting = () => {
|
|||||||
const options = [];
|
const options = [];
|
||||||
|
|
||||||
if (originInputs['discord.client_id'] !== inputs['discord.client_id']) {
|
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 (
|
if (
|
||||||
originInputs['discord.client_secret'] !== inputs['discord.client_secret'] &&
|
originInputs['discord.client_secret'] !==
|
||||||
|
inputs['discord.client_secret'] &&
|
||||||
inputs['discord.client_secret'] !== ''
|
inputs['discord.client_secret'] !== ''
|
||||||
) {
|
) {
|
||||||
options.push({
|
options.push({
|
||||||
@@ -745,8 +749,8 @@ const SystemSetting = () => {
|
|||||||
rel='noreferrer'
|
rel='noreferrer'
|
||||||
>
|
>
|
||||||
new-api-worker
|
new-api-worker
|
||||||
</a>
|
</a>{' '}
|
||||||
{' '}{t('或其兼容new-api-worker格式的其他版本')}
|
{t('或其兼容new-api-worker格式的其他版本')}
|
||||||
</Text>
|
</Text>
|
||||||
<Row
|
<Row
|
||||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ const renderType = (type, record = {}, t) => {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
<div className='max-w-xs'>
|
<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 && (
|
{ionetMeta?.deployment_id && (
|
||||||
<div className='text-xs text-gray-500 mt-1'>
|
<div className='text-xs text-gray-500 mt-1'>
|
||||||
{t('部署 ID')}: {ionetMeta.deployment_id}
|
{t('部署 ID')}: {ionetMeta.deployment_id}
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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';
|
import { API, copy, showError, showSuccess } from '../../../../helpers';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -33,14 +40,21 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
const startOAuth = async () => {
|
const startOAuth = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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) {
|
if (!res?.data?.success) {
|
||||||
console.error('Codex OAuth start failed:', res?.data?.message);
|
console.error('Codex OAuth start failed:', res?.data?.message);
|
||||||
throw new Error(t('启动授权失败'));
|
throw new Error(t('启动授权失败'));
|
||||||
}
|
}
|
||||||
const url = res?.data?.data?.authorize_url || '';
|
const url = res?.data?.data?.authorize_url || '';
|
||||||
if (!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('响应缺少授权链接'));
|
throw new Error(t('响应缺少授权链接'));
|
||||||
}
|
}
|
||||||
setAuthorizeUrl(url);
|
setAuthorizeUrl(url);
|
||||||
@@ -106,7 +120,12 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
<Button theme='borderless' onClick={onCancel} disabled={loading}>
|
<Button theme='borderless' onClick={onCancel} disabled={loading}>
|
||||||
{t('取消')}
|
{t('取消')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button theme='solid' type='primary' onClick={completeOAuth} loading={loading}>
|
<Button
|
||||||
|
theme='solid'
|
||||||
|
type='primary'
|
||||||
|
onClick={completeOAuth}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
{t('生成并填入')}
|
{t('生成并填入')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -141,7 +160,9 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Text type='tertiary' size='small'>
|
<Text type='tertiary' size='small'>
|
||||||
{t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。')}
|
{t(
|
||||||
|
'说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
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';
|
import { API, showError } from '../../../../helpers';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -134,7 +141,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
{statusTag}
|
{statusTag}
|
||||||
<Button size='small' type='tertiary' theme='borderless' onClick={onRefresh}>
|
<Button
|
||||||
|
size='small'
|
||||||
|
type='tertiary'
|
||||||
|
theme='borderless'
|
||||||
|
onClick={onRefresh}
|
||||||
|
>
|
||||||
{tt('刷新')}
|
{tt('刷新')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,7 +255,12 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
|
|||||||
<div className='flex flex-col gap-3'>
|
<div className='flex flex-col gap-3'>
|
||||||
<Text type='danger'>{tt('获取用量失败')}</Text>
|
<Text type='danger'>{tt('获取用量失败')}</Text>
|
||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
<Button size='small' type='primary' theme='outline' onClick={fetchUsage}>
|
<Button
|
||||||
|
size='small'
|
||||||
|
type='primary'
|
||||||
|
theme='outline'
|
||||||
|
onClick={fetchUsage}
|
||||||
|
>
|
||||||
{tt('刷新')}
|
{tt('刷新')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2000,171 +2000,180 @@ const EditChannelModal = (props) => {
|
|||||||
autoComplete='new-password'
|
autoComplete='new-password'
|
||||||
onChange={(value) => handleInputChange('key', value)}
|
onChange={(value) => handleInputChange('key', value)}
|
||||||
disabled={isIonetLocked}
|
disabled={isIonetLocked}
|
||||||
extraText={
|
extraText={
|
||||||
<div className='flex items-center gap-2 flex-wrap'>
|
<div className='flex items-center gap-2 flex-wrap'>
|
||||||
{isEdit &&
|
{isEdit &&
|
||||||
isMultiKeyChannel &&
|
isMultiKeyChannel &&
|
||||||
keyMode === 'append' && (
|
keyMode === 'append' && (
|
||||||
<Text type='warning' size='small'>
|
<Text type='warning' size='small'>
|
||||||
{t(
|
{t(
|
||||||
'追加模式:新密钥将添加到现有密钥列表的末尾',
|
'追加模式:新密钥将添加到现有密钥列表的末尾',
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
|
{isEdit && (
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
type='primary'
|
||||||
|
theme='outline'
|
||||||
|
onClick={handleShow2FAModal}
|
||||||
|
>
|
||||||
|
{t('查看密钥')}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isEdit && (
|
{batchExtra}
|
||||||
<Button
|
</div>
|
||||||
size='small'
|
}
|
||||||
type='primary'
|
showClear
|
||||||
theme='outline'
|
/>
|
||||||
onClick={handleShow2FAModal}
|
)
|
||||||
>
|
) : (
|
||||||
{t('查看密钥')}
|
<>
|
||||||
</Button>
|
{inputs.type === 57 ? (
|
||||||
)}
|
<>
|
||||||
{batchExtra}
|
<Form.TextArea
|
||||||
</div>
|
field='key'
|
||||||
}
|
label={
|
||||||
showClear
|
isEdit
|
||||||
/>
|
? t('密钥(编辑模式下,保存的密钥不会显示)')
|
||||||
)
|
: t('密钥')
|
||||||
) : (
|
}
|
||||||
<>
|
placeholder={t(
|
||||||
{inputs.type === 57 ? (
|
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
|
||||||
<>
|
)}
|
||||||
<Form.TextArea
|
rules={
|
||||||
field='key'
|
isEdit
|
||||||
label={
|
? []
|
||||||
isEdit
|
: [
|
||||||
? t('密钥(编辑模式下,保存的密钥不会显示)')
|
{
|
||||||
: t('密钥')
|
required: true,
|
||||||
}
|
message: t('请输入密钥'),
|
||||||
placeholder={t(
|
},
|
||||||
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
|
]
|
||||||
)}
|
}
|
||||||
rules={
|
autoComplete='new-password'
|
||||||
isEdit
|
onChange={(value) =>
|
||||||
? []
|
handleInputChange('key', value)
|
||||||
: [{ required: true, message: t('请输入密钥') }]
|
}
|
||||||
}
|
disabled={isIonetLocked}
|
||||||
autoComplete='new-password'
|
extraText={
|
||||||
onChange={(value) => handleInputChange('key', value)}
|
<div className='flex flex-col gap-2'>
|
||||||
disabled={isIonetLocked}
|
<Text type='tertiary' size='small'>
|
||||||
extraText={
|
{t(
|
||||||
<div className='flex flex-col gap-2'>
|
'仅支持 JSON 对象,必须包含 access_token 与 account_id',
|
||||||
<Text type='tertiary' size='small'>
|
)}
|
||||||
{t(
|
</Text>
|
||||||
'仅支持 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
|
<Button
|
||||||
size='small'
|
size='small'
|
||||||
type='primary'
|
type={
|
||||||
theme='outline'
|
!useManualInput ? 'primary' : 'tertiary'
|
||||||
onClick={() =>
|
|
||||||
setCodexOAuthModalVisible(true)
|
|
||||||
}
|
}
|
||||||
disabled={isIonetLocked}
|
onClick={() => {
|
||||||
|
setUseManualInput(false);
|
||||||
|
// 切换到文件上传模式时清空手动输入的密钥
|
||||||
|
if (formApiRef.current) {
|
||||||
|
formApiRef.current.setValue('key', '');
|
||||||
|
}
|
||||||
|
handleInputChange('key', '');
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('Codex 授权')}
|
{t('文件上传')}
|
||||||
</Button>
|
</Button>
|
||||||
{isEdit && (
|
|
||||||
<Button
|
|
||||||
size='small'
|
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
onClick={handleRefreshCodexCredential}
|
|
||||||
loading={codexCredentialRefreshing}
|
|
||||||
disabled={isIonetLocked}
|
|
||||||
>
|
|
||||||
{t('刷新凭证')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
size='small'
|
size='small'
|
||||||
type='primary'
|
type={
|
||||||
theme='outline'
|
useManualInput ? 'primary' : 'tertiary'
|
||||||
onClick={() => formatJsonField('key')}
|
}
|
||||||
disabled={isIonetLocked}
|
onClick={() => {
|
||||||
|
setUseManualInput(true);
|
||||||
|
// 切换到手动输入模式时清空文件上传相关状态
|
||||||
|
setVertexKeys([]);
|
||||||
|
setVertexFileList([]);
|
||||||
|
if (formApiRef.current) {
|
||||||
|
formApiRef.current.setValue(
|
||||||
|
'vertex_files',
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setInputs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
vertex_files: [],
|
||||||
|
}));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('格式化')}
|
{t('手动输入')}
|
||||||
</Button>
|
</Button>
|
||||||
{isEdit && (
|
|
||||||
<Button
|
|
||||||
size='small'
|
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
onClick={handleShow2FAModal}
|
|
||||||
disabled={isIonetLocked}
|
|
||||||
>
|
|
||||||
{t('查看密钥')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{batchExtra}
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</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 && (
|
{batch && (
|
||||||
<Banner
|
<Banner
|
||||||
|
|||||||
@@ -533,7 +533,11 @@ const EditTagModal = (props) => {
|
|||||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||||
{/* Header: Advanced Settings */}
|
{/* Header: Advanced Settings */}
|
||||||
<div className='flex items-center mb-2'>
|
<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} />
|
<IconSetting size={16} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
@@ -549,9 +553,7 @@ const EditTagModal = (props) => {
|
|||||||
field='param_override'
|
field='param_override'
|
||||||
label={t('参数覆盖')}
|
label={t('参数覆盖')}
|
||||||
placeholder={
|
placeholder={
|
||||||
t(
|
t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
|
||||||
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
|
|
||||||
) +
|
|
||||||
'\n' +
|
'\n' +
|
||||||
t('旧格式(直接覆盖):') +
|
t('旧格式(直接覆盖):') +
|
||||||
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
|
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ const ModelSelectModal = ({
|
|||||||
}, [normalizedRedirectModels, normalizedSelectedSet]);
|
}, [normalizedRedirectModels, normalizedSelectedSet]);
|
||||||
|
|
||||||
const filteredModels = models.filter((m) =>
|
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',
|
type = 'danger',
|
||||||
deployment,
|
deployment,
|
||||||
t,
|
t,
|
||||||
loading = false
|
loading = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [confirmText, setConfirmText] = useState('');
|
const [confirmText, setConfirmText] = useState('');
|
||||||
|
|
||||||
@@ -66,17 +66,17 @@ const ConfirmationDialog = ({
|
|||||||
okButtonProps={{
|
okButtonProps={{
|
||||||
disabled: !isConfirmed,
|
disabled: !isConfirmed,
|
||||||
type: type === 'danger' ? 'danger' : 'primary',
|
type: type === 'danger' ? 'danger' : 'primary',
|
||||||
loading
|
loading,
|
||||||
}}
|
}}
|
||||||
width={480}
|
width={480}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className='space-y-4'>
|
||||||
<Text type="danger" strong>
|
<Text type='danger' strong>
|
||||||
{t('此操作具有风险,请确认要继续执行')}。
|
{t('此操作具有风险,请确认要继续执行')}。
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
{t('请输入部署名称以完成二次确认')}:
|
{t('请输入部署名称以完成二次确认')}:
|
||||||
<Text code className="ml-1">
|
<Text code className='ml-1'>
|
||||||
{requiredText || t('未知部署')}
|
{requiredText || t('未知部署')}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
@@ -87,7 +87,7 @@ const ConfirmationDialog = ({
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{!isConfirmed && confirmText && (
|
{!isConfirmed && confirmText && (
|
||||||
<Text type="danger" size="small">
|
<Text type='danger' size='small'>
|
||||||
{t('部署名称不匹配,请检查后重新输入')}
|
{t('部署名称不匹配,请检查后重新输入')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -130,9 +130,7 @@ const ExtendDurationModal = ({
|
|||||||
? details.locations
|
? details.locations
|
||||||
.map((location) =>
|
.map((location) =>
|
||||||
Number(
|
Number(
|
||||||
location?.id ??
|
location?.id ?? location?.location_id ?? location?.locationId,
|
||||||
location?.location_id ??
|
|
||||||
location?.locationId,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.filter((id) => Number.isInteger(id) && id > 0)
|
.filter((id) => Number.isInteger(id) && id > 0)
|
||||||
@@ -181,9 +179,7 @@ const ExtendDurationModal = ({
|
|||||||
} else {
|
} else {
|
||||||
const message = response.data.message || '';
|
const message = response.data.message || '';
|
||||||
setPriceEstimation(null);
|
setPriceEstimation(null);
|
||||||
setPriceError(
|
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
|
||||||
t('价格计算失败') + (message ? `: ${message}` : ''),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (costRequestIdRef.current !== requestId) {
|
if (costRequestIdRef.current !== requestId) {
|
||||||
@@ -192,9 +188,7 @@ const ExtendDurationModal = ({
|
|||||||
|
|
||||||
const message = error?.response?.data?.message || error.message || '';
|
const message = error?.response?.data?.message || error.message || '';
|
||||||
setPriceEstimation(null);
|
setPriceEstimation(null);
|
||||||
setPriceError(
|
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
|
||||||
t('价格计算失败') + (message ? `: ${message}` : ''),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (costRequestIdRef.current === requestId) {
|
if (costRequestIdRef.current === requestId) {
|
||||||
setCostLoading(false);
|
setCostLoading(false);
|
||||||
@@ -269,11 +263,8 @@ const ExtendDurationModal = ({
|
|||||||
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
|
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
|
||||||
|
|
||||||
const priceData = priceEstimation || {};
|
const priceData = priceEstimation || {};
|
||||||
const breakdown =
|
const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {};
|
||||||
priceData.price_breakdown || priceData.PriceBreakdown || {};
|
const currencyLabel = (priceData.currency || priceData.Currency || 'USDC')
|
||||||
const currencyLabel = (
|
|
||||||
priceData.currency || priceData.Currency || 'USDC'
|
|
||||||
)
|
|
||||||
.toString()
|
.toString()
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
@@ -316,7 +307,10 @@ const ExtendDurationModal = ({
|
|||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
okButtonProps={{
|
okButtonProps={{
|
||||||
disabled:
|
disabled:
|
||||||
!deployment?.id || detailsLoading || !durationHours || durationHours < 1,
|
!deployment?.id ||
|
||||||
|
detailsLoading ||
|
||||||
|
!durationHours ||
|
||||||
|
durationHours < 1,
|
||||||
}}
|
}}
|
||||||
width={600}
|
width={600}
|
||||||
className='extend-duration-modal'
|
className='extend-duration-modal'
|
||||||
@@ -357,9 +351,7 @@ const ExtendDurationModal = ({
|
|||||||
<p>
|
<p>
|
||||||
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
|
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>{t('延长操作一旦确认无法撤销,费用将立即扣除。')}</p>
|
||||||
{t('延长操作一旦确认无法撤销,费用将立即扣除。')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -370,7 +362,9 @@ const ExtendDurationModal = ({
|
|||||||
onValueChange={(values) => {
|
onValueChange={(values) => {
|
||||||
if (values.duration_hours !== undefined) {
|
if (values.duration_hours !== undefined) {
|
||||||
const numericValue = Number(values.duration_hours);
|
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>
|
||||||
<Col span={24} style={{ display: values.group === 'auto' ? 'block' : 'none' }}>
|
<Col
|
||||||
|
span={24}
|
||||||
|
style={{
|
||||||
|
display: values.group === 'auto' ? 'block' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Form.Switch
|
<Form.Switch
|
||||||
field='cross_group_retry'
|
field='cross_group_retry'
|
||||||
label={t('跨分组重试')}
|
label={t('跨分组重试')}
|
||||||
@@ -561,7 +566,9 @@ const EditTokenModal = (props) => {
|
|||||||
placeholder={t('允许的IP,一行一个,不填写则不限制')}
|
placeholder={t('允许的IP,一行一个,不填写则不限制')}
|
||||||
autosize
|
autosize
|
||||||
rows={1}
|
rows={1}
|
||||||
extraText={t('请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用')}
|
extraText={t(
|
||||||
|
'请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用',
|
||||||
|
)}
|
||||||
showClear
|
showClear
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
Button,
|
||||||
Space,
|
Space,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -71,6 +72,34 @@ function formatRatio(ratio) {
|
|||||||
return String(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
|
// Render functions
|
||||||
function renderType(type, t) {
|
function renderType(type, t) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -250,6 +279,7 @@ export const getLogsColumns = ({
|
|||||||
COLUMN_KEYS,
|
COLUMN_KEYS,
|
||||||
copyText,
|
copyText,
|
||||||
showUserInfoFunc,
|
showUserInfoFunc,
|
||||||
|
openChannelAffinityUsageCacheModal,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
}) => {
|
}) => {
|
||||||
return [
|
return [
|
||||||
@@ -532,42 +562,39 @@ export const getLogsColumns = ({
|
|||||||
return isAdminUser ? (
|
return isAdminUser ? (
|
||||||
<Space>
|
<Space>
|
||||||
<div>{content}</div>
|
<div>{content}</div>
|
||||||
{affinity ? (
|
{affinity ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
<div style={{ lineHeight: 1.6 }}>
|
<div>
|
||||||
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
|
{buildChannelAffinityTooltip(affinity, t)}
|
||||||
<div>
|
<div style={{ marginTop: 6 }}>
|
||||||
<Typography.Text type='secondary'>
|
<Button
|
||||||
{t('规则')}:{affinity.rule_name || '-'}
|
theme='borderless'
|
||||||
</Typography.Text>
|
size='small'
|
||||||
</div>
|
onClick={(e) => {
|
||||||
<div>
|
e.stopPropagation();
|
||||||
<Typography.Text type='secondary'>
|
openChannelAffinityUsageCacheModal?.(affinity);
|
||||||
{t('分组')}:{affinity.selected_group || '-'}
|
}}
|
||||||
</Typography.Text>
|
>
|
||||||
</div>
|
{t('查看详情')}
|
||||||
<div>
|
</Button>
|
||||||
<Typography.Text type='secondary'>
|
|
||||||
{t('Key')}:
|
|
||||||
{(affinity.key_source || '-') +
|
|
||||||
':' +
|
|
||||||
(affinity.key_path || affinity.key_key || '-') +
|
|
||||||
(affinity.key_fp ? `#${affinity.key_fp}` : '')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
|
<Tag
|
||||||
<span className='channel-affinity-tag-content'>
|
className='channel-affinity-tag'
|
||||||
<IconStarStroked style={{ fontSize: 13 }} />
|
color='cyan'
|
||||||
{t('优选')}
|
shape='circle'
|
||||||
</span>
|
>
|
||||||
</Tag>
|
<span className='channel-affinity-tag-content'>
|
||||||
</span>
|
<IconStarStroked style={{ fontSize: 13 }} />
|
||||||
</Tooltip>
|
{t('优选')}
|
||||||
|
</span>
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const LogsTable = (logsData) => {
|
|||||||
handlePageSizeChange,
|
handlePageSizeChange,
|
||||||
copyText,
|
copyText,
|
||||||
showUserInfoFunc,
|
showUserInfoFunc,
|
||||||
|
openChannelAffinityUsageCacheModal,
|
||||||
hasExpandableRows,
|
hasExpandableRows,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
t,
|
t,
|
||||||
@@ -53,9 +54,17 @@ const LogsTable = (logsData) => {
|
|||||||
COLUMN_KEYS,
|
COLUMN_KEYS,
|
||||||
copyText,
|
copyText,
|
||||||
showUserInfoFunc,
|
showUserInfoFunc,
|
||||||
|
openChannelAffinityUsageCacheModal,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
});
|
});
|
||||||
}, [t, COLUMN_KEYS, copyText, showUserInfoFunc, isAdminUser]);
|
}, [
|
||||||
|
t,
|
||||||
|
COLUMN_KEYS,
|
||||||
|
copyText,
|
||||||
|
showUserInfoFunc,
|
||||||
|
openChannelAffinityUsageCacheModal,
|
||||||
|
isAdminUser,
|
||||||
|
]);
|
||||||
|
|
||||||
// Filter columns based on visibility settings
|
// Filter columns based on visibility settings
|
||||||
const getVisibleColumns = () => {
|
const getVisibleColumns = () => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import LogsActions from './UsageLogsActions';
|
|||||||
import LogsFilters from './UsageLogsFilters';
|
import LogsFilters from './UsageLogsFilters';
|
||||||
import ColumnSelectorModal from './modals/ColumnSelectorModal';
|
import ColumnSelectorModal from './modals/ColumnSelectorModal';
|
||||||
import UserInfoModal from './modals/UserInfoModal';
|
import UserInfoModal from './modals/UserInfoModal';
|
||||||
|
import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
|
||||||
import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
|
import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
|
||||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||||
import { createCardProPagination } from '../../../helpers/utils';
|
import { createCardProPagination } from '../../../helpers/utils';
|
||||||
@@ -37,6 +38,7 @@ const LogsPage = () => {
|
|||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<ColumnSelectorModal {...logsData} />
|
<ColumnSelectorModal {...logsData} />
|
||||||
<UserInfoModal {...logsData} />
|
<UserInfoModal {...logsData} />
|
||||||
|
<ChannelAffinityUsageCacheModal {...logsData} />
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<CardPro
|
<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 onlineFormApiRef = useRef(null);
|
||||||
const redeemFormApiRef = useRef(null);
|
const redeemFormApiRef = useRef(null);
|
||||||
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
|
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
|
||||||
console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts);
|
console.log(
|
||||||
|
' enabled screem ?',
|
||||||
|
enableCreemTopUp,
|
||||||
|
' products ?',
|
||||||
|
creemProducts,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||||
{/* 卡片头部 */}
|
{/* 卡片头部 */}
|
||||||
@@ -503,7 +508,8 @@ const RechargeCard = ({
|
|||||||
{t('充值额度')}: {product.quota}
|
{t('充值额度')}: {product.quota}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-lg font-semibold text-blue-600'>
|
<div className='text-lg font-semibold text-blue-600'>
|
||||||
{product.currency === 'EUR' ? '€' : '$'}{product.price}
|
{product.currency === 'EUR' ? '€' : '$'}
|
||||||
|
{product.price}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -651,7 +651,8 @@ const TopUp = () => {
|
|||||||
{t('产品名称')}:{selectedCreemProduct.name}
|
{t('产品名称')}:{selectedCreemProduct.name}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price}
|
{t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}
|
||||||
|
{selectedCreemProduct.price}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{t('充值额度')}:{selectedCreemProduct.quota}
|
{t('充值额度')}:{selectedCreemProduct.quota}
|
||||||
|
|||||||
@@ -236,9 +236,7 @@ async function prepareOAuthState(options = {}) {
|
|||||||
if (shouldLogout) {
|
if (shouldLogout) {
|
||||||
try {
|
try {
|
||||||
await API.get('/api/user/logout', { skipErrorHandler: true });
|
await API.get('/api/user/logout', { skipErrorHandler: true });
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
|
|
||||||
}
|
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
updateAPI();
|
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) => {
|
data.forEach((item) => {
|
||||||
result.uniqueModels.add(item.model_name);
|
result.uniqueModels.add(item.model_name);
|
||||||
@@ -269,7 +269,11 @@ export const processRawData = (
|
|||||||
result.totalQuota += item.quota;
|
result.totalQuota += item.quota;
|
||||||
result.totalTimes += item.count;
|
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)) {
|
if (!result.timePoints.includes(timeKey)) {
|
||||||
result.timePoints.push(timeKey);
|
result.timePoints.push(timeKey);
|
||||||
}
|
}
|
||||||
@@ -328,10 +332,14 @@ export const aggregateDataByTimeAndModel = (data, dataExportDefaultTime) => {
|
|||||||
const aggregatedData = new Map();
|
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) => {
|
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 modelKey = item.model_name;
|
||||||
const key = `${timeKey}-${modelKey}`;
|
const key = `${timeKey}-${modelKey}`;
|
||||||
|
|
||||||
@@ -372,7 +380,7 @@ export const generateChartTimePoints = (
|
|||||||
);
|
);
|
||||||
const showYear = isDataCrossYear(generatedTimestamps);
|
const showYear = isDataCrossYear(generatedTimestamps);
|
||||||
|
|
||||||
chartTimePoints = generatedTimestamps.map(ts =>
|
chartTimePoints = generatedTimestamps.map((ts) =>
|
||||||
timestamp2string1(ts, dataExportDefaultTime, showYear),
|
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) {
|
export function parseHttpStatusCodeRules(input) {
|
||||||
const raw = (input ?? '').toString().trim();
|
const raw = (input ?? '').toString().trim();
|
||||||
if (raw.length === 0) {
|
if (raw.length === 0) {
|
||||||
@@ -35,7 +53,9 @@ export function parseHttpStatusCodeRules(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const merged = mergeRanges(ranges);
|
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(',');
|
const normalized = tokens.join(',');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -78,7 +98,9 @@ function isNumber(s) {
|
|||||||
function mergeRanges(ranges) {
|
function mergeRanges(ranges) {
|
||||||
if (!Array.isArray(ranges) || ranges.length === 0) return [];
|
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]];
|
const merged = [sorted[0]];
|
||||||
|
|
||||||
for (let i = 1; i < sorted.length; i += 1) {
|
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 date = new Date(timestamp * 1000);
|
||||||
let year = date.getFullYear();
|
let year = date.getFullYear();
|
||||||
let month = (date.getMonth() + 1).toString();
|
let month = (date.getMonth() + 1).toString();
|
||||||
@@ -248,7 +252,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho
|
|||||||
nextDay = '0' + nextDay;
|
nextDay = '0' + nextDay;
|
||||||
}
|
}
|
||||||
// 周视图结束日期也仅在跨年时显示年份
|
// 周视图结束日期也仅在跨年时显示年份
|
||||||
let nextStr = showYear ? nextWeekYear + '-' + nextMonth + '-' + nextDay : nextMonth + '-' + nextDay;
|
let nextStr = showYear
|
||||||
|
? nextWeekYear + '-' + nextMonth + '-' + nextDay
|
||||||
|
: nextMonth + '-' + nextDay;
|
||||||
str += ' - ' + nextStr;
|
str += ' - ' + nextStr;
|
||||||
}
|
}
|
||||||
return str;
|
return str;
|
||||||
@@ -257,7 +263,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho
|
|||||||
// 检查时间戳数组是否跨年
|
// 检查时间戳数组是否跨年
|
||||||
export function isDataCrossYear(timestamps) {
|
export function isDataCrossYear(timestamps) {
|
||||||
if (!timestamps || timestamps.length === 0) return false;
|
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;
|
return years.size > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,13 +55,20 @@ export const useModelDeploymentSettings = () => {
|
|||||||
|
|
||||||
const isIoNetEnabled = settings['model_deployment.ionet.enabled'];
|
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 message = (rawMessage || fallbackMessage).trim();
|
||||||
const normalized = message.toLowerCase();
|
const normalized = message.toLowerCase();
|
||||||
if (normalized.includes('expired') || normalized.includes('expire')) {
|
if (normalized.includes('expired') || normalized.includes('expire')) {
|
||||||
return { type: 'expired', message };
|
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 };
|
return { type: 'invalid', message };
|
||||||
}
|
}
|
||||||
if (normalized.includes('network') || normalized.includes('timeout')) {
|
if (normalized.includes('network') || normalized.includes('timeout')) {
|
||||||
@@ -85,7 +92,11 @@ export const useModelDeploymentSettings = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = response?.data?.message || 'Connection failed';
|
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) {
|
} catch (error) {
|
||||||
if (error?.code === 'ERR_NETWORK') {
|
if (error?.code === 'ERR_NETWORK') {
|
||||||
setConnectionState({
|
setConnectionState({
|
||||||
@@ -95,8 +106,13 @@ export const useModelDeploymentSettings = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rawMessage = error?.response?.data?.message || error?.message || 'Unknown error';
|
const rawMessage =
|
||||||
setConnectionState({ loading: false, ok: false, error: buildConnectionError(rawMessage, 'Connection failed') });
|
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]) {
|
if (data.choices?.[0]) {
|
||||||
const choice = data.choices[0];
|
const choice = data.choices[0];
|
||||||
let content = choice.message?.content || '';
|
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);
|
const processed = processThinkTags(content, reasoningContent);
|
||||||
|
|
||||||
@@ -318,8 +321,8 @@ export const useApiRequest = (
|
|||||||
isStreamComplete = true; // 标记流正常完成
|
isStreamComplete = true; // 标记流正常完成
|
||||||
source.close();
|
source.close();
|
||||||
sseSourceRef.current = null;
|
sseSourceRef.current = null;
|
||||||
setDebugData((prev) => ({
|
setDebugData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
response: responseData,
|
response: responseData,
|
||||||
sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记
|
sseMessages: [...(prev.sseMessages || []), '[DONE]'], // 添加 DONE 标记
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
|
|||||||
@@ -36,18 +36,23 @@ import { processIncompleteThinkTags } from '../../helpers';
|
|||||||
|
|
||||||
export const usePlaygroundState = () => {
|
export const usePlaygroundState = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
|
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
|
||||||
const [savedConfig] = useState(() => loadConfig());
|
const [savedConfig] = useState(() => loadConfig());
|
||||||
const [initialMessages] = useState(() => {
|
const [initialMessages] = useState(() => {
|
||||||
const loaded = loadMessages();
|
const loaded = loadMessages();
|
||||||
// 检查是否是旧的中文默认消息,如果是则清除
|
// 检查是否是旧的中文默认消息,如果是则清除
|
||||||
if (loaded && loaded.length === 2 && loaded[0].id === '2' && loaded[1].id === '3') {
|
if (
|
||||||
const hasOldChinese =
|
loaded &&
|
||||||
loaded[0].content === '你好' ||
|
loaded.length === 2 &&
|
||||||
|
loaded[0].id === '2' &&
|
||||||
|
loaded[1].id === '3'
|
||||||
|
) {
|
||||||
|
const hasOldChinese =
|
||||||
|
loaded[0].content === '你好' ||
|
||||||
loaded[1].content === '你好,请问有什么可以帮助您的吗?' ||
|
loaded[1].content === '你好,请问有什么可以帮助您的吗?' ||
|
||||||
loaded[1].content === '你好!很高兴见到你。有什么我可以帮助你的吗?';
|
loaded[1].content === '你好!很高兴见到你。有什么我可以帮助你的吗?';
|
||||||
|
|
||||||
if (hasOldChinese) {
|
if (hasOldChinese) {
|
||||||
// 清除旧的默认消息
|
// 清除旧的默认消息
|
||||||
localStorage.removeItem('playground_messages');
|
localStorage.removeItem('playground_messages');
|
||||||
@@ -81,8 +86,10 @@ export const usePlaygroundState = () => {
|
|||||||
const [status, setStatus] = useState({});
|
const [status, setStatus] = useState({});
|
||||||
|
|
||||||
// 消息相关状态 - 使用加载的消息或默认消息初始化
|
// 消息相关状态 - 使用加载的消息或默认消息初始化
|
||||||
const [message, setMessage] = useState(() => initialMessages || getDefaultMessages(t));
|
const [message, setMessage] = useState(
|
||||||
|
() => initialMessages || getDefaultMessages(t),
|
||||||
|
);
|
||||||
|
|
||||||
// 当语言改变时,如果是默认消息则更新
|
// 当语言改变时,如果是默认消息则更新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 只在没有保存的消息时才更新默认消息
|
// 只在没有保存的消息时才更新默认消息
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ export const useLogsData = () => {
|
|||||||
const [showUserInfo, setShowUserInfoModal] = useState(false);
|
const [showUserInfo, setShowUserInfoModal] = useState(false);
|
||||||
const [userInfoData, setUserInfoData] = useState(null);
|
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
|
// Load saved column preferences from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedColumns = localStorage.getItem(STORAGE_KEY);
|
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
|
// Format logs data
|
||||||
const setLogsFormat = (logs) => {
|
const setLogsFormat = (logs) => {
|
||||||
const requestConversionDisplayValue = (conversionChain) => {
|
const requestConversionDisplayValue = (conversionChain) => {
|
||||||
@@ -372,9 +391,13 @@ export const useLogsData = () => {
|
|||||||
other.cache_ratio || 1.0,
|
other.cache_ratio || 1.0,
|
||||||
other.cache_creation_ratio || 1.0,
|
other.cache_creation_ratio || 1.0,
|
||||||
other.cache_creation_tokens_5m || 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_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(
|
: renderLogContent(
|
||||||
other?.model_ratio,
|
other?.model_ratio,
|
||||||
@@ -524,8 +547,8 @@ export const useLogsData = () => {
|
|||||||
localCountMode = t('上游返回');
|
localCountMode = t('上游返回');
|
||||||
}
|
}
|
||||||
expandDataLocal.push({
|
expandDataLocal.push({
|
||||||
key: t('计费模式'),
|
key: t('计费模式'),
|
||||||
value: localCountMode,
|
value: localCountMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
expandDatesLocal[logs[i].key] = expandDataLocal;
|
expandDatesLocal[logs[i].key] = expandDataLocal;
|
||||||
@@ -680,6 +703,12 @@ export const useLogsData = () => {
|
|||||||
userInfoData,
|
userInfoData,
|
||||||
showUserInfoFunc,
|
showUserInfoFunc,
|
||||||
|
|
||||||
|
// Channel affinity usage cache stats modal
|
||||||
|
showChannelAffinityUsageCacheModal,
|
||||||
|
setShowChannelAffinityUsageCacheModal,
|
||||||
|
channelAffinityUsageCacheTarget,
|
||||||
|
openChannelAffinityUsageCacheModal,
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
loadLogs,
|
loadLogs,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
|
|||||||
@@ -438,14 +438,17 @@ const Playground = () => {
|
|||||||
}, [setMessage, saveMessagesImmediately]);
|
}, [setMessage, saveMessagesImmediately]);
|
||||||
|
|
||||||
// 处理粘贴图片
|
// 处理粘贴图片
|
||||||
const handlePasteImage = useCallback((base64Data) => {
|
const handlePasteImage = useCallback(
|
||||||
if (!inputs.imageEnabled) {
|
(base64Data) => {
|
||||||
return;
|
if (!inputs.imageEnabled) {
|
||||||
}
|
return;
|
||||||
// 添加图片到 imageUrls 数组
|
}
|
||||||
const newUrls = [...(inputs.imageUrls || []), base64Data];
|
// 添加图片到 imageUrls 数组
|
||||||
handleInputChange('imageUrls', newUrls);
|
const newUrls = [...(inputs.imageUrls || []), base64Data];
|
||||||
}, [inputs.imageEnabled, inputs.imageUrls, handleInputChange]);
|
handleInputChange('imageUrls', newUrls);
|
||||||
|
},
|
||||||
|
[inputs.imageEnabled, inputs.imageUrls, handleInputChange],
|
||||||
|
);
|
||||||
|
|
||||||
// Playground Context 值
|
// Playground Context 值
|
||||||
const playgroundContextValue = {
|
const playgroundContextValue = {
|
||||||
@@ -457,10 +460,10 @@ const Playground = () => {
|
|||||||
return (
|
return (
|
||||||
<PlaygroundProvider value={playgroundContextValue}>
|
<PlaygroundProvider value={playgroundContextValue}>
|
||||||
<div className='h-full'>
|
<div className='h-full'>
|
||||||
<Layout className='h-full bg-transparent flex flex-col md:flex-row'>
|
<Layout className='h-full bg-transparent flex flex-col md:flex-row'>
|
||||||
{(showSettings || !isMobile) && (
|
{(showSettings || !isMobile) && (
|
||||||
<Layout.Sider
|
<Layout.Sider
|
||||||
className={`
|
className={`
|
||||||
bg-transparent border-r-0 flex-shrink-0 overflow-auto mt-[60px]
|
bg-transparent border-r-0 flex-shrink-0 overflow-auto mt-[60px]
|
||||||
${
|
${
|
||||||
isMobile
|
isMobile
|
||||||
@@ -468,93 +471,93 @@ const Playground = () => {
|
|||||||
: 'relative z-[1] w-80 h-[calc(100vh-66px)]'
|
: 'relative z-[1] w-80 h-[calc(100vh-66px)]'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
width={isMobile ? '100%' : 320}
|
width={isMobile ? '100%' : 320}
|
||||||
>
|
>
|
||||||
<OptimizedSettingsPanel
|
<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}
|
|
||||||
inputs={inputs}
|
inputs={inputs}
|
||||||
|
parameterEnabled={parameterEnabled}
|
||||||
|
models={models}
|
||||||
|
groups={groups}
|
||||||
styleState={styleState}
|
styleState={styleState}
|
||||||
|
showSettings={showSettings}
|
||||||
showDebugPanel={showDebugPanel}
|
showDebugPanel={showDebugPanel}
|
||||||
roleInfo={roleInfo}
|
customRequestMode={customRequestMode}
|
||||||
onMessageSend={onMessageSend}
|
customRequestBody={customRequestBody}
|
||||||
onMessageCopy={messageActions.handleMessageCopy}
|
onInputChange={handleInputChange}
|
||||||
onMessageReset={messageActions.handleMessageReset}
|
onParameterToggle={handleParameterToggle}
|
||||||
onMessageDelete={messageActions.handleMessageDelete}
|
onCloseSettings={() => setShowSettings(false)}
|
||||||
onStopGenerator={onStopGenerator}
|
onConfigImport={handleConfigImport}
|
||||||
onClearMessages={handleClearMessages}
|
onConfigReset={handleConfigReset}
|
||||||
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
|
onCustomRequestModeChange={setCustomRequestMode}
|
||||||
renderCustomChatContent={renderCustomChatContent}
|
onCustomRequestBodyChange={setCustomRequestBody}
|
||||||
renderChatBoxAction={renderChatBoxAction}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 调试面板 - 桌面端 */}
|
{/* 调试面板 - 移动端覆盖层 */}
|
||||||
{showDebugPanel && !isMobile && (
|
{showDebugPanel && isMobile && (
|
||||||
<div className='w-96 flex-shrink-0 h-full'>
|
<div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
|
||||||
<OptimizedDebugPanel
|
<OptimizedDebugPanel
|
||||||
debugData={debugData}
|
debugData={debugData}
|
||||||
activeDebugTab={activeDebugTab}
|
activeDebugTab={activeDebugTab}
|
||||||
onActiveDebugTabChange={setActiveDebugTab}
|
onActiveDebugTabChange={setActiveDebugTab}
|
||||||
styleState={styleState}
|
styleState={styleState}
|
||||||
|
showDebugPanel={showDebugPanel}
|
||||||
|
onCloseDebugPanel={() => setShowDebugPanel(false)}
|
||||||
customRequestMode={customRequestMode}
|
customRequestMode={customRequestMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 调试面板 - 移动端覆盖层 */}
|
{/* 浮动按钮 */}
|
||||||
{showDebugPanel && isMobile && (
|
<FloatingButtons
|
||||||
<div className='fixed top-0 left-0 right-0 bottom-0 z-[1000] bg-white overflow-auto shadow-lg'>
|
styleState={styleState}
|
||||||
<OptimizedDebugPanel
|
showSettings={showSettings}
|
||||||
debugData={debugData}
|
showDebugPanel={showDebugPanel}
|
||||||
activeDebugTab={activeDebugTab}
|
onToggleSettings={() => setShowSettings(!showSettings)}
|
||||||
onActiveDebugTabChange={setActiveDebugTab}
|
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
|
||||||
styleState={styleState}
|
/>
|
||||||
showDebugPanel={showDebugPanel}
|
</Layout.Content>
|
||||||
onCloseDebugPanel={() => setShowDebugPanel(false)}
|
</Layout>
|
||||||
customRequestMode={customRequestMode}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 浮动按钮 */}
|
|
||||||
<FloatingButtons
|
|
||||||
styleState={styleState}
|
|
||||||
showSettings={showSettings}
|
|
||||||
showDebugPanel={showDebugPanel}
|
|
||||||
onToggleSettings={() => setShowSettings(!showSettings)}
|
|
||||||
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
|
|
||||||
/>
|
|
||||||
</Layout.Content>
|
|
||||||
</Layout>
|
|
||||||
</div>
|
|
||||||
</PlaygroundProvider>
|
</PlaygroundProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ const PrivacyPolicy = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentRenderer
|
<DocumentRenderer
|
||||||
apiEndpoint="/api/privacy-policy"
|
apiEndpoint='/api/privacy-policy'
|
||||||
title={t('隐私政策')}
|
title={t('隐私政策')}
|
||||||
cacheKey="privacy_policy"
|
cacheKey='privacy_policy'
|
||||||
emptyMessage={t('加载隐私政策内容失败...')}
|
emptyMessage={t('加载隐私政策内容失败...')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PrivacyPolicy;
|
export default PrivacyPolicy;
|
||||||
|
|||||||
@@ -199,9 +199,9 @@ export default function SettingGlobalModel(props) {
|
|||||||
'global.pass_through_request_enabled': value,
|
'global.pass_through_request_enabled': value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
extraText={
|
extraText={t(
|
||||||
t('开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启')
|
'开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启',
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -210,11 +210,7 @@ export default function SettingGlobalModel(props) {
|
|||||||
<Form.TextArea
|
<Form.TextArea
|
||||||
label={t('禁用思考处理的模型列表')}
|
label={t('禁用思考处理的模型列表')}
|
||||||
field={'global.thinking_model_blacklist'}
|
field={'global.thinking_model_blacklist'}
|
||||||
placeholder={
|
placeholder={t('例如:') + '\n' + thinkingExample}
|
||||||
t('例如:') +
|
|
||||||
'\n' +
|
|
||||||
thinkingExample
|
|
||||||
}
|
|
||||||
rows={4}
|
rows={4}
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
@@ -270,12 +266,12 @@ export default function SettingGlobalModel(props) {
|
|||||||
|
|
||||||
<Row style={{ marginTop: 10 }}>
|
<Row style={{ marginTop: 10 }}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.TextArea
|
<Form.TextArea
|
||||||
label={t('参数配置')}
|
label={t('参数配置')}
|
||||||
field={chatCompletionsToResponsesPolicyKey}
|
field={chatCompletionsToResponsesPolicyKey}
|
||||||
placeholder={
|
placeholder={
|
||||||
t('例如(指定渠道):') +
|
t('例如(指定渠道):') +
|
||||||
'\n' +
|
'\n' +
|
||||||
chatCompletionsToResponsesPolicyExample +
|
chatCompletionsToResponsesPolicyExample +
|
||||||
'\n\n' +
|
'\n\n' +
|
||||||
t('例如(全渠道):') +
|
t('例如(全渠道):') +
|
||||||
@@ -370,7 +366,9 @@ export default function SettingGlobalModel(props) {
|
|||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Banner
|
<Banner
|
||||||
type='warning'
|
type='warning'
|
||||||
description={t('警告:启用保活后,如果已经写入保活数据后渠道出错,系统无法重试,如果必须开启,推荐设置尽可能大的Ping间隔')}
|
description={t(
|
||||||
|
'警告:启用保活后,如果已经写入保活数据后渠道出错,系统无法重试,如果必须开启,推荐设置尽可能大的Ping间隔',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ export default function SettingGrokModel(props) {
|
|||||||
.validate()
|
.validate()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const updateArray = compareObjects(inputs, inputsRow);
|
const updateArray = compareObjects(inputs, inputsRow);
|
||||||
if (!updateArray.length)
|
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||||
return showWarning(t('你似乎并没有修改什么'));
|
|
||||||
|
|
||||||
const requestQueue = updateArray.map((item) => {
|
const requestQueue = updateArray.map((item) => {
|
||||||
const value = String(inputs[item.key]);
|
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 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 {
|
import {
|
||||||
compareObjects,
|
compareObjects,
|
||||||
API,
|
API,
|
||||||
@@ -88,9 +96,7 @@ export default function SettingModelDeployment(props) {
|
|||||||
showError(t('网络连接失败,请检查网络设置或稍后重试'));
|
showError(t('网络连接失败,请检查网络设置或稍后重试'));
|
||||||
} else {
|
} else {
|
||||||
const rawMessage =
|
const rawMessage =
|
||||||
error?.response?.data?.message ||
|
error?.response?.data?.message || error?.message || '';
|
||||||
error?.message ||
|
|
||||||
'';
|
|
||||||
const localizedMessage = rawMessage
|
const localizedMessage = rawMessage
|
||||||
? getLocalizedMessage(rawMessage)
|
? getLocalizedMessage(rawMessage)
|
||||||
: t('未知错误');
|
: t('未知错误');
|
||||||
@@ -104,7 +110,7 @@ export default function SettingModelDeployment(props) {
|
|||||||
function onSubmit() {
|
function onSubmit() {
|
||||||
const updateArray = compareObjects(inputs, inputsRow);
|
const updateArray = compareObjects(inputs, inputsRow);
|
||||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||||
|
|
||||||
const requestQueue = updateArray.map((item) => {
|
const requestQueue = updateArray.map((item) => {
|
||||||
let value = String(inputs[item.key]);
|
let value = String(inputs[item.key]);
|
||||||
return API.put('/api/option/', {
|
return API.put('/api/option/', {
|
||||||
@@ -112,7 +118,7 @@ export default function SettingModelDeployment(props) {
|
|||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all(requestQueue)
|
Promise.all(requestQueue)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -141,7 +147,7 @@ export default function SettingModelDeployment(props) {
|
|||||||
'model_deployment.ionet.api_key': '',
|
'model_deployment.ionet.api_key': '',
|
||||||
'model_deployment.ionet.enabled': false,
|
'model_deployment.ionet.enabled': false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentInputs = {};
|
const currentInputs = {};
|
||||||
for (let key in defaultInputs) {
|
for (let key in defaultInputs) {
|
||||||
if (props.options.hasOwnProperty(key)) {
|
if (props.options.hasOwnProperty(key)) {
|
||||||
@@ -150,7 +156,7 @@ export default function SettingModelDeployment(props) {
|
|||||||
currentInputs[key] = defaultInputs[key];
|
currentInputs[key] = defaultInputs[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputs(currentInputs);
|
setInputs(currentInputs);
|
||||||
setInputsRow(structuredClone(currentInputs));
|
setInputsRow(structuredClone(currentInputs));
|
||||||
refForm.current?.setValues(currentInputs);
|
refForm.current?.setValues(currentInputs);
|
||||||
@@ -165,9 +171,11 @@ export default function SettingModelDeployment(props) {
|
|||||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||||
style={{ marginBottom: 15 }}
|
style={{ marginBottom: 15 }}
|
||||||
>
|
>
|
||||||
<Form.Section
|
<Form.Section
|
||||||
text={
|
text={
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||||
|
>
|
||||||
<span>{t('模型部署设置')}</span>
|
<span>{t('模型部署设置')}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -186,7 +194,9 @@ export default function SettingModelDeployment(props) {
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||||
|
>
|
||||||
<Cloud size={18} />
|
<Cloud size={18} />
|
||||||
<span>io.net</span>
|
<span>io.net</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,18 +236,16 @@ export default function SettingModelDeployment(props) {
|
|||||||
}
|
}
|
||||||
disabled={!inputs['model_deployment.ionet.enabled']}
|
disabled={!inputs['model_deployment.ionet.enabled']}
|
||||||
extraText={t('请使用 Project 为 io.cloud 的密钥')}
|
extraText={t('请使用 Project 为 io.cloud 的密钥')}
|
||||||
mode="password"
|
mode='password'
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
<Button
|
<Button
|
||||||
type="outline"
|
type='outline'
|
||||||
size="small"
|
size='small'
|
||||||
icon={<Zap size={16} />}
|
icon={<Zap size={16} />}
|
||||||
onClick={testApiKey}
|
onClick={testApiKey}
|
||||||
loading={testing}
|
loading={testing}
|
||||||
disabled={
|
disabled={!inputs['model_deployment.ionet.enabled']}
|
||||||
!inputs['model_deployment.ionet.enabled']
|
|
||||||
}
|
|
||||||
style={{
|
style={{
|
||||||
height: '32px',
|
height: '32px',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
@@ -271,7 +279,10 @@ export default function SettingModelDeployment(props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Text strong style={{ display: 'block', marginBottom: '8px' }}>
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ display: 'block', marginBottom: '8px' }}
|
||||||
|
>
|
||||||
{t('获取 io.net API Key')}
|
{t('获取 io.net API Key')}
|
||||||
</Text>
|
</Text>
|
||||||
<ul
|
<ul
|
||||||
@@ -287,14 +298,16 @@ export default function SettingModelDeployment(props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<li>{t('访问 io.net 控制台的 API Keys 页面')}</li>
|
<li>{t('访问 io.net 控制台的 API Keys 页面')}</li>
|
||||||
<li>{t('创建或选择密钥时,将 Project 设置为 io.cloud')}</li>
|
<li>
|
||||||
|
{t('创建或选择密钥时,将 Project 设置为 io.cloud')}
|
||||||
|
</li>
|
||||||
<li>{t('复制生成的密钥并粘贴到此处')}</li>
|
<li>{t('复制生成的密钥并粘贴到此处')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowUpRight size={16} />}
|
icon={<ArrowUpRight size={16} />}
|
||||||
type="primary"
|
type='primary'
|
||||||
theme="solid"
|
theme='solid'
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open('https://ai.io.net/ai/api-keys', '_blank')
|
window.open('https://ai.io.net/ai/api-keys', '_blank')
|
||||||
@@ -308,7 +321,7 @@ export default function SettingModelDeployment(props) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
<Button size='default' type="primary" onClick={onSubmit}>
|
<Button size='default' type='primary' onClick={onSubmit}>
|
||||||
{t('保存设置')}
|
{t('保存设置')}
|
||||||
</Button>
|
</Button>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const RULE_TEMPLATES = {
|
|||||||
key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
|
key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
|
||||||
value_regex: '',
|
value_regex: '',
|
||||||
ttl_seconds: 0,
|
ttl_seconds: 0,
|
||||||
|
skip_retry_on_failure: false,
|
||||||
include_using_group: true,
|
include_using_group: true,
|
||||||
include_rule_name: true,
|
include_rule_name: true,
|
||||||
},
|
},
|
||||||
@@ -83,6 +84,7 @@ const RULE_TEMPLATES = {
|
|||||||
key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
|
key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
|
||||||
value_regex: '',
|
value_regex: '',
|
||||||
ttl_seconds: 0,
|
ttl_seconds: 0,
|
||||||
|
skip_retry_on_failure: false,
|
||||||
include_using_group: true,
|
include_using_group: true,
|
||||||
include_rule_name: true,
|
include_rule_name: true,
|
||||||
},
|
},
|
||||||
@@ -112,6 +114,7 @@ const RULES_JSON_PLACEHOLDER = `[
|
|||||||
],
|
],
|
||||||
"value_regex": "^[-0-9A-Za-z._:]{1,128}$",
|
"value_regex": "^[-0-9A-Za-z._:]{1,128}$",
|
||||||
"ttl_seconds": 600,
|
"ttl_seconds": 600,
|
||||||
|
"skip_retry_on_failure": false,
|
||||||
"include_using_group": true,
|
"include_using_group": true,
|
||||||
"include_rule_name": true
|
"include_rule_name": true
|
||||||
}
|
}
|
||||||
@@ -153,7 +156,12 @@ const normalizeKeySource = (src) => {
|
|||||||
const type = (src?.type || '').trim();
|
const type = (src?.type || '').trim();
|
||||||
const key = (src?.key || '').trim();
|
const key = (src?.key || '').trim();
|
||||||
const path = (src?.path || '').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) => {
|
const makeUniqueName = (existingNames, baseName) => {
|
||||||
@@ -229,6 +237,7 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
user_agent_include_text: (r.user_agent_include || []).join('\n'),
|
user_agent_include_text: (r.user_agent_include || []).join('\n'),
|
||||||
value_regex: r.value_regex || '',
|
value_regex: r.value_regex || '',
|
||||||
ttl_seconds: Number(r.ttl_seconds || 0),
|
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_using_group: r.include_using_group ?? true,
|
||||||
include_rule_name: r.include_rule_name ?? true,
|
include_rule_name: r.include_rule_name ?? true,
|
||||||
};
|
};
|
||||||
@@ -523,6 +532,7 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
key_sources: [{ type: 'gjson', path: '' }],
|
key_sources: [{ type: 'gjson', path: '' }],
|
||||||
value_regex: '',
|
value_regex: '',
|
||||||
ttl_seconds: 0,
|
ttl_seconds: 0,
|
||||||
|
skip_retry_on_failure: false,
|
||||||
include_using_group: true,
|
include_using_group: true,
|
||||||
include_rule_name: true,
|
include_rule_name: true,
|
||||||
};
|
};
|
||||||
@@ -583,6 +593,9 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
ttl_seconds: Number(values.ttl_seconds || 0),
|
ttl_seconds: Number(values.ttl_seconds || 0),
|
||||||
include_using_group: !!values.include_using_group,
|
include_using_group: !!values.include_using_group,
|
||||||
include_rule_name: !!values.include_rule_name,
|
include_rule_name: !!values.include_rule_name,
|
||||||
|
...(values.skip_retry_on_failure
|
||||||
|
? { skip_retry_on_failure: true }
|
||||||
|
: {}),
|
||||||
...(userAgentInclude.length > 0
|
...(userAgentInclude.length > 0
|
||||||
? { user_agent_include: userAgentInclude }
|
? { user_agent_include: userAgentInclude }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -1041,6 +1054,18 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ export default function SettingsCreditLimit(props) {
|
|||||||
<Form.Switch
|
<Form.Switch
|
||||||
label={t('对免费模型启用预消耗')}
|
label={t('对免费模型启用预消耗')}
|
||||||
field={'quota_setting.enable_free_model_pre_consume'}
|
field={'quota_setting.enable_free_model_pre_consume'}
|
||||||
extraText={t('开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度')}
|
extraText={t(
|
||||||
|
'开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度',
|
||||||
|
)}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setInputs({
|
setInputs({
|
||||||
...inputs,
|
...inputs,
|
||||||
|
|||||||
@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import {
|
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||||
Button,
|
|
||||||
Col,
|
|
||||||
Form,
|
|
||||||
Row,
|
|
||||||
Spin,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import {
|
import {
|
||||||
compareObjects,
|
compareObjects,
|
||||||
API,
|
API,
|
||||||
@@ -46,7 +40,8 @@ export default function SettingsMonitoring(props) {
|
|||||||
AutomaticEnableChannelEnabled: false,
|
AutomaticEnableChannelEnabled: false,
|
||||||
AutomaticDisableKeywords: '',
|
AutomaticDisableKeywords: '',
|
||||||
AutomaticDisableStatusCodes: '401',
|
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_enabled': false,
|
||||||
'monitor_setting.auto_test_channel_minutes': 10,
|
'monitor_setting.auto_test_channel_minutes': 10,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -252,7 +252,11 @@ export default function SettingsSidebarModulesAdmin(props) {
|
|||||||
modules: [
|
modules: [
|
||||||
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
|
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
|
||||||
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
|
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
|
||||||
{ key: 'deployment', title: t('模型部署'), description: t('模型部署管理') },
|
{
|
||||||
|
key: 'deployment',
|
||||||
|
title: t('模型部署'),
|
||||||
|
description: t('模型部署管理'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'redemption',
|
key: 'redemption',
|
||||||
title: t('兑换码管理'),
|
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 React, { useEffect, useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
Button,
|
Button,
|
||||||
Form,
|
Form,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Typography,
|
Typography,
|
||||||
Spin,
|
Spin,
|
||||||
Table,
|
Table,
|
||||||
Modal,
|
Modal,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Select,
|
Select,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
import {
|
import { API, showError, showSuccess } from '../../../helpers';
|
||||||
API,
|
|
||||||
showError,
|
|
||||||
showSuccess,
|
|
||||||
} from '../../../helpers';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function SettingsPaymentGatewayCreem(props) {
|
export default function SettingsPaymentGatewayCreem(props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [inputs, setInputs] = useState({
|
const [inputs, setInputs] = useState({
|
||||||
CreemApiKey: '',
|
CreemApiKey: '',
|
||||||
CreemWebhookSecret: '',
|
CreemWebhookSecret: '',
|
||||||
CreemProducts: '[]',
|
CreemProducts: '[]',
|
||||||
CreemTestMode: false,
|
CreemTestMode: false,
|
||||||
});
|
});
|
||||||
const [originInputs, setOriginInputs] = useState({});
|
const [originInputs, setOriginInputs] = useState({});
|
||||||
const [products, setProducts] = useState([]);
|
const [products, setProducts] = useState([]);
|
||||||
const [showProductModal, setShowProductModal] = useState(false);
|
const [showProductModal, setShowProductModal] = useState(false);
|
||||||
const [editingProduct, setEditingProduct] = useState(null);
|
const [editingProduct, setEditingProduct] = useState(null);
|
||||||
const [productForm, setProductForm] = useState({
|
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: '',
|
name: '',
|
||||||
productId: '',
|
productId: '',
|
||||||
price: 0,
|
price: 0,
|
||||||
quota: 0,
|
quota: 0,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShowProductModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeProductModal = () => {
|
||||||
|
setShowProductModal(false);
|
||||||
|
setEditingProduct(null);
|
||||||
|
setProductForm({
|
||||||
|
name: '',
|
||||||
|
productId: '',
|
||||||
|
price: 0,
|
||||||
|
quota: 0,
|
||||||
|
currency: 'USD',
|
||||||
});
|
});
|
||||||
const formApiRef = useRef(null);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const saveProduct = () => {
|
||||||
if (props.options && formApiRef.current) {
|
if (
|
||||||
const currentInputs = {
|
!productForm.name ||
|
||||||
CreemApiKey: props.options.CreemApiKey || '',
|
!productForm.productId ||
|
||||||
CreemWebhookSecret: props.options.CreemWebhookSecret || '',
|
productForm.price <= 0 ||
|
||||||
CreemProducts: props.options.CreemProducts || '[]',
|
productForm.quota <= 0 ||
|
||||||
CreemTestMode: props.options.CreemTestMode === 'true',
|
!productForm.currency
|
||||||
};
|
) {
|
||||||
setInputs(currentInputs);
|
showError(t('请填写完整的产品信息'));
|
||||||
setOriginInputs({ ...currentInputs });
|
return;
|
||||||
formApiRef.current.setValues(currentInputs);
|
}
|
||||||
|
|
||||||
// Parse products
|
let newProducts = [...products];
|
||||||
try {
|
if (editingProduct) {
|
||||||
const parsedProducts = JSON.parse(currentInputs.CreemProducts);
|
// 编辑现有产品
|
||||||
setProducts(parsedProducts);
|
const index = newProducts.findIndex(
|
||||||
} catch (e) {
|
(p) => p.productId === editingProduct.productId,
|
||||||
setProducts([]);
|
);
|
||||||
}
|
if (index !== -1) {
|
||||||
}
|
newProducts[index] = { ...productForm };
|
||||||
}, [props.options]);
|
}
|
||||||
|
} else {
|
||||||
|
// 添加新产品
|
||||||
|
if (newProducts.find((p) => p.productId === productForm.productId)) {
|
||||||
|
showError(t('产品ID已存在'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newProducts.push({ ...productForm });
|
||||||
|
}
|
||||||
|
|
||||||
const handleFormChange = (values) => {
|
setProducts(newProducts);
|
||||||
setInputs(values);
|
closeProductModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitCreemSetting = async () => {
|
const deleteProduct = (productId) => {
|
||||||
setLoading(true);
|
const newProducts = products.filter((p) => p.productId !== productId);
|
||||||
try {
|
setProducts(newProducts);
|
||||||
const options = [];
|
};
|
||||||
|
|
||||||
if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
|
const columns = [
|
||||||
options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
|
{
|
||||||
}
|
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 !== '') {
|
return (
|
||||||
options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret });
|
<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
|
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||||
options.push({ key: 'CreemTestMode', value: inputs.CreemTestMode ? 'true' : 'false' });
|
<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
|
<div style={{ marginTop: 24 }}>
|
||||||
options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
|
<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
|
||||||
const requestQueue = options.map(opt =>
|
columns={columns}
|
||||||
API.put('/api/option/', {
|
dataSource={products}
|
||||||
key: opt.key,
|
pagination={false}
|
||||||
value: opt.value,
|
empty={
|
||||||
})
|
<div className='text-center py-8'>
|
||||||
);
|
<Text type='tertiary'>{t('暂无产品配置')}</Text>
|
||||||
|
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
}
|
||||||
},
|
/>
|
||||||
];
|
</div>
|
||||||
|
|
||||||
return (
|
<Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
|
||||||
<Spin spinning={loading}>
|
{t('更新 Creem 设置')}
|
||||||
<Form
|
</Button>
|
||||||
initValues={inputs}
|
</Form.Section>
|
||||||
onValueChange={handleFormChange}
|
</Form>
|
||||||
getFormApi={(api) => (formApiRef.current = api)}
|
|
||||||
|
{/* 产品配置模态框 */}
|
||||||
|
<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 设置')}>
|
<Select.Option value='USD'>{t('USD (美元)')}</Select.Option>
|
||||||
<Text>
|
<Select.Option value='EUR'>{t('EUR (欧元)')}</Select.Option>
|
||||||
{t('Creem 介绍')}
|
</Select>
|
||||||
<a
|
</div>
|
||||||
href='https://creem.io'
|
<div>
|
||||||
target='_blank'
|
<Text strong className='block mb-2'>
|
||||||
rel='noreferrer'
|
{t('价格')} (
|
||||||
>Creem Official Site</a>
|
{productForm.currency === 'EUR' ? t('欧元') : t('美元')})
|
||||||
<br />
|
</Text>
|
||||||
</Text>
|
<InputNumber
|
||||||
<Banner
|
value={productForm.price}
|
||||||
type='info'
|
onChange={(value) =>
|
||||||
description={t('Creem Setting Tips')}
|
setProductForm({ ...productForm, price: value })
|
||||||
/>
|
}
|
||||||
|
placeholder={t('例如:4.99')}
|
||||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
min={0.01}
|
||||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
precision={2}
|
||||||
<Form.Input
|
size='large'
|
||||||
field='CreemApiKey'
|
className='w-full'
|
||||||
label={t('API 密钥')}
|
defaultValue={4.49}
|
||||||
placeholder={t('Creem API 密钥,敏感信息不显示')}
|
/>
|
||||||
type='password'
|
</div>
|
||||||
/>
|
<div>
|
||||||
</Col>
|
<Text strong className='block mb-2'>
|
||||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
{t('充值额度')}
|
||||||
<Form.Input
|
</Text>
|
||||||
field='CreemWebhookSecret'
|
<InputNumber
|
||||||
label={t('Webhook 密钥')}
|
value={productForm.quota}
|
||||||
placeholder={t('用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示')}
|
onChange={(value) =>
|
||||||
type='password'
|
setProductForm({ ...productForm, quota: value })
|
||||||
/>
|
}
|
||||||
</Col>
|
placeholder={t('例如:100000')}
|
||||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
min={1}
|
||||||
<Form.Switch
|
precision={0}
|
||||||
field='CreemTestMode'
|
size='large'
|
||||||
label={t('测试模式')}
|
className='w-full'
|
||||||
extraText={t('启用后将使用 Creem Test Mode')}
|
/>
|
||||||
/>
|
</div>
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
</Modal>
|
||||||
|
</Spin>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -168,7 +168,8 @@ export default function SettingsPerformance(props) {
|
|||||||
for (let key in props.options) {
|
for (let key in props.options) {
|
||||||
if (Object.keys(inputs).includes(key)) {
|
if (Object.keys(inputs).includes(key)) {
|
||||||
if (typeof inputs[key] === 'boolean') {
|
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') {
|
} else if (typeof inputs[key] === 'number') {
|
||||||
currentInputs[key] = parseInt(props.options[key]) || inputs[key];
|
currentInputs[key] = parseInt(props.options[key]) || inputs[key];
|
||||||
} else {
|
} else {
|
||||||
@@ -184,9 +185,14 @@ export default function SettingsPerformance(props) {
|
|||||||
fetchStats();
|
fetchStats();
|
||||||
}, [props.options]);
|
}, [props.options]);
|
||||||
|
|
||||||
const diskCacheUsagePercent = stats?.cache_stats?.disk_cache_max_bytes > 0
|
const diskCacheUsagePercent =
|
||||||
? (stats.cache_stats.current_disk_usage_bytes / stats.cache_stats.disk_cache_max_bytes * 100).toFixed(1)
|
stats?.cache_stats?.disk_cache_max_bytes > 0
|
||||||
: 0;
|
? (
|
||||||
|
(stats.cache_stats.current_disk_usage_bytes /
|
||||||
|
stats.cache_stats.disk_cache_max_bytes) *
|
||||||
|
100
|
||||||
|
).toFixed(1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -199,7 +205,9 @@ export default function SettingsPerformance(props) {
|
|||||||
<Form.Section text={t('磁盘缓存设置(磁盘换内存)')}>
|
<Form.Section text={t('磁盘缓存设置(磁盘换内存)')}>
|
||||||
<Banner
|
<Banner
|
||||||
type='info'
|
type='info'
|
||||||
description={t('启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。')}
|
description={t(
|
||||||
|
'启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。',
|
||||||
|
)}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
@@ -211,7 +219,9 @@ export default function SettingsPerformance(props) {
|
|||||||
size='default'
|
size='default'
|
||||||
checkedText='|'
|
checkedText='|'
|
||||||
uncheckedText='〇'
|
uncheckedText='〇'
|
||||||
onChange={handleFieldChange('performance_setting.disk_cache_enabled')}
|
onChange={handleFieldChange(
|
||||||
|
'performance_setting.disk_cache_enabled',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||||
@@ -221,7 +231,9 @@ export default function SettingsPerformance(props) {
|
|||||||
extraText={t('请求体超过此大小时使用磁盘缓存')}
|
extraText={t('请求体超过此大小时使用磁盘缓存')}
|
||||||
min={1}
|
min={1}
|
||||||
max={1024}
|
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']}
|
disabled={!inputs['performance_setting.disk_cache_enabled']}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -239,7 +251,9 @@ export default function SettingsPerformance(props) {
|
|||||||
}
|
}
|
||||||
min={100}
|
min={100}
|
||||||
max={102400}
|
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']}
|
disabled={!inputs['performance_setting.disk_cache_enabled']}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -251,7 +265,9 @@ export default function SettingsPerformance(props) {
|
|||||||
label={t('缓存目录')}
|
label={t('缓存目录')}
|
||||||
extraText={t('留空使用系统临时目录')}
|
extraText={t('留空使用系统临时目录')}
|
||||||
placeholder={t('例如 /var/cache/new-api')}
|
placeholder={t('例如 /var/cache/new-api')}
|
||||||
onChange={handleFieldChange('performance_setting.disk_cache_path')}
|
onChange={handleFieldChange(
|
||||||
|
'performance_setting.disk_cache_path',
|
||||||
|
)}
|
||||||
showClear
|
showClear
|
||||||
disabled={!inputs['performance_setting.disk_cache_enabled']}
|
disabled={!inputs['performance_setting.disk_cache_enabled']}
|
||||||
/>
|
/>
|
||||||
@@ -290,38 +306,98 @@ export default function SettingsPerformance(props) {
|
|||||||
{stats && (
|
{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' }}>
|
<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' }}>
|
<div
|
||||||
<Text strong style={{ marginBottom: 8, display: 'block' }}>{t('请求体磁盘缓存')}</Text>
|
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
|
<Progress
|
||||||
percent={parseFloat(diskCacheUsagePercent)}
|
percent={parseFloat(diskCacheUsagePercent)}
|
||||||
showInfo
|
showInfo
|
||||||
style={{ marginBottom: 8 }}
|
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'>
|
<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>
|
||||||
<Text type='tertiary'>
|
<Text type='tertiary'>
|
||||||
{t('活跃文件')}: {stats.cache_stats.active_disk_files}
|
{t('活跃文件')}: {stats.cache_stats.active_disk_files}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 'auto' }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={12} style={{ display: 'flex' }}>
|
<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' }}>
|
<div
|
||||||
<Text strong style={{ marginBottom: 8, display: 'block' }}>{t('请求体内存缓存')}</Text>
|
style={{
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
padding: 16,
|
||||||
<Text>{t('当前缓存大小')}: {formatBytes(stats.cache_stats.current_memory_usage_bytes)}</Text>
|
background: 'var(--semi-color-fill-0)',
|
||||||
<Text>{t('活跃缓存数')}: {stats.cache_stats.active_memory_buffers}</Text>
|
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>
|
||||||
<div style={{ marginTop: 'auto' }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -331,20 +407,56 @@ export default function SettingsPerformance(props) {
|
|||||||
{stats.disk_space_info?.total > 0 && (
|
{stats.disk_space_info?.total > 0 && (
|
||||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<div style={{ padding: 16, background: 'var(--semi-color-fill-0)', borderRadius: 8 }}>
|
<div
|
||||||
<Text strong style={{ marginBottom: 8, display: 'block' }}>{t('缓存目录磁盘空间')}</Text>
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
background: 'var(--semi-color-fill-0)',
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ marginBottom: 8, display: 'block' }}
|
||||||
|
>
|
||||||
|
{t('缓存目录磁盘空间')}
|
||||||
|
</Text>
|
||||||
<Progress
|
<Progress
|
||||||
percent={parseFloat(stats.disk_space_info.used_percent.toFixed(1))}
|
percent={parseFloat(
|
||||||
|
stats.disk_space_info.used_percent.toFixed(1),
|
||||||
|
)}
|
||||||
showInfo
|
showInfo
|
||||||
style={{ marginBottom: 8 }}
|
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 }}>
|
<div
|
||||||
<Text type='tertiary'>{t('已用')}: {formatBytes(stats.disk_space_info.used)}</Text>
|
style={{
|
||||||
<Text type='tertiary'>{t('可用')}: {formatBytes(stats.disk_space_info.free)}</Text>
|
display: 'flex',
|
||||||
<Text type='tertiary'>{t('总计')}: {formatBytes(stats.disk_space_info.total)}</Text>
|
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>
|
</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
|
<Banner
|
||||||
type='warning'
|
type='warning'
|
||||||
description={t('磁盘可用空间小于缓存最大总量设置')}
|
description={t('磁盘可用空间小于缓存最大总量设置')}
|
||||||
@@ -361,14 +473,32 @@ export default function SettingsPerformance(props) {
|
|||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Descriptions
|
<Descriptions
|
||||||
data={[
|
data={[
|
||||||
{ key: t('已分配内存'), value: formatBytes(stats.memory_stats.alloc) },
|
{
|
||||||
{ key: t('总分配内存'), value: formatBytes(stats.memory_stats.total_alloc) },
|
key: t('已分配内存'),
|
||||||
{ key: t('系统内存'), value: formatBytes(stats.memory_stats.sys) },
|
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('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.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>
|
</Col>
|
||||||
|
|||||||
@@ -205,7 +205,10 @@ export default function GroupRatioSettings(props) {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setInputs({ ...inputs, 'group_ratio_setting.group_special_usable_group': value })
|
setInputs({
|
||||||
|
...inputs,
|
||||||
|
'group_ratio_setting.group_special_usable_group': value,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ const UserAgreement = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentRenderer
|
<DocumentRenderer
|
||||||
apiEndpoint="/api/user-agreement"
|
apiEndpoint='/api/user-agreement'
|
||||||
title={t('用户协议')}
|
title={t('用户协议')}
|
||||||
cacheKey="user_agreement"
|
cacheKey='user_agreement'
|
||||||
emptyMessage={t('加载用户协议内容失败...')}
|
emptyMessage={t('加载用户协议内容失败...')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UserAgreement;
|
export default UserAgreement;
|
||||||
|
|||||||
Reference in New Issue
Block a user