Compare commits

...

31 Commits

Author SHA1 Message Date
thirking
8d67c571e4 fix: remove unnecessary unescapeMapOrSlice call in Gemini relay
The JSON serialization/deserialization already handles escape characters
correctly, so the unescapeMapOrSlice function is redundant.
2026-02-03 11:47:45 +08:00
Seefs
cbebd15692 fix: vertex maas api addr (#2810)
* fix: vertex maas api addr
2026-02-03 00:09:45 +08:00
Calcium-Ion
afa9efa037 feat: default enable channel affinity (#2809) 2026-02-03 00:05:23 +08:00
Seefs
760fbeb6e6 Merge pull request #2811 from seefs001/fix/openrouter-claude-cache-usage
fix: openrouter claude cache usage
2026-02-03 00:03:19 +08:00
Seefs
ee0487806c Merge pull request #2764 from feitianbubu/pr/4baa0f472b6f35ce3426cd8ea0d13f38e2f4eb81
feat: auto-adapt video modal
2026-02-02 22:28:12 +08:00
Seefs
c50eff53d4 feat: default enable channel affinity 2026-02-02 22:21:49 +08:00
CaIon
16d8055397 feat: add support for jfif image format in file decoder 2026-02-02 21:36:08 +08:00
Calcium-Ion
5d2e45a147 Merge pull request #2803 from seefs001/feature/qwen-responses
feat: /v1/responses qwen3 max && perplexity
2026-02-02 21:22:25 +08:00
Calcium-Ion
1788fb290e fix: claude panic (#2804) 2026-02-02 21:22:07 +08:00
Seefs
4978fead3a Merge pull request #2805 from lanfunoe/fix/make-channel-Host-override-take-effect
fix: make channel Host override take effect
2026-02-02 21:20:31 +08:00
Seefs
57b9905539 fix: claude panic 2026-02-02 15:03:30 +08:00
lanfunoe
0d5ae12ebc fix: make channel Host override take effect 2026-02-02 14:59:36 +08:00
Seefs
b6dc75cb86 feat: /v1/responses perplexity 2026-02-02 14:48:45 +08:00
Seefs
2c29993cfc feat: /v1/responses qwen3 max 2026-02-02 14:41:27 +08:00
Seefs
540cf6c991 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
2026-02-02 14:37:31 +08:00
Seefs
6c0e9403a2 Merge pull request #2759 from KiGamji/fix-group-colors
fix(ui): use distinct color palette for group tags
2026-02-02 13:24:15 +08:00
Seefs
76050e66ca Merge pull request #2798 from RedwindA/feat/GeminiCacheBilling
feat(gemini): support cached token billing
2026-02-02 13:21:10 +08:00
Seefs
99745e7e38 Merge pull request #2783 from feitianbubu/pr/9f3276d5637873b7b97dbdbd98ade9b372bd6f63
feat: CodeViewer click link and auto wrap
2026-02-02 13:17:01 +08:00
Seefs
621938699b Merge pull request #2733 from feitianbubu/pr/92eee074a8105d7331d0987d96dc78bae181e331
feat: task pre consume modelPrice default use setting value
2026-02-02 13:16:13 +08:00
Seefs
2d9b408fda Merge pull request #2756 from feitianbubu/pr/bae1c6025ed4c65bf72572ff8684dd7ef068e576
feat: doubao add first and last image to video
2026-02-02 13:15:47 +08:00
Seefs
63b642f39a Merge pull request #2745 from mehunk/feat/custom-stripe-url
feat: Support customizing the success and cancel url of Stripe.
2026-02-02 13:15:05 +08:00
CaIon
ff41e65d9b fix: FreeBSD build failure due to type mismatch in Statfs_t fields (#2793)
Explicitly cast Blocks, Bavail, and Bfree to uint64 for cross-platform compatibility,
as these fields are int64 on FreeBSD but uint64 on Linux.
2026-02-02 00:36:36 +08:00
RedwindA
e3f96120bc feat(gemini): support cached token billing 2026-02-01 22:50:47 +08:00
feitianbubu
ac8a92655e feat: CodeViewer click link and auto wrap 2026-01-30 11:18:58 +08:00
feitianbubu
8c4d2f2c2f feat: auto-adapt video modal 2026-01-28 12:55:08 +08:00
KiGamji
ca81de39c9 fix(ui): use distinct color palette for group tags 2026-01-27 17:27:35 +05:00
feitianbubu
df465ca8fd feat: doubao add first and last image to video 2026-01-27 17:33:00 +08:00
mehunk
65fd33e3ef feat: Add trusted redirect domains. 2026-01-26 22:18:04 +09:00
mehunk
d10f9126a4 docs: Update the comment of the functions. 2026-01-25 20:40:51 +09:00
mehunk
94076def9c feat: Support customizing the success and cancel url of Stripe. 2026-01-25 18:23:51 +09:00
feitianbubu
9c91b8fb18 feat: task pre consume modelPrice default use setting value 2026-01-24 15:32:06 +08:00
82 changed files with 2487 additions and 1096 deletions

View File

@@ -85,3 +85,8 @@ LINUX_DO_USER_ENDPOINT=https://connect.linux.do/api/user
# 节点类型
# 如果是主节点则为master
# NODE_TYPE=master
# 可信任重定向域名列表(逗号分隔,支持子域名匹配)
# 用于验证支付成功/取消回调URL的域名安全性
# 示例: example.com,myapp.io 将允许 example.com, sub.example.com, myapp.io 等
# TRUSTED_REDIRECT_DOMAINS=example.com,myapp.io

View File

@@ -159,4 +159,17 @@ func initConstantEnv() {
}
constant.TaskPricePatches = taskPricePatches
}
// Initialize trusted redirect domains for URL validation
trustedDomainsStr := GetEnvOrDefaultString("TRUSTED_REDIRECT_DOMAINS", "")
var trustedDomains []string
domains := strings.Split(trustedDomainsStr, ",")
for _, domain := range domains {
trimmedDomain := strings.TrimSpace(domain)
if trimmedDomain != "" {
// Normalize domain to lowercase
trustedDomains = append(trustedDomains, strings.ToLower(trimmedDomain))
}
}
constant.TrustedRedirectDomains = trustedDomains
}

39
common/url_validator.go Normal file
View File

@@ -0,0 +1,39 @@
package common
import (
"fmt"
"net/url"
"strings"
"github.com/QuantumNous/new-api/constant"
)
// ValidateRedirectURL validates that a redirect URL is safe to use.
// It checks that:
// - The URL is properly formatted
// - The scheme is either http or https
// - The domain is in the trusted domains list (exact match or subdomain)
//
// Returns nil if the URL is valid and trusted, otherwise returns an error
// describing why the validation failed.
func ValidateRedirectURL(rawURL string) error {
// Parse the URL
parsedURL, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL format: %s", err.Error())
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("invalid URL scheme: only http and https are allowed")
}
domain := strings.ToLower(parsedURL.Hostname())
for _, trustedDomain := range constant.TrustedRedirectDomains {
if domain == trustedDomain || strings.HasSuffix(domain, "."+trustedDomain) {
return nil
}
}
return fmt.Errorf("domain %s is not in the trusted domains list", domain)
}

View File

@@ -0,0 +1,134 @@
package common
import (
"testing"
"github.com/QuantumNous/new-api/constant"
)
func TestValidateRedirectURL(t *testing.T) {
// Save original trusted domains and restore after test
originalDomains := constant.TrustedRedirectDomains
defer func() {
constant.TrustedRedirectDomains = originalDomains
}()
tests := []struct {
name string
url string
trustedDomains []string
wantErr bool
errContains string
}{
// Valid cases
{
name: "exact domain match with https",
url: "https://example.com/success",
trustedDomains: []string{"example.com"},
wantErr: false,
},
{
name: "exact domain match with http",
url: "http://example.com/callback",
trustedDomains: []string{"example.com"},
wantErr: false,
},
{
name: "subdomain match",
url: "https://sub.example.com/success",
trustedDomains: []string{"example.com"},
wantErr: false,
},
{
name: "case insensitive domain",
url: "https://EXAMPLE.COM/success",
trustedDomains: []string{"example.com"},
wantErr: false,
},
// Invalid cases - untrusted domain
{
name: "untrusted domain",
url: "https://evil.com/phishing",
trustedDomains: []string{"example.com"},
wantErr: true,
errContains: "not in the trusted domains list",
},
{
name: "suffix attack - fakeexample.com",
url: "https://fakeexample.com/success",
trustedDomains: []string{"example.com"},
wantErr: true,
errContains: "not in the trusted domains list",
},
{
name: "empty trusted domains list",
url: "https://example.com/success",
trustedDomains: []string{},
wantErr: true,
errContains: "not in the trusted domains list",
},
// Invalid cases - scheme
{
name: "javascript scheme",
url: "javascript:alert('xss')",
trustedDomains: []string{"example.com"},
wantErr: true,
errContains: "invalid URL scheme",
},
{
name: "data scheme",
url: "data:text/html,<script>alert('xss')</script>",
trustedDomains: []string{"example.com"},
wantErr: true,
errContains: "invalid URL scheme",
},
// Edge cases
{
name: "empty URL",
url: "",
trustedDomains: []string{"example.com"},
wantErr: true,
errContains: "invalid URL scheme",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up trusted domains for this test case
constant.TrustedRedirectDomains = tt.trustedDomains
err := ValidateRedirectURL(tt.url)
if tt.wantErr {
if err == nil {
t.Errorf("ValidateRedirectURL(%q) expected error containing %q, got nil", tt.url, tt.errContains)
return
}
if tt.errContains != "" && !contains(err.Error(), tt.errContains) {
t.Errorf("ValidateRedirectURL(%q) error = %q, want error containing %q", tt.url, err.Error(), tt.errContains)
}
} else {
if err != nil {
t.Errorf("ValidateRedirectURL(%q) unexpected error: %v", tt.url, err)
}
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -20,3 +20,7 @@ var TaskQueryLimit int
// temporary variable for sora patch, will be removed in future
var TaskPricePatches []string
// TrustedRedirectDomains is a list of trusted domains for redirect URL validation.
// Domains support subdomain matching (e.g., "example.com" matches "sub.example.com").
var TrustedRedirectDomains []string

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

@@ -24,10 +24,11 @@ func getDiskSpaceInfo() DiskSpaceInfo {
return info
}
// 计算磁盘空间
info.Total = stat.Blocks * uint64(stat.Bsize)
info.Free = stat.Bavail * uint64(stat.Bsize)
info.Used = info.Total - stat.Bfree*uint64(stat.Bsize)
// 计算磁盘空间 (显式转换以兼容 FreeBSD其字段类型为 int64)
bsize := uint64(stat.Bsize)
info.Total = uint64(stat.Blocks) * bsize
info.Free = uint64(stat.Bavail) * bsize
info.Used = info.Total - uint64(stat.Bfree)*bsize
if info.Total > 0 {
info.UsedPercent = float64(info.Used) / float64(info.Total) * 100

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

@@ -28,9 +28,18 @@ const (
var stripeAdaptor = &StripeAdaptor{}
// StripePayRequest represents a payment request for Stripe checkout.
type StripePayRequest struct {
Amount int64 `json:"amount"`
// Amount is the quantity of units to purchase.
Amount int64 `json:"amount"`
// PaymentMethod specifies the payment method (e.g., "stripe").
PaymentMethod string `json:"payment_method"`
// SuccessURL is the optional custom URL to redirect after successful payment.
// If empty, defaults to the server's console log page.
SuccessURL string `json:"success_url,omitempty"`
// CancelURL is the optional custom URL to redirect when payment is canceled.
// If empty, defaults to the server's console topup page.
CancelURL string `json:"cancel_url,omitempty"`
}
type StripeAdaptor struct {
@@ -69,6 +78,16 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
return
}
if req.SuccessURL != "" && common.ValidateRedirectURL(req.SuccessURL) != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "支付成功重定向URL不在可信任域名列表中", "data": ""})
return
}
if req.CancelURL != "" && common.ValidateRedirectURL(req.CancelURL) != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "支付取消重定向URL不在可信任域名列表中", "data": ""})
return
}
id := c.GetInt("id")
user, _ := model.GetUserById(id, false)
chargedMoney := GetChargedAmount(float64(req.Amount), *user)
@@ -76,7 +95,7 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
referenceId := "ref_" + common.Sha1([]byte(reference))
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount)
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount, req.SuccessURL, req.CancelURL)
if err != nil {
log.Println("获取Stripe Checkout支付链接失败", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
@@ -210,17 +229,37 @@ func sessionExpired(event stripe.Event) {
log.Println("充值订单已过期", referenceId)
}
func genStripeLink(referenceId string, customerId string, email string, amount int64) (string, error) {
// genStripeLink generates a Stripe Checkout session URL for payment.
// It creates a new checkout session with the specified parameters and returns the payment URL.
//
// Parameters:
// - referenceId: unique reference identifier for the transaction
// - customerId: existing Stripe customer ID (empty string if new customer)
// - email: customer email address for new customer creation
// - amount: quantity of units to purchase
// - successURL: custom URL to redirect after successful payment (empty for default)
// - cancelURL: custom URL to redirect when payment is canceled (empty for default)
//
// Returns the checkout session URL or an error if the session creation fails.
func genStripeLink(referenceId string, customerId string, email string, amount int64, successURL string, cancelURL string) (string, error) {
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
return "", fmt.Errorf("无效的Stripe API密钥")
}
stripe.Key = setting.StripeApiSecret
// Use custom URLs if provided, otherwise use defaults
if successURL == "" {
successURL = system_setting.ServerAddress + "/console/log"
}
if cancelURL == "" {
cancelURL = system_setting.ServerAddress + "/console/topup"
}
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
SuccessURL: stripe.String(successURL),
CancelURL: stripe.String(cancelURL),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(setting.StripePriceId),

View File

@@ -449,11 +449,12 @@ type GeminiChatResponse struct {
}
type GeminiUsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
CachedContentTokenCount int `json:"cachedContentTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
}
type GeminiPromptTokensDetails struct {

View File

@@ -817,6 +817,10 @@ type OpenAIResponsesRequest struct {
User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
// qwen
EnableThinking json.RawMessage `json:"enable_thinking,omitempty"`
// perplexity
Preset json.RawMessage `json:"preset,omitempty"`
}
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {

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"
@@ -84,6 +84,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.ChannelBaseUrl)
case constant.RelayModeRerank:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
case constant.RelayModeResponses:
fullRequestURL = fmt.Sprintf("%s/api/v2/apps/protocols/compatible-mode/v1/responses", info.ChannelBaseUrl)
case constant.RelayModeImagesGenerations:
if isSyncImageModel(info.OriginModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
@@ -210,8 +212,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
return request, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {

View File

@@ -98,6 +98,19 @@ func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]s
return headerOverride, nil
}
func applyHeaderOverrideToRequest(req *http.Request, headerOverride map[string]string) {
if req == nil {
return
}
for key, value := range headerOverride {
req.Header.Set(key, value)
// set Host in req
if strings.EqualFold(key, "Host") {
req.Host = value
}
}
}
func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
fullRequestURL, err := a.GetRequestURL(info)
if err != nil {
@@ -121,9 +134,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
if err != nil {
return nil, err
}
for key, value := range headerOverride {
headers.Set(key, value)
}
applyHeaderOverrideToRequest(req, headerOverride)
resp, err := doRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
@@ -156,9 +167,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
if err != nil {
return nil, err
}
for key, value := range headerOverride {
headers.Set(key, value)
}
applyHeaderOverrideToRequest(req, headerOverride)
resp, err := doRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)

View File

@@ -437,8 +437,10 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse
}
} else {
if claudeResponse.Type == "message_start" {
response.Id = claudeResponse.Message.Id
response.Model = claudeResponse.Message.Model
if claudeResponse.Message != nil {
response.Id = claudeResponse.Message.Id
response.Model = claudeResponse.Message.Model
}
//claudeUsage = &claudeResponse.Message.Usage
choice.Delta.SetContentString("")
choice.Delta.Role = "assistant"
@@ -589,35 +591,63 @@ type ClaudeResponseInfo struct {
}
func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
if claudeInfo == nil {
return false
}
if claudeInfo.Usage == nil {
claudeInfo.Usage = &dto.Usage{}
}
if requestMode == RequestModeCompletion {
claudeInfo.ResponseText.WriteString(claudeResponse.Completion)
} else {
if claudeResponse.Type == "message_start" {
claudeInfo.ResponseId = claudeResponse.Message.Id
claudeInfo.Model = claudeResponse.Message.Model
if claudeResponse.Message != nil {
claudeInfo.ResponseId = claudeResponse.Message.Id
claudeInfo.Model = claudeResponse.Message.Model
}
// message_start, 获取usage
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta.Text != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
}
if claudeResponse.Delta.Thinking != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta != nil {
if claudeResponse.Delta.Text != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
}
if claudeResponse.Delta.Thinking != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
}
}
} else if claudeResponse.Type == "message_delta" {
// 最终的usage获取
if claudeResponse.Usage.InputTokens > 0 {
// 不叠加,只取最新的
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
if claudeResponse.Usage != nil {
if claudeResponse.Usage.InputTokens > 0 {
// 不叠加,只取最新的
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
}
if claudeResponse.Usage.CacheReadInputTokens > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
}
if claudeResponse.Usage.CacheCreationInputTokens > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
}
if cacheCreation5m := claudeResponse.Usage.GetCacheCreation5mTokens(); cacheCreation5m > 0 {
claudeInfo.Usage.ClaudeCacheCreation5mTokens = cacheCreation5m
}
if cacheCreation1h := claudeResponse.Usage.GetCacheCreation1hTokens(); cacheCreation1h > 0 {
claudeInfo.Usage.ClaudeCacheCreation1hTokens = cacheCreation1h
}
if claudeResponse.Usage.OutputTokens > 0 {
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
}
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
// 判断是否完整
claudeInfo.Done = true
@@ -657,7 +687,9 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
} else {
if claudeResponse.Type == "message_start" {
// message_start, 获取usage
info.UpstreamModelName = claudeResponse.Message.Model
if claudeResponse.Message != nil {
info.UpstreamModelName = claudeResponse.Message.Model
}
} else if claudeResponse.Type == "content_block_delta" {
} else if claudeResponse.Type == "message_delta" {
}
@@ -745,13 +777,18 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
if requestMode == RequestModeCompletion {
claudeInfo.Usage = service.ResponseText2Usage(c, claudeResponse.Completion, info.UpstreamModelName, info.GetEstimatePromptTokens())
} else {
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens()
if claudeInfo.Usage == nil {
claudeInfo.Usage = &dto.Usage{}
}
if claudeResponse.Usage != nil {
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens()
}
}
var responseData []byte
switch info.RelayFormat {
@@ -766,7 +803,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
responseData = data
}
if claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
if claudeResponse.Usage != nil && claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
}

View File

@@ -49,6 +49,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
}
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {

View File

@@ -988,11 +988,9 @@ func unescapeMapOrSlice(data interface{}) interface{} {
func getResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
var argsBytes []byte
var err error
if result, ok := item.FunctionCall.Arguments.(map[string]interface{}); ok {
argsBytes, err = json.Marshal(unescapeMapOrSlice(result))
} else {
argsBytes, err = json.Marshal(item.FunctionCall.Arguments)
}
// 移除 unescapeMapOrSlice 调用,直接使用 json.Marshal
// JSON 序列化/反序列化已经正确处理了转义字符
argsBytes, err = json.Marshal(item.FunctionCall.Arguments)
if err != nil {
return nil
@@ -1251,6 +1249,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
@@ -1395,6 +1394,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
}
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
@@ -1447,6 +1447,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
}
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {

View File

@@ -10,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
@@ -42,6 +43,9 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayMode == relayconstant.RelayModeResponses {
return fmt.Sprintf("%s/v1/responses", info.ChannelBaseUrl), nil
}
return fmt.Sprintf("%s/chat/completions", info.ChannelBaseUrl), nil
}
@@ -71,8 +75,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// TODO implement me
return nil, errors.New("not implemented")
return request, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {

View File

@@ -30,6 +30,7 @@ type ContentItem struct {
Text string `json:"text,omitempty"` // for text type
ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
Video *VideoReference `json:"video,omitempty"` // for video (sample) type
Role string `json:"role,omitempty"` // reference_image / first_frame / last_frame
}
type ImageURL struct {

View File

@@ -24,9 +24,9 @@ import (
)
const (
RequestModeClaude = 1
RequestModeGemini = 2
RequestModeLlama = 3
RequestModeClaude = 1
RequestModeGemini = 2
RequestModeOpenSource = 3
)
var claudeModelMap = map[string]string{
@@ -115,7 +115,7 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
} else if strings.Contains(info.UpstreamModelName, "llama") ||
// open source models
strings.Contains(info.UpstreamModelName, "-maas") {
a.RequestMode = RequestModeLlama
a.RequestMode = RequestModeOpenSource
} else {
a.RequestMode = RequestModeGemini
}
@@ -166,10 +166,9 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
suffix,
), nil
}
} else if a.RequestMode == RequestModeLlama {
} else if a.RequestMode == RequestModeOpenSource {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
region,
"https://aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
adc.ProjectID,
region,
), nil
@@ -242,7 +241,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
model = v
}
return a.getRequestUrl(info, model, suffix)
} else if a.RequestMode == RequestModeLlama {
} else if a.RequestMode == RequestModeOpenSource {
return a.getRequestUrl(info, "", "")
}
return "", errors.New("unsupported request mode")
@@ -340,7 +339,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
c.Set("request_model", request.Model)
return geminiRequest, nil
} else if a.RequestMode == RequestModeLlama {
} else if a.RequestMode == RequestModeOpenSource {
return request, nil
}
return nil, errors.New("unsupported request mode")
@@ -375,7 +374,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
} else {
return gemini.GeminiChatStreamHandler(c, info, resp)
}
case RequestModeLlama:
case RequestModeOpenSource:
return openai.OaiStreamHandler(c, info, resp)
}
} else {
@@ -391,7 +390,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
}
return gemini.GeminiChatHandler(c, info, resp)
}
case RequestModeLlama:
case RequestModeOpenSource:
return openai.OpenaiHandler(c, info, resp)
}
}

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

@@ -144,7 +144,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[modelName]
if !ok {
modelPrice = 0.1
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
} else {
modelPrice = defaultPrice
}

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

@@ -231,6 +231,8 @@ func GetMimeTypeByExtension(ext string) string {
return "image/png"
case "gif":
return "image/gif"
case "jfif":
return "image/jpeg"
// Audio files
case "mp3":

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"`
}
@@ -31,11 +33,40 @@ type ChannelAffinitySetting struct {
}
var channelAffinitySetting = ChannelAffinitySetting{
Enabled: false,
Enabled: true,
SwitchOnSuccess: true,
MaxEntries: 100_000,
DefaultTTLSeconds: 3600,
Rules: []ChannelAffinityRule{},
Rules: []ChannelAffinityRule{
{
Name: "codex trace",
ModelRegex: []string{"^gpt-.*$"},
PathRegex: []string{"/v1/responses"},
KeySources: []ChannelAffinityKeySource{
{Type: "gjson", Path: "prompt_cache_key"},
},
ValueRegex: "",
TTLSeconds: 0,
SkipRetryOnFailure: false,
IncludeUsingGroup: true,
IncludeRuleName: true,
UserAgentInclude: nil,
},
{
Name: "claude code trace",
ModelRegex: []string{"^claude-.*$"},
PathRegex: []string{"/v1/messages"},
KeySources: []ChannelAffinityKeySource{
{Type: "gjson", Path: "metadata.user_id"},
},
ValueRegex: "",
TTLSeconds: 0,
SkipRetryOnFailure: false,
IncludeUsingGroup: true,
IncludeRuleName: true,
UserAgentInclude: nil,
},
},
}
func init() {

View File

@@ -8,6 +8,8 @@ import (
)
var defaultCacheRatio = map[string]float64{
"gemini-3-flash-preview": 0.25,
"gemini-3-pro-preview": 0.25,
"gpt-4": 0.5,
"o1": 0.5,
"o1-2024-12-17": 0.5,

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

@@ -106,6 +106,21 @@ const highlightJson = (str) => {
);
};
const linkRegex = /(https?:\/\/[^\s<"'\]),;}]+)/g;
const linkifyHtml = (html) => {
const parts = html.split(/(<[^>]+>)/g);
return parts
.map((part) => {
if (part.startsWith('<')) return part;
return part.replace(
linkRegex,
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
);
})
.join('');
};
const isJsonLike = (content, language) => {
if (language === 'json') return true;
const trimmed = content.trim();
@@ -179,6 +194,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
return displayContent;
}, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
const renderedContent = useMemo(() => {
return linkifyHtml(highlightedContent);
}, [highlightedContent]);
const handleCopy = useCallback(async () => {
try {
const textToCopy =
@@ -276,6 +295,8 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
style={{
...codeThemeStyles.content,
paddingTop: contentPadding,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
className='model-settings-scroll'
>
@@ -303,7 +324,7 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
{t('正在处理大内容...')}
</div>
) : (
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
<div dangerouslySetInnerHTML={{ __html: renderedContent }} />
)}
</div>

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

@@ -120,7 +120,7 @@ const ContentModal = ({
}
return (
<div style={{ position: 'relative' }}>
<div style={{ position: 'relative', height: '100%' }}>
{isLoading && (
<div
style={{
@@ -137,7 +137,13 @@ const ContentModal = ({
<video
src={modalContent}
controls
style={{ width: '100%' }}
style={{
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
autoPlay
crossOrigin='anonymous'
onError={handleVideoError}
@@ -155,11 +161,13 @@ const ContentModal = ({
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{
height: isVideo ? '450px' : '400px',
height: isVideo ? '70vh' : '400px',
maxHeight: '80vh',
overflow: 'auto',
padding: isVideo && videoError ? '0' : '24px',
}}
width={800}
width={isVideo ? '90vw' : 800}
style={isVideo ? { maxWidth: 960 } : undefined}
>
{isVideo ? (
renderVideoContent()

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

@@ -167,21 +167,21 @@ export const getModelCategories = (() => {
gemini: {
label: 'Gemini',
icon: <Gemini.Color />,
filter: (model) =>
model.model_name.toLowerCase().includes('gemini') ||
filter: (model) =>
model.model_name.toLowerCase().includes('gemini') ||
model.model_name.toLowerCase().includes('gemma') ||
model.model_name.toLowerCase().includes('learnlm') ||
model.model_name.toLowerCase().includes('learnlm') ||
model.model_name.toLowerCase().startsWith('embedding-') ||
model.model_name.toLowerCase().includes('text-embedding-004') ||
model.model_name.toLowerCase().includes('imagen-4') ||
model.model_name.toLowerCase().includes('veo-') ||
model.model_name.toLowerCase().includes('aqa') ,
model.model_name.toLowerCase().includes('imagen-4') ||
model.model_name.toLowerCase().includes('veo-') ||
model.model_name.toLowerCase().includes('aqa'),
},
moonshot: {
label: 'Moonshot',
icon: <Moonshot />,
filter: (model) =>
model.model_name.toLowerCase().includes('moonshot') ||
filter: (model) =>
model.model_name.toLowerCase().includes('moonshot') ||
model.model_name.toLowerCase().includes('kimi'),
},
zhipu: {
@@ -189,8 +189,8 @@ export const getModelCategories = (() => {
icon: <Zhipu.Color />,
filter: (model) =>
model.model_name.toLowerCase().includes('chatglm') ||
model.model_name.toLowerCase().includes('glm-') ||
model.model_name.toLowerCase().includes('cogview') ||
model.model_name.toLowerCase().includes('glm-') ||
model.model_name.toLowerCase().includes('cogview') ||
model.model_name.toLowerCase().includes('cogvideo'),
},
qwen: {
@@ -206,8 +206,8 @@ export const getModelCategories = (() => {
minimax: {
label: 'MiniMax',
icon: <Minimax.Color />,
filter: (model) =>
model.model_name.toLowerCase().includes('abab') ||
filter: (model) =>
model.model_name.toLowerCase().includes('abab') ||
model.model_name.toLowerCase().includes('minimax'),
},
baidu: {
@@ -233,7 +233,7 @@ export const getModelCategories = (() => {
cohere: {
label: 'Cohere',
icon: <Cohere.Color />,
filter: (model) =>
filter: (model) =>
model.model_name.toLowerCase().includes('command') ||
model.model_name.toLowerCase().includes('c4ai-') ||
model.model_name.toLowerCase().includes('embed-'),
@@ -256,7 +256,7 @@ export const getModelCategories = (() => {
mistral: {
label: 'Mistral AI',
icon: <Mistral.Color />,
filter: (model) =>
filter: (model) =>
model.model_name.toLowerCase().includes('mistral') ||
model.model_name.toLowerCase().includes('codestral') ||
model.model_name.toLowerCase().includes('pixtral') ||
@@ -602,6 +602,34 @@ export function stringToColor(str) {
return colors[i];
}
// High-contrast color palette for group tags (avoids similar blue/teal shades)
const groupColors = [
'red',
'orange',
'yellow',
'lime',
'green',
'cyan',
'blue',
'indigo',
'violet',
'purple',
'pink',
'amber',
'grey',
];
export function groupToColor(str) {
// Use a better hash algorithm for more even distribution
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash = hash & hash;
}
hash = Math.abs(hash);
return groupColors[hash % groupColors.length];
}
// 渲染带有模型图标的标签
export function renderModelTag(modelName, options = {}) {
const {
@@ -670,7 +698,7 @@ export function renderGroup(group) {
<span key={group}>
{groups.map((group) => (
<Tag
color={tagColors[group] || stringToColor(group)}
color={tagColors[group] || groupToColor(group)}
key={group}
shape='circle'
onClick={async (event) => {

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

@@ -67,22 +67,24 @@ const KEY_SOURCE_TYPES = [
const RULE_TEMPLATES = {
codex: {
name: 'codex优选',
name: 'codex trace',
model_regex: ['^gpt-.*$'],
path_regex: ['/v1/responses'],
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,
},
claudeCode: {
name: 'claude-code优选',
name: 'claude-code trace',
model_regex: ['^claude-.*$'],
path_regex: ['/v1/messages'],
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;