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:
Seefs
2026-02-02 14:37:31 +08:00
committed by GitHub
parent 6c0e9403a2
commit 540cf6c991
61 changed files with 2012 additions and 1004 deletions

View File

@@ -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,
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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