Merge remote-tracking branch 'origin/main' into nightly

# Conflicts:
#	web/src/helpers/render.jsx
#	web/src/hooks/usage-logs/useUsageLogsData.jsx
#	web/src/i18n/locales/en.json
This commit is contained in:
CaIon
2026-04-09 17:12:21 +08:00
100 changed files with 6875 additions and 2699 deletions
+2
View File
@@ -19,6 +19,8 @@
# HOSTNAME=your-hostname
# 数据库相关配置
# 启用错误日志记录
# ERROR_LOG_ENABLED=true
# 数据库连接字符串
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
# 日志数据库连接字符串
+28
View File
@@ -0,0 +1,28 @@
# ⚠️ 提交说明 / PR Notice
> [!IMPORTANT]
>
> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
## 📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
## 🚀 变更类型 / Type of change
- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
## 🔗 关联任务 / Related Issue
- Closes # (如有)
## ✅ 提交前检查项 / Checklist
- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
## 📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
@@ -1,29 +0,0 @@
# ⚠️ 提交警告 / PR Warning
> **请注意:** 请提供**人工撰写**的简洁摘要。包含大量 AI 灌水内容、逻辑混乱或无视模版的 PR **可能会被无视或直接关闭**。
---
## 💡 沟通提示 / Pre-submission
> **重大功能变更?** 请先提交 Issue 交流,避免无效劳动。
## 📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?你必须理解代码逻辑,禁止粘贴 AI 废话)
## 🚀 变更类型 / Type of change
- [ ] 🐛 Bug 修复 (Bug fix)
- [ ] ✨ 新功能 (New feature) - *重大特性建议先 Issue 沟通*
- [ ] ⚡ 性能优化 / 重构 (Refactor)
- [ ] 📝 文档更新 (Documentation)
## 🔗 关联任务 / Related Issue
- Closes # (如有)
## ✅ 提交前检查项 / Checklist
- [ ] **人工确认:** 我已亲自撰写此描述,去除了 AI 原始输出的冗余。
- [ ] **深度理解:** 我已**完全理解**这些更改的工作原理及潜在影响。
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
- [ ] **本地验证:** 已在本地运行并通过了测试或手动验证。
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
## 📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
+33
View File
@@ -0,0 +1,33 @@
name: PR Check
permissions:
contents: read
issues: read
pull-requests: read
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0.2.1
with:
max-failures: 4
require-description: true
# require-linked-issue: false
blocked-terms: |
🤖 Generated with Claude Code
require-pr-template: true
strict-pr-template-sections: "✅ 提交前检查项 / Checklist"
detect-spam-usernames: true
min-account-age: 30
failure-add-pr-labels: "pr-check-failed"
failure-pr-message: "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容 (AI Slop),我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。/ Thank you for your submission. This PR has been closed because it does not follow our contribution template and has been identified as purely AI-generated content (AI Slop) without meaningful human involvement. We prioritize contributions that are human-verified and reflect individual effort. If you believe this is a mistake, please let us know by replying to this comment."
close-pr: true
+1
View File
@@ -80,6 +80,7 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
var SMTPServer = ""
var SMTPPort = 587
var SMTPSSLEnabled = false
var SMTPForceAuthLogin = false
var SMTPAccount = ""
var SMTPFrom = ""
var SMTPToken = ""
+15 -4
View File
@@ -19,6 +19,20 @@ func generateMessageID() (string, error) {
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
}
func shouldUseSMTPLoginAuth() bool {
if SMTPForceAuthLogin {
return true
}
return isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer)
}
func getSMTPAuth() smtp.Auth {
if shouldUseSMTPLoginAuth() {
return LoginAuth(SMTPAccount, SMTPToken)
}
return smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
}
func SendEmail(subject string, receiver string, content string) error {
if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount
@@ -38,7 +52,7 @@ func SendEmail(subject string, receiver string, content string) error {
"Message-ID: %s\r\n"+ // 添加 Message-ID 头
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
auth := getSMTPAuth()
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
to := strings.Split(receiver, ";")
var err error
@@ -80,9 +94,6 @@ func SendEmail(subject string, receiver string, content string) error {
if err != nil {
return err
}
} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
auth = LoginAuth(SMTPAccount, SMTPToken)
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
} else {
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
}
+26
View File
@@ -1,6 +1,7 @@
package controller
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -8,6 +9,30 @@ import (
"github.com/gin-gonic/gin"
)
func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
if len(pricing) == 0 {
return pricing
}
if len(usableGroup) == 0 {
return []model.Pricing{}
}
filtered := make([]model.Pricing, 0, len(pricing))
for _, item := range pricing {
if common.StringsContains(item.EnableGroup, "all") {
filtered = append(filtered, item)
continue
}
for _, group := range item.EnableGroup {
if _, ok := usableGroup[group]; ok {
filtered = append(filtered, item)
break
}
}
}
return filtered
}
func GetPricing(c *gin.Context) {
pricing := model.GetPricing()
userId, exists := c.Get("id")
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
}
usableGroup = service.GetUserUsableGroups(group)
pricing = filterPricingByUsableGroups(pricing, usableGroup)
// check groupRatio contains usableGroup
for group := range ratio_setting.GetGroupRatioCopy() {
if _, ok := usableGroup[group]; !ok {
+1 -1
View File
@@ -581,7 +581,7 @@ func RelayTask(c *gin.Context) {
ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios,
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName),
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
}
task.Quota = result.Quota
task.Data = result.TaskData
+23
View File
@@ -334,3 +334,26 @@ func DeleteTokenBatch(c *gin.Context) {
"data": count,
})
}
func GetTokenKeysBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if len(tokenBatch.Ids) > 100 {
common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
return
}
userId := c.GetInt("id")
tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId)
if err != nil {
common.ApiError(c, err)
return
}
keysMap := make(map[int]string)
for _, t := range tokens {
keysMap[t.Id] = t.GetFullKey()
}
common.ApiSuccess(c, gin.H{"keys": keysMap})
}
+15
View File
@@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
return
}
func GetQuotaDatesByUser(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": dates,
})
}
func GetUserQuotaDates(c *gin.Context) {
userId := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
+10
View File
@@ -18,6 +18,16 @@ type AudioRequest struct {
Speed *float64 `json:"speed,omitempty"`
StreamFormat string `json:"stream_format,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// vllm-omini
TaskType json.RawMessage `json:"task_type,omitempty"`
Language json.RawMessage `json:"language,omitempty"`
RefAudio json.RawMessage `json:"ref_audio,omitempty"`
RefText json.RawMessage `json:"ref_text,omitempty"`
XVectorOnlyMode json.RawMessage `json:"x_vector_only_mode,omitempty"`
MaxNewTokens json.RawMessage `json:"max_new_tokens,omitempty"`
InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
// TODOensure that the logic remains correct after the stream is started.
//Stream json.RawMessage `json:"stream,omitempty"`
}
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
+18 -24
View File
@@ -98,6 +98,20 @@ func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
return mediaContent
}
func (m *ClaudeMediaMessage) ToFileSource() types.FileSource {
if m.Source == nil {
return nil
}
data := m.Source.Url
if data == "" {
data = common.Interface2String(m.Source.Data)
}
if data == "" {
return nil
}
return types.NewFileSourceFromData(data, m.Source.MediaType)
}
type ClaudeMessageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type,omitempty"`
@@ -223,14 +237,6 @@ type OutputConfigForEffort struct {
Effort string `json:"effort,omitempty"`
}
// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
func createClaudeFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, "")
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
maxTokens := 0
if c.MaxTokens != nil {
@@ -258,22 +264,16 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
case "text":
texts = append(texts, media.GetText())
case "image":
if media.Source != nil {
data := media.Source.Url
if data == "" {
data = common.Interface2String(media.Source.Data)
}
if data != "" {
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
Source: source,
})
}
}
}
}
}
}
// messages
for _, message := range c.Messages {
@@ -293,18 +293,12 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
case "text":
texts = append(texts, media.GetText())
case "image":
if media.Source != nil {
data := media.Source.Url
if data == "" {
data = common.Interface2String(media.Source.Data)
}
if data != "" {
if source := media.ToFileSource(); source != nil {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
Source: source,
})
}
}
case "tool_use":
if media.Name != "" {
texts = append(texts, media.Name)
+13 -11
View File
@@ -64,14 +64,6 @@ type LatLng struct {
Longitude *float64 `json:"longitude,omitempty"`
}
// createGeminiFileSource 根据数据内容创建正确类型的 FileSource
func createGeminiFileSource(data string, mimeType string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, mimeType)
}
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
var files []*types.FileMeta = make([]*types.FileMeta, 0)
@@ -87,9 +79,8 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
if part.InlineData != nil && part.InlineData.Data != "" {
if source := part.InlineData.ToFileSource(); source != nil {
mimeType := part.InlineData.MimeType
source := createGeminiFileSource(part.InlineData.Data, mimeType)
var fileType types.FileType
if strings.HasPrefix(mimeType, "image/") {
fileType = types.FileTypeImage
@@ -103,7 +94,6 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
files = append(files, &types.FileMeta{
FileType: fileType,
Source: source,
MimeType: mimeType,
})
}
}
@@ -121,6 +111,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
if c.Query("alt") == "sse" {
return true
}
// Native Gemini API uses URL action to indicate streaming:
// /v1beta/models/{model}:streamGenerateContent
if strings.Contains(c.Request.URL.Path, "streamGenerateContent") {
return true
}
return false
}
@@ -210,6 +205,13 @@ type GeminiInlineData struct {
Data string `json:"data"`
}
func (d *GeminiInlineData) ToFileSource() types.FileSource {
if d == nil || d.Data == "" {
return nil
}
return types.NewFileSourceFromData(d.Data, d.MimeType)
}
// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
type Alias GeminiInlineData // Use type alias to avoid recursion
+73
View File
@@ -0,0 +1,73 @@
package dto
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestGeminiChatRequest_IsStream(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
path string
query string
expected bool
}{
{
name: "streamGenerateContent without alt=sse",
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
query: "key=sk-xxx",
expected: true,
},
{
name: "streamGenerateContent with alt=sse",
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
query: "alt=sse&key=sk-xxx",
expected: true,
},
{
name: "generateContent without alt=sse",
path: "/v1beta/models/gemini-2.0-flash:generateContent",
query: "key=sk-xxx",
expected: false,
},
{
name: "generateContent with alt=sse",
path: "/v1beta/models/gemini-2.0-flash:generateContent",
query: "alt=sse",
expected: true,
},
{
name: "GenerateContent capitalized",
path: "/v1beta/models/gemini-2.0-flash:GenerateContent",
query: "key=sk-xxx",
expected: false,
},
{
name: "embedding path",
path: "/v1beta/models/gemini-2.0-flash:embedContent",
query: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
url := tt.path
if tt.query != "" {
url += "?" + tt.query
}
c.Request, _ = http.NewRequest("POST", url, nil)
req := &GeminiChatRequest{}
assert.Equal(t, tt.expected, req.IsStream(c))
})
}
}
+52 -46
View File
@@ -108,14 +108,6 @@ type GeneralOpenAIRequest struct {
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
}
// createFileSource 根据数据内容创建正确类型的 FileSource
func createFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, "")
}
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
var tokenCountMeta types.TokenCountMeta
var texts = make([]string, 0)
@@ -159,44 +151,24 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
}
arrayContent := message.ParseContent()
for _, m := range arrayContent {
if m.Type == ContentTypeImageURL {
imageUrl := m.GetImageMedia()
if imageUrl != nil && imageUrl.Url != "" {
source := createFileSource(imageUrl.Url)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
Detail: imageUrl.Detail,
})
source := m.ToFileSource()
if source != nil {
meta := &types.FileMeta{Source: source}
switch m.Type {
case ContentTypeImageURL:
meta.FileType = types.FileTypeImage
if img := m.GetImageMedia(); img != nil {
meta.Detail = img.Detail
}
} else if m.Type == ContentTypeInputAudio {
inputAudio := m.GetInputAudio()
if inputAudio != nil && inputAudio.Data != "" {
source := createFileSource(inputAudio.Data)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeAudio,
Source: source,
})
case ContentTypeInputAudio:
meta.FileType = types.FileTypeAudio
case ContentTypeFile:
meta.FileType = types.FileTypeFile
case ContentTypeVideoUrl:
meta.FileType = types.FileTypeVideo
}
} else if m.Type == ContentTypeFile {
file := m.GetFile()
if file != nil && file.FileData != "" {
source := createFileSource(file.FileData)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile,
Source: source,
})
}
} else if m.Type == ContentTypeVideoUrl {
videoUrl := m.GetVideoUrl()
if videoUrl != nil && videoUrl.Url != "" {
source := createFileSource(videoUrl.Url)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeVideo,
Source: source,
})
}
} else {
fileMeta = append(fileMeta, meta)
} else if m.Type == ContentTypeText {
texts = append(texts, m.Text)
}
}
@@ -391,6 +363,40 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {
return nil
}
func (m *MediaContent) ToFileSource() types.FileSource {
switch m.Type {
case ContentTypeImageURL:
img := m.GetImageMedia()
if img == nil || img.Url == "" {
return nil
}
return types.NewFileSourceFromData(img.Url, img.MimeType)
case ContentTypeInputAudio:
audio := m.GetInputAudio()
if audio == nil || audio.Data == "" {
return nil
}
mimeType := ""
if audio.Format != "" {
mimeType = "audio/" + audio.Format
}
return types.NewFileSourceFromData(audio.Data, mimeType)
case ContentTypeFile:
file := m.GetFile()
if file == nil || file.FileData == "" {
return nil
}
return types.NewFileSourceFromData(file.FileData, "")
case ContentTypeVideoUrl:
video := m.GetVideoUrl()
if video == nil || video.Url == "" {
return nil
}
return types.NewFileSourceFromData(video.Url, "")
}
return nil
}
type MessageImageUrl struct {
Url string `json:"url"`
Detail string `json:"detail,omitempty"`
@@ -865,7 +871,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.ImageUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createFileSource(input.ImageUrl),
Source: types.NewFileSourceFromData(input.ImageUrl, ""),
Detail: input.Detail,
})
}
@@ -873,7 +879,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.FileUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile,
Source: createFileSource(input.FileUrl),
Source: types.NewFileSourceFromData(input.FileUrl, ""),
})
}
} else {
Generated Vendored
+10 -10
View File
@@ -9,7 +9,7 @@
"version": "1.0.0",
"devDependencies": {
"cross-env": "^7.0.3",
"electron": "35.7.5",
"electron": "39.8.5",
"electron-builder": "^26.7.0"
}
},
@@ -777,9 +777,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2145,9 +2145,9 @@
}
},
"node_modules/electron": {
"version": "35.7.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
"integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==",
"version": "39.8.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz",
"integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -3279,9 +3279,9 @@
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
+1 -1
View File
@@ -25,7 +25,7 @@
},
"devDependencies": {
"cross-env": "^7.0.3",
"electron": "35.7.5",
"electron": "39.8.5",
"electron-builder": "^26.7.0"
},
"build": {
+5 -5
View File
@@ -8,9 +8,9 @@ require (
github.com/abema/go-mp4 v1.4.1
github.com/andybalholm/brotli v1.1.1
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
github.com/aws/aws-sdk-go-v2 v1.41.2
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/aws/smithy-go v1.24.2
github.com/bytedance/gopkg v0.1.3
github.com/gin-contrib/cors v1.7.2
@@ -63,9 +63,9 @@ require (
require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
+10 -10
View File
@@ -12,18 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+1
View File
@@ -25,6 +25,7 @@ const (
MsgDeleteFailed = "common.delete_failed"
MsgAlreadyExists = "common.already_exists"
MsgNameCannotBeEmpty = "common.name_cannot_be_empty"
MsgBatchTooMany = "common.batch_too_many"
)
// Token related messages
+1
View File
@@ -21,6 +21,7 @@ common.delete_success: "Deletion successful"
common.delete_failed: "Deletion failed"
common.already_exists: "Already exists"
common.name_cannot_be_empty: "Name cannot be empty"
common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}"
# Token messages
token.name_too_long: "Token name is too long"
+1
View File
@@ -22,6 +22,7 @@ common.delete_success: "删除成功"
common.delete_failed: "删除失败"
common.already_exists: "已存在"
common.name_cannot_be_empty: "名称不能为空"
common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
# Token messages
token.name_too_long: "令牌名称过长"
+1
View File
@@ -22,6 +22,7 @@ common.delete_success: "刪除成功"
common.delete_failed: "刪除失敗"
common.already_exists: "已存在"
common.name_cannot_be_empty: "名稱不能為空"
common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
# Token messages
token.name_too_long: "令牌名稱過長"
+10 -4
View File
@@ -1,7 +1,7 @@
package middleware
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -48,17 +48,23 @@ func checkSystemPerformance() *types.NewAPIError {
// 检查 CPU
if config.CPUThreshold > 0 && int(status.CPUUsage) > config.CPUThreshold {
return types.NewErrorWithStatusCode(errors.New("system cpu overloaded"), "system_cpu_overloaded", http.StatusServiceUnavailable)
return types.NewErrorWithStatusCode(
fmt.Errorf("system cpu overloaded (current: %.1f%%, threshold: %d%%)", status.CPUUsage, config.CPUThreshold),
"system_cpu_overloaded", http.StatusServiceUnavailable)
}
// 检查内存
if config.MemoryThreshold > 0 && int(status.MemoryUsage) > config.MemoryThreshold {
return types.NewErrorWithStatusCode(errors.New("system memory overloaded"), "system_memory_overloaded", http.StatusServiceUnavailable)
return types.NewErrorWithStatusCode(
fmt.Errorf("system memory overloaded (current: %.1f%%, threshold: %d%%)", status.MemoryUsage, config.MemoryThreshold),
"system_memory_overloaded", http.StatusServiceUnavailable)
}
// 检查磁盘
if config.DiskThreshold > 0 && int(status.DiskUsage) > config.DiskThreshold {
return types.NewErrorWithStatusCode(errors.New("system disk overloaded"), "system_disk_overloaded", http.StatusServiceUnavailable)
return types.NewErrorWithStatusCode(
fmt.Errorf("system disk overloaded (current: %.1f%%, threshold: %d%%)", status.DiskUsage, config.DiskThreshold),
"system_disk_overloaded", http.StatusServiceUnavailable)
}
return nil
+12 -1
View File
@@ -2,14 +2,25 @@ package middleware
import (
"context"
"crypto/sha256"
"encoding/hex"
"runtime/debug"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
var _bp = func() string {
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Path != "" {
h := sha256.Sum256([]byte(bi.Main.Path))
return hex.EncodeToString(h[:4])
}
return common.GetRandomString(8)
}()
func RequestId() func(c *gin.Context) {
return func(c *gin.Context) {
id := common.GetTimeString() + common.GetRandomString(8)
id := common.GetTimeString() + _bp + common.GetRandomString(8)
c.Set(common.RequestIdKey, id)
ctx := context.WithValue(c.Request.Context(), common.RequestIdKey, id)
c.Request = c.Request.WithContext(ctx)
+4 -1
View File
@@ -62,6 +62,7 @@ func InitOptionMap() {
common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = ""
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
common.OptionMap["Notice"] = ""
common.OptionMap["About"] = ""
common.OptionMap["HomePageContent"] = ""
@@ -233,7 +234,7 @@ func updateOptionMap(key string, value string) (err error) {
common.ImageDownloadPermission = intValue
}
}
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" {
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" || key == "SMTPForceAuthLogin" {
boolValue := value == "true"
switch key {
case "PasswordRegisterEnabled":
@@ -308,6 +309,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.StopOnSensitiveEnabled = boolValue
case "SMTPSSLEnabled":
common.SMTPSSLEnabled = boolValue
case "SMTPForceAuthLogin":
common.SMTPForceAuthLogin = boolValue
case "WorkerAllowHttpImageRequestEnabled":
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
+8
View File
@@ -481,3 +481,11 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) {
return len(tokens), nil
}
func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) {
var tokens []Token
err := DB.Select("id", commonKeyCol).
Where("user_id = ? AND id IN (?)", userId, ids).
Find(&tokens).Error
return tokens, err
}
+10
View File
@@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
return quotaDatas, err
}
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
err = DB.Table("quota_data").
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
Where("created_at >= ? and created_at <= ?", startTime, endTime).
Group("username, created_at").
Find(&quotaDatas).Error
return quotaDatas, err
}
func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
if username != "" {
return GetQuotaDataByUsername(username, startTime, endTime)
@@ -85,7 +85,7 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
require.EqualValues(t, 50, usage.CacheCreationInputTokens)
require.EqualValues(t, 53, usage.OutputTokens)
require.NotNil(t, usage.CacheCreation)
require.EqualValues(t, 10, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 30, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 20, usage.CacheCreation.Ephemeral1hInputTokens)
})
@@ -108,4 +108,22 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
require.EqualValues(t, 7, usage.CacheReadInputTokens)
require.EqualValues(t, 6, usage.CacheCreationInputTokens)
})
t.Run("default aggregate cache creation to 5m when split missing", func(t *testing.T) {
claudeResponse := &dto.ClaudeResponse{Usage: &dto.ClaudeUsage{
OutputTokens: 53,
CacheCreationInputTokens: 50,
}}
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{
PromptTokensDetails: dto.InputTokenDetails{
CachedCreationTokens: 50,
},
}}
usage := buildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
require.NotNil(t, usage)
require.NotNil(t, usage.CacheCreation)
require.EqualValues(t, 50, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 0, usage.CacheCreation.Ephemeral1hInputTokens)
})
}
+60 -25
View File
@@ -85,7 +85,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
// 解析 UserLocation JSON
var userLocationMap map[string]interface{}
if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
// 检查是否有 approximate 字段
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
@@ -177,7 +177,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
claudeRequest.TopP = common.GetPointer[float64](0)
claudeRequest.TopP = nil
claudeRequest.Temperature = common.GetPointer[float64](1.0)
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
@@ -343,33 +343,39 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
} else {
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
for _, mediaMessage := range message.ParseContent() {
claudeMediaMessage := dto.ClaudeMediaMessage{
Type: mediaMessage.Type,
}
if mediaMessage.Type == "text" {
claudeMediaMessage.Text = common.GetPointer[string](mediaMessage.Text)
} else {
imageUrl := mediaMessage.GetImageMedia()
claudeMediaMessage.Type = "image"
claudeMediaMessage.Source = &dto.ClaudeMessageSource{
Type: "base64",
}
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(imageUrl.Url, "http") {
source = types.NewURLFileSource(imageUrl.Url)
} else {
source = types.NewBase64FileSource(imageUrl.Url, "")
switch mediaMessage.Type {
case "text":
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](mediaMessage.Text),
})
default:
source := mediaMessage.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
claudeMediaMessage := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudeMediaMessage.Type = "document"
} else {
claudeMediaMessage.Type = "image"
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
}
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
continue
}
}
if message.ToolCalls != nil {
for _, toolCall := range message.ParseToolCalls() {
inputObj := make(map[string]any)
@@ -574,6 +580,11 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
return dto.Usage{}
}
clone := *usage
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = service.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
clone.PromptTokens = totalInputTokens
@@ -603,11 +614,26 @@ func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
}
if usage.CacheCreation == nil && (claudeInfo.Usage.ClaudeCacheCreation5mTokens > 0 || claudeInfo.Usage.ClaudeCacheCreation1hTokens > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: claudeInfo.Usage.ClaudeCacheCreation5mTokens,
Ephemeral1hInputTokens: claudeInfo.Usage.ClaudeCacheCreation1hTokens,
cacheCreation5m := 0
cacheCreation1h := 0
if usage.CacheCreation != nil {
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
} else {
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
}
cacheCreation5m, cacheCreation1h = service.NormalizeCacheCreationSplit(
usage.CacheCreationInputTokens,
cacheCreation5m,
cacheCreation1h,
)
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
}
if usage.CacheCreation != nil {
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
}
return usage
}
@@ -783,7 +809,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error")
}
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if claudeInfo.Usage.CompletionTokens == 0 ||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
}
if claudeInfo.Usage.PromptTokens == 0 {
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
}
if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
+125
View File
@@ -1,10 +1,12 @@
package claude
import (
"encoding/base64"
"strings"
"testing"
"github.com/QuantumNous/new-api/dto"
"github.com/stretchr/testify/require"
)
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
@@ -255,3 +257,126 @@ func TestBuildOpenAIStyleUsageFromClaudeUsagePreservesCacheCreationRemainder(t *
})
}
}
func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 100,
CompletionTokens: 20,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 30,
CachedCreationTokens: 50,
},
UsageSemantic: "anthropic",
}
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage)
require.Equal(t, 50, openAIUsage.ClaudeCacheCreation5mTokens)
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
}
func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeText,
Text: "see attachment",
},
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "blob.bin",
FileData: "JVBERi0xLjQK",
},
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 1)
require.Equal(t, "text", content[0].Type)
require.NotNil(t, content[0].Text)
require.Equal(t, "see attachment", *content[0].Text)
}
func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "spec.pdf",
FileData: "JVBERi0xLjQK",
},
},
dto.MediaContent{
Type: dto.ContentTypeText,
Text: "summarize it",
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 2)
require.Equal(t, "document", content[0].Type)
require.NotNil(t, content[0].Source)
require.Equal(t, "base64", content[0].Source.Type)
require.Equal(t, "application/pdf", content[0].Source.MediaType)
require.Equal(t, "JVBERi0xLjQK", content[0].Source.Data)
require.Equal(t, "text", content[1].Type)
require.NotNil(t, content[1].Text)
require.Equal(t, "summarize it", *content[1].Text)
}
func TestRequestOpenAI2ClaudeMessage_ConvertsTextFileContentToText(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "notes.txt",
FileData: base64.StdEncoding.EncodeToString([]byte("alpha\nbeta")),
},
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 1)
require.Equal(t, "text", content[0].Type)
require.NotNil(t, content[0].Text)
require.Equal(t, "alpha\nbeta", *content[0].Text)
}
+5 -37
View File
@@ -37,6 +37,8 @@ var geminiSupportedMimeTypes = map[string]bool{
"image/jpeg": true,
"image/jpg": true, // support old image/jpeg
"image/webp": true,
"image/heic": true,
"image/heif": true,
"text/plain": true,
"video/mov": true,
"video/mpeg": true,
@@ -583,14 +585,10 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
Text: part.Text,
})
}
} else if part.Type == dto.ContentTypeImageURL {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
imageUrl := part.GetImageMedia().Url
if strings.HasPrefix(imageUrl, "http") {
source = types.NewURLFileSource(imageUrl)
} else {
source = types.NewBase64FileSource(imageUrl, "")
source := part.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
if err != nil {
@@ -602,36 +600,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeFile {
if part.GetFile().FileId != "" {
return nil, fmt.Errorf("only base64 file is supported in gemini")
}
fileSource := types.NewBase64FileSource(part.GetFile().FileData, "")
base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeInputAudio {
if part.GetInputAudio().Data == "" {
return nil, fmt.Errorf("only base64 audio is supported in gemini")
}
audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format)
base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
+7 -1
View File
@@ -78,7 +78,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return request, nil
if info.RelayMode != constant.RelayModeImagesGenerations {
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
}
return oaiImage2MiniMaxImageRequest(request), nil
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -121,6 +124,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if info.RelayMode == constant.RelayModeAudioSpeech {
return handleTTSResponse(c, resp, info)
}
if info.RelayMode == constant.RelayModeImagesGenerations {
return miniMaxImageHandler(c, resp, info)
}
switch info.RelayFormat {
case types.RelayFormatClaude:
+137
View File
@@ -0,0 +1,137 @@
package minimax
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
)
func TestGetRequestURLForImageGeneration(t *testing.T) {
t.Parallel()
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelBaseUrl: "https://api.minimax.chat",
},
}
got, err := GetRequestURL(info)
if err != nil {
t.Fatalf("GetRequestURL returned error: %v", err)
}
want := "https://api.minimax.chat/v1/image_generation"
if got != want {
t.Fatalf("GetRequestURL() = %q, want %q", got, want)
}
}
func TestConvertImageRequest(t *testing.T) {
t.Parallel()
adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
OriginModelName: "image-01",
}
request := dto.ImageRequest{
Model: "image-01",
Prompt: "a red fox in snowfall",
Size: "1536x1024",
ResponseFormat: "url",
N: uintPtr(2),
}
got, err := adaptor.ConvertImageRequest(gin.CreateTestContextOnly(httptest.NewRecorder(), gin.New()), info, request)
if err != nil {
t.Fatalf("ConvertImageRequest returned error: %v", err)
}
body, err := json.Marshal(got)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if payload["model"] != "image-01" {
t.Fatalf("model = %#v, want %q", payload["model"], "image-01")
}
if payload["prompt"] != request.Prompt {
t.Fatalf("prompt = %#v, want %q", payload["prompt"], request.Prompt)
}
if payload["n"] != float64(2) {
t.Fatalf("n = %#v, want 2", payload["n"])
}
if payload["aspect_ratio"] != "3:2" {
t.Fatalf("aspect_ratio = %#v, want %q", payload["aspect_ratio"], "3:2")
}
if payload["response_format"] != "url" {
t.Fatalf("response_format = %#v, want %q", payload["response_format"], "url")
}
}
func TestDoResponseForImageGeneration(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
StartTime: time.Unix(1700000000, 0),
}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: httptest.NewRecorder().Result().Body,
}
resp.Body = ioNopCloser(`{"data":{"image_urls":["https://example.com/minimax.png"]}}`)
adaptor := &Adaptor{}
usage, err := adaptor.DoResponse(c, resp, info)
if err != nil {
t.Fatalf("DoResponse returned error: %v", err)
}
if usage == nil {
t.Fatalf("DoResponse returned nil usage")
}
body := recorder.Body.String()
if !strings.Contains(body, `"url":"https://example.com/minimax.png"`) {
t.Fatalf("response body = %s, want OpenAI image response with image URL", body)
}
if strings.Contains(body, `"image_urls"`) {
t.Fatalf("response body = %s, should not expose raw MiniMax image_urls payload", body)
}
}
type nopReadCloser struct {
*strings.Reader
}
func (n nopReadCloser) Close() error {
return nil
}
func ioNopCloser(body string) nopReadCloser {
return nopReadCloser{Reader: strings.NewReader(body)}
}
func uintPtr(v uint) *uint {
return &v
}
+4
View File
@@ -8,6 +8,8 @@ var ModelList = []string{
"abab6-chat",
"abab5.5-chat",
"abab5.5s-chat",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"speech-2.5-hd-preview",
"speech-2.5-turbo-preview",
"speech-02-hd",
@@ -19,6 +21,8 @@ var ModelList = []string{
"MiniMax-M2",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"image-01",
"image-01-live",
}
var ChannelName = "minimax"
+213
View File
@@ -0,0 +1,213 @@
package minimax
import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
type MiniMaxImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
AspectRatio string `json:"aspect_ratio,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
N int `json:"n,omitempty"`
PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
AigcWatermark *bool `json:"aigc_watermark,omitempty"`
}
type MiniMaxImageResponse struct {
ID string `json:"id"`
Data struct {
ImageURLs []string `json:"image_urls"`
ImageBase64 []string `json:"image_base64"`
} `json:"data"`
Metadata map[string]any `json:"metadata"`
BaseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
}
func oaiImage2MiniMaxImageRequest(request dto.ImageRequest) MiniMaxImageRequest {
responseFormat := normalizeMiniMaxResponseFormat(request.ResponseFormat)
minimaxRequest := MiniMaxImageRequest{
Model: request.Model,
Prompt: request.Prompt,
ResponseFormat: responseFormat,
N: 1,
AigcWatermark: request.Watermark,
}
if request.Model == "" {
minimaxRequest.Model = "image-01"
}
if request.N != nil && *request.N > 0 {
minimaxRequest.N = int(*request.N)
}
if aspectRatio := aspectRatioFromImageRequest(request); aspectRatio != "" {
minimaxRequest.AspectRatio = aspectRatio
}
if raw, ok := request.Extra["prompt_optimizer"]; ok {
var promptOptimizer bool
if err := common.Unmarshal(raw, &promptOptimizer); err == nil {
minimaxRequest.PromptOptimizer = &promptOptimizer
}
}
return minimaxRequest
}
func aspectRatioFromImageRequest(request dto.ImageRequest) string {
if raw, ok := request.Extra["aspect_ratio"]; ok {
var aspectRatio string
if err := common.Unmarshal(raw, &aspectRatio); err == nil && aspectRatio != "" {
return aspectRatio
}
}
switch request.Size {
case "1024x1024":
return "1:1"
case "1792x1024":
return "16:9"
case "1024x1792":
return "9:16"
case "1536x1024", "1248x832":
return "3:2"
case "1024x1536", "832x1248":
return "2:3"
case "1152x864":
return "4:3"
case "864x1152":
return "3:4"
case "1344x576":
return "21:9"
}
width, height, ok := parseImageSize(request.Size)
if !ok {
return ""
}
ratio := reduceAspectRatio(width, height)
switch ratio {
case "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9":
return ratio
default:
return ""
}
}
func parseImageSize(size string) (int, int, bool) {
parts := strings.Split(size, "x")
if len(parts) != 2 {
return 0, 0, false
}
width, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
height, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
if width <= 0 || height <= 0 {
return 0, 0, false
}
return width, height, true
}
func reduceAspectRatio(width, height int) string {
divisor := gcd(width, height)
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
}
func gcd(a, b int) int {
for b != 0 {
a, b = b, a%b
}
if a == 0 {
return 1
}
return a
}
func normalizeMiniMaxResponseFormat(responseFormat string) string {
switch strings.ToLower(responseFormat) {
case "", "url":
return "url"
case "b64_json", "base64":
return "base64"
default:
return responseFormat
}
}
func responseMiniMax2OpenAIImage(response *MiniMaxImageResponse, info *relaycommon.RelayInfo) (*dto.ImageResponse, error) {
imageResponse := &dto.ImageResponse{
Created: info.StartTime.Unix(),
}
for _, imageURL := range response.Data.ImageURLs {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: imageURL})
}
for _, imageBase64 := range response.Data.ImageBase64 {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: imageBase64})
}
if len(response.Metadata) > 0 {
metadata, err := common.Marshal(response.Metadata)
if err != nil {
return nil, err
}
imageResponse.Metadata = metadata
}
return imageResponse, nil
}
func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
var minimaxResponse MiniMaxImageResponse
if err := common.Unmarshal(responseBody, &minimaxResponse); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if minimaxResponse.BaseResp.StatusCode != 0 {
return nil, types.WithOpenAIError(types.OpenAIError{
Message: minimaxResponse.BaseResp.StatusMsg,
Type: "minimax_image_error",
Code: fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode),
}, resp.StatusCode)
}
openAIResponse, err := responseMiniMax2OpenAIImage(&minimaxResponse, info)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
jsonResponse, err := common.Marshal(openAIResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
if _, err := c.Writer.Write(jsonResponse); err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
return &dto.Usage{}, nil
}
+2
View File
@@ -21,6 +21,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode {
case constant.RelayModeChatCompletions:
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil
case constant.RelayModeAudioSpeech:
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
default:
+2 -9
View File
@@ -98,15 +98,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
parts := m.ParseContent()
for _, part := range parts {
if part.Type == dto.ContentTypeImageURL {
img := part.GetImageMedia()
if img != nil && img.Url != "" {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(img.Url, "http") {
source = types.NewURLFileSource(img.Url)
} else {
source = types.NewBase64FileSource(img.Url, "")
}
source := part.ToFileSource()
if source != nil {
base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
if err != nil {
return nil, err
+1 -1
View File
@@ -369,7 +369,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
a.ResponseFormat = request.ResponseFormat
if info.RelayMode == relayconstant.RelayModeAudioSpeech {
jsonData, err := json.Marshal(request)
jsonData, err := common.Marshal(request)
if err != nil {
return nil, fmt.Errorf("error marshalling object: %w", err)
}
+3 -3
View File
@@ -80,9 +80,9 @@ type AliVideoOutput struct {
// AliUsage 使用统计
type AliUsage struct {
Duration int `json:"duration,omitempty"`
VideoCount int `json:"video_count,omitempty"`
SR int `json:"SR,omitempty"`
Duration dto.IntValue `json:"duration,omitempty"`
VideoCount dto.IntValue `json:"video_count,omitempty"`
SR dto.IntValue `json:"SR,omitempty"`
}
type AliMetadata struct {
+88 -31
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
@@ -13,12 +14,13 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel"
taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"github.com/samber/lo"
)
// ============================
@@ -26,35 +28,35 @@ import (
// ============================
type ContentItem struct {
Type string `json:"type"` // "text", "image_url" or "video"
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 string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
ImageURL *MediaURL `json:"image_url,omitempty"`
VideoURL *MediaURL `json:"video_url,omitempty"`
AudioURL *MediaURL `json:"audio_url,omitempty"`
Role string `json:"role,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
}
type VideoReference struct {
URL string `json:"url"` // Draft video URL
type MediaURL struct {
URL string `json:"url,omitempty"`
}
type requestPayload struct {
Model string `json:"model"`
Content []ContentItem `json:"content"`
Content []ContentItem `json:"content,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
ReturnLastFrame *dto.BoolValue `json:"return_last_frame,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
ExecutionExpiresAfter dto.IntValue `json:"execution_expires_after,omitempty"`
ExecutionExpiresAfter *dto.IntValue `json:"execution_expires_after,omitempty"`
GenerateAudio *dto.BoolValue `json:"generate_audio,omitempty"`
Draft *dto.BoolValue `json:"draft,omitempty"`
Tools []struct {
Type string `json:"type,omitempty"`
} `json:"tools,omitempty"`
Resolution string `json:"resolution,omitempty"`
Ratio string `json:"ratio,omitempty"`
Duration dto.IntValue `json:"duration,omitempty"`
Frames dto.IntValue `json:"frames,omitempty"`
Seed dto.IntValue `json:"seed,omitempty"`
Duration *dto.IntValue `json:"duration,omitempty"`
Frames *dto.IntValue `json:"frames,omitempty"`
Seed *dto.IntValue `json:"seed,omitempty"`
CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"`
Watermark *dto.BoolValue `json:"watermark,omitempty"`
}
@@ -76,10 +78,20 @@ type responseTask struct {
Ratio string `json:"ratio"`
FramesPerSecond int `json:"framespersecond"`
ServiceTier string `json:"service_tier"`
Tools []struct {
Type string `json:"type"`
} `json:"tools"`
Usage struct {
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
ToolUsage struct {
WebSearch int `json:"web_search"`
} `json:"tool_usage"`
} `json:"usage"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
@@ -108,18 +120,61 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+a.apiKey)
return nil
}
// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。
func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
req, err := relaycommon.GetTaskRequest(c)
if err != nil {
return nil
}
if hasVideoInMetadata(req.Metadata) {
if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok {
return map[string]float64{"video_input": ratio}
}
}
return nil
}
// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目,
// 避免构建完整的上游 requestPayload。
func hasVideoInMetadata(metadata map[string]interface{}) bool {
if metadata == nil {
return false
}
contentRaw, ok := metadata["content"]
if !ok {
return false
}
contentSlice, ok := contentRaw.([]interface{})
if !ok {
return false
}
for _, item := range contentSlice {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if itemMap["type"] == "video_url" {
return true
}
if _, has := itemMap["video_url"]; has {
return true
}
}
return false
}
// BuildRequestBody converts request into Doubao specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
req, err := relaycommon.GetTaskRequest(c)
@@ -218,20 +273,12 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
Content: []ContentItem{},
}
// Add text prompt
if req.Prompt != "" {
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
}
// Add images if present
if req.HasImage() {
for _, imgURL := range req.Images {
r.Content = append(r.Content, ContentItem{
Type: "image_url",
ImageURL: &ImageURL{
ImageURL: &MediaURL{
URL: imgURL,
},
})
@@ -243,6 +290,16 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
return nil, errors.Wrap(err, "unmarshal metadata failed")
}
if sec, _ := strconv.Atoi(req.Seconds); sec > 0 {
r.Duration = lo.ToPtr(dto.IntValue(sec))
}
r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" })
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
return &r, nil
}
@@ -274,7 +331,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
case "failed":
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
taskResult.Reason = "task failed"
taskResult.Reason = resTask.Error.Message
default:
// Unknown status, treat as processing
taskResult.Status = model.TaskStatusInProgress
@@ -302,8 +359,8 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro
if dResp.Status == "failed" {
openAIVideo.Error = &dto.OpenAIVideoError{
Message: "task failed",
Code: "failed",
Message: dResp.Error.Message,
Code: dResp.Error.Code,
}
}
+15
View File
@@ -5,6 +5,21 @@ var ModelList = []string{
"doubao-seedance-1-0-lite-t2v",
"doubao-seedance-1-0-lite-i2v",
"doubao-seedance-1-5-pro-251215",
"doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128",
}
var ChannelName = "doubao-video"
// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。
// 管理员应将 ModelRatio 设置为"不含视频"的较高费率,
// 系统在检测到视频输入时自动乘以此折扣。
var videoInputRatioMap = map[string]float64{
"doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087
"doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946
}
func GetVideoInputRatio(modelName string) (float64, bool) {
r, ok := videoInputRatioMap[modelName]
return r, ok
}
+3
View File
@@ -64,6 +64,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
}
return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
case relayconstant.RelayModeImagesGenerations:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
}
return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
default:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
+16
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -696,6 +697,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
type Alias TaskSubmitReq
aux := &struct {
Metadata json.RawMessage `json:"metadata,omitempty"`
Duration json.RawMessage `json:"duration,omitempty"`
*Alias
}{
Alias: (*Alias)(t),
@@ -705,6 +707,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
return err
}
if len(aux.Duration) > 0 {
var durationInt int
if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
t.Duration = durationInt
} else {
var durationStr string
if err := common.Unmarshal(aux.Duration, &durationStr); err == nil && durationStr != "" {
if v, err := strconv.Atoi(durationStr); err == nil {
t.Duration = v
}
}
}
}
if len(aux.Metadata) > 0 {
var metadataStr string
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {
+3 -1
View File
@@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
if err != nil {
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
}
} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
}
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
}
+26 -12
View File
@@ -146,21 +146,23 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return priceData, nil
}
// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task)
// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) {
groupRatioInfo := HandleGroupRatio(c, info)
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
// 如果没有配置价格,检查模型倍率配置
if !success {
usePrice := success
var modelRatio float64
// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
if ok {
modelPrice = defaultPrice
usePrice = true
} else {
// 没有配置倍率也不接受没配置,那就返回错误
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
var ratioSuccess bool
var matchName string
modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
acceptUnsetRatio = true
@@ -168,25 +170,37 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
if !ratioSuccess && !acceptUnsetRatio {
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
}
// 未配置价格但配置了倍率,使用默认预扣价格
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
}
}
}
quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
// 免费模型检测(与 ModelPriceHelper 对齐)
var quota int
freeModel := false
if usePrice {
quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
quota = 0
freeModel = true
}
}
} else {
// 按量计费:以模型倍率的一半作为预扣额度
quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
modelPrice = -1
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
quota = 0
freeModel = true
}
}
}
priceData := types.PriceData{
FreeModel: freeModel,
ModelPrice: modelPrice,
ModelRatio: modelRatio,
UsePrice: usePrice,
Quota: quota,
GroupRatioInfo: groupRatioInfo,
}
+2
View File
@@ -257,6 +257,7 @@ func SetApiRouter(router *gin.Engine) {
tokenRoute.PUT("/", controller.UpdateToken)
tokenRoute.DELETE("/:id", controller.DeleteToken)
tokenRoute.POST("/batch", controller.DeleteTokenBatch)
tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
}
usageRoute := apiRouter.Group("/usage")
@@ -292,6 +293,7 @@ func SetApiRouter(router *gin.Engine) {
dataRoute := apiRouter.Group("/data")
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
+17 -4
View File
@@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
unknown++
continue
}
if rule.IncludeUsingGroup {
if rule.IncludeModelName {
if len(parts) < 3 {
unknown++
continue
}
}
if rule.IncludeUsingGroup {
minParts := 3
if rule.IncludeModelName {
minParts = 4
}
if len(parts) < minParts {
unknown++
continue
}
}
byRuleName[ruleName]++
}
@@ -319,11 +329,14 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
}
}
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 3)
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 4)
if rule.IncludeRuleName && rule.Name != "" {
parts = append(parts, rule.Name)
}
if rule.IncludeModelName && modelName != "" {
parts = append(parts, modelName)
}
if rule.IncludeUsingGroup && usingGroup != "" {
parts = append(parts, usingGroup)
}
@@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
if ttlSeconds <= 0 {
ttlSeconds = setting.DefaultTTLSeconds
}
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
setChannelAffinityContext(c, channelAffinityMeta{
CacheKey: cacheKeyFull,
+1 -1
View File
@@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
require.NotNil(t, codexRule)
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
+32 -10
View File
@@ -227,21 +227,31 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
if oaiUsage == nil {
return nil
}
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
oaiUsage.PromptTokensDetails.CachedCreationTokens,
oaiUsage.ClaudeCacheCreation5mTokens,
oaiUsage.ClaudeCacheCreation1hTokens,
)
usage := &dto.ClaudeUsage{
InputTokens: oaiUsage.PromptTokens,
OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
}
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
if cacheCreation5m > 0 || cacheCreation1h > 0 {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return usage
}
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
return tokens5m + remainder, tokens1h
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
if info.ClaudeConvertInfo.Done {
return nil
@@ -426,23 +436,28 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
if len(openAIResponse.Choices) == 0 {
// no choices
// 可能为非标准的 OpenAI 响应,判断是否已经完成
if info.ClaudeConvertInfo.Done {
stopOpenBlocks()
oaiUsage := info.ClaudeConvertInfo.Usage
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
}
if oaiUsage != nil {
stopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
StopReason: common.GetPointer[string](stopReason),
},
})
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
info.ClaudeConvertInfo.Done = true
}
return claudeResponses
} else {
@@ -450,6 +465,13 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk {
info.FinishReason = *chosenChoice.FinishReason
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Defer closing until usage is available so the final message_delta carries it.
return claudeResponses
}
}
var claudeResponse dto.ClaudeResponse
+9
View File
@@ -104,6 +104,11 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
return sniffed, nil
}
// Try HEIF/HEIC detection (Go standard library doesn't recognize it)
if heifMime := detectHEIF(readData); heifMime != "" {
return heifMime, nil
}
if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil {
switch strings.ToLower(format) {
case "jpeg", "jpg":
@@ -168,6 +173,10 @@ func GetMimeTypeByExtension(ext string) string {
return "image/gif"
case "jfif":
return "image/jpeg"
case "heic":
return "image/heic"
case "heif":
return "image/heif"
// Audio files
case "mp3":
+165 -29
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"image"
_ "image/gif"
@@ -24,14 +25,26 @@ import (
// FileService 统一的文件处理服务
// 提供文件下载、解码、缓存等功能的统一入口
// getContextCacheKey 生成 context 缓存的 key
// getContextCacheKey 生成 URL context 缓存的 key
func getContextCacheKey(url string) string {
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
}
// getBase64ContextCacheKey 生成 base64 context 缓存的 key
// 使用 length + MIME + 前 128 字符作为输入,避免对整个 base64 数据做 hash
func getBase64ContextCacheKey(data string, mimeType string) string {
keyMaterial := fmt.Sprintf("%d:%s:", len(data), mimeType)
if len(data) > 128 {
keyMaterial += data[:128]
} else {
keyMaterial += data
}
return fmt.Sprintf("b64_cache_%s", common.GenerateHMAC(keyMaterial))
}
// LoadFileSource 加载文件源数据
// 这是统一的入口,会自动处理缓存和不同的来源类型
func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) {
func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (*types.CachedFileData, error) {
if source == nil {
return nil, fmt.Errorf("file source is nil")
}
@@ -42,7 +55,6 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
// 1. 快速检查内部缓存
if source.HasCache() {
// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
if c != nil {
registerSourceForCleanup(c, source)
}
@@ -61,39 +73,49 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
return source.GetCache(), nil
}
// 4. 如果是 URL,检查 Context 缓存
// 4. 根据来源类型加载(含 URL context 缓存查找)
var cachedData *types.CachedFileData
var contextKey string
if source.IsURL() && c != nil {
contextKey = getContextCacheKey(source.URL)
if cachedData, exists := c.Get(contextKey); exists {
data := cachedData.(*types.CachedFileData)
var err error
switch s := source.(type) {
case *types.URLSource:
if c != nil {
contextKey = getContextCacheKey(s.URL)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
// 5. 执行加载逻辑
var cachedData *types.CachedFileData
var err error
if source.IsURL() {
cachedData, err = loadFromURL(c, source.URL, reason...)
} else {
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
cachedData, err = loadFromURL(c, s.URL, reason...)
case *types.Base64Source:
if c != nil {
contextKey = getBase64ContextCacheKey(s.Base64Data, s.MimeType)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
cachedData, err = loadFromBase64(s.Base64Data, s.MimeType)
default:
return nil, fmt.Errorf("unsupported file source type: %T", source)
}
if err != nil {
return nil, err
}
// 6. 设置缓存
// 5. 设置缓存
source.SetCache(cachedData)
if contextKey != "" && c != nil {
c.Set(contextKey, cachedData)
}
// 7. 注册到 context 以便请求结束时自动清理
// 6. 注册到 context 以便请求结束时自动清理
if c != nil {
registerSourceForCleanup(c, source)
}
@@ -102,15 +124,15 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
}
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
func registerSourceForCleanup(c *gin.Context, source types.FileSource) {
if source.IsRegistered() {
return
}
key := string(constant.ContextKeyFileSourcesToCleanup)
var sources []*types.FileSource
var sources []types.FileSource
if existing, exists := c.Get(key); exists {
sources = existing.([]*types.FileSource)
sources = existing.([]types.FileSource)
}
sources = append(sources, source)
c.Set(key, sources)
@@ -122,12 +144,12 @@ func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
func CleanupFileSources(c *gin.Context) {
key := string(constant.ContextKeyFileSourcesToCleanup)
if sources, exists := c.Get(key); exists {
for _, source := range sources.([]*types.FileSource) {
for _, source := range sources.([]types.FileSource) {
if cache := source.GetCache(); cache != nil {
cache.Close()
}
}
c.Set(key, nil) // 清除引用
c.Set(key, nil)
}
}
@@ -275,6 +297,11 @@ func smartDetectMimeType(resp *http.Response, url string, fileBytes []byte) stri
}
return sniffed
}
// 4.5 尝试 HEIF/HEIC 检测(Go 标准库不识别)
if heifMime := detectHEIF(fileBytes); heifMime != "" {
return heifMime
}
}
// 5. 尝试作为图片解码获取格式
@@ -357,7 +384,7 @@ func loadFromBase64(base64String string, providedMimeType string) (*types.Cached
}
// GetImageConfig 获取图片配置
func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) {
func GetImageConfig(c *gin.Context, source types.FileSource) (image.Config, string, error) {
cachedData, err := LoadFileSource(c, source, "get_image_config")
if err != nil {
return image.Config{}, "", err
@@ -388,7 +415,7 @@ func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, str
}
// GetBase64Data 获取 base64 编码的数据
func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) {
func GetBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) {
cachedData, err := LoadFileSource(c, source, reason...)
if err != nil {
return "", "", err
@@ -401,13 +428,13 @@ func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (
}
// GetMimeType 获取文件的 MIME 类型
func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) {
func GetMimeType(c *gin.Context, source types.FileSource) (string, error) {
if source.HasCache() {
return source.GetCache().MimeType, nil
}
if source.IsURL() {
mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type")
if urlSource, ok := source.(*types.URLSource); ok {
mimeType, err := GetFileTypeFromUrl(c, urlSource.URL, "get_mime_type")
if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
return mimeType, nil
}
@@ -449,9 +476,118 @@ func decodeImageConfig(data []byte) (image.Config, string, error) {
return config, "webp", nil
}
// Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions
if heifMime := detectHEIF(data); heifMime != "" {
formatName := "heif"
if heifMime == "image/heic" {
formatName = "heic"
}
if w, h, ok := parseHEIFDimensions(data); ok {
return image.Config{Width: w, Height: h}, formatName, nil
}
return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions")
}
return image.Config{}, "", fmt.Errorf("failed to decode image config: unsupported format")
}
// detectHEIF checks ISOBMFF magic bytes to detect HEIC/HEIF files.
// Returns "image/heic", "image/heif", or "" if not recognized.
func detectHEIF(data []byte) string {
if len(data) < 12 {
return ""
}
// ISOBMFF: bytes[4:8] must be "ftyp"
if string(data[4:8]) != "ftyp" {
return ""
}
brand := string(data[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "heim", "heis":
return "image/heic"
case "mif1", "msf1":
return "image/heif"
default:
return ""
}
}
// parseHEIFDimensions parses ISOBMFF box tree to find the ispe box
// and extract image width/height. Returns (width, height, ok).
func parseHEIFDimensions(data []byte) (int, int, bool) {
size := len(data)
if size < 12 {
return 0, 0, false
}
// Walk top-level boxes to find "meta"
offset := 0
for offset+8 <= size {
boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4]))
boxType := string(data[offset+4 : offset+8])
headerLen := 8
if boxSize == 1 {
// 64-bit extended size
if offset+16 > size {
break
}
boxSize = int(binary.BigEndian.Uint64(data[offset+8 : offset+16]))
headerLen = 16
} else if boxSize == 0 {
// box extends to end of data
boxSize = size - offset
}
if boxSize < headerLen || offset+boxSize > size {
break
}
if boxType == "meta" {
// meta is a full box: 4 bytes version/flags after header
metaData := data[offset+headerLen : offset+boxSize]
if len(metaData) < 4 {
return 0, 0, false
}
return findISPE(metaData[4:])
}
offset += boxSize
}
return 0, 0, false
}
// findISPE recursively searches for the ispe box within container boxes.
// Path: meta -> iprp -> ipco -> ispe
func findISPE(data []byte) (int, int, bool) {
offset := 0
size := len(data)
for offset+8 <= size {
boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4]))
boxType := string(data[offset+4 : offset+8])
if boxSize < 8 || offset+boxSize > size {
break
}
content := data[offset+8 : offset+boxSize]
switch boxType {
case "iprp", "ipco":
if w, h, ok := findISPE(content); ok {
return w, h, true
}
case "ispe":
// ispe is a full box: 4 bytes version/flags, then 4 bytes width, 4 bytes height
if len(content) >= 12 {
w := int(binary.BigEndian.Uint32(content[4:8]))
h := int(binary.BigEndian.Uint32(content[8:12]))
if w > 0 && h > 0 {
return w, h, true
}
}
}
offset += boxSize
}
return 0, 0, false
}
// guessMimeTypeFromURL 从 URL 猜测 MIME 类型
func guessMimeTypeFromURL(url string) string {
cleanedURL := url
+30 -14
View File
@@ -159,20 +159,36 @@ func DecodeUrlImageData(imageUrl string) (image.Config, string, error) {
}
func getImageConfig(reader io.Reader) (image.Config, string, error) {
// Read all data so we can retry with different decoders
data, readErr := io.ReadAll(reader)
if readErr != nil {
return image.Config{}, "", fmt.Errorf("failed to read image data: %w", readErr)
}
// 读取图片的头部信息来获取图片尺寸
config, format, err := image.DecodeConfig(reader)
if err != nil {
err = errors.New(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
common.SysLog(err.Error())
config, err = webp.DecodeConfig(reader)
if err != nil {
err = errors.New(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
common.SysLog(err.Error())
}
format = "webp"
}
if err != nil {
return image.Config{}, "", err
}
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err == nil {
return config, format, nil
}
common.SysLog(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
config, err = webp.DecodeConfig(bytes.NewReader(data))
if err == nil {
return config, "webp", nil
}
common.SysLog(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
// Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions
if heifMime := detectHEIF(data); heifMime != "" {
formatName := "heif"
if heifMime == "image/heic" {
formatName = "heic"
}
if w, h, ok := parseHEIFDimensions(data); ok {
return image.Config{Width: w, Height: h}, formatName, nil
}
return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions")
}
return image.Config{}, "", err
}
+20 -4
View File
@@ -36,8 +36,12 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
}
}
other := make(map[string]interface{})
other["is_task"] = true
other["request_path"] = c.Request.URL.Path
other["model_price"] = info.PriceData.ModelPrice
if info.PriceData.ModelRatio > 0 {
other["model_ratio"] = info.PriceData.ModelRatio
}
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
if info.PriceData.GroupRatioInfo.HasSpecialRatio {
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
@@ -117,6 +121,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} {
other := make(map[string]interface{})
if bc := task.PrivateData.BillingContext; bc != nil {
other["model_price"] = bc.ModelPrice
if bc.ModelRatio > 0 {
other["model_ratio"] = bc.ModelRatio
}
other["group_ratio"] = bc.GroupRatio
if len(bc.OtherRatios) > 0 {
for k, v := range bc.OtherRatios {
@@ -222,7 +229,6 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
}
other := taskBillingOther(task)
other["task_id"] = task.TaskID
//other["reason"] = reason
other["pre_consumed_quota"] = preConsumedQuota
other["actual_quota"] = actualQuota
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
@@ -277,9 +283,19 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
finalGroupRatio = groupRatio
}
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio)
// 计算 OtherRatios 乘积(视频折扣、时长等)
otherMultiplier := 1.0
if bc := task.PrivateData.BillingContext; bc != nil {
for _, r := range bc.OtherRatios {
if r != 1.0 && r > 0 {
otherMultiplier *= r
}
}
}
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio)
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
RecalculateTaskQuota(ctx, task, actualQuota, reason)
}
-3
View File
@@ -100,8 +100,6 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
if err != nil {
return 0, err
}
fileMeta.MimeType = format
if config.Width == 0 || config.Height == 0 {
// not an image, but might be a valid file
if format != "" {
@@ -268,7 +266,6 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
}
continue
}
file.MimeType = cachedData.MimeType
file.FileType = DetectFileType(cachedData.MimeType)
}
}
@@ -20,9 +20,10 @@ type ChannelAffinityRule struct {
ParamOverrideTemplate map[string]interface{} `json:"param_override_template,omitempty"`
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
SkipRetryOnFailure bool `json:"skip_retry_on_failure"`
IncludeUsingGroup bool `json:"include_using_group"`
IncludeModelName bool `json:"include_model_name"`
IncludeRuleName bool `json:"include_rule_name"`
}
+4 -4
View File
@@ -361,6 +361,10 @@ func UpdateModelPriceByJSONString(jsonStr string) error {
func GetModelPrice(name string, printErr bool) (float64, bool) {
name = FormatMatchingModelName(name)
if price, ok := modelPriceMap.Get(name); ok {
return price, true
}
if strings.HasSuffix(name, CompactModelSuffix) {
price, ok := modelPriceMap.Get(CompactWildcardModelKey)
if !ok {
@@ -372,14 +376,10 @@ func GetModelPrice(name string, printErr bool) (float64, bool) {
return price, true
}
price, ok := modelPriceMap.Get(name)
if !ok {
if printErr {
common.SysError("model price not found: " + name)
}
return -1, false
}
return price, true
}
func UpdateModelRatioByJSONString(jsonStr string) error {
+128 -127
View File
@@ -4,39 +4,144 @@ import (
"fmt"
"image"
"os"
"strings"
"sync"
)
// FileSourceType 文件来源类型
type FileSourceType string
const (
FileSourceTypeURL FileSourceType = "url" // URL 来源
FileSourceTypeBase64 FileSourceType = "base64" // Base64 内联数据
)
// FileSource 统一的文件来源抽象
// FileSource 统一的文件来源抽象接口
// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制
type FileSource struct {
Type FileSourceType `json:"type"` // 来源类型
URL string `json:"url,omitempty"` // URL(当 Type 为 url 时)
Base64Data string `json:"base64_data,omitempty"` // Base64 数据(当 Type 为 base64 时)
MimeType string `json:"mime_type,omitempty"` // MIME 类型(可选,会自动检测)
type FileSource interface {
IsURL() bool
GetIdentifier() string
GetRawData() string
ClearRawData()
// 内部缓存(不导出,不序列化)
SetCache(data *CachedFileData)
GetCache() *CachedFileData
HasCache() bool
ClearCache()
IsRegistered() bool
SetRegistered(registered bool)
Mu() *sync.Mutex
}
// baseFileSource 共享的缓存/锁/清理注册状态
type baseFileSource struct {
cachedData *CachedFileData
cacheLoaded bool
registered bool // 是否已注册到清理列表
mu sync.Mutex // 保护加载过程
registered bool
mu sync.Mutex
}
// Mu 获取内部锁
func (f *FileSource) Mu() *sync.Mutex {
return &f.mu
func (b *baseFileSource) SetCache(data *CachedFileData) {
b.cachedData = data
b.cacheLoaded = true
}
// CachedFileData 缓存的文件数据
// 支持内存缓存和磁盘缓存两种模式
func (b *baseFileSource) GetCache() *CachedFileData {
return b.cachedData
}
func (b *baseFileSource) HasCache() bool {
return b.cacheLoaded && b.cachedData != nil
}
func (b *baseFileSource) ClearCache() {
if b.cachedData != nil {
b.cachedData.Close()
}
b.cachedData = nil
b.cacheLoaded = false
}
func (b *baseFileSource) IsRegistered() bool {
return b.registered
}
func (b *baseFileSource) SetRegistered(registered bool) {
b.registered = registered
}
func (b *baseFileSource) Mu() *sync.Mutex {
return &b.mu
}
// ---------------------------------------------------------------------------
// URLSource — URL 来源的 FileSource 实现
// ---------------------------------------------------------------------------
type URLSource struct {
baseFileSource
URL string
}
func (u *URLSource) IsURL() bool { return true }
func (u *URLSource) GetIdentifier() string {
if len(u.URL) > 100 {
return u.URL[:100] + "..."
}
return u.URL
}
func (u *URLSource) GetRawData() string { return u.URL }
func (u *URLSource) ClearRawData() {}
// ---------------------------------------------------------------------------
// Base64Source — Base64 内联数据来源的 FileSource 实现
// ---------------------------------------------------------------------------
type Base64Source struct {
baseFileSource
Base64Data string
MimeType string
}
func (b *Base64Source) IsURL() bool { return false }
func (b *Base64Source) GetIdentifier() string {
if len(b.Base64Data) > 50 {
return "base64:" + b.Base64Data[:50] + "..."
}
return "base64:" + b.Base64Data
}
func (b *Base64Source) GetRawData() string { return b.Base64Data }
func (b *Base64Source) ClearRawData() {
if len(b.Base64Data) > 1024 {
b.Base64Data = ""
}
}
// ---------------------------------------------------------------------------
// Constructors
// ---------------------------------------------------------------------------
func NewURLFileSource(url string) *URLSource {
return &URLSource{URL: url}
}
func NewBase64FileSource(base64Data string, mimeType string) *Base64Source {
return &Base64Source{
Base64Data: base64Data,
MimeType: mimeType,
}
}
func NewFileSourceFromData(data string, mimeType string) FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return NewURLFileSource(data)
}
return NewBase64FileSource(data, mimeType)
}
// ---------------------------------------------------------------------------
// CachedFileData — 缓存的文件数据(支持内存和磁盘两种模式)
// ---------------------------------------------------------------------------
type CachedFileData struct {
base64Data string // 内存中的 base64 数据(小文件)
MimeType string // MIME 类型
@@ -45,18 +150,15 @@ type CachedFileData struct {
ImageConfig *image.Config // 图片配置(如果是图片)
ImageFormat string // 图片格式(如果是图片)
// 磁盘缓存相关
diskPath string // 磁盘缓存文件路径(大文件)
isDisk bool // 是否使用磁盘缓存
diskMu sync.Mutex // 磁盘操作锁(保护磁盘文件的读取和删除)
diskClosed bool // 是否已关闭/清理
statDecremented bool // 是否已扣减统计
// 统计回调,避免循环依赖
OnClose func(size int64)
}
// NewMemoryCachedData 创建内存缓存的数据
func NewMemoryCachedData(base64Data string, mimeType string, size int64) *CachedFileData {
return &CachedFileData{
base64Data: base64Data,
@@ -66,7 +168,6 @@ func NewMemoryCachedData(base64Data string, mimeType string, size int64) *Cached
}
}
// NewDiskCachedData 创建磁盘缓存的数据
func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFileData {
return &CachedFileData{
diskPath: diskPath,
@@ -76,7 +177,6 @@ func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFile
}
}
// GetBase64Data 获取 base64 数据(自动处理内存/磁盘)
func (c *CachedFileData) GetBase64Data() (string, error) {
if !c.isDisk {
return c.base64Data, nil
@@ -89,7 +189,6 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
return "", fmt.Errorf("disk cache already closed")
}
// 从磁盘读取
data, err := os.ReadFile(c.diskPath)
if err != nil {
return "", fmt.Errorf("failed to read from disk cache: %w", err)
@@ -97,22 +196,19 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
return string(data), nil
}
// SetBase64Data 设置 base64 数据(仅用于内存模式)
func (c *CachedFileData) SetBase64Data(data string) {
if !c.isDisk {
c.base64Data = data
}
}
// IsDisk 是否使用磁盘缓存
func (c *CachedFileData) IsDisk() bool {
return c.isDisk
}
// Close 关闭并清理资源
func (c *CachedFileData) Close() error {
if !c.isDisk {
c.base64Data = "" // 释放内存
c.base64Data = ""
return nil
}
@@ -126,7 +222,6 @@ func (c *CachedFileData) Close() error {
c.diskClosed = true
if c.diskPath != "" {
err := os.Remove(c.diskPath)
// 只有在删除成功且未扣减过统计时,才执行回调
if err == nil && !c.statDecremented && c.OnClose != nil {
c.OnClose(c.DiskSize)
c.statDecremented = true
@@ -135,97 +230,3 @@ func (c *CachedFileData) Close() error {
}
return nil
}
// NewURLFileSource 创建 URL 来源的 FileSource
func NewURLFileSource(url string) *FileSource {
return &FileSource{
Type: FileSourceTypeURL,
URL: url,
}
}
// NewBase64FileSource 创建 base64 来源的 FileSource
func NewBase64FileSource(base64Data string, mimeType string) *FileSource {
return &FileSource{
Type: FileSourceTypeBase64,
Base64Data: base64Data,
MimeType: mimeType,
}
}
// IsURL 判断是否是 URL 来源
func (f *FileSource) IsURL() bool {
return f.Type == FileSourceTypeURL
}
// IsBase64 判断是否是 base64 来源
func (f *FileSource) IsBase64() bool {
return f.Type == FileSourceTypeBase64
}
// GetIdentifier 获取文件标识符(用于日志和错误追踪)
func (f *FileSource) GetIdentifier() string {
if f.IsURL() {
if len(f.URL) > 100 {
return f.URL[:100] + "..."
}
return f.URL
}
if len(f.Base64Data) > 50 {
return "base64:" + f.Base64Data[:50] + "..."
}
return "base64:" + f.Base64Data
}
// GetRawData 获取原始数据(URL 或完整的 base64 字符串)
func (f *FileSource) GetRawData() string {
if f.IsURL() {
return f.URL
}
return f.Base64Data
}
// SetCache 设置缓存数据
func (f *FileSource) SetCache(data *CachedFileData) {
f.cachedData = data
f.cacheLoaded = true
}
// IsRegistered 是否已注册到清理列表
func (f *FileSource) IsRegistered() bool {
return f.registered
}
// SetRegistered 设置注册状态
func (f *FileSource) SetRegistered(registered bool) {
f.registered = registered
}
// GetCache 获取缓存数据
func (f *FileSource) GetCache() *CachedFileData {
return f.cachedData
}
// HasCache 是否有缓存
func (f *FileSource) HasCache() bool {
return f.cacheLoaded && f.cachedData != nil
}
// ClearCache 清除缓存,释放内存和磁盘文件
func (f *FileSource) ClearCache() {
// 如果有缓存数据,先关闭它(会清理磁盘文件)
if f.cachedData != nil {
f.cachedData.Close()
}
f.cachedData = nil
f.cacheLoaded = false
}
// ClearRawData 清除原始数据,只保留必要的元信息
// 用于在处理完成后释放大文件的内存
func (f *FileSource) ClearRawData() {
// 保留 URL(通常很短),只清除大的 base64 数据
if f.IsBase64() && len(f.Base64Data) > 1024 {
f.Base64Data = ""
}
}
+3 -4
View File
@@ -32,13 +32,12 @@ type TokenCountMeta struct {
type FileMeta struct {
FileType
MimeType string
Source *FileSource // 统一的文件来源(URL 或 base64)
Source FileSource // 统一的文件来源(URL 或 base64)
Detail string // 图片细节级别(low/high/auto
}
// NewFileMeta 创建新的 FileMeta
func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
func NewFileMeta(fileType FileType, source FileSource) *FileMeta {
return &FileMeta{
FileType: fileType,
Source: source,
@@ -46,7 +45,7 @@ func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
}
// NewImageFileMeta 创建图片类型的 FileMeta
func NewImageFileMeta(source *FileSource, detail string) *FileMeta {
func NewImageFileMeta(source FileSource, detail string) *FileMeta {
return &FileMeta{
FileType: FileTypeImage,
Source: source,
+5 -4
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "react-template",
@@ -10,7 +11,7 @@
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "1.12.0",
"axios": "1.13.5",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"history": "^5.3.0",
@@ -776,7 +777,7 @@
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
"axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="],
"axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
@@ -1104,13 +1105,13 @@
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { API, showError } from '../../../helpers';
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
const { Title } = Typography;
@@ -28,7 +28,7 @@ import {
import { useTranslation } from 'react-i18next';
import MarkdownRenderer from '../markdown/MarkdownRenderer';
// URL
// Check whether content is a URL.
const isUrl = (content) => {
try {
new URL(content.trim());
@@ -38,27 +38,23 @@ const isUrl = (content) => {
}
};
// HTML
// Check whether content contains HTML.
const isHtmlContent = (content) => {
if (!content || typeof content !== 'string') return false;
// HTML
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
return htmlTagRegex.test(content);
};
// HTML
// Parse HTML content and extract inline styles.
const sanitizeHtml = (html) => {
// HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
//
const styles = Array.from(tempDiv.querySelectorAll('style'))
.map((style) => style.innerHTML)
.join('\n');
// bodybody使
const bodyContent = tempDiv.querySelector('body');
const content = bodyContent ? bodyContent.innerHTML : html;
@@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { t } = useTranslation();
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [htmlStyles, setHtmlStyles] = useState('');
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
const loadContent = async () => {
//
const cachedContent = localStorage.getItem(cacheKey) || '';
if (cachedContent) {
setContent(cachedContent);
processContent(cachedContent);
setLoading(false);
}
@@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { success, message, data } = res.data;
if (success && data) {
setContent(data);
processContent(data);
localStorage.setItem(cacheKey, data);
} else {
if (!cachedContent) {
@@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
}
};
const processContent = (rawContent) => {
if (isHtmlContent(rawContent)) {
const { content: htmlContent, styles } = sanitizeHtml(rawContent);
setProcessedHtmlContent(htmlContent);
setHtmlStyles(styles);
} else {
setProcessedHtmlContent('');
setHtmlStyles('');
const htmlPayload = useMemo(() => {
if (!isHtmlContent(content)) {
return { content: '', styles: '' };
}
};
return sanitizeHtml(content);
}, [content]);
useEffect(() => {
loadContent();
@@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// HTML
useEffect(() => {
const styleId = `document-renderer-styles-${cacheKey}`;
const { styles } = htmlPayload;
if (htmlStyles) {
if (styles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
@@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = htmlStyles;
styleEl.innerHTML = styles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
@@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const el = document.getElementById(styleId);
if (el) el.remove();
};
}, [htmlStyles, cacheKey]);
}, [cacheKey, htmlPayload]);
//
if (loading) {
@@ -207,15 +195,6 @@ 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'>
@@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
</Title>
<div
className='prose prose-lg max-w-none'
dangerouslySetInnerHTML={{ __html: htmlContent }}
dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
/>
</div>
</div>
@@ -0,0 +1,52 @@
import React from 'react';
import { Empty, Button } from '@douyinfe/semi-ui';
import {
IllustrationFailure,
IllustrationFailureDark,
} from '@douyinfe/semi-illustrations';
import { withTranslation } from 'react-i18next';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('[ErrorBoundary]', error, errorInfo);
}
render() {
if (this.state.hasError) {
const { t } = this.props;
return (
<div className='flex flex-col justify-center items-center h-screen p-8'>
<Empty
image={
<IllustrationFailure style={{ width: 250, height: 250 }} />
}
darkModeImage={
<IllustrationFailureDark style={{ width: 250, height: 250 }} />
}
description={t('页面渲染出错,请刷新页面重试')}
/>
<Button
theme='solid'
type='primary'
style={{ marginTop: 16 }}
onClick={() => window.location.reload()}
>
{t('刷新页面')}
</Button>
</div>
);
}
return this.props.children;
}
}
export default withTranslation()(ErrorBoundary);
+10 -3
View File
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
import { Server, Gauge, ExternalLink } from 'lucide-react';
import { Server, Gauge, ExternalLink, Copy } from 'lucide-react';
import {
IllustrationConstruction,
IllustrationConstructionDark,
@@ -87,11 +87,18 @@ const ApiInfoPanel = ({
</Tag>
</div>
</div>
<div
className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
<div className='flex items-center gap-1 mb-1'>
<span
className='!text-semi-color-primary break-all cursor-pointer hover:underline'
onClick={() => handleCopyUrl(api.url)}
>
{api.url}
</span>
<Copy
size={14}
className='flex-shrink-0 text-gray-400 hover:text-semi-color-primary cursor-pointer transition-colors'
onClick={() => handleCopyUrl(api.url)}
/>
</div>
<div className='text-gray-500'>{api.description}</div>
</div>
+16 -1
View File
@@ -29,6 +29,9 @@ const ChartsPanel = ({
spec_model_line,
spec_pie,
spec_rank_bar,
spec_user_rank,
spec_user_trend,
isAdminUser,
CARD_PROPS,
CHART_CONFIG,
FLEX_CENTER_GAP2,
@@ -51,9 +54,15 @@ const ChartsPanel = ({
onChange={setActiveChartTab}
>
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
<TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
<TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
)}
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
)}
</Tabs>
</div>
}
@@ -72,6 +81,12 @@ const ChartsPanel = ({
{activeChartTab === '4' && (
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
)}
{activeChartTab === '5' && isAdminUser && (
<VChart spec={spec_user_rank} option={CHART_CONFIG} />
)}
{activeChartTab === '6' && isAdminUser && (
<VChart spec={spec_user_trend} option={CHART_CONFIG} />
)}
</div>
</Card>
);
+15
View File
@@ -86,12 +86,22 @@ const Dashboard = () => {
);
// ========== ==========
const loadUserData = async () => {
if (dashboardData.isAdminUser) {
const userData = await dashboardData.loadUserQuotaData();
if (userData && userData.length > 0) {
dashboardCharts.updateUserChartData(userData);
}
}
};
const initChart = async () => {
await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
});
await loadUserData();
await dashboardData.loadUptimeData();
};
@@ -100,10 +110,12 @@ const Dashboard = () => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
await loadUserData();
};
const handleSearchConfirm = async () => {
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
await loadUserData();
};
// ========== ==========
@@ -182,6 +194,9 @@ const Dashboard = () => {
spec_model_line={dashboardCharts.spec_model_line}
spec_pie={dashboardCharts.spec_pie}
spec_rank_bar={dashboardCharts.spec_rank_bar}
spec_user_rank={dashboardCharts.spec_user_rank}
spec_user_trend={dashboardCharts.spec_user_trend}
isAdminUser={dashboardData.isAdminUser}
CARD_PROPS={CARD_PROPS}
CHART_CONFIG={CHART_CONFIG}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
+3
View File
@@ -23,6 +23,7 @@ import SiderBar from './SiderBar';
import App from '../../App';
import FooterBar from './Footer';
import { ToastContainer } from 'react-toastify';
import ErrorBoundary from '../common/ErrorBoundary';
import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
@@ -216,7 +217,9 @@ const PageLayout = () => {
position: 'relative',
}}
>
<ErrorBoundary>
<App />
</ErrorBoundary>
</Content>
{!shouldHideFooter && (
<Layout.Footer
@@ -95,6 +95,7 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
</Dropdown.Menu>
}
>
<span className='inline-flex'>
<Button
icon={currentButtonIcon}
aria-label={t('切换主题')}
@@ -102,6 +103,7 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
type='tertiary'
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
/>
</span>
</Dropdown>
);
};
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
import {
Input,
InputNumber,
Slider,
Typography,
Button,
Tag,
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import {
Hash,
@@ -241,15 +248,14 @@ const ParameterControl = ({
disabled={disabled}
/>
</div>
<Input
<InputNumber
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className='!rounded-lg'
onNumberChange={(value) => onInputChange('max_tokens', value)}
min={0}
precision={0}
style={{ width: '100%' }}
disabled={!parameterEnabled.max_tokens || disabled}
/>
</div>
@@ -65,11 +65,15 @@ export const loadConfig = () => {
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
const parsedMaxTokens = parseInt(parsedConfig?.inputs?.max_tokens, 10);
const mergedConfig = {
inputs: {
...DEFAULT_CONFIG.inputs,
...parsedConfig.inputs,
max_tokens: Number.isNaN(parsedMaxTokens)
? parsedConfig?.inputs?.max_tokens
: parsedMaxTokens,
},
parameterEnabled: {
...DEFAULT_CONFIG.parameterEnabled,
+4 -9
View File
@@ -21,9 +21,8 @@ import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import ModelPricingCombined from '../../pages/Setting/Ratio/ModelPricingCombined';
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings';
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor';
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
import ToolPriceSettings from '../../pages/Setting/Ratio/ToolPriceSettings';
@@ -96,18 +95,14 @@ const RatioSetting = () => {
return (
<Spin spinning={loading} size='large'>
{/* 模型倍率设置以及价格编辑器 */}
<Card style={{ marginTop: '10px' }}>
<Tabs type='card' defaultActiveKey='visual'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings options={inputs} refresh={onRefresh} />
<Tabs type='card' defaultActiveKey='pricing'>
<Tabs.TabPane tab={t('模型定价设置')} itemKey='pricing'>
<ModelPricingCombined options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
@@ -91,6 +91,7 @@ const SystemSetting = () => {
EmailDomainRestrictionEnabled: '',
EmailAliasRestrictionEnabled: '',
SMTPSSLEnabled: '',
SMTPForceAuthLogin: '',
EmailDomainWhitelist: [],
TelegramOAuthEnabled: '',
TelegramBotToken: '',
@@ -182,6 +183,7 @@ const SystemSetting = () => {
case 'EmailDomainRestrictionEnabled':
case 'EmailAliasRestrictionEnabled':
case 'SMTPSSLEnabled':
case 'SMTPForceAuthLogin':
case 'LinuxDOOAuthEnabled':
case 'discord.enabled':
case 'oidc.enabled':
@@ -1335,6 +1337,15 @@ const SystemSetting = () => {
>
{t('启用SMTP SSL')}
</Form.Checkbox>
<Form.Checkbox
field='SMTPForceAuthLogin'
noLabel
onChange={(e) =>
handleCheckboxChange('SMTPForceAuthLogin', e)
}
>
{t('强制使用 AUTH LOGIN')}
</Form.Checkbox>
</Col>
</Row>
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
@@ -88,7 +88,7 @@ const renderStatus = (text, record, t) => {
};
// Render group column
const renderGroupColumn = (text, record, t) => {
const renderGroupColumn = (text, record, t, groupRatios = {}) => {
if (text === 'auto') {
return (
<Tooltip
@@ -104,7 +104,17 @@ const renderGroupColumn = (text, record, t) => {
</Tooltip>
);
}
return renderGroup(text);
const ratio = groupRatios[text];
return (
<span className='flex items-center gap-1'>
{renderGroup(text)}
{ratio !== undefined && (
<Tag size='small' color='green' shape='circle'>
{ratio}x
</Tag>
)}
</span>
);
};
// Render token key column with show/hide and copy functionality
@@ -469,6 +479,7 @@ export const getTokensColumns = ({
setEditingToken,
setShowEdit,
refresh,
groupRatios = {},
}) => {
return [
{
@@ -490,7 +501,7 @@ export const getTokensColumns = ({
title: t('分组'),
dataIndex: 'group',
key: 'group',
render: (text, record) => renderGroupColumn(text, record, t),
render: (text, record) => renderGroupColumn(text, record, t, groupRatios),
},
{
title: t('密钥'),
@@ -49,6 +49,7 @@ const TokensTable = (tokensData) => {
setEditingToken,
setShowEdit,
refresh,
groupRatios,
t,
} = tokensData;
@@ -67,6 +68,7 @@ const TokensTable = (tokensData) => {
setEditingToken,
setShowEdit,
refresh,
groupRatios,
});
}, [
t,
@@ -81,6 +83,7 @@ const TokensTable = (tokensData) => {
setEditingToken,
setShowEdit,
refresh,
groupRatios,
]);
// Handle compact mode by removing fixed positioning
@@ -366,6 +366,14 @@ const EditTokenModal = (props) => {
placeholder={t('令牌分组,默认为用户的分组')}
optionList={groups}
renderOptionItem={renderGroupOption}
filter={(input, option) => {
const q = input.toLowerCase();
return (
option.value?.toLowerCase().includes(q) ||
(typeof option.label === 'string' &&
option.label.toLowerCase().includes(q))
);
}}
showClear
style={{ width: '100%' }}
/>
@@ -36,7 +36,7 @@ import {
renderTieredModelPriceSimple,
} from '../../../helpers';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import { Route, Sparkles } from 'lucide-react';
import { CircleAlert, Route, Sparkles } from 'lucide-react';
const colors = [
'amber',
@@ -142,12 +142,58 @@ function renderType(type, t) {
}
}
function renderIsStream(bool, t) {
function buildStreamStatusTooltip(ss, t) {
if (!ss) return null;
const lines = [
t('流状态') + '' + t('异常'),
(ss.end_reason || 'unknown'),
];
if (ss.error_count > 0) {
lines.push(`${t('软错误')}: ${ss.error_count}`);
}
if (ss.end_error) {
lines.push(ss.end_error);
}
return (
<div style={{ lineHeight: 1.6, display: 'flex', flexDirection: 'column' }}>
{lines.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
);
}
function renderIsStream(bool, t, streamStatus) {
const isError = streamStatus && streamStatus.status !== 'ok';
if (bool) {
return (
<span style={{ position: 'relative', display: 'inline-block' }}>
<Tag color='blue' shape='circle'>
{t('流')}
</Tag>
{isError && (
<Tooltip content={buildStreamStatusTooltip(streamStatus, t)}>
<span
style={{
position: 'absolute',
right: -4,
top: -4,
lineHeight: 1,
color: '#ef4444',
cursor: 'pointer',
userSelect: 'none',
}}
>
<CircleAlert
size={14}
strokeWidth={2.5}
color='currentColor'
/>
</span>
</Tooltip>
)}
</span>
);
} else {
return (
@@ -663,7 +709,7 @@ export const getLogsColumns = ({
<Space>
{renderUseTime(text, t)}
{renderFirstUseTime(other?.frt, t)}
{renderIsStream(record.is_stream, t)}
{renderIsStream(record.is_stream, t, other?.stream_status)}
</Space>
</>
);
+12 -1
View File
@@ -150,7 +150,18 @@ export const buildApiPayload = (
const value = inputs[param];
const hasValue = value !== undefined && value !== null;
if (enabled && hasValue) {
if (!enabled) {
return;
}
if (param === 'max_tokens') {
if (typeof value === 'number') {
payload[param] = value;
}
return;
}
if (hasValue) {
payload[param] = value;
}
});
+55
View File
@@ -387,3 +387,58 @@ export const generateChartTimePoints = (
return chartTimePoints;
};
// ========== ==========
export const processUserData = (data, dataExportDefaultTime, limit = 10) => {
const userQuotaTotal = new Map();
data.forEach((item) => {
const prev = userQuotaTotal.get(item.username) || 0;
userQuotaTotal.set(item.username, prev + item.quota);
});
const sorted = Array.from(userQuotaTotal.entries()).sort(
(a, b) => b[1] - a[1],
);
const topUsers = sorted.slice(0, limit).map(([u]) => u);
const topUserSet = new Set(topUsers);
const rankingData = sorted.slice(0, limit).map(([username, quota]) => ({
User: username,
Quota: quota,
}));
const showYear = isDataCrossYear(data.map((item) => item.created_at));
const timeUserMap = new Map();
const allTimePoints = new Set();
data.forEach((item) => {
const timeKey = timestamp2string1(
item.created_at,
dataExportDefaultTime,
showYear,
);
allTimePoints.add(timeKey);
const user = topUserSet.has(item.username) ? item.username : null;
if (!user) return;
const key = `${timeKey}-${user}`;
const prev = timeUserMap.get(key) || { quota: 0 };
timeUserMap.set(key, { quota: prev.quota + item.quota });
});
const sortedTimePoints = Array.from(allTimePoints).sort();
const trendData = [];
sortedTimePoints.forEach((time) => {
topUsers.forEach((user) => {
const key = `${time}-${user}`;
const val = timeUserMap.get(key);
trendData.push({
Time: time,
User: user,
Quota: val?.quota || 0,
});
});
});
return { rankingData, trendData, topUsers };
};
+12
View File
@@ -1625,6 +1625,18 @@ function renderPriceSimpleCore({
return result;
}
export function renderTaskBillingProcess(other, content) {
if (other?.task_id != null) {
return renderBillingArticle(
[content].filter(Boolean),
{ showReferenceNote: false },
);
}
return renderBillingArticle([
buildBillingText('任务预扣费(将在任务完成后按实际token重算)'),
]);
}
export function renderModelPrice(opts) {
const {
prompt_tokens: inputTokens = 0,
+14
View File
@@ -33,6 +33,20 @@ export async function fetchTokenKey(tokenId) {
return data.key;
}
/**
* 批量获取多个令牌的真实 key
* @param {number[]} tokenIds
* @returns {Promise<Record<number, string>>} 返回 {id: key} mapkey 不带 sk- 前缀
*/
export async function fetchTokenKeysBatch(tokenIds) {
const response = await API.post('/api/token/batch/keys', { ids: tokenIds });
const { success, data, message } = response.data || {};
if (!success || !data?.keys) {
throw new Error(message || 'Failed to fetch token keys');
}
return data.keys;
}
/**
* 获取可用的 token keys
* @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组
+131 -6
View File
@@ -34,8 +34,14 @@ import {
updateChartSpec,
updateMapValue,
initializeMaps,
processUserData,
} from '../../helpers/dashboard';
const USER_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
];
export const useDashboardCharts = (
dataExportDefaultTime,
setTrendData,
@@ -179,7 +185,6 @@ export const useDashboardCharts = (
},
});
// 线
const [spec_model_line, setSpecModelLine] = useState({
type: 'line',
data: [
@@ -197,7 +202,7 @@ export const useDashboardCharts = (
},
title: {
visible: true,
text: t('模型消耗趋势'),
text: t('调用趋势'),
subtext: '',
},
tooltip: {
@@ -215,7 +220,6 @@ export const useDashboardCharts = (
},
});
//
const [spec_rank_bar, setSpecRankBar] = useState({
type: 'bar',
data: [
@@ -259,6 +263,82 @@ export const useDashboardCharts = (
},
});
// ========== Admin: ==========
const [spec_user_rank, setSpecUserRank] = useState({
type: 'bar',
data: [{ id: 'userRankData', values: [] }],
xField: 'rawQuota',
yField: 'User',
seriesField: 'User',
direction: 'horizontal',
legends: { visible: false },
title: {
visible: true,
text: t('用户消耗排行'),
subtext: '',
},
bar: {
state: { hover: { stroke: '#000', lineWidth: 1 } },
},
label: {
visible: true,
position: 'outside',
formatMethod: (value, datum) => renderQuota(datum['rawQuota'] || 0, 2),
},
axes: [{
orient: 'left',
type: 'band',
label: { visible: true },
}, {
orient: 'bottom',
type: 'linear',
visible: false,
}],
tooltip: {
mark: {
content: [{
key: (datum) => datum['User'],
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
}],
},
},
color: { type: 'ordinal', range: USER_COLORS },
});
// ========== Admin: ==========
const [spec_user_trend, setSpecUserTrend] = useState({
type: 'area',
data: [{ id: 'userTrendData', values: [] }],
xField: 'Time',
yField: 'rawQuota',
seriesField: 'User',
stack: false,
legends: { visible: true, selectMode: 'single' },
title: {
visible: true,
text: t('用户消耗趋势'),
subtext: '',
},
axes: [{
orient: 'left',
label: {
formatMethod: (value) => renderQuota(value, 2),
},
}],
area: { style: { fillOpacity: 0.15 } },
line: { style: { lineWidth: 2 } },
point: { visible: false },
tooltip: {
mark: {
content: [{
key: (datum) => datum['User'],
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
}],
},
},
color: { type: 'ordinal', range: USER_COLORS },
});
// ========== ==========
const generateModelColors = useCallback((uniqueModels, modelColors) => {
const newModelColors = {};
@@ -426,6 +506,51 @@ export const useDashboardCharts = (
],
);
// ========== ==========
const updateUserChartData = useCallback(
(data) => {
const { rankingData, trendData: userTrend } = processUserData(
data,
dataExportDefaultTime,
10,
);
const userRankValues = rankingData.map((item) => ({
User: item.User,
rawQuota: item.Quota,
Quota: getQuotaWithUnit(item.Quota, 4),
})).sort((a, b) => b.rawQuota - a.rawQuota);
const totalUserQuota = rankingData.reduce((s, i) => s + i.Quota, 0);
setSpecUserRank((prev) => ({
...prev,
data: [{ id: 'userRankData', values: userRankValues }],
title: {
...prev.title,
subtext: `${t('总计')}${renderQuota(totalUserQuota, 2)}`,
},
}));
const userTrendValues = userTrend.map((item) => ({
Time: item.Time,
User: item.User,
rawQuota: item.Quota,
Usage: item.Quota ? getQuotaWithUnit(item.Quota, 4) : 0,
}));
setSpecUserTrend((prev) => ({
...prev,
data: [{ id: 'userTrendData', values: userTrendValues }],
title: {
...prev.title,
subtext: `${t('总计')}${renderQuota(totalUserQuota, 2)}`,
},
}));
},
[dataExportDefaultTime, t],
);
// ========== ==========
useEffect(() => {
initVChartSemiTheme({
@@ -434,14 +559,14 @@ export const useDashboardCharts = (
}, []);
return {
//
spec_pie,
spec_line,
spec_model_line,
spec_rank_bar,
//
spec_user_rank,
spec_user_trend,
updateChartData,
updateUserChartData,
generateModelColors,
};
};
+22
View File
@@ -213,6 +213,27 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
}
}, [activeUptimeTab]);
const loadUserQuotaData = useCallback(async () => {
if (!isAdminUser) return [];
try {
const { start_timestamp, end_timestamp } = inputs;
const localStartTimestamp = Date.parse(start_timestamp) / 1000;
const localEndTimestamp = Date.parse(end_timestamp) / 1000;
const url = `/api/data/users?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
return data || [];
} else {
showError(message);
return [];
}
} catch (err) {
console.error(err);
return [];
}
}, [inputs, isAdminUser]);
const getUserData = useCallback(async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
@@ -311,6 +332,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
showSearchModal,
handleCloseModal,
loadQuotaData,
loadUserQuotaData,
loadUptimeData,
getUserData,
refresh,
+8 -1
View File
@@ -167,7 +167,14 @@ export const usePlaygroundState = () => {
// 配置导入/重置
const handleConfigImport = useCallback((importedConfig) => {
if (importedConfig.inputs) {
setInputs((prev) => ({ ...prev, ...importedConfig.inputs }));
const parsedMaxTokens = parseInt(importedConfig.inputs.max_tokens, 10);
setInputs((prev) => ({
...prev,
...importedConfig.inputs,
max_tokens: Number.isNaN(parsedMaxTokens)
? importedConfig.inputs.max_tokens
: parsedMaxTokens,
}));
}
if (importedConfig.parameterEnabled) {
setParameterEnabled((prev) => ({
+23 -6
View File
@@ -31,6 +31,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import {
fetchTokenKey as fetchTokenKeyById,
fetchTokenKeysBatch,
getServerAddress,
encodeChannelConnectionString,
} from '../../helpers/token';
@@ -41,6 +42,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
// Basic state
const [tokens, setTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [groupRatios, setGroupRatios] = useState({});
const [activePage, setActivePage] = useState(1);
const [tokenCount, setTokenCount] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
@@ -408,14 +410,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
return;
}
try {
const keys = await Promise.all(
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
);
const ids = selectedKeys.map((token) => token.id);
const keysMap = await fetchTokenKeysBatch(ids);
setResolvedTokenKeys((prev) => ({ ...prev, ...keysMap }));
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
const fullKey = keys[i];
for (const token of selectedKeys) {
const fullKey = keysMap[token.id];
if (!fullKey) continue;
if (copyType === 'name+key') {
content += `${selectedKeys[i].name} sk-${fullKey}\n`;
content += `${token.name} sk-${fullKey}\n`;
} else {
content += `sk-${fullKey}\n`;
}
@@ -433,6 +438,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
.catch((reason) => {
showError(reason);
});
API.get('/api/user/self/groups')
.then((res) => {
if (res.data.success && res.data.data) {
const ratios = {};
for (const [name, info] of Object.entries(res.data.data)) {
ratios[name] = info.ratio;
}
setGroupRatios(ratios);
}
})
.catch(() => {});
}, [pageSize]);
return {
@@ -443,6 +459,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
tokenCount,
pageSize,
searching,
groupRatios,
// Selection state
selectedKeys,
+5 -1
View File
@@ -37,6 +37,7 @@ import {
renderClaudeModelPrice,
renderModelPrice,
renderTieredModelPrice,
renderTaskBillingProcess,
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
@@ -475,7 +476,10 @@ export const useLogsData = () => {
completion_tokens: logs[i].completion_tokens,
displayMode: billingDisplayMode,
};
if (other?.ws || other?.audio) {
const isTaskLog = other?.is_task === true || other?.task_id != null;
if (isTaskLog && other?.model_price === -1) {
content = renderTaskBillingProcess(other, logs[i].content);
} else if (other?.ws || other?.audio) {
content = renderAudioModelPrice(logOpts);
} else if (other?.claude) {
content = renderClaudeModelPrice(logOpts);
+446 -191
View File
File diff suppressed because it is too large Load Diff
+456 -316
View File
File diff suppressed because it is too large Load Diff
+449 -309
View File
File diff suppressed because it is too large Load Diff
+453 -313
View File
File diff suppressed because it is too large Load Diff
+442 -304
View File
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -680,6 +680,7 @@
"启用Gemini思考后缀适配": "启用Gemini思考后缀适配",
"启用Ping间隔": "启用Ping间隔",
"启用SMTP SSL": "启用SMTP SSL",
"强制使用 AUTH LOGIN": "强制使用 AUTH LOGIN",
"启用SSRF防护(推荐开启以保护服务器安全)": "启用SSRF防护(推荐开启以保护服务器安全)",
"启用全部": "启用全部",
"启用后可接入 io.net GPU 资源": "启用后可接入 io.net GPU 资源",
@@ -1985,6 +1986,19 @@
"自定义请求体模式": "自定义请求体模式",
"自定义货币": "自定义货币",
"自定义货币符号": "自定义货币符号",
"自定义货币符号将显示在所有额度数值前,例如 €1.50": "自定义货币符号将显示在所有额度数值前,例如 €1.50",
"额度展示类型": "额度展示类型",
"站点所有额度将以美元 ($) 显示": "站点所有额度将以美元 ($) 显示",
"站点所有额度将按汇率换算为人民币 (¥) 显示": "站点所有额度将按汇率换算为人民币 (¥) 显示",
"站点所有额度将以原始 Token 数显示,不做货币换算": "站点所有额度将以原始 Token 数显示,不做货币换算",
"站点所有额度将按汇率换算为自定义货币显示": "站点所有额度将按汇率换算为自定义货币显示",
"汇率": "汇率",
"每美元对应 Token 数": "每美元对应 Token 数",
"预览效果": "预览效果",
"请输入汇率": "请输入汇率",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费": "系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费",
"系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作": "系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作",
"系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费": "系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费",
"自定义镜像": "自定义镜像",
"自用模式": "自用模式",
"自适应列表": "自适应列表",
@@ -2301,6 +2315,10 @@
"调用次数": "调用次数",
"调用次数分布": "调用次数分布",
"调用次数排行": "调用次数排行",
"调用趋势": "调用趋势",
"模型排行": "模型排行",
"用户消耗排行": "用户消耗排行",
"用户消耗趋势": "用户消耗趋势",
"调试信息": "调试信息",
"谨慎": "谨慎",
"警告": "警告",
@@ -2559,6 +2577,8 @@
"重置配置": "重置配置",
"重要提醒": "重要提醒",
"重试": "重试",
"不重试": "不重试",
"失败后是否重试": "失败后是否重试",
"重试连接": "重试连接",
"钱包管理": "钱包管理",
"链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1",
@@ -3087,6 +3107,8 @@
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
"连接信息已填入": "连接信息已填入",
"无法读取剪贴板": "无法读取剪贴板"
"无法读取剪贴板": "无法读取剪贴板",
"页面渲染出错,请刷新页面重试": "页面渲染出错,请刷新页面重试",
"刷新页面": "刷新页面"
}
}
+663 -524
View File
File diff suppressed because it is too large Load Diff
@@ -103,6 +103,7 @@ const RULES_JSON_PLACEHOLDER = `[
},
"skip_retry_on_failure": false,
"include_using_group": true,
"include_model_name": false,
"include_rule_name": true
}
]`;
@@ -191,6 +192,36 @@ const parseOptionalObjectJson = (jsonString, label) => {
}
};
const buildChannelAffinityRulePayload = ({
values,
isEdit,
editingRuleId,
rulesLength,
modelRegex,
pathRegex,
keySources,
userAgentInclude,
paramOverrideTemplate,
}) => ({
id: isEdit ? editingRuleId : rulesLength,
name: (values?.name || '').trim(),
model_regex: modelRegex,
path_regex: pathRegex,
key_sources: keySources,
value_regex: (values?.value_regex || '').trim(),
ttl_seconds: Number(values?.ttl_seconds || 0),
include_using_group: !!values?.include_using_group,
include_model_name: !!values?.include_model_name,
include_rule_name: !!values?.include_rule_name,
skip_retry_on_failure: !!values?.skip_retry_on_failure,
...(userAgentInclude.length > 0
? { user_agent_include: userAgentInclude }
: {}),
...(paramOverrideTemplate
? { param_override_template: paramOverrideTemplate }
: {}),
});
export default function SettingsChannelAffinity(props) {
const { t } = useTranslation();
const { Text } = Typography;
@@ -246,6 +277,7 @@ export default function SettingsChannelAffinity(props) {
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_model_name: !!r.include_model_name,
include_rule_name: r.include_rule_name ?? true,
param_override_template_json: r.param_override_template
? stringifyPretty(r.param_override_template)
@@ -454,14 +486,12 @@ export default function SettingsChannelAffinity(props) {
const templates = [
CHANNEL_AFFINITY_RULE_TEMPLATES.codexCli,
CHANNEL_AFFINITY_RULE_TEMPLATES.claudeCli,
].map(
(tpl) => {
].map((tpl) => {
const baseTemplate = cloneChannelAffinityTemplate(tpl);
const name = makeUniqueName(existingNames, tpl.name);
existingNames.add(name);
return { ...baseTemplate, name };
},
);
});
const next = [...(rules || []), ...templates].map((r, idx) => ({
...(r || {}),
@@ -540,11 +570,11 @@ export default function SettingsChannelAffinity(props) {
render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
},
{
title: t('失败后重试'),
title: t('失败后是否重试'),
dataIndex: 'skip_retry_on_failure',
render: (value) => (
<Tag color={value ? 'orange' : 'grey'} style={{ marginRight: 4 }}>
{value ? t('') : t('')}
<Tag color={value ? 'orange' : 'green'} style={{ marginRight: 4 }}>
{value ? t('不重试') : t('重试')}
</Tag>
),
},
@@ -581,8 +611,9 @@ export default function SettingsChannelAffinity(props) {
title: t('作用域'),
render: (_, record) => {
const tags = [];
if (record?.include_using_group) tags.push('分组');
if (record?.include_rule_name) tags.push('规则');
if (record?.include_using_group) tags.push(t('分组'));
if (record?.include_model_name) tags.push(t('模型'));
if (record?.include_rule_name) tags.push(t('规则'));
if (tags.length === 0) return '-';
return tags.map((x) => (
<Tag key={x} style={{ marginRight: 4 }}>
@@ -650,6 +681,7 @@ export default function SettingsChannelAffinity(props) {
ttl_seconds: 0,
skip_retry_on_failure: false,
include_using_group: true,
include_model_name: false,
include_rule_name: true,
};
setEditingRule(nextRule);
@@ -712,26 +744,17 @@ export default function SettingsChannelAffinity(props) {
return showError(t(paramTemplateValidation.message));
}
const rulePayload = {
id: isEdit ? editingRule.id : rules.length,
name: (values.name || '').trim(),
model_regex: modelRegex,
path_regex: normalizeStringList(values.path_regex_text),
key_sources: keySourcesValidation.value,
value_regex: (values.value_regex || '').trim(),
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 }
: {}),
...(paramTemplateValidation.value
? { param_override_template: paramTemplateValidation.value }
: {}),
};
const rulePayload = buildChannelAffinityRulePayload({
values,
isEdit,
editingRuleId: editingRule?.id,
rulesLength: rules.length,
modelRegex,
pathRegex: normalizeStringList(values.path_regex_text),
keySources: keySourcesValidation.value,
userAgentInclude,
paramOverrideTemplate: paramTemplateValidation.value,
});
if (!rulePayload.name) return showError(t('名称不能为空'));
@@ -1251,7 +1274,7 @@ export default function SettingsChannelAffinity(props) {
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Col xs={24} sm={8}>
<Form.Switch
field='include_using_group'
label={t('作用域:包含分组')}
@@ -1262,7 +1285,16 @@ export default function SettingsChannelAffinity(props) {
)}
</Text>
</Col>
<Col xs={24} sm={12}>
<Col xs={24} sm={8}>
<Form.Switch
field='include_model_name'
label={t('作用域:包含模型名称')}
/>
<Text type='tertiary' size='small'>
{t('开启后,模型名称会参与 cache key(不同模型隔离)。')}
</Text>
</Col>
<Col xs={24} sm={8}>
<Form.Switch
field='include_rule_name'
label={t('作用域:包含规则名称')}
@@ -26,9 +26,8 @@ import {
Row,
Spin,
Modal,
Select,
InputGroup,
Input,
Typography,
} from '@douyinfe/semi-ui';
import {
compareObjects,
@@ -39,6 +38,8 @@ import {
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
export default function GeneralSettings(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
@@ -126,6 +127,77 @@ export default function GeneralSettings(props) {
}
};
const showTokensOption = useMemo(() => {
const initialType = props.options?.['general_setting.quota_display_type'];
const initialQuotaPerUnit = parseFloat(props.options?.QuotaPerUnit);
const legacyTokensMode =
initialType === undefined &&
props.options?.DisplayInCurrencyEnabled !== undefined &&
!props.options.DisplayInCurrencyEnabled;
return (
initialType === 'TOKENS' ||
legacyTokensMode ||
(!isNaN(initialQuotaPerUnit) && initialQuotaPerUnit !== 500000)
);
}, [props.options]);
const quotaDisplayType = inputs['general_setting.quota_display_type'];
const quotaDisplayTypeDesc = useMemo(() => {
const descMap = {
USD: t('站点所有额度将以美元 ($) 显示'),
CNY: t('站点所有额度将按汇率换算为人民币 (¥) 显示'),
TOKENS: t('站点所有额度将以原始 Token 数显示,不做货币换算'),
CUSTOM: t('站点所有额度将按汇率换算为自定义货币显示'),
};
return descMap[quotaDisplayType] || '';
}, [quotaDisplayType, t]);
const rateLabel = useMemo(() => {
if (quotaDisplayType === 'CNY') return t('汇率');
if (quotaDisplayType === 'TOKENS') return t('每美元对应 Token 数');
if (quotaDisplayType === 'CUSTOM') return t('汇率');
return '';
}, [quotaDisplayType, t]);
const rateSuffix = useMemo(() => {
if (quotaDisplayType === 'CNY') return 'CNY (¥)';
if (quotaDisplayType === 'TOKENS') return 'Tokens';
if (quotaDisplayType === 'CUSTOM')
return inputs['general_setting.custom_currency_symbol'] || '¤';
return '';
}, [quotaDisplayType, inputs]);
const rateExtraText = useMemo(() => {
if (quotaDisplayType === 'CNY')
return t(
'系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费',
);
if (quotaDisplayType === 'TOKENS')
return t(
'系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作',
);
if (quotaDisplayType === 'CUSTOM')
return t(
'系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费',
);
return '';
}, [quotaDisplayType, t]);
const previewText = useMemo(() => {
if (quotaDisplayType === 'USD') return '$1.00';
const rate = parseFloat(combinedRate);
if (!rate || isNaN(rate)) return t('请输入汇率');
if (quotaDisplayType === 'CNY') return `$1.00 → ¥${rate.toFixed(2)}`;
if (quotaDisplayType === 'TOKENS')
return `$1.00 → ${Number(rate).toLocaleString()} Tokens`;
if (quotaDisplayType === 'CUSTOM') {
const symbol = inputs['general_setting.custom_currency_symbol'] || '¤';
return `$1.00 → ${symbol}${rate.toFixed(2)}`;
}
return '';
}, [quotaDisplayType, combinedRate, inputs, t]);
useEffect(() => {
const currentInputs = {};
for (let key in props.options) {
@@ -202,48 +274,79 @@ export default function GeneralSettings(props) {
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Slot label={t('站点额度展示类型及汇率')}>
<InputGroup style={{ width: '100%' }}>
<Input
prefix={'1 USD = '}
style={{ width: '50%' }}
value={combinedRate}
onChange={onCombinedRateChange}
disabled={
inputs['general_setting.quota_display_type'] === 'USD'
}
/>
<Select
style={{ width: '50%' }}
value={inputs['general_setting.quota_display_type']}
<Form.Select
field='general_setting.quota_display_type'
label={t('额度展示类型')}
extraText={quotaDisplayTypeDesc}
onChange={handleFieldChange(
'general_setting.quota_display_type',
)}
>
<Select.Option value='USD'>USD ($)</Select.Option>
<Select.Option value='CNY'>CNY (¥)</Select.Option>
<Select.Option value='TOKENS'>Tokens</Select.Option>
<Select.Option value='CUSTOM'>
<Form.Select.Option value='USD'>
USD ($)
</Form.Select.Option>
<Form.Select.Option value='CNY'>
CNY (¥)
</Form.Select.Option>
{showTokensOption && (
<Form.Select.Option value='TOKENS'>
Tokens
</Form.Select.Option>
)}
<Form.Select.Option value='CUSTOM'>
{t('自定义货币')}
</Select.Option>
</Select>
</InputGroup>
</Form.Select.Option>
</Form.Select>
</Col>
{quotaDisplayType !== 'USD' && (
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Slot label={rateLabel}>
<Input
prefix='1 USD = '
suffix={rateSuffix}
value={combinedRate}
onChange={onCombinedRateChange}
/>
<Text
type='tertiary'
size='small'
style={{ marginTop: 4, display: 'block' }}
>
{rateExtraText}
</Text>
</Form.Slot>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
)}
<Col
xs={24}
sm={12}
md={8}
lg={8}
xl={8}
style={
quotaDisplayType !== 'CUSTOM'
? { display: 'none' }
: undefined
}
>
<Form.Input
field={'general_setting.custom_currency_symbol'}
field='general_setting.custom_currency_symbol'
label={t('自定义货币符号')}
extraText={t(
'自定义货币符号将显示在所有额度数值前,例如 €1.50',
)}
placeholder={t('例如 €, £, Rp, ₩, ₹...')}
onChange={handleFieldChange(
'general_setting.custom_currency_symbol',
)}
showClear
disabled={
inputs['general_setting.quota_display_type'] !== 'CUSTOM'
}
/>
</Col>
<Col span={24}>
<Text type='tertiary' size='small'>
{t('预览效果')}{previewText}
</Text>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
@@ -356,7 +356,6 @@ export default function SettingsPerformance(props) {
label={t('CPU 阈值 (%)')}
extraText={t('CPU 使用率超过此值时拒绝请求')}
min={0}
max={100}
onChange={handleFieldChange(
'performance_setting.monitor_cpu_threshold',
)}
@@ -17,8 +17,23 @@ 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 { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import {
Button,
Col,
Collapsible,
Form,
Radio,
RadioGroup,
Row,
SideSheet,
Spin,
Switch,
Tabs,
Typography,
} from '@douyinfe/semi-ui';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import {
compareObjects,
API,
@@ -28,10 +43,37 @@ import {
verifyJSON,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import GroupTable from './components/GroupTable';
import AutoGroupList from './components/AutoGroupList';
import GroupGroupRatioRules from './components/GroupGroupRatioRules';
import GroupSpecialUsableRules from './components/GroupSpecialUsableRules';
const { Text, Title, Paragraph } = Typography;
const OPTION_KEYS = [
'GroupRatio',
'UserUsableGroups',
'GroupGroupRatio',
'group_ratio_setting.group_special_usable_group',
'AutoGroups',
'DefaultUseAutoGroup',
];
function parseJSONSafe(str, fallback) {
if (!str || !str.trim()) return fallback;
try {
return JSON.parse(str);
} catch {
return fallback;
}
}
export default function GroupRatioSettings(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [editMode, setEditMode] = useState('visual');
const [showGuide, setShowGuide] = useState(false);
const [inputs, setInputs] = useState({
GroupRatio: '',
UserUsableGroups: '',
@@ -42,15 +84,27 @@ export default function GroupRatioSettings(props) {
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
const dataVersionRef = useRef(0);
const groupNames = useMemo(() => {
const ratioMap = parseJSONSafe(inputs.GroupRatio, {});
return Object.keys(ratioMap);
}, [inputs.GroupRatio]);
async function onSubmit() {
if (editMode === 'manual') {
try {
await refForm.current
.validate()
.then(() => {
await refForm.current.validate();
} catch {
showError(t('请检查输入'));
return;
}
}
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length)
if (!updateArray.length) {
return showWarning(t('你似乎并没有修改什么'));
}
const requestQueue = updateArray.map((item) => {
const value =
@@ -61,8 +115,8 @@ export default function GroupRatioSettings(props) {
});
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
try {
const res = await Promise.all(requestQueue);
if (res.includes(undefined)) {
return showError(
requestQueue.length > 1
@@ -70,52 +124,149 @@ export default function GroupRatioSettings(props) {
: t('保存失败'),
);
}
for (let i = 0; i < res.length; i++) {
if (!res[i].data.success) {
return showError(res[i].data.message);
}
}
showSuccess(t('保存成功'));
props.refresh();
})
.catch((error) => {
} catch (error) {
console.error('Unexpected error:', error);
showError(t('保存失败,请重试'));
})
.finally(() => {
} finally {
setLoading(false);
});
})
.catch(() => {
showError(t('请检查输入'));
});
} catch (error) {
showError(t('请检查输入'));
console.error(error);
}
}
useEffect(() => {
const currentInputs = {};
for (let key in props.options) {
if (Object.keys(inputs).includes(key)) {
if (OPTION_KEYS.includes(key)) {
currentInputs[key] = props.options[key];
}
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
dataVersionRef.current += 1;
if (refForm.current) {
refForm.current.setValues(currentInputs);
}
}, [props.options]);
return (
<Spin spinning={loading}>
const handleGroupTableChange = useCallback(
({ GroupRatio, UserUsableGroups }) => {
setInputs((prev) => ({ ...prev, GroupRatio, UserUsableGroups }));
},
[],
);
const handleAutoGroupsChange = useCallback((value) => {
setInputs((prev) => ({ ...prev, AutoGroups: value }));
}, []);
const handleGroupGroupRatioChange = useCallback((value) => {
setInputs((prev) => ({ ...prev, GroupGroupRatio: value }));
}, []);
const handleSpecialUsableChange = useCallback((value) => {
setInputs((prev) => ({
...prev,
'group_ratio_setting.group_special_usable_group': value,
}));
}, []);
const dv = dataVersionRef.current;
const renderVisualMode = () => (
<Form key='form-visual' values={inputs} style={{ marginBottom: 15 }}>
<Form.Section text={t('分组管理')}>
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
{t('倍率用于计费乘数,勾选「用户可选」后用户可在创建令牌时选择该分组')}
</Text>
<GroupTable
key={`gt_${dv}`}
groupRatio={inputs.GroupRatio}
userUsableGroups={inputs.UserUsableGroups}
onChange={handleGroupTableChange}
/>
</Form.Section>
<Form.Section text={t('自动分组')}>
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
{t('令牌分组设为 auto 时,按以下顺序依次尝试选择可用分组,排在前面的优先级更高')}
</Text>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Slot label={t('默认使用auto分组')}>
<div className='flex items-center gap-2'>
<Switch
checked={!!inputs.DefaultUseAutoGroup}
size='default'
checkedText=''
uncheckedText=''
onChange={(value) =>
setInputs((prev) => ({
...prev,
DefaultUseAutoGroup: value,
}))
}
/>
</div>
<Text type='tertiary' size='small' style={{ marginTop: 4 }}>
{t('开启后创建令牌默认选择auto分组,初始令牌也将设为auto')}
</Text>
</Form.Slot>
</Col>
</Row>
<AutoGroupList
key={`ag_${dv}`}
value={inputs.AutoGroups}
groupNames={groupNames}
onChange={handleAutoGroupsChange}
/>
</Form.Section>
<Form.Section text={t('分组特殊倍率')}>
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
{t('当某个分组的用户使用另一个分组的令牌时,可设置特殊倍率覆盖基础倍率。例如:vip 分组的用户使用 default 分组时倍率为 0.5')}
</Text>
<GroupGroupRatioRules
key={`ggr_${dv}`}
value={inputs.GroupGroupRatio}
groupNames={groupNames}
onChange={handleGroupGroupRatioChange}
/>
</Form.Section>
<Form.Section text={t('分组特殊可用分组')}>
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
{t('为特定用户分组配置可用分组的增减规则。「添加」为该分组新增可用分组,「移除」移除默认可用分组,「追加」直接追加分组')}
</Text>
<GroupSpecialUsableRules
key={`gsu_${dv}`}
value={inputs['group_ratio_setting.group_special_usable_group']}
groupNames={groupNames}
onChange={handleSpecialUsableChange}
/>
</Form.Section>
</Form>
);
useEffect(() => {
if (editMode === 'manual' && refForm.current) {
refForm.current.setValues(inputs);
}
}, [editMode]);
const renderManualMode = () => (
<Form
values={inputs}
key='form-manual'
initValues={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={t('分组JSON设置')}>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
@@ -134,7 +285,9 @@ export default function GroupRatioSettings(props) {
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) => setInputs({ ...inputs, GroupRatio: value })}
onChange={(value) =>
setInputs((prev) => ({ ...prev, GroupRatio: value }))
}
/>
</Col>
</Row>
@@ -142,7 +295,9 @@ export default function GroupRatioSettings(props) {
<Col xs={24} sm={16}>
<Form.TextArea
label={t('用户可选分组')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
placeholder={t(
'为一个 JSON 文本,键为分组名称,值为分组描述',
)}
extraText={t(
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
)}
@@ -157,7 +312,7 @@ export default function GroupRatioSettings(props) {
},
]}
onChange={(value) =>
setInputs({ ...inputs, UserUsableGroups: value })
setInputs((prev) => ({ ...prev, UserUsableGroups: value }))
}
/>
</Col>
@@ -181,7 +336,7 @@ export default function GroupRatioSettings(props) {
},
]}
onChange={(value) =>
setInputs({ ...inputs, GroupGroupRatio: value })
setInputs((prev) => ({ ...prev, GroupGroupRatio: value }))
}
/>
</Col>
@@ -205,10 +360,10 @@ export default function GroupRatioSettings(props) {
},
]}
onChange={(value) =>
setInputs({
...inputs,
setInputs((prev) => ({
...prev,
'group_ratio_setting.group_special_usable_group': value,
})
}))
}
/>
</Col>
@@ -225,29 +380,23 @@ export default function GroupRatioSettings(props) {
rules={[
{
validator: (rule, value) => {
if (!value || value.trim() === '') {
return true; // Allow empty values
}
// First check if it's valid JSON
if (!value || value.trim() === '') return true;
try {
const parsed = JSON.parse(value);
// Check if it's an array
if (!Array.isArray(parsed)) {
return false;
}
// Check if every element is a string
if (!Array.isArray(parsed)) return false;
return parsed.every((item) => typeof item === 'string');
} catch (error) {
} catch {
return false;
}
},
message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
message: t(
'必须是有效的 JSON 字符串数组,例如:["g1","g2"]',
),
},
]}
onChange={(value) => setInputs({ ...inputs, AutoGroups: value })}
onChange={(value) =>
setInputs((prev) => ({ ...prev, AutoGroups: value }))
}
/>
</Col>
</Row>
@@ -259,13 +408,351 @@ export default function GroupRatioSettings(props) {
)}
field={'DefaultUseAutoGroup'}
onChange={(value) =>
setInputs({ ...inputs, DefaultUseAutoGroup: value })
setInputs((prev) => ({
...prev,
DefaultUseAutoGroup: value,
}))
}
/>
</Col>
</Row>
</Form.Section>
</Form>
<Button onClick={onSubmit}>{t('保存分组相关设置')}</Button>
);
const GuideSection = ({ title, children }) => {
const [open, setOpen] = useState(false);
return (
<div style={{ marginTop: 16 }}>
<Button
theme='borderless'
size='small'
icon={open ? <IconChevronUp /> : <IconChevronDown />}
onClick={() => setOpen(!open)}
style={{ padding: '4px 0', color: 'var(--semi-color-primary)' }}
>
{title}
</Button>
<Collapsible isOpen={open} keepDOM>
<div
style={{
background: 'var(--semi-color-fill-0)',
padding: '12px 16px',
borderRadius: 8,
marginTop: 8,
}}
>
{children}
</div>
</Collapsible>
</div>
);
};
const CodeBlock = ({ children }) => (
<pre
style={{
background: 'var(--semi-color-bg-2)',
border: '1px solid var(--semi-color-border)',
padding: '10px 14px',
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 13,
margin: '8px 0',
whiteSpace: 'pre-wrap',
lineHeight: 1.6,
overflowX: 'auto',
}}
>
{children}
</pre>
);
const renderGuide = () => (
<SideSheet
title={t('分组设置使用说明')}
visible={showGuide}
onCancel={() => setShowGuide(false)}
width={560}
bodyStyle={{ overflow: 'auto', padding: '0 24px 24px' }}
>
<Tabs type='line' size='small'>
<Tabs.TabPane tab={t('概览')} itemKey='overview'>
<div style={{ paddingTop: 20 }}>
<Title heading={5}>{t('什么是分组?')}</Title>
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
{t(
'分组是用于控制计费倍率和模型访问权限的核心概念。每个用户属于一个分组,每个令牌也可以指定使用某个分组。',
)}
</Paragraph>
<Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
{t(
'通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。',
)}
</Paragraph>
<GuideSection title={t('核心概念')}>
<Paragraph style={{ lineHeight: 1.8 }}>
<Text strong>{t('用户分组')}</Text>{' — '}
{t('由管理员分配,决定用户身份等级(如 default、vip)。')}
</Paragraph>
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
<Text strong>{t('令牌分组')}</Text>{' — '}
{t('用户创建令牌时选择的分组,决定该令牌的实际计费倍率。一个用户可以创建多个令牌,使用不同分组。')}
</Paragraph>
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
<Text strong>{t('倍率')}</Text>{' — '}
{t('计费乘数,倍率越低费用越低。例如倍率 0.5 表示半价。')}
</Paragraph>
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
<Text strong>{t('用户可选')}</Text>{' — '}
{t('勾选后,该分组会出现在用户创建令牌时的下拉菜单中。未勾选的分组只能由管理员分配,用户自己无法选择。')}
</Paragraph>
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
<Text strong>{t('自动分组')}</Text>{' — '}
{t('令牌分组设为 auto 时,系统按优先级顺序自动选择一个可用分组。')}
</Paragraph>
</GuideSection>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={t('分组管理')} itemKey='groups'>
<div style={{ paddingTop: 20 }}>
<Title heading={5}>{t('创建和管理分组')}</Title>
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
{t('每个分组代表一个价格档位。管理员创建分组后,可以选择哪些档位对用户开放自选。')}
</Paragraph>
<GuideSection title={t('查看示例')}>
<Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
{t('场景:站点提供两个价格档位,用户可以按需选择')}
</Paragraph>
<CodeBlock>
{`${t('分组名')} ${t('倍率')} ${t('用户可选')} ${t('说明')}\n──────────────────────────────────────\nstandard 1.0 ${t('是')} ${t('标准价格')}\npremium 0.5 ${t('是')} ${t('高级套餐,半价优惠')}`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
{t('两个分组都勾选了「用户可选」,所以用户创建令牌时可以看到这两个选项:')}
</Paragraph>
<CodeBlock>
{t('用户创建令牌 → 选择分组下拉框:')}{'\n'}
{` ├─ standard (${t('标准价格')})`}{'\n'}
{` └─ premium (${t('高级套餐,半价优惠')})`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
{t('选择 premium 创建的令牌,调用 API 时费用为 standard 的 50%。')}
</Paragraph>
<Paragraph size='small' style={{ marginTop: 16, lineHeight: 1.8 }}>
<Text strong>{t('对比:不勾选「用户可选」的场景')}</Text>
</Paragraph>
<Paragraph size='small' style={{ marginTop: 4, lineHeight: 1.8 }}>
{t('假设再加两个分组 default 和 vip,但不勾选用户可选:')}
</Paragraph>
<CodeBlock>
{`${t('分组名')} ${t('倍率')} ${t('用户可选')} ${t('说明')}\n──────────────────────────────────────\ndefault 1.0 ${t('否')} ${t('管理员分配的基础分组')}\nvip 0.5 ${t('否')} ${t('管理员分配的优惠分组')}\nstandard 1.0 ${t('是')} ${t('标准价格')}\npremium 0.5 ${t('是')} ${t('高级套餐,半价优惠')}`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 8, lineHeight: 1.8 }}>
{t('此时用户创建令牌时只能看到 standard 和 premium')}
</Paragraph>
<CodeBlock>
{t('用户创建令牌 → 选择分组下拉框:')}{'\n'}
{` ├─ standard (${t('标准价格')})`}{'\n'}
{` └─ premium (${t('高级套餐,半价优惠')})`}{'\n\n'}
{` ${t('不会出现')} default ${t('和')} vip`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 8, lineHeight: 1.8 }}>
{t('default 和 vip 只能由管理员在「用户管理」中分配给用户。适用于按用户等级定价、内部测试等不希望用户自主选择的场景。')}
</Paragraph>
<Paragraph size='small' style={{ marginTop: 12, lineHeight: 1.8 }}>
<Text strong>{t('用户分组的联动作用')}</Text>
</Paragraph>
<Paragraph size='small' style={{ lineHeight: 1.8 }}>
{t('管理员给用户分配的分组(如 vip)不仅决定用户身份,还会影响后续两个功能:')}
</Paragraph>
<Paragraph size='small' style={{ lineHeight: 1.8, marginTop: 4 }}>
{'1. '}<Text strong>{t('特殊倍率')}</Text>{' — '}
{t('可以根据用户分组设置不同的计费倍率。例如 vip 用户使用 standard 令牌时倍率从 1.0 降为 0.8。')}
</Paragraph>
<Paragraph size='small' style={{ lineHeight: 1.8, marginTop: 2 }}>
{'2. '}<Text strong>{t('可用分组')}</Text>{' — '}
{t('可以根据用户分组增减令牌可选的分组范围。例如 vip 用户额外开放 premium 分组,或移除某个分组的选择权。')}
</Paragraph>
<Paragraph size='small' type='tertiary' style={{ lineHeight: 1.8, marginTop: 6 }}>
{t('详见「特殊倍率」和「可用分组」标签页。')}
</Paragraph>
</GuideSection>
<GuideSection title={t('JSON 格式参考')}>
<Paragraph size='small' style={{ marginBottom: 4 }}>
<Text strong code>GroupRatio</Text>{' — '}{t('分组名称到倍率的映射')}
</Paragraph>
<CodeBlock>{`{"default": 1, "vip": 0.5, "standard": 1, "premium": 0.5}`}</CodeBlock>
<Paragraph size='small' style={{ marginBottom: 4, marginTop: 8 }}>
<Text strong code>UserUsableGroups</Text>{' — '}{t('用户可选分组的名称和描述(只包含勾选了用户可选的分组)')}
</Paragraph>
<CodeBlock>{`{"standard": "${t('标准价格')}", "premium": "${t('高级套餐,半价优惠')}"}`}</CodeBlock>
</GuideSection>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={t('自动分组')} itemKey='auto'>
<div style={{ paddingTop: 20 }}>
<Title heading={5}>{t('自动分组选择')}</Title>
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
{t('当令牌分组设为 auto 时,系统按列表顺序依次选择可用分组。排在前面的优先级更高。')}
</Paragraph>
<GuideSection title={t('查看示例')}>
<Paragraph size='small' type='tertiary' style={{ marginBottom: 6 }}>
{t('场景:设置自动选择优先级')}
</Paragraph>
<CodeBlock>
{`1. default ${t('最高优先级')}\n2. vip`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 6, lineHeight: 1.6 }}>
{t('开启「默认使用 auto 分组」后,新建令牌和初始令牌都会自动设为 auto。')}
</Paragraph>
</GuideSection>
<GuideSection title={t('JSON 格式参考')}>
<Paragraph size='small' style={{ marginBottom: 4 }}>
<Text strong code>AutoGroups</Text>{' — '}{t('有序字符串数组')}
</Paragraph>
<CodeBlock>{`["default", "vip"]`}</CodeBlock>
</GuideSection>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={t('特殊倍率')} itemKey='ratios'>
<div style={{ paddingTop: 20 }}>
<Title heading={5}>{t('跨分组特殊倍率')}</Title>
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
{t('正常情况下,令牌的计费倍率由令牌所选的分组决定。特殊倍率可以根据「用户所在分组」进一步覆盖这个倍率。')}
</Paragraph>
<Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
{t('简单来说:同一个令牌分组,不同等级的用户可以享受不同的价格。')}
</Paragraph>
<GuideSection title={t('查看示例')}>
<Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
{t('场景:站点有 standard(倍率 1.0)和 premium(倍率 0.5)两个分组,希望 vip 用户使用 standard 令牌时也能享受折扣')}
</Paragraph>
<Paragraph size='small' style={{ marginBottom: 8, lineHeight: 1.8 }}>
<Text strong>{t('不配置特殊倍率时:')}</Text>
</Paragraph>
<CodeBlock>
{`${t('普通用户')} + standard ${t('令牌')}${t('倍率')} 1.0 (${t('原价')})\nvip ${t('用户')} + standard ${t('令牌')}${t('倍率')} 1.0 (${t('原价,和普通用户一样')})`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, marginBottom: 8, lineHeight: 1.8 }}>
<Text strong>{t('配置特殊倍率后:')}</Text>
</Paragraph>
<CodeBlock>
{`${t('用户分组')} ${t('使用分组')} ${t('倍率')}\n────────────────────────────\nvip standard 0.8\nvip premium 0.3`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
{t('配置后的效果:')}
</Paragraph>
<CodeBlock>
{`${t('普通用户')} + standard ${t('令牌')}${t('倍率')} 1.0 (${t('不变')})\nvip ${t('用户')} + standard ${t('令牌')}${t('倍率')} 0.8 (${t('享受 8 折')})\nvip ${t('用户')} + premium ${t('令牌')}${t('倍率')} 0.3 (${t('从 0.5 降到 0.3')})`}
</CodeBlock>
<Paragraph size='small' type='tertiary' style={{ marginTop: 10, lineHeight: 1.8 }}>
{t('只有配置了规则的组合才会覆盖,未配置的组合仍使用令牌分组的基础倍率。')}
</Paragraph>
</GuideSection>
<GuideSection title={t('JSON 格式参考')}>
<Paragraph size='small' style={{ marginBottom: 4 }}>
<Text strong code>GroupGroupRatio</Text>{' — '}{t('嵌套映射:用户分组 → 使用分组 → 倍率')}
</Paragraph>
<CodeBlock>{`{\n "vip": {\n "standard": 0.8,\n "premium": 0.3\n }\n}`}</CodeBlock>
</GuideSection>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={t('可用分组')} itemKey='usable'>
<div style={{ paddingTop: 20 }}>
<Title heading={5}>{t('特殊可用分组规则')}</Title>
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
{t('默认情况下,所有用户创建令牌时看到的可选分组列表是一样的(即「用户可选」列勾选的分组)。')}
</Paragraph>
<Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
{t('通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。')}
</Paragraph>
<GuideSection title={t('查看示例')}>
<Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
{t('场景:站点有 standard 和 premium 两个用户可选分组。希望 vip 用户额外看到 exclusive 分组,同时不再看到 standard 分组')}
</Paragraph>
<Paragraph size='small' style={{ marginBottom: 8, lineHeight: 1.8 }}>
<Text strong>{t('不配置规则时,所有用户看到的下拉框一样:')}</Text>
</Paragraph>
<CodeBlock>
{`${t('所有用户')}${t('创建令牌可选')}:\n ├─ standard\n └─ premium`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, marginBottom: 8, lineHeight: 1.8 }}>
<Text strong>{t('为 vip 用户配置规则:')}</Text>
</Paragraph>
<CodeBlock>
{`${t('用户分组')} ${t('操作')} ${t('目标分组')} ${t('描述')}\n──────────────────────────────────────────\nvip ${t('添加')} (+:) exclusive ${t('专属分组')}\nvip ${t('移除')} (-:) standard -`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
{t('配置后的效果:')}
</Paragraph>
<CodeBlock>
{`${t('普通用户')}${t('创建令牌可选')}:\n ├─ standard\n └─ premium\n\nvip ${t('用户')}${t('创建令牌可选')}:\n ├─ premium (${t('保留')})\n └─ exclusive (${t('新增')})\n\n ${t('standard 已被移除,vip 用户看不到')}`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 14, lineHeight: 1.8 }}>
<Text strong>{t('三种操作的区别:')}</Text>
</Paragraph>
<CodeBlock>
{`${t('添加')} (+:) → ${t('在默认列表基础上新增一个分组')}\n${t('移除')} (-:) → ${t('从默认列表中去掉一个分组')}\n${t('追加')}${t('直接追加(和添加类似,但无前缀)')}`}
</CodeBlock>
</GuideSection>
<GuideSection title={t('JSON 格式参考')}>
<Paragraph size='small' style={{ marginBottom: 4 }}>
<Text strong code>group_special_usable_group</Text>
</Paragraph>
<CodeBlock>{`{\n "vip": {\n "+:exclusive": "${t('专属分组')}",\n "-:standard": "remove"\n }\n}`}</CodeBlock>
<Paragraph size='small' type='tertiary' style={{ marginTop: 6, lineHeight: 1.6 }}>
{t('键的前缀 +: 表示添加,-: 表示移除,无前缀表示追加。值为分组描述(移除时填 "remove")。')}
</Paragraph>
</GuideSection>
</div>
</Tabs.TabPane>
</Tabs>
</SideSheet>
);
return (
<Spin spinning={loading}>
<div style={{ marginBottom: 15 }}>
<div className='flex items-center gap-3' style={{ marginTop: 12, marginBottom: 16 }}>
<RadioGroup
type='button'
size='small'
value={editMode}
onChange={(e) => setEditMode(e.target.value)}
>
<Radio value='visual'>{t('可视化编辑')}</Radio>
<Radio value='manual'>{t('手动编辑')}</Radio>
</RadioGroup>
<Button
icon={<IconHelpCircle />}
theme='borderless'
type='tertiary'
size='small'
onClick={() => setShowGuide(true)}
>
{t('使用说明')}
</Button>
</div>
{editMode === 'visual' ? renderVisualMode() : renderManualMode()}
</div>
<Button size='default' onClick={onSubmit}>
{t('保存分组相关设置')}
</Button>
{renderGuide()}
</Spin>
);
}
@@ -0,0 +1,50 @@
/*
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, { useState } from 'react';
import { Radio, RadioGroup } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import ModelPricingEditor from './components/ModelPricingEditor';
import ModelRatioSettings from './ModelRatioSettings';
export default function ModelPricingCombined({ options, refresh }) {
const { t } = useTranslation();
const [editMode, setEditMode] = useState('visual');
return (
<div>
<div style={{ marginTop: 12, marginBottom: 16 }}>
<RadioGroup
type='button'
size='small'
value={editMode}
onChange={(e) => setEditMode(e.target.value)}
>
<Radio value='visual'>{t('可视化编辑')}</Radio>
<Radio value='manual'>{t('手动编辑')}</Radio>
</RadioGroup>
</div>
{editMode === 'visual' ? (
<ModelPricingEditor options={options} refresh={refresh} />
) : (
<ModelRatioSettings options={options} refresh={refresh} />
)}
</div>
);
}
@@ -0,0 +1,169 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Select,
Typography,
Popconfirm,
Tag,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconDelete,
IconChevronUp,
IconChevronDown,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
let _idCounter = 0;
const uid = () => `ag_${++_idCounter}`;
function parseAutoGroups(str) {
if (!str || !str.trim()) return [];
try {
const parsed = JSON.parse(str);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item) => typeof item === 'string')
.map((name) => ({ _id: uid(), name }));
} catch {
return [];
}
}
function serializeAutoGroups(items) {
const names = items.map((i) => i.name).filter(Boolean);
return names.length === 0 ? '' : JSON.stringify(names);
}
export default function AutoGroupList({ value, groupNames = [], onChange }) {
const { t } = useTranslation();
const [items, setItems] = useState(() => parseAutoGroups(value));
const emitChange = useCallback(
(newItems) => {
setItems(newItems);
onChange?.(serializeAutoGroups(newItems));
},
[onChange],
);
const groupOptions = useMemo(
() => groupNames.map((n) => ({ value: n, label: n })),
[groupNames],
);
const addItem = useCallback(() => {
emitChange([...items, { _id: uid(), name: '' }]);
}, [items, emitChange]);
const removeItem = useCallback(
(id) => {
emitChange(items.filter((i) => i._id !== id));
},
[items, emitChange],
);
const updateItem = useCallback(
(id, name) => {
emitChange(items.map((i) => (i._id === id ? { ...i, name } : i)));
},
[items, emitChange],
);
const moveUp = useCallback(
(index) => {
if (index <= 0) return;
const next = [...items];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
emitChange(next);
},
[items, emitChange],
);
const moveDown = useCallback(
(index) => {
if (index >= items.length - 1) return;
const next = [...items];
[next[index], next[index + 1]] = [next[index + 1], next[index]];
emitChange(next);
},
[items, emitChange],
);
if (items.length === 0) {
return (
<div>
<Text type='tertiary' className='block text-center py-4'>
{t('暂无自动分组,点击下方按钮添加')}
</Text>
<div className='mt-2 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addItem}>
{t('添加分组')}
</Button>
</div>
</div>
);
}
return (
<div>
<div className='space-y-2'>
{items.map((item, index) => (
<div
key={item._id}
className='flex items-center gap-2'
>
<Tag size='small' color='blue' className='shrink-0'>
{index + 1}
</Tag>
<Select
size='small'
filter
value={item.name || undefined}
placeholder={t('选择分组')}
optionList={groupOptions}
onChange={(v) => updateItem(item._id, v)}
style={{ flex: 1 }}
allowCreate
position='bottomLeft'
/>
<Button
icon={<IconChevronUp />}
theme='borderless'
size='small'
disabled={index === 0}
onClick={() => moveUp(index)}
/>
<Button
icon={<IconChevronDown />}
theme='borderless'
size='small'
disabled={index === items.length - 1}
onClick={() => moveDown(index)}
/>
<Popconfirm
title={t('确认移除?')}
onConfirm={() => removeItem(item._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
</div>
))}
</div>
<div className='mt-3 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addItem}>
{t('添加分组')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,287 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Collapsible,
Input,
InputNumber,
Select,
Tag,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconDelete,
IconChevronDown,
IconChevronUp,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
let _idCounter = 0;
const uid = () => `ggr_${++_idCounter}`;
function parseJSON(str) {
if (!str || !str.trim()) return {};
try {
return JSON.parse(str);
} catch {
return {};
}
}
function flattenRules(nested) {
const rules = [];
for (const [userGroup, inner] of Object.entries(nested)) {
if (typeof inner !== 'object' || inner === null) continue;
for (const [usingGroup, ratio] of Object.entries(inner)) {
rules.push({
_id: uid(),
userGroup,
usingGroup,
ratio: typeof ratio === 'number' ? ratio : 1,
});
}
}
return rules;
}
function nestRules(rules) {
const result = {};
rules.forEach(({ userGroup, usingGroup, ratio }) => {
if (!userGroup || !usingGroup) return;
if (!result[userGroup]) result[userGroup] = {};
result[userGroup][usingGroup] = ratio;
});
return result;
}
export function serializeGroupGroupRatio(rules) {
const nested = nestRules(rules);
return Object.keys(nested).length === 0
? ''
: JSON.stringify(nested, null, 2);
}
function GroupSection({ groupName, items, groupOptions, onUpdate, onRemove, onAdd, t }) {
const [open, setOpen] = useState(false);
return (
<div
style={{
border: '1px solid var(--semi-color-border)',
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
className='flex items-center justify-between cursor-pointer'
style={{
padding: '8px 12px',
background: 'var(--semi-color-fill-0)',
}}
onClick={() => setOpen(!open)}
>
<div className='flex items-center gap-2'>
{open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
<Text strong>{groupName}</Text>
<Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
</div>
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
<Button
icon={<IconPlus />}
size='small'
theme='borderless'
onClick={() => onAdd(groupName)}
/>
<Popconfirm
title={t('确认删除该分组的所有规则?')}
onConfirm={() => items.forEach((item) => onRemove(item._id))}
position='left'
>
<Button
icon={<IconDelete />}
size='small'
type='danger'
theme='borderless'
/>
</Popconfirm>
</div>
</div>
<Collapsible isOpen={open} keepDOM>
<div style={{ padding: '8px 12px' }}>
{items.map((rule) => (
<div
key={rule._id}
className='flex items-center gap-2'
style={{ marginBottom: 6 }}
>
<Select
size='small'
filter
value={rule.usingGroup || undefined}
placeholder={t('选择使用分组')}
optionList={groupOptions}
onChange={(v) => onUpdate(rule._id, 'usingGroup', v)}
style={{ flex: 1 }}
allowCreate
position='bottomLeft'
/>
<InputNumber
size='small'
min={0}
step={0.1}
value={rule.ratio}
style={{ width: 100 }}
onChange={(v) => onUpdate(rule._id, 'ratio', v ?? 0)}
/>
<Popconfirm
title={t('确认删除该规则?')}
onConfirm={() => onRemove(rule._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
</div>
))}
</div>
</Collapsible>
</div>
);
}
export default function GroupGroupRatioRules({
value,
groupNames = [],
onChange,
}) {
const { t } = useTranslation();
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
const [newGroupName, setNewGroupName] = useState('');
const emitChange = useCallback(
(newRules) => {
setRules(newRules);
onChange?.(serializeGroupGroupRatio(newRules));
},
[onChange],
);
const updateRule = useCallback(
(id, field, val) => {
emitChange(rules.map((r) => (r._id === id ? { ...r, [field]: val } : r)));
},
[rules, emitChange],
);
const removeRule = useCallback(
(id) => {
emitChange(rules.filter((r) => r._id !== id));
},
[rules, emitChange],
);
const addRuleToGroup = useCallback(
(groupName) => {
emitChange([
...rules,
{ _id: uid(), userGroup: groupName, usingGroup: '', ratio: 1 },
]);
},
[rules, emitChange],
);
const addNewGroup = useCallback(() => {
const name = newGroupName.trim();
if (!name) return;
emitChange([
...rules,
{ _id: uid(), userGroup: name, usingGroup: '', ratio: 1 },
]);
setNewGroupName('');
}, [rules, emitChange, newGroupName]);
const groupOptions = useMemo(
() => groupNames.map((n) => ({ value: n, label: n })),
[groupNames],
);
const grouped = useMemo(() => {
const map = {};
const order = [];
rules.forEach((r) => {
if (!r.userGroup) return;
if (!map[r.userGroup]) {
map[r.userGroup] = [];
order.push(r.userGroup);
}
map[r.userGroup].push(r);
});
return order.map((name) => ({ name, items: map[name] }));
}, [rules]);
if (grouped.length === 0 && rules.length === 0) {
return (
<div>
<Text type='tertiary' className='block text-center py-4'>
{t('暂无规则,点击下方按钮添加')}
</Text>
<div className='mt-2 flex justify-center gap-2'>
<Select
size='small'
filter
allowCreate
placeholder={t('选择用户分组')}
optionList={groupOptions}
value={newGroupName || undefined}
onChange={setNewGroupName}
style={{ width: 200 }}
position='bottomLeft'
/>
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
{t('添加分组规则')}
</Button>
</div>
</div>
);
}
return (
<div className='space-y-2'>
{grouped.map((group) => (
<GroupSection
key={group.name}
groupName={group.name}
items={group.items}
groupOptions={groupOptions}
onUpdate={updateRule}
onRemove={removeRule}
onAdd={addRuleToGroup}
t={t}
/>
))}
<div className='mt-3 flex justify-center gap-2'>
<Select
size='small'
filter
allowCreate
placeholder={t('选择用户分组')}
optionList={groupOptions}
value={newGroupName || undefined}
onChange={setNewGroupName}
style={{ width: 200 }}
position='bottomLeft'
/>
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
{t('添加分组规则')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,351 @@
/*
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, { useState, useCallback, useMemo } from 'react';
import {
Button,
Collapsible,
Input,
Select,
Tag,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconDelete,
IconChevronDown,
IconChevronUp,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
let _idCounter = 0;
const uid = () => `gsu_${++_idCounter}`;
const OP_ADD = 'add';
const OP_REMOVE = 'remove';
const OP_APPEND = 'append';
function parsePrefix(rawKey) {
if (rawKey.startsWith('+:')) return { op: OP_ADD, groupName: rawKey.slice(2) };
if (rawKey.startsWith('-:')) return { op: OP_REMOVE, groupName: rawKey.slice(2) };
return { op: OP_APPEND, groupName: rawKey };
}
function toRawKey(op, groupName) {
if (op === OP_ADD) return `+:${groupName}`;
if (op === OP_REMOVE) return `-:${groupName}`;
return groupName;
}
function parseJSON(str) {
if (!str || !str.trim()) return {};
try { return JSON.parse(str); } catch { return {}; }
}
function flattenRules(nested) {
const rules = [];
for (const [userGroup, inner] of Object.entries(nested)) {
if (typeof inner !== 'object' || inner === null) continue;
for (const [rawKey, desc] of Object.entries(inner)) {
const { op, groupName } = parsePrefix(rawKey);
rules.push({
_id: uid(),
userGroup,
op,
targetGroup: groupName,
description: op === OP_REMOVE ? 'remove' : (typeof desc === 'string' ? desc : ''),
});
}
}
return rules;
}
function nestRules(rules) {
const result = {};
rules.forEach(({ userGroup, op, targetGroup, description }) => {
if (!userGroup || !targetGroup) return;
if (!result[userGroup]) result[userGroup] = {};
result[userGroup][toRawKey(op, targetGroup)] = description;
});
return result;
}
export function serializeGroupSpecialUsable(rules) {
const nested = nestRules(rules);
return Object.keys(nested).length === 0 ? '' : JSON.stringify(nested, null, 2);
}
const OP_TAG_MAP = {
[OP_ADD]: { color: 'green', label: '添加 (+:)' },
[OP_REMOVE]: { color: 'red', label: '移除 (-:)' },
[OP_APPEND]: { color: 'blue', label: '追加' },
};
function UsableGroupSection({ groupName, items, opOptions, onUpdate, onRemove, onAdd, t }) {
const [open, setOpen] = useState(false);
return (
<div
style={{
border: '1px solid var(--semi-color-border)',
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
className='flex items-center justify-between cursor-pointer'
style={{
padding: '8px 12px',
background: 'var(--semi-color-fill-0)',
}}
onClick={() => setOpen(!open)}
>
<div className='flex items-center gap-2'>
{open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
<Text strong>{groupName}</Text>
<Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
</div>
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
<Button
icon={<IconPlus />}
size='small'
theme='borderless'
onClick={() => onAdd(groupName)}
/>
<Popconfirm
title={t('确认删除该分组的所有规则?')}
onConfirm={() => items.forEach((item) => onRemove(item._id))}
position='left'
>
<Button
icon={<IconDelete />}
size='small'
type='danger'
theme='borderless'
/>
</Popconfirm>
</div>
</div>
<Collapsible isOpen={open} keepDOM>
<div style={{ padding: '8px 12px' }}>
{items.map((rule) => (
<div
key={rule._id}
className='flex items-center gap-2'
style={{ marginBottom: 6 }}
>
<Select
size='small'
value={rule.op}
optionList={opOptions}
onChange={(v) => onUpdate(rule._id, 'op', v)}
style={{ width: 120 }}
renderSelectedItem={(optionNode) => {
const info = OP_TAG_MAP[optionNode.value] || {};
return <Tag size='small' color={info.color}>{optionNode.label}</Tag>;
}}
/>
<Input
size='small'
value={rule.targetGroup}
placeholder={t('分组名称')}
onChange={(v) => onUpdate(rule._id, 'targetGroup', v)}
style={{ flex: 1 }}
/>
{rule.op !== OP_REMOVE ? (
<Input
size='small'
value={rule.description}
placeholder={t('分组描述')}
onChange={(v) => onUpdate(rule._id, 'description', v)}
style={{ flex: 1 }}
/>
) : (
<div style={{ flex: 1 }}>
<Text type='tertiary' size='small'>-</Text>
</div>
)}
<Popconfirm
title={t('确认删除该规则?')}
onConfirm={() => onRemove(rule._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
</div>
))}
</div>
</Collapsible>
</div>
);
}
export default function GroupSpecialUsableRules({
value,
groupNames = [],
onChange,
}) {
const { t } = useTranslation();
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
const [newGroupName, setNewGroupName] = useState('');
const emitChange = useCallback(
(newRules) => {
setRules(newRules);
onChange?.(serializeGroupSpecialUsable(newRules));
},
[onChange],
);
const updateRule = useCallback(
(id, field, val) => {
emitChange(
rules.map((r) => {
if (r._id !== id) return r;
const updated = { ...r, [field]: val };
if (field === 'op' && val === OP_REMOVE) updated.description = 'remove';
else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
if (updated.description === 'remove') updated.description = '';
}
return updated;
}),
);
},
[rules, emitChange],
);
const removeRule = useCallback(
(id) => emitChange(rules.filter((r) => r._id !== id)),
[rules, emitChange],
);
const addRuleToGroup = useCallback(
(groupName) => {
emitChange([
...rules,
{ _id: uid(), userGroup: groupName, op: OP_APPEND, targetGroup: '', description: '' },
]);
},
[rules, emitChange],
);
const addNewGroup = useCallback(() => {
const name = newGroupName.trim();
if (!name) return;
emitChange([
...rules,
{ _id: uid(), userGroup: name, op: OP_APPEND, targetGroup: '', description: '' },
]);
setNewGroupName('');
}, [rules, emitChange, newGroupName]);
const groupOptions = useMemo(
() => groupNames.map((n) => ({ value: n, label: n })),
[groupNames],
);
const opOptions = useMemo(
() => [
{ value: OP_ADD, label: t('添加 (+:)') },
{ value: OP_REMOVE, label: t('移除 (-:)') },
{ value: OP_APPEND, label: t('追加') },
],
[t],
);
const grouped = useMemo(() => {
const map = {};
const order = [];
rules.forEach((r) => {
if (!r.userGroup) return;
if (!map[r.userGroup]) {
map[r.userGroup] = [];
order.push(r.userGroup);
}
map[r.userGroup].push(r);
});
return order.map((name) => ({ name, items: map[name] }));
}, [rules]);
if (grouped.length === 0 && rules.length === 0) {
return (
<div>
<Text type='tertiary' className='block text-center py-4'>
{t('暂无规则,点击下方按钮添加')}
</Text>
<div className='mt-2 flex justify-center gap-2'>
<Select
size='small'
filter
allowCreate
placeholder={t('选择用户分组')}
optionList={groupOptions}
value={newGroupName || undefined}
onChange={setNewGroupName}
style={{ width: 200 }}
position='bottomLeft'
/>
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
{t('添加分组规则')}
</Button>
</div>
</div>
);
}
return (
<div className='space-y-2'>
{grouped.map((group) => (
<UsableGroupSection
key={group.name}
groupName={group.name}
items={group.items}
opOptions={opOptions}
onUpdate={updateRule}
onRemove={removeRule}
onAdd={addRuleToGroup}
t={t}
/>
))}
<div className='mt-3 flex justify-center gap-2'>
<Select
size='small'
filter
allowCreate
placeholder={t('选择用户分组')}
optionList={groupOptions}
value={newGroupName || undefined}
onChange={setNewGroupName}
style={{ width: 200 }}
position='bottomLeft'
/>
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
{t('添加分组规则')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,242 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Input,
InputNumber,
Checkbox,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import CardTable from '../../../../components/common/ui/CardTable';
const { Text } = Typography;
let _idCounter = 0;
const uid = () => `gr_${++_idCounter}`;
function parseJSON(str, fallback) {
if (!str || !str.trim()) return fallback;
try {
return JSON.parse(str);
} catch {
return fallback;
}
}
function buildRows(groupRatioStr, userUsableGroupsStr) {
const ratioMap = parseJSON(groupRatioStr, {});
const usableMap = parseJSON(userUsableGroupsStr, {});
const allNames = new Set([
...Object.keys(ratioMap),
...Object.keys(usableMap),
]);
return Array.from(allNames).map((name) => ({
_id: uid(),
name,
ratio: ratioMap[name] ?? 1,
selectable: name in usableMap,
description: usableMap[name] ?? '',
}));
}
export function serializeGroupTable(rows) {
const groupRatio = {};
const userUsableGroups = {};
rows.forEach((row) => {
if (!row.name) return;
groupRatio[row.name] = row.ratio;
if (row.selectable) {
userUsableGroups[row.name] = row.description;
}
});
return {
GroupRatio: JSON.stringify(groupRatio, null, 2),
UserUsableGroups: JSON.stringify(userUsableGroups, null, 2),
};
}
export default function GroupTable({
groupRatio,
userUsableGroups,
onChange,
}) {
const { t } = useTranslation();
const [rows, setRows] = useState(() =>
buildRows(groupRatio, userUsableGroups),
);
const emitChange = useCallback(
(newRows) => {
setRows(newRows);
onChange?.(serializeGroupTable(newRows));
},
[onChange],
);
const updateRow = useCallback(
(id, field, value) => {
const next = rows.map((r) =>
r._id === id ? { ...r, [field]: value } : r,
);
emitChange(next);
},
[rows, emitChange],
);
const addRow = useCallback(() => {
const existingNames = new Set(rows.map((r) => r.name));
let counter = 1;
let newName = `group_${counter}`;
while (existingNames.has(newName)) {
counter++;
newName = `group_${counter}`;
}
emitChange([
...rows,
{
_id: uid(),
name: newName,
ratio: 1,
selectable: true,
description: '',
},
]);
}, [rows, emitChange]);
const removeRow = useCallback(
(id) => {
emitChange(rows.filter((r) => r._id !== id));
},
[rows, emitChange],
);
const groupNames = useMemo(() => rows.map((r) => r.name), [rows]);
const duplicateNames = useMemo(() => {
const counts = {};
groupNames.forEach((n) => {
counts[n] = (counts[n] || 0) + 1;
});
return new Set(Object.keys(counts).filter((k) => counts[k] > 1));
}, [groupNames]);
const columns = useMemo(
() => [
{
title: t('分组名称'),
dataIndex: 'name',
key: 'name',
width: 180,
render: (_, record) => (
<Input
size='small'
value={record.name}
status={duplicateNames.has(record.name) ? 'warning' : undefined}
onChange={(v) => updateRow(record._id, 'name', v)}
/>
),
},
{
title: t('倍率'),
dataIndex: 'ratio',
key: 'ratio',
width: 120,
render: (_, record) => (
<InputNumber
size='small'
min={0}
step={0.1}
value={record.ratio}
style={{ width: '100%' }}
onChange={(v) => updateRow(record._id, 'ratio', v ?? 0)}
/>
),
},
{
title: t('用户可选'),
dataIndex: 'selectable',
key: 'selectable',
width: 90,
align: 'center',
render: (_, record) => (
<Checkbox
checked={record.selectable}
onChange={(e) =>
updateRow(record._id, 'selectable', e.target.checked)
}
/>
),
},
{
title: t('描述'),
dataIndex: 'description',
key: 'description',
render: (_, record) =>
record.selectable ? (
<Input
size='small'
value={record.description}
placeholder={t('分组描述')}
onChange={(v) => updateRow(record._id, 'description', v)}
/>
) : (
<Text type='tertiary' size='small'>
-
</Text>
),
},
{
title: '',
key: 'actions',
width: 50,
render: (_, record) => (
<Popconfirm
title={t('确认删除该分组?')}
onConfirm={() => removeRow(record._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
),
},
],
[t, duplicateNames, updateRow, removeRow],
);
return (
<div>
<CardTable
columns={columns}
dataSource={rows}
rowKey='_id'
hidePagination
size='small'
empty={
<Text type='tertiary'>{t('暂无分组,点击下方按钮添加')}</Text>
}
/>
<div className='mt-3 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addRow}>
{t('添加分组')}
</Button>
</div>
{duplicateNames.size > 0 && (
<Text type='warning' size='small' className='mt-2 block'>
{t('存在重复的分组名称:')}{Array.from(duplicateNames).join(', ')}
</Text>
)}
</div>
);
}