Merge remote-tracking branch 'origin/main' into nightly
Some checks failed
Release (Linux, macOS, Windows) / Linux Release (push) Has been cancelled
Release (Linux, macOS, Windows) / macOS Release (push) Has been cancelled
Release (Linux, macOS, Windows) / Windows Release (push) Has been cancelled

# 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
commit 4d2993e4cc
No known key found for this signature in database
GPG Key ID: 0CFA613529A9921D
100 changed files with 6875 additions and 2699 deletions

View File

@ -19,6 +19,8 @@
# HOSTNAME=your-hostname # HOSTNAME=your-hostname
# 数据库相关配置 # 数据库相关配置
# 启用错误日志记录
# ERROR_LOG_ENABLED=true
# 数据库连接字符串 # 数据库连接字符串
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true # SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
# 日志数据库连接字符串 # 日志数据库连接字符串

28
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
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
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)

View File

@ -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
.github/workflows/pr-check.yml vendored Normal file
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

View File

@ -80,6 +80,7 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
var SMTPServer = "" var SMTPServer = ""
var SMTPPort = 587 var SMTPPort = 587
var SMTPSSLEnabled = false var SMTPSSLEnabled = false
var SMTPForceAuthLogin = false
var SMTPAccount = "" var SMTPAccount = ""
var SMTPFrom = "" var SMTPFrom = ""
var SMTPToken = "" var SMTPToken = ""

View File

@ -19,6 +19,20 @@ func generateMessageID() (string, error) {
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil 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 { func SendEmail(subject string, receiver string, content string) error {
if SMTPFrom == "" { // for compatibility if SMTPFrom == "" { // for compatibility
SMTPFrom = SMTPAccount SMTPFrom = SMTPAccount
@ -38,7 +52,7 @@ func SendEmail(subject string, receiver string, content string) error {
"Message-ID: %s\r\n"+ // 添加 Message-ID 头 "Message-ID: %s\r\n"+ // 添加 Message-ID 头
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n", "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)) 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) addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
to := strings.Split(receiver, ";") to := strings.Split(receiver, ";")
var err error var err error
@ -80,9 +94,6 @@ func SendEmail(subject string, receiver string, content string) error {
if err != nil { if err != nil {
return err return err
} }
} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
auth = LoginAuth(SMTPAccount, SMTPToken)
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
} else { } else {
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail) err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
} }

View File

@ -1,6 +1,7 @@
package controller package controller
import ( import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
@ -8,6 +9,30 @@ import (
"github.com/gin-gonic/gin" "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) { func GetPricing(c *gin.Context) {
pricing := model.GetPricing() pricing := model.GetPricing()
userId, exists := c.Get("id") userId, exists := c.Get("id")
@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
} }
usableGroup = service.GetUserUsableGroups(group) usableGroup = service.GetUserUsableGroups(group)
pricing = filterPricingByUsableGroups(pricing, usableGroup)
// check groupRatio contains usableGroup // check groupRatio contains usableGroup
for group := range ratio_setting.GetGroupRatioCopy() { for group := range ratio_setting.GetGroupRatioCopy() {
if _, ok := usableGroup[group]; !ok { if _, ok := usableGroup[group]; !ok {

View File

@ -581,7 +581,7 @@ func RelayTask(c *gin.Context) {
ModelRatio: relayInfo.PriceData.ModelRatio, ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios, OtherRatios: relayInfo.PriceData.OtherRatios,
OriginModelName: relayInfo.OriginModelName, 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.Quota = result.Quota
task.Data = result.TaskData task.Data = result.TaskData

View File

@ -334,3 +334,26 @@ func DeleteTokenBatch(c *gin.Context) {
"data": count, "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})
}

View File

@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
return 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) { func GetUserQuotaDates(c *gin.Context) {
userId := c.GetInt("id") userId := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)

View File

@ -18,6 +18,16 @@ type AudioRequest struct {
Speed *float64 `json:"speed,omitempty"` Speed *float64 `json:"speed,omitempty"`
StreamFormat string `json:"stream_format,omitempty"` StreamFormat string `json:"stream_format,omitempty"`
Metadata json.RawMessage `json:"metadata,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 { func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {

View File

@ -98,6 +98,20 @@ func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
return mediaContent 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 ClaudeMessageSource struct {
Type string `json:"type"` Type string `json:"type"`
MediaType string `json:"media_type,omitempty"` MediaType string `json:"media_type,omitempty"`
@ -223,14 +237,6 @@ type OutputConfigForEffort struct {
Effort string `json:"effort,omitempty"` 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 { func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
maxTokens := 0 maxTokens := 0
if c.MaxTokens != nil { if c.MaxTokens != nil {
@ -258,17 +264,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
case "text": case "text":
texts = append(texts, media.GetText()) texts = append(texts, media.GetText())
case "image": case "image":
if media.Source != nil { if source := media.ToFileSource(); source != nil {
data := media.Source.Url fileMeta = append(fileMeta, &types.FileMeta{
if data == "" { FileType: types.FileTypeImage,
data = common.Interface2String(media.Source.Data) Source: source,
} })
if data != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
})
}
} }
} }
} }
@ -293,17 +293,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
case "text": case "text":
texts = append(texts, media.GetText()) texts = append(texts, media.GetText())
case "image": case "image":
if media.Source != nil { if source := media.ToFileSource(); source != nil {
data := media.Source.Url fileMeta = append(fileMeta, &types.FileMeta{
if data == "" { FileType: types.FileTypeImage,
data = common.Interface2String(media.Source.Data) Source: source,
} })
if data != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
})
}
} }
case "tool_use": case "tool_use":
if media.Name != "" { if media.Name != "" {

View File

@ -64,14 +64,6 @@ type LatLng struct {
Longitude *float64 `json:"longitude,omitempty"` 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 { func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
var files []*types.FileMeta = make([]*types.FileMeta, 0) var files []*types.FileMeta = make([]*types.FileMeta, 0)
@ -87,9 +79,8 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
if part.Text != "" { if part.Text != "" {
inputTexts = append(inputTexts, 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 mimeType := part.InlineData.MimeType
source := createGeminiFileSource(part.InlineData.Data, mimeType)
var fileType types.FileType var fileType types.FileType
if strings.HasPrefix(mimeType, "image/") { if strings.HasPrefix(mimeType, "image/") {
fileType = types.FileTypeImage fileType = types.FileTypeImage
@ -103,7 +94,6 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
files = append(files, &types.FileMeta{ files = append(files, &types.FileMeta{
FileType: fileType, FileType: fileType,
Source: source, Source: source,
MimeType: mimeType,
}) })
} }
} }
@ -121,6 +111,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
if c.Query("alt") == "sse" { if c.Query("alt") == "sse" {
return true 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 return false
} }
@ -210,6 +205,13 @@ type GeminiInlineData struct {
Data string `json:"data"` 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 // UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error { func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
type Alias GeminiInlineData // Use type alias to avoid recursion type Alias GeminiInlineData // Use type alias to avoid recursion

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

View File

@ -108,14 +108,6 @@ type GeneralOpenAIRequest struct {
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"` 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 { func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
var tokenCountMeta types.TokenCountMeta var tokenCountMeta types.TokenCountMeta
var texts = make([]string, 0) var texts = make([]string, 0)
@ -159,44 +151,24 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
} }
arrayContent := message.ParseContent() arrayContent := message.ParseContent()
for _, m := range arrayContent { for _, m := range arrayContent {
if m.Type == ContentTypeImageURL { source := m.ToFileSource()
imageUrl := m.GetImageMedia() if source != nil {
if imageUrl != nil && imageUrl.Url != "" { meta := &types.FileMeta{Source: source}
source := createFileSource(imageUrl.Url) switch m.Type {
fileMeta = append(fileMeta, &types.FileMeta{ case ContentTypeImageURL:
FileType: types.FileTypeImage, meta.FileType = types.FileTypeImage
Source: source, if img := m.GetImageMedia(); img != nil {
Detail: imageUrl.Detail, meta.Detail = img.Detail
}) }
case ContentTypeInputAudio:
meta.FileType = types.FileTypeAudio
case ContentTypeFile:
meta.FileType = types.FileTypeFile
case ContentTypeVideoUrl:
meta.FileType = types.FileTypeVideo
} }
} else if m.Type == ContentTypeInputAudio { fileMeta = append(fileMeta, meta)
inputAudio := m.GetInputAudio() } else if m.Type == ContentTypeText {
if inputAudio != nil && inputAudio.Data != "" {
source := createFileSource(inputAudio.Data)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeAudio,
Source: source,
})
}
} 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 {
texts = append(texts, m.Text) texts = append(texts, m.Text)
} }
} }
@ -391,6 +363,40 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {
return nil 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 { type MessageImageUrl struct {
Url string `json:"url"` Url string `json:"url"`
Detail string `json:"detail,omitempty"` Detail string `json:"detail,omitempty"`
@ -865,7 +871,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.ImageUrl != "" { if input.ImageUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{ fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage, FileType: types.FileTypeImage,
Source: createFileSource(input.ImageUrl), Source: types.NewFileSourceFromData(input.ImageUrl, ""),
Detail: input.Detail, Detail: input.Detail,
}) })
} }
@ -873,7 +879,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.FileUrl != "" { if input.FileUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{ fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile, FileType: types.FileTypeFile,
Source: createFileSource(input.FileUrl), Source: types.NewFileSourceFromData(input.FileUrl, ""),
}) })
} }
} else { } else {

20
electron/package-lock.json generated vendored
View File

@ -9,7 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"electron": "35.7.5", "electron": "39.8.5",
"electron-builder": "^26.7.0" "electron-builder": "^26.7.0"
} }
}, },
@ -777,9 +777,9 @@
} }
}, },
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.11", "version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -2145,9 +2145,9 @@
} }
}, },
"node_modules/electron": { "node_modules/electron": {
"version": "35.7.5", "version": "39.8.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz", "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz",
"integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==", "integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@ -3279,9 +3279,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },

View File

@ -25,7 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"electron": "35.7.5", "electron": "39.8.5",
"electron-builder": "^26.7.0" "electron-builder": "^26.7.0"
}, },
"build": { "build": {

10
go.mod
View File

@ -8,9 +8,9 @@ require (
github.com/abema/go-mp4 v1.4.1 github.com/abema/go-mp4 v1.4.1
github.com/andybalholm/brotli v1.1.1 github.com/andybalholm/brotli v1.1.1
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 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/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/aws/smithy-go v1.24.2
github.com/bytedance/gopkg v0.1.3 github.com/bytedance/gopkg v0.1.3
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
@ -63,9 +63,9 @@ require (
require ( require (
github.com/DmitriyVTitov/size v1.5.0 // indirect github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // 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/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // 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.18 // 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/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect github.com/boombuler/barcode v1.1.0 // indirect
github.com/bytedance/sonic v1.14.1 // indirect github.com/bytedance/sonic v1.14.1 // indirect

20
go.sum
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/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 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8= 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.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= 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.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= 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.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c= 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 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/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.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
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/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
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.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
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/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
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.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU= 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 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

View File

@ -25,6 +25,7 @@ const (
MsgDeleteFailed = "common.delete_failed" MsgDeleteFailed = "common.delete_failed"
MsgAlreadyExists = "common.already_exists" MsgAlreadyExists = "common.already_exists"
MsgNameCannotBeEmpty = "common.name_cannot_be_empty" MsgNameCannotBeEmpty = "common.name_cannot_be_empty"
MsgBatchTooMany = "common.batch_too_many"
) )
// Token related messages // Token related messages

View File

@ -21,6 +21,7 @@ common.delete_success: "Deletion successful"
common.delete_failed: "Deletion failed" common.delete_failed: "Deletion failed"
common.already_exists: "Already exists" common.already_exists: "Already exists"
common.name_cannot_be_empty: "Name cannot be empty" 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 messages
token.name_too_long: "Token name is too long" token.name_too_long: "Token name is too long"

View File

@ -22,6 +22,7 @@ common.delete_success: "删除成功"
common.delete_failed: "删除失败" common.delete_failed: "删除失败"
common.already_exists: "已存在" common.already_exists: "已存在"
common.name_cannot_be_empty: "名称不能为空" common.name_cannot_be_empty: "名称不能为空"
common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
# Token messages # Token messages
token.name_too_long: "令牌名称过长" token.name_too_long: "令牌名称过长"

View File

@ -22,6 +22,7 @@ common.delete_success: "刪除成功"
common.delete_failed: "刪除失敗" common.delete_failed: "刪除失敗"
common.already_exists: "已存在" common.already_exists: "已存在"
common.name_cannot_be_empty: "名稱不能為空" common.name_cannot_be_empty: "名稱不能為空"
common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
# Token messages # Token messages
token.name_too_long: "令牌名稱過長" token.name_too_long: "令牌名稱過長"

View File

@ -1,7 +1,7 @@
package middleware package middleware
import ( import (
"errors" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -48,17 +48,23 @@ func checkSystemPerformance() *types.NewAPIError {
// 检查 CPU // 检查 CPU
if config.CPUThreshold > 0 && int(status.CPUUsage) > config.CPUThreshold { 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 { 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 { 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 return nil

View File

@ -2,14 +2,25 @@ package middleware
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"runtime/debug"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin" "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) { func RequestId() func(c *gin.Context) {
return 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) c.Set(common.RequestIdKey, id)
ctx := context.WithValue(c.Request.Context(), common.RequestIdKey, id) ctx := context.WithValue(c.Request.Context(), common.RequestIdKey, id)
c.Request = c.Request.WithContext(ctx) c.Request = c.Request.WithContext(ctx)

View File

@ -62,6 +62,7 @@ func InitOptionMap() {
common.OptionMap["SMTPAccount"] = "" common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = "" common.OptionMap["SMTPToken"] = ""
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled) common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
common.OptionMap["Notice"] = "" common.OptionMap["Notice"] = ""
common.OptionMap["About"] = "" common.OptionMap["About"] = ""
common.OptionMap["HomePageContent"] = "" common.OptionMap["HomePageContent"] = ""
@ -233,7 +234,7 @@ func updateOptionMap(key string, value string) (err error) {
common.ImageDownloadPermission = intValue 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" boolValue := value == "true"
switch key { switch key {
case "PasswordRegisterEnabled": case "PasswordRegisterEnabled":
@ -308,6 +309,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.StopOnSensitiveEnabled = boolValue setting.StopOnSensitiveEnabled = boolValue
case "SMTPSSLEnabled": case "SMTPSSLEnabled":
common.SMTPSSLEnabled = boolValue common.SMTPSSLEnabled = boolValue
case "SMTPForceAuthLogin":
common.SMTPForceAuthLogin = boolValue
case "WorkerAllowHttpImageRequestEnabled": case "WorkerAllowHttpImageRequestEnabled":
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup": case "DefaultUseAutoGroup":

View File

@ -481,3 +481,11 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) {
return len(tokens), nil 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
}

View File

@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
return quotaDatas, err 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) { func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
if username != "" { if username != "" {
return GetQuotaDataByUsername(username, startTime, endTime) return GetQuotaDataByUsername(username, startTime, endTime)

View File

@ -85,7 +85,7 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
require.EqualValues(t, 50, usage.CacheCreationInputTokens) require.EqualValues(t, 50, usage.CacheCreationInputTokens)
require.EqualValues(t, 53, usage.OutputTokens) require.EqualValues(t, 53, usage.OutputTokens)
require.NotNil(t, usage.CacheCreation) 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) 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, 7, usage.CacheReadInputTokens)
require.EqualValues(t, 6, usage.CacheCreationInputTokens) 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)
})
} }

View File

@ -85,7 +85,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
// 解析 UserLocation JSON // 解析 UserLocation JSON
var userLocationMap map[string]interface{} 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 字段 // 检查是否有 approximate 字段
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok { if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" { if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
@ -177,7 +177,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
} }
// TODO: 临时处理 // TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking // 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) claudeRequest.Temperature = common.GetPointer[float64](1.0)
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) { if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking") claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
@ -343,33 +343,39 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
} else { } else {
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0) claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
for _, mediaMessage := range message.ParseContent() { for _, mediaMessage := range message.ParseContent() {
claudeMediaMessage := dto.ClaudeMediaMessage{ switch mediaMessage.Type {
Type: mediaMessage.Type, case "text":
} claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
if mediaMessage.Type == "text" { Type: "text",
claudeMediaMessage.Text = common.GetPointer[string](mediaMessage.Text) Text: common.GetPointer[string](mediaMessage.Text),
} else { })
imageUrl := mediaMessage.GetImageMedia() default:
claudeMediaMessage.Type = "image" source := mediaMessage.ToFileSource()
claudeMediaMessage.Source = &dto.ClaudeMessageSource{ if source == nil {
Type: "base64", continue
}
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(imageUrl.Url, "http") {
source = types.NewURLFileSource(imageUrl.Url)
} else {
source = types.NewBase64FileSource(imageUrl.Url, "")
} }
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude") base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
if err != nil { if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error()) 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.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data claudeMediaMessage.Source.Data = base64Data
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
continue
} }
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
} }
if message.ToolCalls != nil { if message.ToolCalls != nil {
for _, toolCall := range message.ParseToolCalls() { for _, toolCall := range message.ParseToolCalls() {
inputObj := make(map[string]any) inputObj := make(map[string]any)
@ -574,6 +580,11 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
return dto.Usage{} return dto.Usage{}
} }
clone := *usage clone := *usage
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = service.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage) cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
clone.PromptTokens = totalInputTokens clone.PromptTokens = totalInputTokens
@ -603,11 +614,26 @@ func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 { if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
} }
if usage.CacheCreation == nil && (claudeInfo.Usage.ClaudeCacheCreation5mTokens > 0 || claudeInfo.Usage.ClaudeCacheCreation1hTokens > 0) { cacheCreation5m := 0
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{ cacheCreation1h := 0
Ephemeral5mInputTokens: claudeInfo.Usage.ClaudeCacheCreation5mTokens, if usage.CacheCreation != nil {
Ephemeral1hInputTokens: claudeInfo.Usage.ClaudeCacheCreation1hTokens, 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 return usage
} }
@ -783,7 +809,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if common.DebugEnabled { if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error") 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 { if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic" claudeInfo.Usage.UsageSemantic = "anthropic"

View File

@ -1,10 +1,12 @@
package claude package claude
import ( import (
"encoding/base64"
"strings" "strings"
"testing" "testing"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/stretchr/testify/require"
) )
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) { 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)
}

View File

@ -37,6 +37,8 @@ var geminiSupportedMimeTypes = map[string]bool{
"image/jpeg": true, "image/jpeg": true,
"image/jpg": true, // support old image/jpeg "image/jpg": true, // support old image/jpeg
"image/webp": true, "image/webp": true,
"image/heic": true,
"image/heif": true,
"text/plain": true, "text/plain": true,
"video/mov": true, "video/mov": true,
"video/mpeg": true, "video/mpeg": true,
@ -583,14 +585,10 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
Text: part.Text, Text: part.Text,
}) })
} }
} else if part.Type == dto.ContentTypeImageURL { } else {
// 使用统一的文件服务获取图片数据 source := part.ToFileSource()
var source *types.FileSource if source == nil {
imageUrl := part.GetImageMedia().Url continue
if strings.HasPrefix(imageUrl, "http") {
source = types.NewURLFileSource(imageUrl)
} else {
source = types.NewBase64FileSource(imageUrl, "")
} }
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini") base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
if err != nil { 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()) 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{ parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{ InlineData: &dto.GeminiInlineData{
MimeType: mimeType, MimeType: mimeType,

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) { 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) { 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 { if info.RelayMode == constant.RelayModeAudioSpeech {
return handleTTSResponse(c, resp, info) return handleTTSResponse(c, resp, info)
} }
if info.RelayMode == constant.RelayModeImagesGenerations {
return miniMaxImageHandler(c, resp, info)
}
switch info.RelayFormat { switch info.RelayFormat {
case types.RelayFormatClaude: case types.RelayFormatClaude:

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
}

View File

@ -8,6 +8,8 @@ var ModelList = []string{
"abab6-chat", "abab6-chat",
"abab5.5-chat", "abab5.5-chat",
"abab5.5s-chat", "abab5.5s-chat",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"speech-2.5-hd-preview", "speech-2.5-hd-preview",
"speech-2.5-turbo-preview", "speech-2.5-turbo-preview",
"speech-02-hd", "speech-02-hd",
@ -19,6 +21,8 @@ var ModelList = []string{
"MiniMax-M2", "MiniMax-M2",
"MiniMax-M2.5", "MiniMax-M2.5",
"MiniMax-M2.5-highspeed", "MiniMax-M2.5-highspeed",
"image-01",
"image-01-live",
} }
var ChannelName = "minimax" var ChannelName = "minimax"

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
}

View File

@ -21,6 +21,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode { switch info.RelayMode {
case constant.RelayModeChatCompletions: case constant.RelayModeChatCompletions:
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil 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: case constant.RelayModeAudioSpeech:
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
default: default:

View File

@ -98,15 +98,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
parts := m.ParseContent() parts := m.ParseContent()
for _, part := range parts { for _, part := range parts {
if part.Type == dto.ContentTypeImageURL { if part.Type == dto.ContentTypeImageURL {
img := part.GetImageMedia() source := part.ToFileSource()
if img != nil && img.Url != "" { if source != nil {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(img.Url, "http") {
source = types.NewURLFileSource(img.Url)
} else {
source = types.NewBase64FileSource(img.Url, "")
}
base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat") base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
if err != nil { if err != nil {
return nil, err return nil, err

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) { func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
a.ResponseFormat = request.ResponseFormat a.ResponseFormat = request.ResponseFormat
if info.RelayMode == relayconstant.RelayModeAudioSpeech { if info.RelayMode == relayconstant.RelayModeAudioSpeech {
jsonData, err := json.Marshal(request) jsonData, err := common.Marshal(request)
if err != nil { if err != nil {
return nil, fmt.Errorf("error marshalling object: %w", err) return nil, fmt.Errorf("error marshalling object: %w", err)
} }

View File

@ -80,9 +80,9 @@ type AliVideoOutput struct {
// AliUsage 使用统计 // AliUsage 使用统计
type AliUsage struct { type AliUsage struct {
Duration int `json:"duration,omitempty"` Duration dto.IntValue `json:"duration,omitempty"`
VideoCount int `json:"video_count,omitempty"` VideoCount dto.IntValue `json:"video_count,omitempty"`
SR int `json:"SR,omitempty"` SR dto.IntValue `json:"SR,omitempty"`
} }
type AliMetadata struct { type AliMetadata struct {

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
@ -13,12 +14,13 @@ import (
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel" "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" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/samber/lo"
) )
// ============================ // ============================
@ -26,37 +28,37 @@ import (
// ============================ // ============================
type ContentItem struct { type ContentItem struct {
Type string `json:"type"` // "text", "image_url" or "video" Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"` // for text type Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type ImageURL *MediaURL `json:"image_url,omitempty"`
Video *VideoReference `json:"video,omitempty"` // for video (sample) type VideoURL *MediaURL `json:"video_url,omitempty"`
Role string `json:"role,omitempty"` // reference_image / first_frame / last_frame AudioURL *MediaURL `json:"audio_url,omitempty"`
Role string `json:"role,omitempty"`
} }
type ImageURL struct { type MediaURL struct {
URL string `json:"url"` URL string `json:"url,omitempty"`
}
type VideoReference struct {
URL string `json:"url"` // Draft video URL
} }
type requestPayload struct { type requestPayload struct {
Model string `json:"model"` Model string `json:"model"`
Content []ContentItem `json:"content"` Content []ContentItem `json:"content,omitempty"`
CallbackURL string `json:"callback_url,omitempty"` CallbackURL string `json:"callback_url,omitempty"`
ReturnLastFrame *dto.BoolValue `json:"return_last_frame,omitempty"` ReturnLastFrame *dto.BoolValue `json:"return_last_frame,omitempty"`
ServiceTier string `json:"service_tier,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"` GenerateAudio *dto.BoolValue `json:"generate_audio,omitempty"`
Draft *dto.BoolValue `json:"draft,omitempty"` Draft *dto.BoolValue `json:"draft,omitempty"`
Resolution string `json:"resolution,omitempty"` Tools []struct {
Ratio string `json:"ratio,omitempty"` Type string `json:"type,omitempty"`
Duration dto.IntValue `json:"duration,omitempty"` } `json:"tools,omitempty"`
Frames dto.IntValue `json:"frames,omitempty"` Resolution string `json:"resolution,omitempty"`
Seed dto.IntValue `json:"seed,omitempty"` Ratio string `json:"ratio,omitempty"`
CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"` Duration *dto.IntValue `json:"duration,omitempty"`
Watermark *dto.BoolValue `json:"watermark,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"`
} }
type responsePayload struct { type responsePayload struct {
@ -76,10 +78,20 @@ type responseTask struct {
Ratio string `json:"ratio"` Ratio string `json:"ratio"`
FramesPerSecond int `json:"framespersecond"` FramesPerSecond int `json:"framespersecond"`
ServiceTier string `json:"service_tier"` ServiceTier string `json:"service_tier"`
Usage struct { Tools []struct {
Type string `json:"type"`
} `json:"tools"`
Usage struct {
CompletionTokens int `json:"completion_tokens"` CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"` TotalTokens int `json:"total_tokens"`
ToolUsage struct {
WebSearch int `json:"web_search"`
} `json:"tool_usage"`
} `json:"usage"` } `json:"usage"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
@ -108,18 +120,61 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
} }
// BuildRequestURL constructs the upstream URL. // 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 return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
} }
// BuildRequestHeader sets required headers. // 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("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+a.apiKey) req.Header.Set("Authorization", "Bearer "+a.apiKey)
return nil 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. // BuildRequestBody converts request into Doubao specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
req, err := relaycommon.GetTaskRequest(c) req, err := relaycommon.GetTaskRequest(c)
@ -218,20 +273,12 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
Content: []ContentItem{}, Content: []ContentItem{},
} }
// Add text prompt
if req.Prompt != "" {
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
}
// Add images if present // Add images if present
if req.HasImage() { if req.HasImage() {
for _, imgURL := range req.Images { for _, imgURL := range req.Images {
r.Content = append(r.Content, ContentItem{ r.Content = append(r.Content, ContentItem{
Type: "image_url", Type: "image_url",
ImageURL: &ImageURL{ ImageURL: &MediaURL{
URL: imgURL, URL: imgURL,
}, },
}) })
@ -243,6 +290,16 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
return nil, errors.Wrap(err, "unmarshal metadata failed") 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 return &r, nil
} }
@ -274,7 +331,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
case "failed": case "failed":
taskResult.Status = model.TaskStatusFailure taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%" taskResult.Progress = "100%"
taskResult.Reason = "task failed" taskResult.Reason = resTask.Error.Message
default: default:
// Unknown status, treat as processing // Unknown status, treat as processing
taskResult.Status = model.TaskStatusInProgress taskResult.Status = model.TaskStatusInProgress
@ -302,8 +359,8 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro
if dResp.Status == "failed" { if dResp.Status == "failed" {
openAIVideo.Error = &dto.OpenAIVideoError{ openAIVideo.Error = &dto.OpenAIVideoError{
Message: "task failed", Message: dResp.Error.Message,
Code: "failed", Code: dResp.Error.Code,
} }
} }

View File

@ -5,6 +5,21 @@ var ModelList = []string{
"doubao-seedance-1-0-lite-t2v", "doubao-seedance-1-0-lite-t2v",
"doubao-seedance-1-0-lite-i2v", "doubao-seedance-1-0-lite-i2v",
"doubao-seedance-1-5-pro-251215", "doubao-seedance-1-5-pro-251215",
"doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128",
} }
var ChannelName = "doubao-video" 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
}

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 return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
case relayconstant.RelayModeImagesGenerations: 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 return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
default: default:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@ -696,6 +697,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
type Alias TaskSubmitReq type Alias TaskSubmitReq
aux := &struct { aux := &struct {
Metadata json.RawMessage `json:"metadata,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"`
Duration json.RawMessage `json:"duration,omitempty"`
*Alias *Alias
}{ }{
Alias: (*Alias)(t), Alias: (*Alias)(t),
@ -705,6 +707,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
return err 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 { if len(aux.Metadata) > 0 {
var metadataStr string var metadataStr string
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" { if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {

View File

@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
if err != nil { if err != nil {
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true) 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) return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
} }

View File

@ -146,21 +146,23 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return priceData, nil return priceData, nil
} }
// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task) // ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) { func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) {
groupRatioInfo := HandleGroupRatio(c, info) groupRatioInfo := HandleGroupRatio(c, info)
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
// 如果没有配置价格,检查模型倍率配置 usePrice := success
if !success { var modelRatio float64
// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用 if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
if ok { if ok {
modelPrice = defaultPrice modelPrice = defaultPrice
usePrice = true
} else { } else {
// 没有配置倍率也不接受没配置,那就返回错误 var ratioSuccess bool
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName) var matchName string
modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
acceptUnsetRatio := false acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel { if info.UserSetting.AcceptUnsetRatioModel {
acceptUnsetRatio = true acceptUnsetRatio = true
@ -168,25 +170,37 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
if !ratioSuccess && !acceptUnsetRatio { 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) 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 freeModel := false
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 { if usePrice {
quota = 0 quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
freeModel = true 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{ priceData := types.PriceData{
FreeModel: freeModel, FreeModel: freeModel,
ModelPrice: modelPrice, ModelPrice: modelPrice,
ModelRatio: modelRatio,
UsePrice: usePrice,
Quota: quota, Quota: quota,
GroupRatioInfo: groupRatioInfo, GroupRatioInfo: groupRatioInfo,
} }

View File

@ -257,6 +257,7 @@ func SetApiRouter(router *gin.Engine) {
tokenRoute.PUT("/", controller.UpdateToken) tokenRoute.PUT("/", controller.UpdateToken)
tokenRoute.DELETE("/:id", controller.DeleteToken) tokenRoute.DELETE("/:id", controller.DeleteToken)
tokenRoute.POST("/batch", controller.DeleteTokenBatch) tokenRoute.POST("/batch", controller.DeleteTokenBatch)
tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
} }
usageRoute := apiRouter.Group("/usage") usageRoute := apiRouter.Group("/usage")
@ -292,6 +293,7 @@ func SetApiRouter(router *gin.Engine) {
dataRoute := apiRouter.Group("/data") dataRoute := apiRouter.Group("/data")
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates) dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates) dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit()) logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())

View File

@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
unknown++ unknown++
continue continue
} }
if rule.IncludeUsingGroup { if rule.IncludeModelName {
if len(parts) < 3 { if len(parts) < 3 {
unknown++ unknown++
continue continue
} }
} }
if rule.IncludeUsingGroup {
minParts := 3
if rule.IncludeModelName {
minParts = 4
}
if len(parts) < minParts {
unknown++
continue
}
}
byRuleName[ruleName]++ 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 { func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 3) parts := make([]string, 0, 4)
if rule.IncludeRuleName && rule.Name != "" { if rule.IncludeRuleName && rule.Name != "" {
parts = append(parts, rule.Name) parts = append(parts, rule.Name)
} }
if rule.IncludeModelName && modelName != "" {
parts = append(parts, modelName)
}
if rule.IncludeUsingGroup && usingGroup != "" { if rule.IncludeUsingGroup && usingGroup != "" {
parts = append(parts, usingGroup) parts = append(parts, usingGroup)
} }
@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
if ttlSeconds <= 0 { if ttlSeconds <= 0 {
ttlSeconds = setting.DefaultTTLSeconds ttlSeconds = setting.DefaultTTLSeconds
} }
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue) cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
setChannelAffinityContext(c, channelAffinityMeta{ setChannelAffinityContext(c, channelAffinityMeta{
CacheKey: cacheKeyFull, CacheKey: cacheKeyFull,

View File

@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
require.NotNil(t, codexRule) require.NotNil(t, codexRule)
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano()) affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue) cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache() cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute)) require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))

View File

@ -227,21 +227,31 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
if oaiUsage == nil { if oaiUsage == nil {
return nil return nil
} }
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
oaiUsage.PromptTokensDetails.CachedCreationTokens,
oaiUsage.ClaudeCacheCreation5mTokens,
oaiUsage.ClaudeCacheCreation1hTokens,
)
usage := &dto.ClaudeUsage{ usage := &dto.ClaudeUsage{
InputTokens: oaiUsage.PromptTokens, InputTokens: oaiUsage.PromptTokens,
OutputTokens: oaiUsage.CompletionTokens, OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens, CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens, CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
} }
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 { if cacheCreation5m > 0 || cacheCreation1h > 0 {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{ usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens, Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens, Ephemeral1hInputTokens: cacheCreation1h,
} }
} }
return usage 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 { func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
if info.ClaudeConvertInfo.Done { if info.ClaudeConvertInfo.Done {
return nil return nil
@ -426,23 +436,28 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
} }
if len(openAIResponse.Choices) == 0 { if len(openAIResponse.Choices) == 0 {
// no choices // Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
// 可能为非标准的 OpenAI 响应,判断是否已经完成 oaiUsage := openAIResponse.Usage
if info.ClaudeConvertInfo.Done { if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
}
if oaiUsage != nil {
stopOpenBlocks() stopOpenBlocks()
oaiUsage := info.ClaudeConvertInfo.Usage stopReason := stopReasonOpenAI2Claude(info.FinishReason)
if oaiUsage != nil { if stopReason == "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ stopReason = "end_turn"
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
},
})
} }
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReason),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop", Type: "message_stop",
}) })
info.ClaudeConvertInfo.Done = true
} }
return claudeResponses return claudeResponses
} else { } else {
@ -450,6 +465,13 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != "" doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk { if doneChunk {
info.FinishReason = *chosenChoice.FinishReason 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 var claudeResponse dto.ClaudeResponse

View File

@ -104,6 +104,11 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
return sniffed, nil 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 { if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil {
switch strings.ToLower(format) { switch strings.ToLower(format) {
case "jpeg", "jpg": case "jpeg", "jpg":
@ -168,6 +173,10 @@ func GetMimeTypeByExtension(ext string) string {
return "image/gif" return "image/gif"
case "jfif": case "jfif":
return "image/jpeg" return "image/jpeg"
case "heic":
return "image/heic"
case "heif":
return "image/heif"
// Audio files // Audio files
case "mp3": case "mp3":

View File

@ -3,6 +3,7 @@ package service
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/binary"
"fmt" "fmt"
"image" "image"
_ "image/gif" _ "image/gif"
@ -24,14 +25,26 @@ import (
// FileService 统一的文件处理服务 // FileService 统一的文件处理服务
// 提供文件下载、解码、缓存等功能的统一入口 // 提供文件下载、解码、缓存等功能的统一入口
// getContextCacheKey 生成 context 缓存的 key // getContextCacheKey 生成 URL context 缓存的 key
func getContextCacheKey(url string) string { func getContextCacheKey(url string) string {
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url)) 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 加载文件源数据 // 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 { if source == nil {
return nil, fmt.Errorf("file source is nil") return nil, fmt.Errorf("file source is nil")
} }
@ -42,7 +55,6 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
// 1. 快速检查内部缓存 // 1. 快速检查内部缓存
if source.HasCache() { if source.HasCache() {
// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
if c != nil { if c != nil {
registerSourceForCleanup(c, source) registerSourceForCleanup(c, source)
} }
@ -61,39 +73,49 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
return source.GetCache(), nil return source.GetCache(), nil
} }
// 4. 如果是 URL检查 Context 缓存 // 4. 根据来源类型加载(含 URL context 缓存查找)
var contextKey string
if source.IsURL() && c != nil {
contextKey = getContextCacheKey(source.URL)
if cachedData, exists := c.Get(contextKey); exists {
data := cachedData.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
// 5. 执行加载逻辑
var cachedData *types.CachedFileData var cachedData *types.CachedFileData
var contextKey string
var err error var err error
if source.IsURL() { switch s := source.(type) {
cachedData, err = loadFromURL(c, source.URL, reason...) case *types.URLSource:
} else { if c != nil {
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType) contextKey = getContextCacheKey(s.URL)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
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 { if err != nil {
return nil, err return nil, err
} }
// 6. 设置缓存 // 5. 设置缓存
source.SetCache(cachedData) source.SetCache(cachedData)
if contextKey != "" && c != nil { if contextKey != "" && c != nil {
c.Set(contextKey, cachedData) c.Set(contextKey, cachedData)
} }
// 7. 注册到 context 以便请求结束时自动清理 // 6. 注册到 context 以便请求结束时自动清理
if c != nil { if c != nil {
registerSourceForCleanup(c, source) registerSourceForCleanup(c, source)
} }
@ -102,15 +124,15 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
} }
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理 // registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
func registerSourceForCleanup(c *gin.Context, source *types.FileSource) { func registerSourceForCleanup(c *gin.Context, source types.FileSource) {
if source.IsRegistered() { if source.IsRegistered() {
return return
} }
key := string(constant.ContextKeyFileSourcesToCleanup) key := string(constant.ContextKeyFileSourcesToCleanup)
var sources []*types.FileSource var sources []types.FileSource
if existing, exists := c.Get(key); exists { if existing, exists := c.Get(key); exists {
sources = existing.([]*types.FileSource) sources = existing.([]types.FileSource)
} }
sources = append(sources, source) sources = append(sources, source)
c.Set(key, sources) c.Set(key, sources)
@ -122,12 +144,12 @@ func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
func CleanupFileSources(c *gin.Context) { func CleanupFileSources(c *gin.Context) {
key := string(constant.ContextKeyFileSourcesToCleanup) key := string(constant.ContextKeyFileSourcesToCleanup)
if sources, exists := c.Get(key); exists { 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 { if cache := source.GetCache(); cache != nil {
cache.Close() 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 return sniffed
} }
// 4.5 尝试 HEIF/HEIC 检测Go 标准库不识别)
if heifMime := detectHEIF(fileBytes); heifMime != "" {
return heifMime
}
} }
// 5. 尝试作为图片解码获取格式 // 5. 尝试作为图片解码获取格式
@ -357,7 +384,7 @@ func loadFromBase64(base64String string, providedMimeType string) (*types.Cached
} }
// GetImageConfig 获取图片配置 // 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") cachedData, err := LoadFileSource(c, source, "get_image_config")
if err != nil { if err != nil {
return image.Config{}, "", err return image.Config{}, "", err
@ -388,7 +415,7 @@ func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, str
} }
// GetBase64Data 获取 base64 编码的数据 // 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...) cachedData, err := LoadFileSource(c, source, reason...)
if err != nil { if err != nil {
return "", "", err return "", "", err
@ -401,13 +428,13 @@ func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (
} }
// GetMimeType 获取文件的 MIME 类型 // 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() { if source.HasCache() {
return source.GetCache().MimeType, nil return source.GetCache().MimeType, nil
} }
if source.IsURL() { if urlSource, ok := source.(*types.URLSource); ok {
mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type") mimeType, err := GetFileTypeFromUrl(c, urlSource.URL, "get_mime_type")
if err == nil && mimeType != "" && mimeType != "application/octet-stream" { if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
return mimeType, nil return mimeType, nil
} }
@ -449,9 +476,118 @@ func decodeImageConfig(data []byte) (image.Config, string, error) {
return config, "webp", nil 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") 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 类型 // guessMimeTypeFromURL 从 URL 猜测 MIME 类型
func guessMimeTypeFromURL(url string) string { func guessMimeTypeFromURL(url string) string {
cleanedURL := url cleanedURL := url

View File

@ -159,20 +159,36 @@ func DecodeUrlImageData(imageUrl string) (image.Config, string, error) {
} }
func getImageConfig(reader io.Reader) (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) config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil { if err == nil {
err = errors.New(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error())) return config, format, nil
common.SysLog(err.Error()) }
config, err = webp.DecodeConfig(reader) common.SysLog(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
if err != nil {
err = errors.New(fmt.Sprintf("fail to decode image config(webp): %s", err.Error())) config, err = webp.DecodeConfig(bytes.NewReader(data))
common.SysLog(err.Error()) 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"
} }
format = "webp" 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")
} }
if err != nil {
return image.Config{}, "", err return image.Config{}, "", err
}
return config, format, nil
} }

View File

@ -36,8 +36,12 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
} }
} }
other := make(map[string]interface{}) other := make(map[string]interface{})
other["is_task"] = true
other["request_path"] = c.Request.URL.Path other["request_path"] = c.Request.URL.Path
other["model_price"] = info.PriceData.ModelPrice other["model_price"] = info.PriceData.ModelPrice
if info.PriceData.ModelRatio > 0 {
other["model_ratio"] = info.PriceData.ModelRatio
}
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
if info.PriceData.GroupRatioInfo.HasSpecialRatio { if info.PriceData.GroupRatioInfo.HasSpecialRatio {
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio 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{}) other := make(map[string]interface{})
if bc := task.PrivateData.BillingContext; bc != nil { if bc := task.PrivateData.BillingContext; bc != nil {
other["model_price"] = bc.ModelPrice other["model_price"] = bc.ModelPrice
if bc.ModelRatio > 0 {
other["model_ratio"] = bc.ModelRatio
}
other["group_ratio"] = bc.GroupRatio other["group_ratio"] = bc.GroupRatio
if len(bc.OtherRatios) > 0 { if len(bc.OtherRatios) > 0 {
for k, v := range bc.OtherRatios { for k, v := range bc.OtherRatios {
@ -222,7 +229,6 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
} }
other := taskBillingOther(task) other := taskBillingOther(task)
other["task_id"] = task.TaskID other["task_id"] = task.TaskID
//other["reason"] = reason
other["pre_consumed_quota"] = preConsumedQuota other["pre_consumed_quota"] = preConsumedQuota
other["actual_quota"] = actualQuota other["actual_quota"] = actualQuota
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
@ -277,9 +283,19 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
finalGroupRatio = groupRatio finalGroupRatio = groupRatio
} }
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio // 计算 OtherRatios 乘积(视频折扣、时长等)
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio) 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) RecalculateTaskQuota(ctx, task, actualQuota, reason)
} }

View File

@ -100,8 +100,6 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
if err != nil { if err != nil {
return 0, err return 0, err
} }
fileMeta.MimeType = format
if config.Width == 0 || config.Height == 0 { if config.Width == 0 || config.Height == 0 {
// not an image, but might be a valid file // not an image, but might be a valid file
if format != "" { if format != "" {
@ -268,7 +266,6 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
} }
continue continue
} }
file.MimeType = cachedData.MimeType
file.FileType = DetectFileType(cachedData.MimeType) file.FileType = DetectFileType(cachedData.MimeType)
} }
} }

View File

@ -20,9 +20,10 @@ type ChannelAffinityRule struct {
ParamOverrideTemplate map[string]interface{} `json:"param_override_template,omitempty"` 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"` IncludeUsingGroup bool `json:"include_using_group"`
IncludeModelName bool `json:"include_model_name"`
IncludeRuleName bool `json:"include_rule_name"` IncludeRuleName bool `json:"include_rule_name"`
} }

View File

@ -361,6 +361,10 @@ func UpdateModelPriceByJSONString(jsonStr string) error {
func GetModelPrice(name string, printErr bool) (float64, bool) { func GetModelPrice(name string, printErr bool) (float64, bool) {
name = FormatMatchingModelName(name) name = FormatMatchingModelName(name)
if price, ok := modelPriceMap.Get(name); ok {
return price, true
}
if strings.HasSuffix(name, CompactModelSuffix) { if strings.HasSuffix(name, CompactModelSuffix) {
price, ok := modelPriceMap.Get(CompactWildcardModelKey) price, ok := modelPriceMap.Get(CompactWildcardModelKey)
if !ok { if !ok {
@ -372,14 +376,10 @@ func GetModelPrice(name string, printErr bool) (float64, bool) {
return price, true return price, true
} }
price, ok := modelPriceMap.Get(name) if printErr {
if !ok { common.SysError("model price not found: " + name)
if printErr {
common.SysError("model price not found: " + name)
}
return -1, false
} }
return price, true return -1, false
} }
func UpdateModelRatioByJSONString(jsonStr string) error { func UpdateModelRatioByJSONString(jsonStr string) error {

View File

@ -4,39 +4,144 @@ import (
"fmt" "fmt"
"image" "image"
"os" "os"
"strings"
"sync" "sync"
) )
// FileSourceType 文件来源类型 // FileSource 统一的文件来源抽象接口
type FileSourceType string
const (
FileSourceTypeURL FileSourceType = "url" // URL 来源
FileSourceTypeBase64 FileSourceType = "base64" // Base64 内联数据
)
// FileSource 统一的文件来源抽象
// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制 // 支持 URL 和 base64 两种来源,提供懒加载和缓存机制
type FileSource struct { type FileSource interface {
Type FileSourceType `json:"type"` // 来源类型 IsURL() bool
URL string `json:"url,omitempty"` // URL当 Type 为 url 时) GetIdentifier() string
Base64Data string `json:"base64_data,omitempty"` // Base64 数据(当 Type 为 base64 时) GetRawData() string
MimeType string `json:"mime_type,omitempty"` // MIME 类型(可选,会自动检测) ClearRawData()
// 内部缓存(不导出,不序列化) SetCache(data *CachedFileData)
GetCache() *CachedFileData
HasCache() bool
ClearCache()
IsRegistered() bool
SetRegistered(registered bool)
Mu() *sync.Mutex
}
// baseFileSource 共享的缓存/锁/清理注册状态
type baseFileSource struct {
cachedData *CachedFileData cachedData *CachedFileData
cacheLoaded bool cacheLoaded bool
registered bool // 是否已注册到清理列表 registered bool
mu sync.Mutex // 保护加载过程 mu sync.Mutex
} }
// Mu 获取内部锁 func (b *baseFileSource) SetCache(data *CachedFileData) {
func (f *FileSource) Mu() *sync.Mutex { b.cachedData = data
return &f.mu 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 { type CachedFileData struct {
base64Data string // 内存中的 base64 数据(小文件) base64Data string // 内存中的 base64 数据(小文件)
MimeType string // MIME 类型 MimeType string // MIME 类型
@ -45,18 +150,15 @@ type CachedFileData struct {
ImageConfig *image.Config // 图片配置(如果是图片) ImageConfig *image.Config // 图片配置(如果是图片)
ImageFormat string // 图片格式(如果是图片) ImageFormat string // 图片格式(如果是图片)
// 磁盘缓存相关
diskPath string // 磁盘缓存文件路径(大文件) diskPath string // 磁盘缓存文件路径(大文件)
isDisk bool // 是否使用磁盘缓存 isDisk bool // 是否使用磁盘缓存
diskMu sync.Mutex // 磁盘操作锁(保护磁盘文件的读取和删除) diskMu sync.Mutex // 磁盘操作锁(保护磁盘文件的读取和删除)
diskClosed bool // 是否已关闭/清理 diskClosed bool // 是否已关闭/清理
statDecremented bool // 是否已扣减统计 statDecremented bool // 是否已扣减统计
// 统计回调,避免循环依赖
OnClose func(size int64) OnClose func(size int64)
} }
// NewMemoryCachedData 创建内存缓存的数据
func NewMemoryCachedData(base64Data string, mimeType string, size int64) *CachedFileData { func NewMemoryCachedData(base64Data string, mimeType string, size int64) *CachedFileData {
return &CachedFileData{ return &CachedFileData{
base64Data: base64Data, 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 { func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFileData {
return &CachedFileData{ return &CachedFileData{
diskPath: diskPath, diskPath: diskPath,
@ -76,7 +177,6 @@ func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFile
} }
} }
// GetBase64Data 获取 base64 数据(自动处理内存/磁盘)
func (c *CachedFileData) GetBase64Data() (string, error) { func (c *CachedFileData) GetBase64Data() (string, error) {
if !c.isDisk { if !c.isDisk {
return c.base64Data, nil return c.base64Data, nil
@ -89,7 +189,6 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
return "", fmt.Errorf("disk cache already closed") return "", fmt.Errorf("disk cache already closed")
} }
// 从磁盘读取
data, err := os.ReadFile(c.diskPath) data, err := os.ReadFile(c.diskPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read from disk cache: %w", err) 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 return string(data), nil
} }
// SetBase64Data 设置 base64 数据(仅用于内存模式)
func (c *CachedFileData) SetBase64Data(data string) { func (c *CachedFileData) SetBase64Data(data string) {
if !c.isDisk { if !c.isDisk {
c.base64Data = data c.base64Data = data
} }
} }
// IsDisk 是否使用磁盘缓存
func (c *CachedFileData) IsDisk() bool { func (c *CachedFileData) IsDisk() bool {
return c.isDisk return c.isDisk
} }
// Close 关闭并清理资源
func (c *CachedFileData) Close() error { func (c *CachedFileData) Close() error {
if !c.isDisk { if !c.isDisk {
c.base64Data = "" // 释放内存 c.base64Data = ""
return nil return nil
} }
@ -126,7 +222,6 @@ func (c *CachedFileData) Close() error {
c.diskClosed = true c.diskClosed = true
if c.diskPath != "" { if c.diskPath != "" {
err := os.Remove(c.diskPath) err := os.Remove(c.diskPath)
// 只有在删除成功且未扣减过统计时,才执行回调
if err == nil && !c.statDecremented && c.OnClose != nil { if err == nil && !c.statDecremented && c.OnClose != nil {
c.OnClose(c.DiskSize) c.OnClose(c.DiskSize)
c.statDecremented = true c.statDecremented = true
@ -135,97 +230,3 @@ func (c *CachedFileData) Close() error {
} }
return nil 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 = ""
}
}

View File

@ -32,13 +32,12 @@ type TokenCountMeta struct {
type FileMeta struct { type FileMeta struct {
FileType FileType
MimeType string Source FileSource // 统一的文件来源URL 或 base64
Source *FileSource // 统一的文件来源URL 或 base64 Detail string // 图片细节级别low/high/auto
Detail string // 图片细节级别low/high/auto
} }
// NewFileMeta 创建新的 FileMeta // NewFileMeta 创建新的 FileMeta
func NewFileMeta(fileType FileType, source *FileSource) *FileMeta { func NewFileMeta(fileType FileType, source FileSource) *FileMeta {
return &FileMeta{ return &FileMeta{
FileType: fileType, FileType: fileType,
Source: source, Source: source,
@ -46,7 +45,7 @@ func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
} }
// NewImageFileMeta 创建图片类型的 FileMeta // NewImageFileMeta 创建图片类型的 FileMeta
func NewImageFileMeta(source *FileSource, detail string) *FileMeta { func NewImageFileMeta(source FileSource, detail string) *FileMeta {
return &FileMeta{ return &FileMeta{
FileType: FileTypeImage, FileType: FileTypeImage,
Source: source, Source: source,

9
web/bun.lock vendored
View File

@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "react-template", "name": "react-template",
@ -10,7 +11,7 @@
"@visactor/react-vchart": "~1.8.8", "@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8", "@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8",
"axios": "1.12.0", "axios": "1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"history": "^5.3.0", "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],

View File

@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { API, showError } from '../../../helpers';
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui'; import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
const { Title } = Typography; const { Title } = Typography;
@ -28,7 +28,7 @@ import {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import MarkdownRenderer from '../markdown/MarkdownRenderer'; import MarkdownRenderer from '../markdown/MarkdownRenderer';
// URL // Check whether content is a URL.
const isUrl = (content) => { const isUrl = (content) => {
try { try {
new URL(content.trim()); new URL(content.trim());
@ -38,27 +38,23 @@ const isUrl = (content) => {
} }
}; };
// HTML // Check whether content contains HTML.
const isHtmlContent = (content) => { const isHtmlContent = (content) => {
if (!content || typeof content !== 'string') return false; if (!content || typeof content !== 'string') return false;
// HTML
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
return htmlTagRegex.test(content); return htmlTagRegex.test(content);
}; };
// HTML // Parse HTML content and extract inline styles.
const sanitizeHtml = (html) => { const sanitizeHtml = (html) => {
// HTML
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = html; tempDiv.innerHTML = html;
//
const styles = Array.from(tempDiv.querySelectorAll('style')) const styles = Array.from(tempDiv.querySelectorAll('style'))
.map((style) => style.innerHTML) .map((style) => style.innerHTML)
.join('\n'); .join('\n');
// bodybody使
const bodyContent = tempDiv.querySelector('body'); const bodyContent = tempDiv.querySelector('body');
const content = bodyContent ? bodyContent.innerHTML : html; const content = bodyContent ? bodyContent.innerHTML : html;
@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [htmlStyles, setHtmlStyles] = useState('');
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
const loadContent = async () => { const loadContent = async () => {
//
const cachedContent = localStorage.getItem(cacheKey) || ''; const cachedContent = localStorage.getItem(cacheKey) || '';
if (cachedContent) { if (cachedContent) {
setContent(cachedContent); setContent(cachedContent);
processContent(cachedContent);
setLoading(false); setLoading(false);
} }
@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success && data) { if (success && data) {
setContent(data); setContent(data);
processContent(data);
localStorage.setItem(cacheKey, data); localStorage.setItem(cacheKey, data);
} else { } else {
if (!cachedContent) { if (!cachedContent) {
@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
} }
}; };
const processContent = (rawContent) => { const htmlPayload = useMemo(() => {
if (isHtmlContent(rawContent)) { if (!isHtmlContent(content)) {
const { content: htmlContent, styles } = sanitizeHtml(rawContent); return { content: '', styles: '' };
setProcessedHtmlContent(htmlContent);
setHtmlStyles(styles);
} else {
setProcessedHtmlContent('');
setHtmlStyles('');
} }
}; return sanitizeHtml(content);
}, [content]);
useEffect(() => { useEffect(() => {
loadContent(); loadContent();
@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// HTML // HTML
useEffect(() => { useEffect(() => {
const styleId = `document-renderer-styles-${cacheKey}`; const styleId = `document-renderer-styles-${cacheKey}`;
const { styles } = htmlPayload;
if (htmlStyles) { if (styles) {
let styleEl = document.getElementById(styleId); let styleEl = document.getElementById(styleId);
if (!styleEl) { if (!styleEl) {
styleEl = document.createElement('style'); styleEl = document.createElement('style');
@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
styleEl.type = 'text/css'; styleEl.type = 'text/css';
document.head.appendChild(styleEl); document.head.appendChild(styleEl);
} }
styleEl.innerHTML = htmlStyles; styleEl.innerHTML = styles;
} else { } else {
const el = document.getElementById(styleId); const el = document.getElementById(styleId);
if (el) el.remove(); if (el) el.remove();
@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const el = document.getElementById(styleId); const el = document.getElementById(styleId);
if (el) el.remove(); if (el) el.remove();
}; };
}, [htmlStyles, cacheKey]); }, [cacheKey, htmlPayload]);
// //
if (loading) { if (loading) {
@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// HTML // HTML
if (isHtmlContent(content)) { if (isHtmlContent(content)) {
const { content: htmlContent, styles } = sanitizeHtml(content);
//
useEffect(() => {
if (styles && styles !== htmlStyles) {
setHtmlStyles(styles);
}
}, [content, styles, htmlStyles]);
return ( return (
<div className='min-h-screen bg-gray-50'> <div className='min-h-screen bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'> <div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
</Title> </Title>
<div <div
className='prose prose-lg max-w-none' className='prose prose-lg max-w-none'
dangerouslySetInnerHTML={{ __html: htmlContent }} dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
/> />
</div> </div>
</div> </div>

View File

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

View File

@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react'; import React from 'react';
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui'; 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 { import {
IllustrationConstruction, IllustrationConstruction,
IllustrationConstructionDark, IllustrationConstructionDark,
@ -87,11 +87,18 @@ const ApiInfoPanel = ({
</Tag> </Tag>
</div> </div>
</div> </div>
<div <div className='flex items-center gap-1 mb-1'>
className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1' <span
onClick={() => handleCopyUrl(api.url)} className='!text-semi-color-primary break-all cursor-pointer hover:underline'
> onClick={() => handleCopyUrl(api.url)}
{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>
<div className='text-gray-500'>{api.description}</div> <div className='text-gray-500'>{api.description}</div>
</div> </div>

View File

@ -29,6 +29,9 @@ const ChartsPanel = ({
spec_model_line, spec_model_line,
spec_pie, spec_pie,
spec_rank_bar, spec_rank_bar,
spec_user_rank,
spec_user_trend,
isAdminUser,
CARD_PROPS, CARD_PROPS,
CHART_CONFIG, CHART_CONFIG,
FLEX_CENTER_GAP2, FLEX_CENTER_GAP2,
@ -51,9 +54,15 @@ const ChartsPanel = ({
onChange={setActiveChartTab} onChange={setActiveChartTab}
> >
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' /> <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='3' />
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' /> <TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
)}
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
)}
</Tabs> </Tabs>
</div> </div>
} }
@ -72,6 +81,12 @@ const ChartsPanel = ({
{activeChartTab === '4' && ( {activeChartTab === '4' && (
<VChart spec={spec_rank_bar} option={CHART_CONFIG} /> <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> </div>
</Card> </Card>
); );

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 () => { const initChart = async () => {
await dashboardData.loadQuotaData().then((data) => { await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) { if (data && data.length > 0) {
dashboardCharts.updateChartData(data); dashboardCharts.updateChartData(data);
} }
}); });
await loadUserData();
await dashboardData.loadUptimeData(); await dashboardData.loadUptimeData();
}; };
@ -100,10 +110,12 @@ const Dashboard = () => {
if (data && data.length > 0) { if (data && data.length > 0) {
dashboardCharts.updateChartData(data); dashboardCharts.updateChartData(data);
} }
await loadUserData();
}; };
const handleSearchConfirm = async () => { const handleSearchConfirm = async () => {
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData); await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
await loadUserData();
}; };
// ========== ========== // ========== ==========
@ -182,6 +194,9 @@ const Dashboard = () => {
spec_model_line={dashboardCharts.spec_model_line} spec_model_line={dashboardCharts.spec_model_line}
spec_pie={dashboardCharts.spec_pie} spec_pie={dashboardCharts.spec_pie}
spec_rank_bar={dashboardCharts.spec_rank_bar} 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} CARD_PROPS={CARD_PROPS}
CHART_CONFIG={CHART_CONFIG} CHART_CONFIG={CHART_CONFIG}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2} FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}

View File

@ -23,6 +23,7 @@ import SiderBar from './SiderBar';
import App from '../../App'; import App from '../../App';
import FooterBar from './Footer'; import FooterBar from './Footer';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import ErrorBoundary from '../common/ErrorBoundary';
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile'; import { useIsMobile } from '../../hooks/common/useIsMobile';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
@ -216,7 +217,9 @@ const PageLayout = () => {
position: 'relative', position: 'relative',
}} }}
> >
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</Content> </Content>
{!shouldHideFooter && ( {!shouldHideFooter && (
<Layout.Footer <Layout.Footer

View File

@ -95,13 +95,15 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
</Dropdown.Menu> </Dropdown.Menu>
} }
> >
<Button <span className='inline-flex'>
icon={currentButtonIcon} <Button
aria-label={t('切换主题')} icon={currentButtonIcon}
theme='borderless' aria-label={t('切换主题')}
type='tertiary' theme='borderless'
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' 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> </Dropdown>
); );
}; };

View File

@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; 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 { useTranslation } from 'react-i18next';
import { import {
Hash, Hash,
@ -241,15 +248,14 @@ const ParameterControl = ({
disabled={disabled} disabled={disabled}
/> />
</div> </div>
<Input <InputNumber
placeholder='MaxTokens' placeholder='MaxTokens'
name='max_tokens' name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens} value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)} onNumberChange={(value) => onInputChange('max_tokens', value)}
className='!rounded-lg' min={0}
precision={0}
style={{ width: '100%' }}
disabled={!parameterEnabled.max_tokens || disabled} disabled={!parameterEnabled.max_tokens || disabled}
/> />
</div> </div>

View File

@ -65,11 +65,15 @@ export const loadConfig = () => {
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG); const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) { if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig); const parsedConfig = JSON.parse(savedConfig);
const parsedMaxTokens = parseInt(parsedConfig?.inputs?.max_tokens, 10);
const mergedConfig = { const mergedConfig = {
inputs: { inputs: {
...DEFAULT_CONFIG.inputs, ...DEFAULT_CONFIG.inputs,
...parsedConfig.inputs, ...parsedConfig.inputs,
max_tokens: Number.isNaN(parsedMaxTokens)
? parsedConfig?.inputs?.max_tokens
: parsedMaxTokens,
}, },
parameterEnabled: { parameterEnabled: {
...DEFAULT_CONFIG.parameterEnabled, ...DEFAULT_CONFIG.parameterEnabled,

View File

@ -21,9 +21,8 @@ import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui'; import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ModelPricingCombined from '../../pages/Setting/Ratio/ModelPricingCombined';
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings'; 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 ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync'; import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
import ToolPriceSettings from '../../pages/Setting/Ratio/ToolPriceSettings'; import ToolPriceSettings from '../../pages/Setting/Ratio/ToolPriceSettings';
@ -96,18 +95,14 @@ const RatioSetting = () => {
return ( return (
<Spin spinning={loading} size='large'> <Spin spinning={loading} size='large'>
{/* 模型倍率设置以及价格编辑器 */}
<Card style={{ marginTop: '10px' }}> <Card style={{ marginTop: '10px' }}>
<Tabs type='card' defaultActiveKey='visual'> <Tabs type='card' defaultActiveKey='pricing'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'> <Tabs.TabPane tab={t('模型定价设置')} itemKey='pricing'>
<ModelRatioSettings options={inputs} refresh={onRefresh} /> <ModelPricingCombined options={inputs} refresh={onRefresh} />
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'> <Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
<GroupRatioSettings options={inputs} refresh={onRefresh} /> <GroupRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'> <Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} /> <ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane> </Tabs.TabPane>

View File

@ -91,6 +91,7 @@ const SystemSetting = () => {
EmailDomainRestrictionEnabled: '', EmailDomainRestrictionEnabled: '',
EmailAliasRestrictionEnabled: '', EmailAliasRestrictionEnabled: '',
SMTPSSLEnabled: '', SMTPSSLEnabled: '',
SMTPForceAuthLogin: '',
EmailDomainWhitelist: [], EmailDomainWhitelist: [],
TelegramOAuthEnabled: '', TelegramOAuthEnabled: '',
TelegramBotToken: '', TelegramBotToken: '',
@ -182,6 +183,7 @@ const SystemSetting = () => {
case 'EmailDomainRestrictionEnabled': case 'EmailDomainRestrictionEnabled':
case 'EmailAliasRestrictionEnabled': case 'EmailAliasRestrictionEnabled':
case 'SMTPSSLEnabled': case 'SMTPSSLEnabled':
case 'SMTPForceAuthLogin':
case 'LinuxDOOAuthEnabled': case 'LinuxDOOAuthEnabled':
case 'discord.enabled': case 'discord.enabled':
case 'oidc.enabled': case 'oidc.enabled':
@ -1335,6 +1337,15 @@ const SystemSetting = () => {
> >
{t('启用SMTP SSL')} {t('启用SMTP SSL')}
</Form.Checkbox> </Form.Checkbox>
<Form.Checkbox
field='SMTPForceAuthLogin'
noLabel
onChange={(e) =>
handleCheckboxChange('SMTPForceAuthLogin', e)
}
>
{t('强制使用 AUTH LOGIN')}
</Form.Checkbox>
</Col> </Col>
</Row> </Row>
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button> <Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>

View File

@ -88,7 +88,7 @@ const renderStatus = (text, record, t) => {
}; };
// Render group column // Render group column
const renderGroupColumn = (text, record, t) => { const renderGroupColumn = (text, record, t, groupRatios = {}) => {
if (text === 'auto') { if (text === 'auto') {
return ( return (
<Tooltip <Tooltip
@ -104,7 +104,17 @@ const renderGroupColumn = (text, record, t) => {
</Tooltip> </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 // Render token key column with show/hide and copy functionality
@ -469,6 +479,7 @@ export const getTokensColumns = ({
setEditingToken, setEditingToken,
setShowEdit, setShowEdit,
refresh, refresh,
groupRatios = {},
}) => { }) => {
return [ return [
{ {
@ -490,7 +501,7 @@ export const getTokensColumns = ({
title: t('分组'), title: t('分组'),
dataIndex: 'group', dataIndex: 'group',
key: 'group', key: 'group',
render: (text, record) => renderGroupColumn(text, record, t), render: (text, record) => renderGroupColumn(text, record, t, groupRatios),
}, },
{ {
title: t('密钥'), title: t('密钥'),

View File

@ -49,6 +49,7 @@ const TokensTable = (tokensData) => {
setEditingToken, setEditingToken,
setShowEdit, setShowEdit,
refresh, refresh,
groupRatios,
t, t,
} = tokensData; } = tokensData;
@ -67,6 +68,7 @@ const TokensTable = (tokensData) => {
setEditingToken, setEditingToken,
setShowEdit, setShowEdit,
refresh, refresh,
groupRatios,
}); });
}, [ }, [
t, t,
@ -81,6 +83,7 @@ const TokensTable = (tokensData) => {
setEditingToken, setEditingToken,
setShowEdit, setShowEdit,
refresh, refresh,
groupRatios,
]); ]);
// Handle compact mode by removing fixed positioning // Handle compact mode by removing fixed positioning

View File

@ -366,6 +366,14 @@ const EditTokenModal = (props) => {
placeholder={t('令牌分组,默认为用户的分组')} placeholder={t('令牌分组,默认为用户的分组')}
optionList={groups} optionList={groups}
renderOptionItem={renderGroupOption} 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 showClear
style={{ width: '100%' }} style={{ width: '100%' }}
/> />

View File

@ -36,7 +36,7 @@ import {
renderTieredModelPriceSimple, renderTieredModelPriceSimple,
} from '../../../helpers'; } from '../../../helpers';
import { IconHelpCircle } from '@douyinfe/semi-icons'; import { IconHelpCircle } from '@douyinfe/semi-icons';
import { Route, Sparkles } from 'lucide-react'; import { CircleAlert, Route, Sparkles } from 'lucide-react';
const colors = [ const colors = [
'amber', '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) { if (bool) {
return ( return (
<Tag color='blue' shape='circle'> <span style={{ position: 'relative', display: 'inline-block' }}>
{t('流')} <Tag color='blue' shape='circle'>
</Tag> {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 { } else {
return ( return (
@ -663,7 +709,7 @@ export const getLogsColumns = ({
<Space> <Space>
{renderUseTime(text, t)} {renderUseTime(text, t)}
{renderFirstUseTime(other?.frt, t)} {renderFirstUseTime(other?.frt, t)}
{renderIsStream(record.is_stream, t)} {renderIsStream(record.is_stream, t, other?.stream_status)}
</Space> </Space>
</> </>
); );

View File

@ -150,7 +150,18 @@ export const buildApiPayload = (
const value = inputs[param]; const value = inputs[param];
const hasValue = value !== undefined && value !== null; 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; payload[param] = value;
} }
}); });

View File

@ -387,3 +387,58 @@ export const generateChartTimePoints = (
return chartTimePoints; 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 };
};

View File

@ -1625,6 +1625,18 @@ function renderPriceSimpleCore({
return result; 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) { export function renderModelPrice(opts) {
const { const {
prompt_tokens: inputTokens = 0, prompt_tokens: inputTokens = 0,

View File

@ -33,6 +33,20 @@ export async function fetchTokenKey(tokenId) {
return data.key; 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 * 获取可用的 token keys
* @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组 * @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组

View File

@ -34,8 +34,14 @@ import {
updateChartSpec, updateChartSpec,
updateMapValue, updateMapValue,
initializeMaps, initializeMaps,
processUserData,
} from '../../helpers/dashboard'; } from '../../helpers/dashboard';
const USER_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
];
export const useDashboardCharts = ( export const useDashboardCharts = (
dataExportDefaultTime, dataExportDefaultTime,
setTrendData, setTrendData,
@ -179,7 +185,6 @@ export const useDashboardCharts = (
}, },
}); });
// 线
const [spec_model_line, setSpecModelLine] = useState({ const [spec_model_line, setSpecModelLine] = useState({
type: 'line', type: 'line',
data: [ data: [
@ -197,7 +202,7 @@ export const useDashboardCharts = (
}, },
title: { title: {
visible: true, visible: true,
text: t('模型消耗趋势'), text: t('调用趋势'),
subtext: '', subtext: '',
}, },
tooltip: { tooltip: {
@ -215,7 +220,6 @@ export const useDashboardCharts = (
}, },
}); });
//
const [spec_rank_bar, setSpecRankBar] = useState({ const [spec_rank_bar, setSpecRankBar] = useState({
type: 'bar', type: 'bar',
data: [ 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 generateModelColors = useCallback((uniqueModels, modelColors) => {
const newModelColors = {}; 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(() => { useEffect(() => {
initVChartSemiTheme({ initVChartSemiTheme({
@ -434,14 +559,14 @@ export const useDashboardCharts = (
}, []); }, []);
return { return {
//
spec_pie, spec_pie,
spec_line, spec_line,
spec_model_line, spec_model_line,
spec_rank_bar, spec_rank_bar,
spec_user_rank,
// spec_user_trend,
updateChartData, updateChartData,
updateUserChartData,
generateModelColors, generateModelColors,
}; };
}; };

View File

@ -213,6 +213,27 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
} }
}, [activeUptimeTab]); }, [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 () => { const getUserData = useCallback(async () => {
let res = await API.get(`/api/user/self`); let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data; const { success, message, data } = res.data;
@ -311,6 +332,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
showSearchModal, showSearchModal,
handleCloseModal, handleCloseModal,
loadQuotaData, loadQuotaData,
loadUserQuotaData,
loadUptimeData, loadUptimeData,
getUserData, getUserData,
refresh, refresh,

View File

@ -167,7 +167,14 @@ export const usePlaygroundState = () => {
// 配置导入/重置 // 配置导入/重置
const handleConfigImport = useCallback((importedConfig) => { const handleConfigImport = useCallback((importedConfig) => {
if (importedConfig.inputs) { 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) { if (importedConfig.parameterEnabled) {
setParameterEnabled((prev) => ({ setParameterEnabled((prev) => ({

View File

@ -31,6 +31,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode'; import { useTableCompactMode } from '../common/useTableCompactMode';
import { import {
fetchTokenKey as fetchTokenKeyById, fetchTokenKey as fetchTokenKeyById,
fetchTokenKeysBatch,
getServerAddress, getServerAddress,
encodeChannelConnectionString, encodeChannelConnectionString,
} from '../../helpers/token'; } from '../../helpers/token';
@ -41,6 +42,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
// Basic state // Basic state
const [tokens, setTokens] = useState([]); const [tokens, setTokens] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [groupRatios, setGroupRatios] = useState({});
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
const [tokenCount, setTokenCount] = useState(0); const [tokenCount, setTokenCount] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
@ -408,14 +410,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
return; return;
} }
try { try {
const keys = await Promise.all( const ids = selectedKeys.map((token) => token.id);
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })), const keysMap = await fetchTokenKeysBatch(ids);
);
setResolvedTokenKeys((prev) => ({ ...prev, ...keysMap }));
let content = ''; let content = '';
for (let i = 0; i < selectedKeys.length; i++) { for (const token of selectedKeys) {
const fullKey = keys[i]; const fullKey = keysMap[token.id];
if (!fullKey) continue;
if (copyType === 'name+key') { if (copyType === 'name+key') {
content += `${selectedKeys[i].name} sk-${fullKey}\n`; content += `${token.name} sk-${fullKey}\n`;
} else { } else {
content += `sk-${fullKey}\n`; content += `sk-${fullKey}\n`;
} }
@ -433,6 +438,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
.catch((reason) => { .catch((reason) => {
showError(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]); }, [pageSize]);
return { return {
@ -443,6 +459,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
tokenCount, tokenCount,
pageSize, pageSize,
searching, searching,
groupRatios,
// Selection state // Selection state
selectedKeys, selectedKeys,

View File

@ -37,6 +37,7 @@ import {
renderClaudeModelPrice, renderClaudeModelPrice,
renderModelPrice, renderModelPrice,
renderTieredModelPrice, renderTieredModelPrice,
renderTaskBillingProcess,
} from '../../helpers'; } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode'; import { useTableCompactMode } from '../common/useTableCompactMode';
@ -475,7 +476,10 @@ export const useLogsData = () => {
completion_tokens: logs[i].completion_tokens, completion_tokens: logs[i].completion_tokens,
displayMode: billingDisplayMode, 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); content = renderAudioModelPrice(logOpts);
} else if (other?.claude) { } else if (other?.claude) {
content = renderClaudeModelPrice(logOpts); content = renderClaudeModelPrice(logOpts);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -680,6 +680,7 @@
"启用Gemini思考后缀适配": "启用Gemini思考后缀适配", "启用Gemini思考后缀适配": "启用Gemini思考后缀适配",
"启用Ping间隔": "启用Ping间隔", "启用Ping间隔": "启用Ping间隔",
"启用SMTP SSL": "启用SMTP SSL", "启用SMTP SSL": "启用SMTP SSL",
"强制使用 AUTH LOGIN": "强制使用 AUTH LOGIN",
"启用SSRF防护推荐开启以保护服务器安全": "启用SSRF防护推荐开启以保护服务器安全", "启用SSRF防护推荐开启以保护服务器安全": "启用SSRF防护推荐开启以保护服务器安全",
"启用全部": "启用全部", "启用全部": "启用全部",
"启用后可接入 io.net GPU 资源": "启用后可接入 io.net GPU 资源", "启用后可接入 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", "链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "链接中的{key}将自动替换为sk-xxxx{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1",
@ -3087,6 +3107,8 @@
"从剪贴板粘贴配置": "从剪贴板粘贴配置", "从剪贴板粘贴配置": "从剪贴板粘贴配置",
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息", "剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
"连接信息已填入": "连接信息已填入", "连接信息已填入": "连接信息已填入",
"无法读取剪贴板": "无法读取剪贴板" "无法读取剪贴板": "无法读取剪贴板",
"页面渲染出错,请刷新页面重试": "页面渲染出错,请刷新页面重试",
"刷新页面": "刷新页面"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -103,6 +103,7 @@ const RULES_JSON_PLACEHOLDER = `[
}, },
"skip_retry_on_failure": false, "skip_retry_on_failure": false,
"include_using_group": true, "include_using_group": true,
"include_model_name": false,
"include_rule_name": true "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) { export default function SettingsChannelAffinity(props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { Text } = Typography; const { Text } = Typography;
@ -246,6 +277,7 @@ export default function SettingsChannelAffinity(props) {
ttl_seconds: Number(r.ttl_seconds || 0), ttl_seconds: Number(r.ttl_seconds || 0),
skip_retry_on_failure: !!r.skip_retry_on_failure, skip_retry_on_failure: !!r.skip_retry_on_failure,
include_using_group: r.include_using_group ?? true, include_using_group: r.include_using_group ?? true,
include_model_name: !!r.include_model_name,
include_rule_name: r.include_rule_name ?? true, include_rule_name: r.include_rule_name ?? true,
param_override_template_json: r.param_override_template param_override_template_json: r.param_override_template
? stringifyPretty(r.param_override_template) ? stringifyPretty(r.param_override_template)
@ -454,14 +486,12 @@ export default function SettingsChannelAffinity(props) {
const templates = [ const templates = [
CHANNEL_AFFINITY_RULE_TEMPLATES.codexCli, CHANNEL_AFFINITY_RULE_TEMPLATES.codexCli,
CHANNEL_AFFINITY_RULE_TEMPLATES.claudeCli, CHANNEL_AFFINITY_RULE_TEMPLATES.claudeCli,
].map( ].map((tpl) => {
(tpl) => { const baseTemplate = cloneChannelAffinityTemplate(tpl);
const baseTemplate = cloneChannelAffinityTemplate(tpl); const name = makeUniqueName(existingNames, tpl.name);
const name = makeUniqueName(existingNames, tpl.name); existingNames.add(name);
existingNames.add(name); return { ...baseTemplate, name };
return { ...baseTemplate, name }; });
},
);
const next = [...(rules || []), ...templates].map((r, idx) => ({ const next = [...(rules || []), ...templates].map((r, idx) => ({
...(r || {}), ...(r || {}),
@ -540,11 +570,11 @@ export default function SettingsChannelAffinity(props) {
render: (v) => <Text>{Number(v || 0) || '-'}</Text>, render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
}, },
{ {
title: t('失败后重试'), title: t('失败后是否重试'),
dataIndex: 'skip_retry_on_failure', dataIndex: 'skip_retry_on_failure',
render: (value) => ( render: (value) => (
<Tag color={value ? 'orange' : 'grey'} style={{ marginRight: 4 }}> <Tag color={value ? 'orange' : 'green'} style={{ marginRight: 4 }}>
{value ? t('是') : t('否')} {value ? t('不重试') : t('重试')}
</Tag> </Tag>
), ),
}, },
@ -581,8 +611,9 @@ export default function SettingsChannelAffinity(props) {
title: t('作用域'), title: t('作用域'),
render: (_, record) => { render: (_, record) => {
const tags = []; const tags = [];
if (record?.include_using_group) tags.push('分组'); if (record?.include_using_group) tags.push(t('分组'));
if (record?.include_rule_name) tags.push('规则'); if (record?.include_model_name) tags.push(t('模型'));
if (record?.include_rule_name) tags.push(t('规则'));
if (tags.length === 0) return '-'; if (tags.length === 0) return '-';
return tags.map((x) => ( return tags.map((x) => (
<Tag key={x} style={{ marginRight: 4 }}> <Tag key={x} style={{ marginRight: 4 }}>
@ -650,6 +681,7 @@ export default function SettingsChannelAffinity(props) {
ttl_seconds: 0, ttl_seconds: 0,
skip_retry_on_failure: false, skip_retry_on_failure: false,
include_using_group: true, include_using_group: true,
include_model_name: false,
include_rule_name: true, include_rule_name: true,
}; };
setEditingRule(nextRule); setEditingRule(nextRule);
@ -712,26 +744,17 @@ export default function SettingsChannelAffinity(props) {
return showError(t(paramTemplateValidation.message)); return showError(t(paramTemplateValidation.message));
} }
const rulePayload = { const rulePayload = buildChannelAffinityRulePayload({
id: isEdit ? editingRule.id : rules.length, values,
name: (values.name || '').trim(), isEdit,
model_regex: modelRegex, editingRuleId: editingRule?.id,
path_regex: normalizeStringList(values.path_regex_text), rulesLength: rules.length,
key_sources: keySourcesValidation.value, modelRegex,
value_regex: (values.value_regex || '').trim(), pathRegex: normalizeStringList(values.path_regex_text),
ttl_seconds: Number(values.ttl_seconds || 0), keySources: keySourcesValidation.value,
include_using_group: !!values.include_using_group, userAgentInclude,
include_rule_name: !!values.include_rule_name, paramOverrideTemplate: paramTemplateValidation.value,
...(values.skip_retry_on_failure });
? { skip_retry_on_failure: true }
: {}),
...(userAgentInclude.length > 0
? { user_agent_include: userAgentInclude }
: {}),
...(paramTemplateValidation.value
? { param_override_template: paramTemplateValidation.value }
: {}),
};
if (!rulePayload.name) return showError(t('名称不能为空')); if (!rulePayload.name) return showError(t('名称不能为空'));
@ -1251,7 +1274,7 @@ export default function SettingsChannelAffinity(props) {
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} sm={12}> <Col xs={24} sm={8}>
<Form.Switch <Form.Switch
field='include_using_group' field='include_using_group'
label={t('作用域:包含分组')} label={t('作用域:包含分组')}
@ -1262,7 +1285,16 @@ export default function SettingsChannelAffinity(props) {
)} )}
</Text> </Text>
</Col> </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 <Form.Switch
field='include_rule_name' field='include_rule_name'
label={t('作用域:包含规则名称')} label={t('作用域:包含规则名称')}

View File

@ -26,9 +26,8 @@ import {
Row, Row,
Spin, Spin,
Modal, Modal,
Select,
InputGroup,
Input, Input,
Typography,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
compareObjects, compareObjects,
@ -39,6 +38,8 @@ import {
} from '../../../helpers'; } from '../../../helpers';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const { Text } = Typography;
export default function GeneralSettings(props) { export default function GeneralSettings(props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); 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(() => { useEffect(() => {
const currentInputs = {}; const currentInputs = {};
for (let key in props.options) { for (let key in props.options) {
@ -202,48 +274,79 @@ export default function GeneralSettings(props) {
/> />
</Col> </Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Slot label={t('站点额度展示类型及汇率')}> <Form.Select
<InputGroup style={{ width: '100%' }}> field='general_setting.quota_display_type'
label={t('额度展示类型')}
extraText={quotaDisplayTypeDesc}
onChange={handleFieldChange(
'general_setting.quota_display_type',
)}
>
<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('自定义货币')}
</Form.Select.Option>
</Form.Select>
</Col>
{quotaDisplayType !== 'USD' && (
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Slot label={rateLabel}>
<Input <Input
prefix={'1 USD = '} prefix='1 USD = '
style={{ width: '50%' }} suffix={rateSuffix}
value={combinedRate} value={combinedRate}
onChange={onCombinedRateChange} onChange={onCombinedRateChange}
disabled={
inputs['general_setting.quota_display_type'] === 'USD'
}
/> />
<Select <Text
style={{ width: '50%' }} type='tertiary'
value={inputs['general_setting.quota_display_type']} size='small'
onChange={handleFieldChange( style={{ marginTop: 4, display: 'block' }}
'general_setting.quota_display_type',
)}
> >
<Select.Option value='USD'>USD ($)</Select.Option> {rateExtraText}
<Select.Option value='CNY'>CNY (¥)</Select.Option> </Text>
<Select.Option value='TOKENS'>Tokens</Select.Option> </Form.Slot>
<Select.Option value='CUSTOM'> </Col>
{t('自定义货币')} )}
</Select.Option> <Col
</Select> xs={24}
</InputGroup> sm={12}
</Form.Slot> md={8}
</Col> lg={8}
<Col xs={24} sm={12} md={8} lg={8} xl={8}> xl={8}
style={
quotaDisplayType !== 'CUSTOM'
? { display: 'none' }
: undefined
}
>
<Form.Input <Form.Input
field={'general_setting.custom_currency_symbol'} field='general_setting.custom_currency_symbol'
label={t('自定义货币符号')} label={t('自定义货币符号')}
extraText={t(
'自定义货币符号将显示在所有额度数值前,例如 €1.50',
)}
placeholder={t('例如 €, £, Rp, ₩, ₹...')} placeholder={t('例如 €, £, Rp, ₩, ₹...')}
onChange={handleFieldChange( onChange={handleFieldChange(
'general_setting.custom_currency_symbol', 'general_setting.custom_currency_symbol',
)} )}
showClear showClear
disabled={
inputs['general_setting.quota_display_type'] !== 'CUSTOM'
}
/> />
</Col> </Col>
<Col span={24}>
<Text type='tertiary' size='small'>
{t('预览效果')}{previewText}
</Text>
</Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}> <Col xs={24} sm={12} md={8} lg={8} xl={8}>

View File

@ -356,7 +356,6 @@ export default function SettingsPerformance(props) {
label={t('CPU 阈值 (%)')} label={t('CPU 阈值 (%)')}
extraText={t('CPU 使用率超过此值时拒绝请求')} extraText={t('CPU 使用率超过此值时拒绝请求')}
min={0} min={0}
max={100}
onChange={handleFieldChange( onChange={handleFieldChange(
'performance_setting.monitor_cpu_threshold', 'performance_setting.monitor_cpu_threshold',
)} )}

View File

@ -17,8 +17,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; 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 { import {
compareObjects, compareObjects,
API, API,
@ -28,10 +43,37 @@ import {
verifyJSON, verifyJSON,
} from '../../../helpers'; } from '../../../helpers';
import { useTranslation } from 'react-i18next'; 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) { export default function GroupRatioSettings(props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editMode, setEditMode] = useState('visual');
const [showGuide, setShowGuide] = useState(false);
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
GroupRatio: '', GroupRatio: '',
UserUsableGroups: '', UserUsableGroups: '',
@ -42,80 +84,189 @@ export default function GroupRatioSettings(props) {
}); });
const refForm = useRef(); const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs); 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() { async function onSubmit() {
if (editMode === 'manual') {
try {
await refForm.current.validate();
} catch {
showError(t('请检查输入'));
return;
}
}
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) {
return showWarning(t('你似乎并没有修改什么'));
}
const requestQueue = updateArray.map((item) => {
const value =
typeof inputs[item.key] === 'boolean'
? String(inputs[item.key])
: inputs[item.key];
return API.put('/api/option/', { key: item.key, value });
});
setLoading(true);
try { try {
await refForm.current const res = await Promise.all(requestQueue);
.validate() if (res.includes(undefined)) {
.then(() => { return showError(
const updateArray = compareObjects(inputs, inputsRow); requestQueue.length > 1
if (!updateArray.length) ? t('部分保存失败,请重试')
return showWarning(t('你似乎并没有修改什么')); : t('保存失败'),
);
const requestQueue = updateArray.map((item) => { }
const value = for (let i = 0; i < res.length; i++) {
typeof inputs[item.key] === 'boolean' if (!res[i].data.success) {
? String(inputs[item.key]) return showError(res[i].data.message);
: inputs[item.key]; }
return API.put('/api/option/', { key: item.key, value }); }
}); showSuccess(t('保存成功'));
props.refresh();
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
if (res.includes(undefined)) {
return showError(
requestQueue.length > 1
? t('部分保存失败,请重试')
: 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) => {
console.error('Unexpected error:', error);
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
});
})
.catch(() => {
showError(t('请检查输入'));
});
} catch (error) { } catch (error) {
showError(t('请检查输入')); console.error('Unexpected error:', error);
console.error(error); showError(t('保存失败,请重试'));
} finally {
setLoading(false);
} }
} }
useEffect(() => { useEffect(() => {
const currentInputs = {}; const currentInputs = {};
for (let key in props.options) { for (let key in props.options) {
if (Object.keys(inputs).includes(key)) { if (OPTION_KEYS.includes(key)) {
currentInputs[key] = props.options[key]; currentInputs[key] = props.options[key];
} }
} }
setInputs(currentInputs); setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs)); setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs); dataVersionRef.current += 1;
if (refForm.current) {
refForm.current.setValues(currentInputs);
}
}, [props.options]); }, [props.options]);
return ( const handleGroupTableChange = useCallback(
<Spin spinning={loading}> ({ GroupRatio, UserUsableGroups }) => {
<Form setInputs((prev) => ({ ...prev, GroupRatio, UserUsableGroups }));
values={inputs} },
getFormApi={(formAPI) => (refForm.current = formAPI)} [],
style={{ marginBottom: 15 }} );
>
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
key='form-manual'
initValues={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={t('分组JSON设置')}>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} sm={16}> <Col xs={24} sm={16}>
<Form.TextArea <Form.TextArea
@ -134,7 +285,9 @@ export default function GroupRatioSettings(props) {
message: t('不是合法的 JSON 字符串'), message: t('不是合法的 JSON 字符串'),
}, },
]} ]}
onChange={(value) => setInputs({ ...inputs, GroupRatio: value })} onChange={(value) =>
setInputs((prev) => ({ ...prev, GroupRatio: value }))
}
/> />
</Col> </Col>
</Row> </Row>
@ -142,7 +295,9 @@ export default function GroupRatioSettings(props) {
<Col xs={24} sm={16}> <Col xs={24} sm={16}>
<Form.TextArea <Form.TextArea
label={t('用户可选分组')} label={t('用户可选分组')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')} placeholder={t(
'为一个 JSON 文本,键为分组名称,值为分组描述',
)}
extraText={t( extraText={t(
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组', '用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
)} )}
@ -157,7 +312,7 @@ export default function GroupRatioSettings(props) {
}, },
]} ]}
onChange={(value) => onChange={(value) =>
setInputs({ ...inputs, UserUsableGroups: value }) setInputs((prev) => ({ ...prev, UserUsableGroups: value }))
} }
/> />
</Col> </Col>
@ -181,7 +336,7 @@ export default function GroupRatioSettings(props) {
}, },
]} ]}
onChange={(value) => onChange={(value) =>
setInputs({ ...inputs, GroupGroupRatio: value }) setInputs((prev) => ({ ...prev, GroupGroupRatio: value }))
} }
/> />
</Col> </Col>
@ -205,10 +360,10 @@ export default function GroupRatioSettings(props) {
}, },
]} ]}
onChange={(value) => onChange={(value) =>
setInputs({ setInputs((prev) => ({
...inputs, ...prev,
'group_ratio_setting.group_special_usable_group': value, 'group_ratio_setting.group_special_usable_group': value,
}) }))
} }
/> />
</Col> </Col>
@ -225,29 +380,23 @@ export default function GroupRatioSettings(props) {
rules={[ rules={[
{ {
validator: (rule, value) => { validator: (rule, value) => {
if (!value || value.trim() === '') { if (!value || value.trim() === '') return true;
return true; // Allow empty values
}
// First check if it's valid JSON
try { try {
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) return false;
// Check if it's an array
if (!Array.isArray(parsed)) {
return false;
}
// Check if every element is a string
return parsed.every((item) => typeof item === 'string'); return parsed.every((item) => typeof item === 'string');
} catch (error) { } catch {
return false; 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> </Col>
</Row> </Row>
@ -259,13 +408,351 @@ export default function GroupRatioSettings(props) {
)} )}
field={'DefaultUseAutoGroup'} field={'DefaultUseAutoGroup'}
onChange={(value) => onChange={(value) =>
setInputs({ ...inputs, DefaultUseAutoGroup: value }) setInputs((prev) => ({
...prev,
DefaultUseAutoGroup: value,
}))
} }
/> />
</Col> </Col>
</Row> </Row>
</Form> </Form.Section>
<Button onClick={onSubmit}>{t('保存分组相关设置')}</Button> </Form>
);
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> </Spin>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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