Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b4ffb0875 | |||
| 606a4eee96 | |||
| 9ffb85a36b | |||
| c3b8fa29b2 | |||
| a057eddac1 | |||
| 1110403750 | |||
| 3a2aecbc01 | |||
| 49648d8b80 | |||
| 59d5aef393 | |||
| 48695e0e6f | |||
| e96ca77542 | |||
| 1ad2557668 | |||
| ded3bb9cb1 | |||
| dc83c4af31 | |||
| cf1b485389 | |||
| 741aaf4436 | |||
| c66636a0c7 | |||
| f7cdc727df | |||
| 07843d7898 | |||
| 559c98f261 | |||
| 960bf9c49e | |||
| 12a48c620e | |||
| eacc245bad | |||
| 03758a4a85 | |||
| 8fc0eb78e2 | |||
| 427fb7eaf6 | |||
| 4cd0e3651d | |||
| 677d02f2ab | |||
| c583382af7 | |||
| b1950655e7 | |||
| 22cbbcab4c | |||
| 873067f7d1 | |||
| 82c2008d2c | |||
| 495e4f5e17 | |||
| 23fde25b15 | |||
| ac90d9f185 | |||
| bb5b9eaca2 | |||
| b713e277cd | |||
| 08a5243bbc | |||
| c9611c493f | |||
| 314ae40820 | |||
| 116e0b8f1c | |||
| 70560d5371 |
@@ -19,6 +19,8 @@
|
||||
# HOSTNAME=your-hostname
|
||||
|
||||
# 数据库相关配置
|
||||
# 启用错误日志记录
|
||||
# ERROR_LOG_ENABLED=true
|
||||
# 数据库连接字符串
|
||||
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
|
||||
# 日志数据库连接字符串
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
@@ -8,6 +9,30 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
|
||||
if len(pricing) == 0 {
|
||||
return pricing
|
||||
}
|
||||
if len(usableGroup) == 0 {
|
||||
return []model.Pricing{}
|
||||
}
|
||||
|
||||
filtered := make([]model.Pricing, 0, len(pricing))
|
||||
for _, item := range pricing {
|
||||
if common.StringsContains(item.EnableGroup, "all") {
|
||||
filtered = append(filtered, item)
|
||||
continue
|
||||
}
|
||||
for _, group := range item.EnableGroup {
|
||||
if _, ok := usableGroup[group]; ok {
|
||||
filtered = append(filtered, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func GetPricing(c *gin.Context) {
|
||||
pricing := model.GetPricing()
|
||||
userId, exists := c.Get("id")
|
||||
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
|
||||
}
|
||||
|
||||
usableGroup = service.GetUserUsableGroups(group)
|
||||
pricing = filterPricingByUsableGroups(pricing, usableGroup)
|
||||
// check groupRatio contains usableGroup
|
||||
for group := range ratio_setting.GetGroupRatioCopy() {
|
||||
if _, ok := usableGroup[group]; !ok {
|
||||
|
||||
+1
-1
@@ -581,7 +581,7 @@ func RelayTask(c *gin.Context) {
|
||||
ModelRatio: relayInfo.PriceData.ModelRatio,
|
||||
OtherRatios: relayInfo.PriceData.OtherRatios,
|
||||
OriginModelName: relayInfo.OriginModelName,
|
||||
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName),
|
||||
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
|
||||
}
|
||||
task.Quota = result.Quota
|
||||
task.Data = result.TaskData
|
||||
|
||||
@@ -334,3 +334,26 @@ func DeleteTokenBatch(c *gin.Context) {
|
||||
"data": count,
|
||||
})
|
||||
}
|
||||
|
||||
func GetTokenKeysBatch(c *gin.Context) {
|
||||
tokenBatch := TokenBatch{}
|
||||
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
if len(tokenBatch.Ids) > 100 {
|
||||
common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
|
||||
return
|
||||
}
|
||||
userId := c.GetInt("id")
|
||||
tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
keysMap := make(map[int]string)
|
||||
for _, t := range tokens {
|
||||
keysMap[t.Id] = t.GetFullKey()
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"keys": keysMap})
|
||||
}
|
||||
|
||||
@@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func GetQuotaDatesByUser(c *gin.Context) {
|
||||
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
||||
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
|
||||
dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": dates,
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserQuotaDates(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
||||
|
||||
+24
-30
@@ -98,6 +98,20 @@ func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
|
||||
return mediaContent
|
||||
}
|
||||
|
||||
func (m *ClaudeMediaMessage) ToFileSource() types.FileSource {
|
||||
if m.Source == nil {
|
||||
return nil
|
||||
}
|
||||
data := m.Source.Url
|
||||
if data == "" {
|
||||
data = common.Interface2String(m.Source.Data)
|
||||
}
|
||||
if data == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(data, m.Source.MediaType)
|
||||
}
|
||||
|
||||
type ClaudeMessageSource struct {
|
||||
Type string `json:"type"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
@@ -223,14 +237,6 @@ type OutputConfigForEffort struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
}
|
||||
|
||||
// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
|
||||
func createClaudeFileSource(data string) *types.FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
return types.NewURLFileSource(data)
|
||||
}
|
||||
return types.NewBase64FileSource(data, "")
|
||||
}
|
||||
|
||||
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
maxTokens := 0
|
||||
if c.MaxTokens != nil {
|
||||
@@ -258,17 +264,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
case "text":
|
||||
texts = append(texts, media.GetText())
|
||||
case "image":
|
||||
if media.Source != nil {
|
||||
data := media.Source.Url
|
||||
if data == "" {
|
||||
data = common.Interface2String(media.Source.Data)
|
||||
}
|
||||
if data != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createClaudeFileSource(data),
|
||||
})
|
||||
}
|
||||
if source := media.ToFileSource(); source != nil {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,17 +293,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
case "text":
|
||||
texts = append(texts, media.GetText())
|
||||
case "image":
|
||||
if media.Source != nil {
|
||||
data := media.Source.Url
|
||||
if data == "" {
|
||||
data = common.Interface2String(media.Source.Data)
|
||||
}
|
||||
if data != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createClaudeFileSource(data),
|
||||
})
|
||||
}
|
||||
if source := media.ToFileSource(); source != nil {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
case "tool_use":
|
||||
if media.Name != "" {
|
||||
|
||||
+13
-11
@@ -64,14 +64,6 @@ type LatLng struct {
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// createGeminiFileSource 根据数据内容创建正确类型的 FileSource
|
||||
func createGeminiFileSource(data string, mimeType string) *types.FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
return types.NewURLFileSource(data)
|
||||
}
|
||||
return types.NewBase64FileSource(data, mimeType)
|
||||
}
|
||||
|
||||
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
var files []*types.FileMeta = make([]*types.FileMeta, 0)
|
||||
|
||||
@@ -87,9 +79,8 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
if part.Text != "" {
|
||||
inputTexts = append(inputTexts, part.Text)
|
||||
}
|
||||
if part.InlineData != nil && part.InlineData.Data != "" {
|
||||
if source := part.InlineData.ToFileSource(); source != nil {
|
||||
mimeType := part.InlineData.MimeType
|
||||
source := createGeminiFileSource(part.InlineData.Data, mimeType)
|
||||
var fileType types.FileType
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
fileType = types.FileTypeImage
|
||||
@@ -103,7 +94,6 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
files = append(files, &types.FileMeta{
|
||||
FileType: fileType,
|
||||
Source: source,
|
||||
MimeType: mimeType,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -121,6 +111,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
|
||||
if c.Query("alt") == "sse" {
|
||||
return true
|
||||
}
|
||||
// Native Gemini API uses URL action to indicate streaming:
|
||||
// /v1beta/models/{model}:streamGenerateContent
|
||||
if strings.Contains(c.Request.URL.Path, "streamGenerateContent") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -210,6 +205,13 @@ type GeminiInlineData struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
func (d *GeminiInlineData) ToFileSource() types.FileSource {
|
||||
if d == nil || d.Data == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(d.Data, d.MimeType)
|
||||
}
|
||||
|
||||
// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
|
||||
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
|
||||
type Alias GeminiInlineData // Use type alias to avoid recursion
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
+53
-47
@@ -108,14 +108,6 @@ type GeneralOpenAIRequest struct {
|
||||
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
|
||||
}
|
||||
|
||||
// createFileSource 根据数据内容创建正确类型的 FileSource
|
||||
func createFileSource(data string) *types.FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
return types.NewURLFileSource(data)
|
||||
}
|
||||
return types.NewBase64FileSource(data, "")
|
||||
}
|
||||
|
||||
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
var tokenCountMeta types.TokenCountMeta
|
||||
var texts = make([]string, 0)
|
||||
@@ -159,44 +151,24 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
arrayContent := message.ParseContent()
|
||||
for _, m := range arrayContent {
|
||||
if m.Type == ContentTypeImageURL {
|
||||
imageUrl := m.GetImageMedia()
|
||||
if imageUrl != nil && imageUrl.Url != "" {
|
||||
source := createFileSource(imageUrl.Url)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: source,
|
||||
Detail: imageUrl.Detail,
|
||||
})
|
||||
source := m.ToFileSource()
|
||||
if source != nil {
|
||||
meta := &types.FileMeta{Source: source}
|
||||
switch m.Type {
|
||||
case ContentTypeImageURL:
|
||||
meta.FileType = types.FileTypeImage
|
||||
if img := m.GetImageMedia(); img != nil {
|
||||
meta.Detail = img.Detail
|
||||
}
|
||||
case ContentTypeInputAudio:
|
||||
meta.FileType = types.FileTypeAudio
|
||||
case ContentTypeFile:
|
||||
meta.FileType = types.FileTypeFile
|
||||
case ContentTypeVideoUrl:
|
||||
meta.FileType = types.FileTypeVideo
|
||||
}
|
||||
} else if m.Type == ContentTypeInputAudio {
|
||||
inputAudio := m.GetInputAudio()
|
||||
if inputAudio != nil && inputAudio.Data != "" {
|
||||
source := createFileSource(inputAudio.Data)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeAudio,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
} else if m.Type == ContentTypeFile {
|
||||
file := m.GetFile()
|
||||
if file != nil && file.FileData != "" {
|
||||
source := createFileSource(file.FileData)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeFile,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
} else if m.Type == ContentTypeVideoUrl {
|
||||
videoUrl := m.GetVideoUrl()
|
||||
if videoUrl != nil && videoUrl.Url != "" {
|
||||
source := createFileSource(videoUrl.Url)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeVideo,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
fileMeta = append(fileMeta, meta)
|
||||
} else if m.Type == ContentTypeText {
|
||||
texts = append(texts, m.Text)
|
||||
}
|
||||
}
|
||||
@@ -391,6 +363,40 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MediaContent) ToFileSource() types.FileSource {
|
||||
switch m.Type {
|
||||
case ContentTypeImageURL:
|
||||
img := m.GetImageMedia()
|
||||
if img == nil || img.Url == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(img.Url, img.MimeType)
|
||||
case ContentTypeInputAudio:
|
||||
audio := m.GetInputAudio()
|
||||
if audio == nil || audio.Data == "" {
|
||||
return nil
|
||||
}
|
||||
mimeType := ""
|
||||
if audio.Format != "" {
|
||||
mimeType = "audio/" + audio.Format
|
||||
}
|
||||
return types.NewFileSourceFromData(audio.Data, mimeType)
|
||||
case ContentTypeFile:
|
||||
file := m.GetFile()
|
||||
if file == nil || file.FileData == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(file.FileData, "")
|
||||
case ContentTypeVideoUrl:
|
||||
video := m.GetVideoUrl()
|
||||
if video == nil || video.Url == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(video.Url, "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type MessageImageUrl struct {
|
||||
Url string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
@@ -865,7 +871,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
if input.ImageUrl != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createFileSource(input.ImageUrl),
|
||||
Source: types.NewFileSourceFromData(input.ImageUrl, ""),
|
||||
Detail: input.Detail,
|
||||
})
|
||||
}
|
||||
@@ -873,7 +879,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
if input.FileUrl != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeFile,
|
||||
Source: createFileSource(input.FileUrl),
|
||||
Source: types.NewFileSourceFromData(input.FileUrl, ""),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
+10
-10
@@ -9,7 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "35.7.5",
|
||||
"electron": "39.8.5",
|
||||
"electron-builder": "^26.7.0"
|
||||
}
|
||||
},
|
||||
@@ -777,9 +777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2145,9 +2145,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "35.7.5",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
|
||||
"integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==",
|
||||
"version": "39.8.5",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz",
|
||||
"integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -3279,9 +3279,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
Vendored
+1
-1
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "35.7.5",
|
||||
"electron": "39.8.5",
|
||||
"electron-builder": "^26.7.0"
|
||||
},
|
||||
"build": {
|
||||
|
||||
@@ -8,9 +8,9 @@ require (
|
||||
github.com/abema/go-mp4 v1.4.1
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
|
||||
github.com/aws/smithy-go v1.24.2
|
||||
github.com/bytedance/gopkg v0.1.3
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
@@ -63,9 +63,9 @@ require (
|
||||
require (
|
||||
github.com/DmitriyVTitov/size v1.5.0 // indirect
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
|
||||
@@ -12,18 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
MsgDeleteFailed = "common.delete_failed"
|
||||
MsgAlreadyExists = "common.already_exists"
|
||||
MsgNameCannotBeEmpty = "common.name_cannot_be_empty"
|
||||
MsgBatchTooMany = "common.batch_too_many"
|
||||
)
|
||||
|
||||
// Token related messages
|
||||
|
||||
@@ -21,6 +21,7 @@ common.delete_success: "Deletion successful"
|
||||
common.delete_failed: "Deletion failed"
|
||||
common.already_exists: "Already exists"
|
||||
common.name_cannot_be_empty: "Name cannot be empty"
|
||||
common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "Token name is too long"
|
||||
|
||||
@@ -22,6 +22,7 @@ common.delete_success: "删除成功"
|
||||
common.delete_failed: "删除失败"
|
||||
common.already_exists: "已存在"
|
||||
common.name_cannot_be_empty: "名称不能为空"
|
||||
common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "令牌名称过长"
|
||||
|
||||
@@ -22,6 +22,7 @@ common.delete_success: "刪除成功"
|
||||
common.delete_failed: "刪除失敗"
|
||||
common.already_exists: "已存在"
|
||||
common.name_cannot_be_empty: "名稱不能為空"
|
||||
common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "令牌名稱過長"
|
||||
|
||||
@@ -481,3 +481,11 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) {
|
||||
|
||||
return len(tokens), nil
|
||||
}
|
||||
|
||||
func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) {
|
||||
var tokens []Token
|
||||
err := DB.Select("id", commonKeyCol).
|
||||
Where("user_id = ? AND id IN (?)", userId, ids).
|
||||
Find(&tokens).Error
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
@@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
|
||||
return quotaDatas, err
|
||||
}
|
||||
|
||||
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
|
||||
var quotaDatas []*QuotaData
|
||||
err = DB.Table("quota_data").
|
||||
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
|
||||
Where("created_at >= ? and created_at <= ?", startTime, endTime).
|
||||
Group("username, created_at").
|
||||
Find("aDatas).Error
|
||||
return quotaDatas, err
|
||||
}
|
||||
|
||||
func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
|
||||
if username != "" {
|
||||
return GetQuotaDataByUsername(username, startTime, endTime)
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
|
||||
require.EqualValues(t, 50, usage.CacheCreationInputTokens)
|
||||
require.EqualValues(t, 53, usage.OutputTokens)
|
||||
require.NotNil(t, usage.CacheCreation)
|
||||
require.EqualValues(t, 10, usage.CacheCreation.Ephemeral5mInputTokens)
|
||||
require.EqualValues(t, 30, usage.CacheCreation.Ephemeral5mInputTokens)
|
||||
require.EqualValues(t, 20, usage.CacheCreation.Ephemeral1hInputTokens)
|
||||
})
|
||||
|
||||
@@ -108,4 +108,22 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
|
||||
require.EqualValues(t, 7, usage.CacheReadInputTokens)
|
||||
require.EqualValues(t, 6, usage.CacheCreationInputTokens)
|
||||
})
|
||||
|
||||
t.Run("default aggregate cache creation to 5m when split missing", func(t *testing.T) {
|
||||
claudeResponse := &dto.ClaudeResponse{Usage: &dto.ClaudeUsage{
|
||||
OutputTokens: 53,
|
||||
CacheCreationInputTokens: 50,
|
||||
}}
|
||||
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedCreationTokens: 50,
|
||||
},
|
||||
}}
|
||||
|
||||
usage := buildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
|
||||
require.NotNil(t, usage)
|
||||
require.NotNil(t, usage.CacheCreation)
|
||||
require.EqualValues(t, 50, usage.CacheCreation.Ephemeral5mInputTokens)
|
||||
require.EqualValues(t, 0, usage.CacheCreation.Ephemeral1hInputTokens)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -46,61 +44,6 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
|
||||
}
|
||||
}
|
||||
|
||||
func createClaudeFileSource(file *dto.MessageFile) *types.FileSource {
|
||||
if file == nil || file.FileData == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(file.FileData, "http://") || strings.HasPrefix(file.FileData, "https://") {
|
||||
return types.NewURLFileSource(file.FileData)
|
||||
}
|
||||
mimeType := ""
|
||||
if ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.FileName)), "."); ext != "" {
|
||||
if detected := service.GetMimeTypeByExtension(ext); detected != "application/octet-stream" {
|
||||
mimeType = detected
|
||||
}
|
||||
}
|
||||
return types.NewBase64FileSource(file.FileData, mimeType)
|
||||
}
|
||||
|
||||
func buildClaudeFileMessage(c *gin.Context, file *dto.MessageFile) (*dto.ClaudeMediaMessage, error) {
|
||||
source := createClaudeFileSource(file)
|
||||
if source == nil {
|
||||
return nil, nil
|
||||
}
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting document for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data failed: %w", err)
|
||||
}
|
||||
switch strings.ToLower(mimeType) {
|
||||
case "application/pdf":
|
||||
return &dto.ClaudeMediaMessage{
|
||||
Type: "document",
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
MediaType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
}, nil
|
||||
case "text/plain":
|
||||
decodedData, err := base64.StdEncoding.DecodeString(base64Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode text file data failed: %w", err)
|
||||
}
|
||||
return &dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer(string(decodedData)),
|
||||
}, nil
|
||||
default:
|
||||
msg := fmt.Sprintf("claude: skip unsupported file content, filename=%q, mime=%q", file.FileName, mimeType)
|
||||
if c != nil {
|
||||
logger.LogInfo(c, msg)
|
||||
} else {
|
||||
common.SysLog(msg)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
|
||||
claudeTools := make([]any, 0, len(textRequest.Tools))
|
||||
|
||||
@@ -142,7 +85,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
|
||||
// 解析 UserLocation JSON
|
||||
var userLocationMap map[string]interface{}
|
||||
if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
|
||||
if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
|
||||
// 检查是否有 approximate 字段
|
||||
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
|
||||
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
|
||||
@@ -234,7 +177,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
}
|
||||
// TODO: 临时处理
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
claudeRequest.TopP = common.GetPointer[float64](0)
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
|
||||
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
@@ -406,44 +349,33 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](mediaMessage.Text),
|
||||
})
|
||||
case dto.ContentTypeImageURL:
|
||||
claudeMediaMessage := dto.ClaudeMediaMessage{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
},
|
||||
}
|
||||
imageUrl := mediaMessage.GetImageMedia()
|
||||
if imageUrl == nil {
|
||||
default:
|
||||
source := mediaMessage.ToFileSource()
|
||||
if source == nil {
|
||||
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")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||
}
|
||||
claudeMediaMessage := dto.ClaudeMediaMessage{
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
},
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "application/pdf") {
|
||||
claudeMediaMessage.Type = "document"
|
||||
} else {
|
||||
claudeMediaMessage.Type = "image"
|
||||
}
|
||||
|
||||
claudeMediaMessage.Source.MediaType = mimeType
|
||||
claudeMediaMessage.Source.Data = base64Data
|
||||
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
|
||||
// FIXME
|
||||
//case dto.ContentTypeFile:
|
||||
// claudeFileMessage, err := buildClaudeFileMessage(c, mediaMessage.GetFile())
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// if claudeFileMessage != nil {
|
||||
// claudeMediaMessages = append(claudeMediaMessages, *claudeFileMessage)
|
||||
// }
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if message.ToolCalls != nil {
|
||||
for _, toolCall := range message.ParseToolCalls() {
|
||||
inputObj := make(map[string]any)
|
||||
@@ -648,6 +580,11 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
|
||||
return dto.Usage{}
|
||||
}
|
||||
clone := *usage
|
||||
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = service.NormalizeCacheCreationSplit(
|
||||
usage.PromptTokensDetails.CachedCreationTokens,
|
||||
usage.ClaudeCacheCreation5mTokens,
|
||||
usage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
|
||||
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
|
||||
clone.PromptTokens = totalInputTokens
|
||||
@@ -677,11 +614,26 @@ func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo
|
||||
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
|
||||
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
|
||||
}
|
||||
if usage.CacheCreation == nil && (claudeInfo.Usage.ClaudeCacheCreation5mTokens > 0 || claudeInfo.Usage.ClaudeCacheCreation1hTokens > 0) {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: claudeInfo.Usage.ClaudeCacheCreation5mTokens,
|
||||
Ephemeral1hInputTokens: claudeInfo.Usage.ClaudeCacheCreation1hTokens,
|
||||
}
|
||||
cacheCreation5m := 0
|
||||
cacheCreation1h := 0
|
||||
if usage.CacheCreation != nil {
|
||||
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
|
||||
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
|
||||
} else {
|
||||
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
|
||||
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
|
||||
}
|
||||
cacheCreation5m, cacheCreation1h = service.NormalizeCacheCreationSplit(
|
||||
usage.CacheCreationInputTokens,
|
||||
cacheCreation5m,
|
||||
cacheCreation1h,
|
||||
)
|
||||
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
|
||||
}
|
||||
if usage.CacheCreation != nil {
|
||||
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
|
||||
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
|
||||
}
|
||||
return usage
|
||||
}
|
||||
@@ -857,7 +809,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
|
||||
if common.DebugEnabled {
|
||||
common.SysLog("claude response usage is not complete, maybe upstream error")
|
||||
}
|
||||
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
|
||||
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
|
||||
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
if claudeInfo.Usage.CompletionTokens == 0 ||
|
||||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
|
||||
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
|
||||
}
|
||||
if claudeInfo.Usage.PromptTokens == 0 {
|
||||
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
|
||||
}
|
||||
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
|
||||
}
|
||||
if claudeInfo.Usage != nil {
|
||||
claudeInfo.Usage.UsageSemantic = "anthropic"
|
||||
|
||||
@@ -258,6 +258,23 @@ 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",
|
||||
|
||||
@@ -585,14 +585,10 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
Text: part.Text,
|
||||
})
|
||||
}
|
||||
} else if part.Type == dto.ContentTypeImageURL {
|
||||
// 使用统一的文件服务获取图片数据
|
||||
var source *types.FileSource
|
||||
imageUrl := part.GetImageMedia().Url
|
||||
if strings.HasPrefix(imageUrl, "http") {
|
||||
source = types.NewURLFileSource(imageUrl)
|
||||
} else {
|
||||
source = types.NewBase64FileSource(imageUrl, "")
|
||||
} else {
|
||||
source := part.ToFileSource()
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
|
||||
if err != nil {
|
||||
@@ -604,36 +600,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList())
|
||||
}
|
||||
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
})
|
||||
} else if part.Type == dto.ContentTypeFile {
|
||||
if part.GetFile().FileId != "" {
|
||||
return nil, fmt.Errorf("only base64 file is supported in gemini")
|
||||
}
|
||||
fileSource := types.NewBase64FileSource(part.GetFile().FileData, "")
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
|
||||
}
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
})
|
||||
} else if part.Type == dto.ContentTypeInputAudio {
|
||||
if part.GetInputAudio().Data == "" {
|
||||
return nil, fmt.Errorf("only base64 audio is supported in gemini")
|
||||
}
|
||||
audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format)
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
|
||||
}
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
|
||||
@@ -98,15 +98,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
parts := m.ParseContent()
|
||||
for _, part := range parts {
|
||||
if part.Type == dto.ContentTypeImageURL {
|
||||
img := part.GetImageMedia()
|
||||
if img != nil && img.Url != "" {
|
||||
// 使用统一的文件服务获取图片数据
|
||||
var source *types.FileSource
|
||||
if strings.HasPrefix(img.Url, "http") {
|
||||
source = types.NewURLFileSource(img.Url)
|
||||
} else {
|
||||
source = types.NewBase64FileSource(img.Url, "")
|
||||
}
|
||||
source := part.ToFileSource()
|
||||
if source != nil {
|
||||
base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -132,6 +132,49 @@ func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *r
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。
|
||||
func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
|
||||
req, err := relaycommon.GetTaskRequest(c)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if hasVideoInMetadata(req.Metadata) {
|
||||
if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok {
|
||||
return map[string]float64{"video_input": ratio}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目,
|
||||
// 避免构建完整的上游 requestPayload。
|
||||
func hasVideoInMetadata(metadata map[string]interface{}) bool {
|
||||
if metadata == nil {
|
||||
return false
|
||||
}
|
||||
contentRaw, ok := metadata["content"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
contentSlice, ok := contentRaw.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, item := range contentSlice {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if itemMap["type"] == "video_url" {
|
||||
return true
|
||||
}
|
||||
if _, has := itemMap["video_url"]; has {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BuildRequestBody converts request into Doubao specific format.
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
req, err := relaycommon.GetTaskRequest(c)
|
||||
|
||||
@@ -10,3 +10,16 @@ var ModelList = []string{
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -690,6 +691,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
|
||||
type Alias TaskSubmitReq
|
||||
aux := &struct {
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Duration json.RawMessage `json:"duration,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(t),
|
||||
@@ -699,6 +701,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(aux.Duration) > 0 {
|
||||
var durationInt int
|
||||
if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
|
||||
t.Duration = durationInt
|
||||
} else {
|
||||
var durationStr string
|
||||
if err := common.Unmarshal(aux.Duration, &durationStr); err == nil && durationStr != "" {
|
||||
if v, err := strconv.Atoi(durationStr); err == nil {
|
||||
t.Duration = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(aux.Metadata) > 0 {
|
||||
var metadataStr string
|
||||
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {
|
||||
|
||||
@@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
|
||||
if err != nil {
|
||||
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
|
||||
}
|
||||
} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
}
|
||||
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
|
||||
}
|
||||
|
||||
|
||||
+29
-15
@@ -139,21 +139,23 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
||||
return priceData, nil
|
||||
}
|
||||
|
||||
// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task)
|
||||
// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
|
||||
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) {
|
||||
groupRatioInfo := HandleGroupRatio(c, info)
|
||||
|
||||
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
|
||||
// 如果没有配置价格,检查模型倍率配置
|
||||
if !success {
|
||||
usePrice := success
|
||||
var modelRatio float64
|
||||
|
||||
// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用
|
||||
if !success {
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
|
||||
if ok {
|
||||
modelPrice = defaultPrice
|
||||
usePrice = true
|
||||
} else {
|
||||
// 没有配置倍率也不接受没配置,那就返回错误
|
||||
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
|
||||
var ratioSuccess bool
|
||||
var matchName string
|
||||
modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
|
||||
acceptUnsetRatio := false
|
||||
if info.UserSetting.AcceptUnsetRatioModel {
|
||||
acceptUnsetRatio = true
|
||||
@@ -161,25 +163,37 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
|
||||
if !ratioSuccess && !acceptUnsetRatio {
|
||||
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
|
||||
}
|
||||
// 未配置价格但配置了倍率,使用默认预扣价格
|
||||
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
|
||||
}
|
||||
|
||||
}
|
||||
quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
|
||||
// 免费模型检测(与 ModelPriceHelper 对齐)
|
||||
var quota int
|
||||
freeModel := false
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
|
||||
quota = 0
|
||||
freeModel = true
|
||||
|
||||
if usePrice {
|
||||
quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
|
||||
quota = 0
|
||||
freeModel = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 按量计费:以模型倍率的一半作为预扣额度
|
||||
quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
modelPrice = -1
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
|
||||
quota = 0
|
||||
freeModel = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
priceData := types.PriceData{
|
||||
FreeModel: freeModel,
|
||||
ModelPrice: modelPrice,
|
||||
ModelRatio: modelRatio,
|
||||
UsePrice: usePrice,
|
||||
Quota: quota,
|
||||
GroupRatioInfo: groupRatioInfo,
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
tokenRoute.PUT("/", controller.UpdateToken)
|
||||
tokenRoute.DELETE("/:id", controller.DeleteToken)
|
||||
tokenRoute.POST("/batch", controller.DeleteTokenBatch)
|
||||
tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
|
||||
}
|
||||
|
||||
usageRoute := apiRouter.Group("/usage")
|
||||
@@ -292,6 +293,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
|
||||
dataRoute := apiRouter.Group("/data")
|
||||
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
|
||||
dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
|
||||
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
|
||||
|
||||
logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
|
||||
|
||||
@@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
if rule.IncludeUsingGroup {
|
||||
if rule.IncludeModelName {
|
||||
if len(parts) < 3 {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
}
|
||||
if rule.IncludeUsingGroup {
|
||||
minParts := 3
|
||||
if rule.IncludeModelName {
|
||||
minParts = 4
|
||||
}
|
||||
if len(parts) < minParts {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
}
|
||||
byRuleName[ruleName]++
|
||||
}
|
||||
|
||||
@@ -319,11 +329,14 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
|
||||
}
|
||||
}
|
||||
|
||||
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
|
||||
parts := make([]string, 0, 4)
|
||||
if rule.IncludeRuleName && rule.Name != "" {
|
||||
parts = append(parts, rule.Name)
|
||||
}
|
||||
if rule.IncludeModelName && modelName != "" {
|
||||
parts = append(parts, modelName)
|
||||
}
|
||||
if rule.IncludeUsingGroup && usingGroup != "" {
|
||||
parts = append(parts, usingGroup)
|
||||
}
|
||||
@@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = setting.DefaultTTLSeconds
|
||||
}
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
|
||||
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
|
||||
setChannelAffinityContext(c, channelAffinityMeta{
|
||||
CacheKey: cacheKeyFull,
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
|
||||
require.NotNil(t, codexRule)
|
||||
|
||||
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
|
||||
|
||||
cache := getChannelAffinityCache()
|
||||
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
|
||||
|
||||
+37
-15
@@ -227,21 +227,31 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
||||
if oaiUsage == nil {
|
||||
return nil
|
||||
}
|
||||
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
|
||||
oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
oaiUsage.ClaudeCacheCreation5mTokens,
|
||||
oaiUsage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
usage := &dto.ClaudeUsage{
|
||||
InputTokens: oaiUsage.PromptTokens,
|
||||
OutputTokens: oaiUsage.CompletionTokens,
|
||||
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
|
||||
}
|
||||
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
|
||||
if cacheCreation5m > 0 || cacheCreation1h > 0 {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
|
||||
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
|
||||
Ephemeral5mInputTokens: cacheCreation5m,
|
||||
Ephemeral1hInputTokens: cacheCreation1h,
|
||||
}
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
||||
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
|
||||
return tokens5m + remainder, tokens1h
|
||||
}
|
||||
|
||||
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
|
||||
if info.ClaudeConvertInfo.Done {
|
||||
return nil
|
||||
@@ -426,23 +436,28 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
}
|
||||
|
||||
if len(openAIResponse.Choices) == 0 {
|
||||
// no choices
|
||||
// 可能为非标准的 OpenAI 响应,判断是否已经完成
|
||||
if info.ClaudeConvertInfo.Done {
|
||||
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
}
|
||||
if oaiUsage != nil {
|
||||
stopOpenBlocks()
|
||||
oaiUsage := info.ClaudeConvertInfo.Usage
|
||||
if oaiUsage != nil {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||
},
|
||||
})
|
||||
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
|
||||
if stopReason == "" {
|
||||
stopReason = "end_turn"
|
||||
}
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReason),
|
||||
},
|
||||
})
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_stop",
|
||||
})
|
||||
info.ClaudeConvertInfo.Done = true
|
||||
}
|
||||
return claudeResponses
|
||||
} else {
|
||||
@@ -450,6 +465,13 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
|
||||
if doneChunk {
|
||||
info.FinishReason = *chosenChoice.FinishReason
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
|
||||
// Defer closing until usage is available so the final message_delta carries it.
|
||||
return claudeResponses
|
||||
}
|
||||
}
|
||||
|
||||
var claudeResponse dto.ClaudeResponse
|
||||
|
||||
+53
-32
@@ -25,14 +25,26 @@ import (
|
||||
// FileService 统一的文件处理服务
|
||||
// 提供文件下载、解码、缓存等功能的统一入口
|
||||
|
||||
// getContextCacheKey 生成 context 缓存的 key
|
||||
// getContextCacheKey 生成 URL context 缓存的 key
|
||||
func getContextCacheKey(url string) string {
|
||||
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
|
||||
}
|
||||
|
||||
// getBase64ContextCacheKey 生成 base64 context 缓存的 key
|
||||
// 使用 length + MIME + 前 128 字符作为输入,避免对整个 base64 数据做 hash
|
||||
func getBase64ContextCacheKey(data string, mimeType string) string {
|
||||
keyMaterial := fmt.Sprintf("%d:%s:", len(data), mimeType)
|
||||
if len(data) > 128 {
|
||||
keyMaterial += data[:128]
|
||||
} else {
|
||||
keyMaterial += data
|
||||
}
|
||||
return fmt.Sprintf("b64_cache_%s", common.GenerateHMAC(keyMaterial))
|
||||
}
|
||||
|
||||
// LoadFileSource 加载文件源数据
|
||||
// 这是统一的入口,会自动处理缓存和不同的来源类型
|
||||
func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) {
|
||||
func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (*types.CachedFileData, error) {
|
||||
if source == nil {
|
||||
return nil, fmt.Errorf("file source is nil")
|
||||
}
|
||||
@@ -43,7 +55,6 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
|
||||
|
||||
// 1. 快速检查内部缓存
|
||||
if source.HasCache() {
|
||||
// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
|
||||
if c != nil {
|
||||
registerSourceForCleanup(c, source)
|
||||
}
|
||||
@@ -62,39 +73,49 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
|
||||
return source.GetCache(), nil
|
||||
}
|
||||
|
||||
// 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. 执行加载逻辑
|
||||
// 4. 根据来源类型加载(含 URL context 缓存查找)
|
||||
var cachedData *types.CachedFileData
|
||||
var contextKey string
|
||||
var err error
|
||||
|
||||
if source.IsURL() {
|
||||
cachedData, err = loadFromURL(c, source.URL, reason...)
|
||||
} else {
|
||||
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
|
||||
switch s := source.(type) {
|
||||
case *types.URLSource:
|
||||
if c != nil {
|
||||
contextKey = getContextCacheKey(s.URL)
|
||||
if cached, exists := c.Get(contextKey); exists {
|
||||
data := cached.(*types.CachedFileData)
|
||||
source.SetCache(data)
|
||||
registerSourceForCleanup(c, source)
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
cachedData, err = loadFromURL(c, s.URL, reason...)
|
||||
case *types.Base64Source:
|
||||
if c != nil {
|
||||
contextKey = getBase64ContextCacheKey(s.Base64Data, s.MimeType)
|
||||
if cached, exists := c.Get(contextKey); exists {
|
||||
data := cached.(*types.CachedFileData)
|
||||
source.SetCache(data)
|
||||
registerSourceForCleanup(c, source)
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
cachedData, err = loadFromBase64(s.Base64Data, s.MimeType)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported file source type: %T", source)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. 设置缓存
|
||||
// 5. 设置缓存
|
||||
source.SetCache(cachedData)
|
||||
if contextKey != "" && c != nil {
|
||||
c.Set(contextKey, cachedData)
|
||||
}
|
||||
|
||||
// 7. 注册到 context 以便请求结束时自动清理
|
||||
// 6. 注册到 context 以便请求结束时自动清理
|
||||
if c != nil {
|
||||
registerSourceForCleanup(c, source)
|
||||
}
|
||||
@@ -103,15 +124,15 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
|
||||
}
|
||||
|
||||
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
|
||||
func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
|
||||
func registerSourceForCleanup(c *gin.Context, source types.FileSource) {
|
||||
if source.IsRegistered() {
|
||||
return
|
||||
}
|
||||
|
||||
key := string(constant.ContextKeyFileSourcesToCleanup)
|
||||
var sources []*types.FileSource
|
||||
var sources []types.FileSource
|
||||
if existing, exists := c.Get(key); exists {
|
||||
sources = existing.([]*types.FileSource)
|
||||
sources = existing.([]types.FileSource)
|
||||
}
|
||||
sources = append(sources, source)
|
||||
c.Set(key, sources)
|
||||
@@ -123,12 +144,12 @@ func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
|
||||
func CleanupFileSources(c *gin.Context) {
|
||||
key := string(constant.ContextKeyFileSourcesToCleanup)
|
||||
if sources, exists := c.Get(key); exists {
|
||||
for _, source := range sources.([]*types.FileSource) {
|
||||
for _, source := range sources.([]types.FileSource) {
|
||||
if cache := source.GetCache(); cache != nil {
|
||||
cache.Close()
|
||||
}
|
||||
}
|
||||
c.Set(key, nil) // 清除引用
|
||||
c.Set(key, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +384,7 @@ func loadFromBase64(base64String string, providedMimeType string) (*types.Cached
|
||||
}
|
||||
|
||||
// GetImageConfig 获取图片配置
|
||||
func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) {
|
||||
func GetImageConfig(c *gin.Context, source types.FileSource) (image.Config, string, error) {
|
||||
cachedData, err := LoadFileSource(c, source, "get_image_config")
|
||||
if err != nil {
|
||||
return image.Config{}, "", err
|
||||
@@ -394,7 +415,7 @@ func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, str
|
||||
}
|
||||
|
||||
// GetBase64Data 获取 base64 编码的数据
|
||||
func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) {
|
||||
func GetBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) {
|
||||
cachedData, err := LoadFileSource(c, source, reason...)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -407,13 +428,13 @@ func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (
|
||||
}
|
||||
|
||||
// GetMimeType 获取文件的 MIME 类型
|
||||
func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) {
|
||||
func GetMimeType(c *gin.Context, source types.FileSource) (string, error) {
|
||||
if source.HasCache() {
|
||||
return source.GetCache().MimeType, nil
|
||||
}
|
||||
|
||||
if source.IsURL() {
|
||||
mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type")
|
||||
if urlSource, ok := source.(*types.URLSource); ok {
|
||||
mimeType, err := GetFileTypeFromUrl(c, urlSource.URL, "get_mime_type")
|
||||
if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
|
||||
return mimeType, nil
|
||||
}
|
||||
|
||||
+20
-4
@@ -36,8 +36,12 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
|
||||
}
|
||||
}
|
||||
other := make(map[string]interface{})
|
||||
other["is_task"] = true
|
||||
other["request_path"] = c.Request.URL.Path
|
||||
other["model_price"] = info.PriceData.ModelPrice
|
||||
if info.PriceData.ModelRatio > 0 {
|
||||
other["model_ratio"] = info.PriceData.ModelRatio
|
||||
}
|
||||
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
|
||||
if info.PriceData.GroupRatioInfo.HasSpecialRatio {
|
||||
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
|
||||
@@ -117,6 +121,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} {
|
||||
other := make(map[string]interface{})
|
||||
if bc := task.PrivateData.BillingContext; bc != nil {
|
||||
other["model_price"] = bc.ModelPrice
|
||||
if bc.ModelRatio > 0 {
|
||||
other["model_ratio"] = bc.ModelRatio
|
||||
}
|
||||
other["group_ratio"] = bc.GroupRatio
|
||||
if len(bc.OtherRatios) > 0 {
|
||||
for k, v := range bc.OtherRatios {
|
||||
@@ -222,7 +229,6 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
|
||||
}
|
||||
other := taskBillingOther(task)
|
||||
other["task_id"] = task.TaskID
|
||||
//other["reason"] = reason
|
||||
other["pre_consumed_quota"] = preConsumedQuota
|
||||
other["actual_quota"] = actualQuota
|
||||
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
|
||||
@@ -277,9 +283,19 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
|
||||
finalGroupRatio = groupRatio
|
||||
}
|
||||
|
||||
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
|
||||
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio)
|
||||
// 计算 OtherRatios 乘积(视频折扣、时长等)
|
||||
otherMultiplier := 1.0
|
||||
if bc := task.PrivateData.BillingContext; bc != nil {
|
||||
for _, r := range bc.OtherRatios {
|
||||
if r != 1.0 && r > 0 {
|
||||
otherMultiplier *= r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio)
|
||||
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier
|
||||
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
|
||||
|
||||
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
|
||||
RecalculateTaskQuota(ctx, task, actualQuota, reason)
|
||||
}
|
||||
|
||||
@@ -100,8 +100,6 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fileMeta.MimeType = format
|
||||
|
||||
if config.Width == 0 || config.Height == 0 {
|
||||
// not an image, but might be a valid file
|
||||
if format != "" {
|
||||
@@ -268,7 +266,6 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
|
||||
}
|
||||
continue
|
||||
}
|
||||
file.MimeType = cachedData.MimeType
|
||||
file.FileType = DetectFileType(cachedData.MimeType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ type ChannelAffinityRule struct {
|
||||
|
||||
ParamOverrideTemplate map[string]interface{} `json:"param_override_template,omitempty"`
|
||||
|
||||
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
|
||||
SkipRetryOnFailure bool `json:"skip_retry_on_failure"`
|
||||
|
||||
IncludeUsingGroup bool `json:"include_using_group"`
|
||||
IncludeModelName bool `json:"include_model_name"`
|
||||
IncludeRuleName bool `json:"include_rule_name"`
|
||||
}
|
||||
|
||||
|
||||
+128
-127
@@ -4,39 +4,144 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FileSourceType 文件来源类型
|
||||
type FileSourceType string
|
||||
|
||||
const (
|
||||
FileSourceTypeURL FileSourceType = "url" // URL 来源
|
||||
FileSourceTypeBase64 FileSourceType = "base64" // Base64 内联数据
|
||||
)
|
||||
|
||||
// FileSource 统一的文件来源抽象
|
||||
// FileSource 统一的文件来源抽象接口
|
||||
// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制
|
||||
type FileSource struct {
|
||||
Type FileSourceType `json:"type"` // 来源类型
|
||||
URL string `json:"url,omitempty"` // URL(当 Type 为 url 时)
|
||||
Base64Data string `json:"base64_data,omitempty"` // Base64 数据(当 Type 为 base64 时)
|
||||
MimeType string `json:"mime_type,omitempty"` // MIME 类型(可选,会自动检测)
|
||||
type FileSource interface {
|
||||
IsURL() bool
|
||||
GetIdentifier() string
|
||||
GetRawData() string
|
||||
ClearRawData()
|
||||
|
||||
// 内部缓存(不导出,不序列化)
|
||||
SetCache(data *CachedFileData)
|
||||
GetCache() *CachedFileData
|
||||
HasCache() bool
|
||||
ClearCache()
|
||||
|
||||
IsRegistered() bool
|
||||
SetRegistered(registered bool)
|
||||
Mu() *sync.Mutex
|
||||
}
|
||||
|
||||
// baseFileSource 共享的缓存/锁/清理注册状态
|
||||
type baseFileSource struct {
|
||||
cachedData *CachedFileData
|
||||
cacheLoaded bool
|
||||
registered bool // 是否已注册到清理列表
|
||||
mu sync.Mutex // 保护加载过程
|
||||
registered bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Mu 获取内部锁
|
||||
func (f *FileSource) Mu() *sync.Mutex {
|
||||
return &f.mu
|
||||
func (b *baseFileSource) SetCache(data *CachedFileData) {
|
||||
b.cachedData = data
|
||||
b.cacheLoaded = true
|
||||
}
|
||||
|
||||
// CachedFileData 缓存的文件数据
|
||||
// 支持内存缓存和磁盘缓存两种模式
|
||||
func (b *baseFileSource) GetCache() *CachedFileData {
|
||||
return b.cachedData
|
||||
}
|
||||
|
||||
func (b *baseFileSource) HasCache() bool {
|
||||
return b.cacheLoaded && b.cachedData != nil
|
||||
}
|
||||
|
||||
func (b *baseFileSource) ClearCache() {
|
||||
if b.cachedData != nil {
|
||||
b.cachedData.Close()
|
||||
}
|
||||
b.cachedData = nil
|
||||
b.cacheLoaded = false
|
||||
}
|
||||
|
||||
func (b *baseFileSource) IsRegistered() bool {
|
||||
return b.registered
|
||||
}
|
||||
|
||||
func (b *baseFileSource) SetRegistered(registered bool) {
|
||||
b.registered = registered
|
||||
}
|
||||
|
||||
func (b *baseFileSource) Mu() *sync.Mutex {
|
||||
return &b.mu
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URLSource — URL 来源的 FileSource 实现
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type URLSource struct {
|
||||
baseFileSource
|
||||
URL string
|
||||
}
|
||||
|
||||
func (u *URLSource) IsURL() bool { return true }
|
||||
|
||||
func (u *URLSource) GetIdentifier() string {
|
||||
if len(u.URL) > 100 {
|
||||
return u.URL[:100] + "..."
|
||||
}
|
||||
return u.URL
|
||||
}
|
||||
|
||||
func (u *URLSource) GetRawData() string { return u.URL }
|
||||
|
||||
func (u *URLSource) ClearRawData() {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64Source — Base64 内联数据来源的 FileSource 实现
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Base64Source struct {
|
||||
baseFileSource
|
||||
Base64Data string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
func (b *Base64Source) IsURL() bool { return false }
|
||||
|
||||
func (b *Base64Source) GetIdentifier() string {
|
||||
if len(b.Base64Data) > 50 {
|
||||
return "base64:" + b.Base64Data[:50] + "..."
|
||||
}
|
||||
return "base64:" + b.Base64Data
|
||||
}
|
||||
|
||||
func (b *Base64Source) GetRawData() string { return b.Base64Data }
|
||||
|
||||
func (b *Base64Source) ClearRawData() {
|
||||
if len(b.Base64Data) > 1024 {
|
||||
b.Base64Data = ""
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func NewURLFileSource(url string) *URLSource {
|
||||
return &URLSource{URL: url}
|
||||
}
|
||||
|
||||
func NewBase64FileSource(base64Data string, mimeType string) *Base64Source {
|
||||
return &Base64Source{
|
||||
Base64Data: base64Data,
|
||||
MimeType: mimeType,
|
||||
}
|
||||
}
|
||||
|
||||
func NewFileSourceFromData(data string, mimeType string) FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
return NewURLFileSource(data)
|
||||
}
|
||||
return NewBase64FileSource(data, mimeType)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CachedFileData — 缓存的文件数据(支持内存和磁盘两种模式)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CachedFileData struct {
|
||||
base64Data string // 内存中的 base64 数据(小文件)
|
||||
MimeType string // MIME 类型
|
||||
@@ -45,18 +150,15 @@ type CachedFileData struct {
|
||||
ImageConfig *image.Config // 图片配置(如果是图片)
|
||||
ImageFormat string // 图片格式(如果是图片)
|
||||
|
||||
// 磁盘缓存相关
|
||||
diskPath string // 磁盘缓存文件路径(大文件)
|
||||
isDisk bool // 是否使用磁盘缓存
|
||||
diskMu sync.Mutex // 磁盘操作锁(保护磁盘文件的读取和删除)
|
||||
diskClosed bool // 是否已关闭/清理
|
||||
statDecremented bool // 是否已扣减统计
|
||||
|
||||
// 统计回调,避免循环依赖
|
||||
OnClose func(size int64)
|
||||
}
|
||||
|
||||
// NewMemoryCachedData 创建内存缓存的数据
|
||||
func NewMemoryCachedData(base64Data string, mimeType string, size int64) *CachedFileData {
|
||||
return &CachedFileData{
|
||||
base64Data: base64Data,
|
||||
@@ -66,7 +168,6 @@ func NewMemoryCachedData(base64Data string, mimeType string, size int64) *Cached
|
||||
}
|
||||
}
|
||||
|
||||
// NewDiskCachedData 创建磁盘缓存的数据
|
||||
func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFileData {
|
||||
return &CachedFileData{
|
||||
diskPath: diskPath,
|
||||
@@ -76,7 +177,6 @@ func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFile
|
||||
}
|
||||
}
|
||||
|
||||
// GetBase64Data 获取 base64 数据(自动处理内存/磁盘)
|
||||
func (c *CachedFileData) GetBase64Data() (string, error) {
|
||||
if !c.isDisk {
|
||||
return c.base64Data, nil
|
||||
@@ -89,7 +189,6 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
|
||||
return "", fmt.Errorf("disk cache already closed")
|
||||
}
|
||||
|
||||
// 从磁盘读取
|
||||
data, err := os.ReadFile(c.diskPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read from disk cache: %w", err)
|
||||
@@ -97,22 +196,19 @@ func (c *CachedFileData) GetBase64Data() (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// SetBase64Data 设置 base64 数据(仅用于内存模式)
|
||||
func (c *CachedFileData) SetBase64Data(data string) {
|
||||
if !c.isDisk {
|
||||
c.base64Data = data
|
||||
}
|
||||
}
|
||||
|
||||
// IsDisk 是否使用磁盘缓存
|
||||
func (c *CachedFileData) IsDisk() bool {
|
||||
return c.isDisk
|
||||
}
|
||||
|
||||
// Close 关闭并清理资源
|
||||
func (c *CachedFileData) Close() error {
|
||||
if !c.isDisk {
|
||||
c.base64Data = "" // 释放内存
|
||||
c.base64Data = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -126,7 +222,6 @@ func (c *CachedFileData) Close() error {
|
||||
c.diskClosed = true
|
||||
if c.diskPath != "" {
|
||||
err := os.Remove(c.diskPath)
|
||||
// 只有在删除成功且未扣减过统计时,才执行回调
|
||||
if err == nil && !c.statDecremented && c.OnClose != nil {
|
||||
c.OnClose(c.DiskSize)
|
||||
c.statDecremented = true
|
||||
@@ -135,97 +230,3 @@ func (c *CachedFileData) Close() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewURLFileSource 创建 URL 来源的 FileSource
|
||||
func NewURLFileSource(url string) *FileSource {
|
||||
return &FileSource{
|
||||
Type: FileSourceTypeURL,
|
||||
URL: url,
|
||||
}
|
||||
}
|
||||
|
||||
// NewBase64FileSource 创建 base64 来源的 FileSource
|
||||
func NewBase64FileSource(base64Data string, mimeType string) *FileSource {
|
||||
return &FileSource{
|
||||
Type: FileSourceTypeBase64,
|
||||
Base64Data: base64Data,
|
||||
MimeType: mimeType,
|
||||
}
|
||||
}
|
||||
|
||||
// IsURL 判断是否是 URL 来源
|
||||
func (f *FileSource) IsURL() bool {
|
||||
return f.Type == FileSourceTypeURL
|
||||
}
|
||||
|
||||
// IsBase64 判断是否是 base64 来源
|
||||
func (f *FileSource) IsBase64() bool {
|
||||
return f.Type == FileSourceTypeBase64
|
||||
}
|
||||
|
||||
// GetIdentifier 获取文件标识符(用于日志和错误追踪)
|
||||
func (f *FileSource) GetIdentifier() string {
|
||||
if f.IsURL() {
|
||||
if len(f.URL) > 100 {
|
||||
return f.URL[:100] + "..."
|
||||
}
|
||||
return f.URL
|
||||
}
|
||||
if len(f.Base64Data) > 50 {
|
||||
return "base64:" + f.Base64Data[:50] + "..."
|
||||
}
|
||||
return "base64:" + f.Base64Data
|
||||
}
|
||||
|
||||
// GetRawData 获取原始数据(URL 或完整的 base64 字符串)
|
||||
func (f *FileSource) GetRawData() string {
|
||||
if f.IsURL() {
|
||||
return f.URL
|
||||
}
|
||||
return f.Base64Data
|
||||
}
|
||||
|
||||
// SetCache 设置缓存数据
|
||||
func (f *FileSource) SetCache(data *CachedFileData) {
|
||||
f.cachedData = data
|
||||
f.cacheLoaded = true
|
||||
}
|
||||
|
||||
// IsRegistered 是否已注册到清理列表
|
||||
func (f *FileSource) IsRegistered() bool {
|
||||
return f.registered
|
||||
}
|
||||
|
||||
// SetRegistered 设置注册状态
|
||||
func (f *FileSource) SetRegistered(registered bool) {
|
||||
f.registered = registered
|
||||
}
|
||||
|
||||
// GetCache 获取缓存数据
|
||||
func (f *FileSource) GetCache() *CachedFileData {
|
||||
return f.cachedData
|
||||
}
|
||||
|
||||
// HasCache 是否有缓存
|
||||
func (f *FileSource) HasCache() bool {
|
||||
return f.cacheLoaded && f.cachedData != nil
|
||||
}
|
||||
|
||||
// ClearCache 清除缓存,释放内存和磁盘文件
|
||||
func (f *FileSource) ClearCache() {
|
||||
// 如果有缓存数据,先关闭它(会清理磁盘文件)
|
||||
if f.cachedData != nil {
|
||||
f.cachedData.Close()
|
||||
}
|
||||
f.cachedData = nil
|
||||
f.cacheLoaded = false
|
||||
}
|
||||
|
||||
// ClearRawData 清除原始数据,只保留必要的元信息
|
||||
// 用于在处理完成后释放大文件的内存
|
||||
func (f *FileSource) ClearRawData() {
|
||||
// 保留 URL(通常很短),只清除大的 base64 数据
|
||||
if f.IsBase64() && len(f.Base64Data) > 1024 {
|
||||
f.Base64Data = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,12 @@ type TokenCountMeta struct {
|
||||
|
||||
type FileMeta struct {
|
||||
FileType
|
||||
MimeType string
|
||||
Source *FileSource // 统一的文件来源(URL 或 base64)
|
||||
Detail string // 图片细节级别(low/high/auto)
|
||||
Source FileSource // 统一的文件来源(URL 或 base64)
|
||||
Detail string // 图片细节级别(low/high/auto)
|
||||
}
|
||||
|
||||
// NewFileMeta 创建新的 FileMeta
|
||||
func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
|
||||
func NewFileMeta(fileType FileType, source FileSource) *FileMeta {
|
||||
return &FileMeta{
|
||||
FileType: fileType,
|
||||
Source: source,
|
||||
@@ -46,7 +45,7 @@ func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
|
||||
}
|
||||
|
||||
// NewImageFileMeta 创建图片类型的 FileMeta
|
||||
func NewImageFileMeta(source *FileSource, detail string) *FileMeta {
|
||||
func NewImageFileMeta(source FileSource, detail string) *FileMeta {
|
||||
return &FileMeta{
|
||||
FileType: FileTypeImage,
|
||||
Source: source,
|
||||
|
||||
@@ -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);
|
||||
@@ -29,6 +29,9 @@ const ChartsPanel = ({
|
||||
spec_model_line,
|
||||
spec_pie,
|
||||
spec_rank_bar,
|
||||
spec_user_rank,
|
||||
spec_user_trend,
|
||||
isAdminUser,
|
||||
CARD_PROPS,
|
||||
CHART_CONFIG,
|
||||
FLEX_CENTER_GAP2,
|
||||
@@ -51,9 +54,15 @@ const ChartsPanel = ({
|
||||
onChange={setActiveChartTab}
|
||||
>
|
||||
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
|
||||
<TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
|
||||
<TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
|
||||
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
|
||||
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
|
||||
{isAdminUser && (
|
||||
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
|
||||
)}
|
||||
{isAdminUser && (
|
||||
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
@@ -72,6 +81,12 @@ const ChartsPanel = ({
|
||||
{activeChartTab === '4' && (
|
||||
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '5' && isAdminUser && (
|
||||
<VChart spec={spec_user_rank} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '6' && isAdminUser && (
|
||||
<VChart spec={spec_user_trend} option={CHART_CONFIG} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -86,12 +86,22 @@ const Dashboard = () => {
|
||||
);
|
||||
|
||||
// ========== 数据处理 ==========
|
||||
const loadUserData = async () => {
|
||||
if (dashboardData.isAdminUser) {
|
||||
const userData = await dashboardData.loadUserQuotaData();
|
||||
if (userData && userData.length > 0) {
|
||||
dashboardCharts.updateUserChartData(userData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initChart = async () => {
|
||||
await dashboardData.loadQuotaData().then((data) => {
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
});
|
||||
await loadUserData();
|
||||
await dashboardData.loadUptimeData();
|
||||
};
|
||||
|
||||
@@ -100,10 +110,12 @@ const Dashboard = () => {
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
await loadUserData();
|
||||
};
|
||||
|
||||
const handleSearchConfirm = async () => {
|
||||
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
|
||||
await loadUserData();
|
||||
};
|
||||
|
||||
// ========== 数据准备 ==========
|
||||
@@ -182,6 +194,9 @@ const Dashboard = () => {
|
||||
spec_model_line={dashboardCharts.spec_model_line}
|
||||
spec_pie={dashboardCharts.spec_pie}
|
||||
spec_rank_bar={dashboardCharts.spec_rank_bar}
|
||||
spec_user_rank={dashboardCharts.spec_user_rank}
|
||||
spec_user_trend={dashboardCharts.spec_user_trend}
|
||||
isAdminUser={dashboardData.isAdminUser}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
CHART_CONFIG={CHART_CONFIG}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
|
||||
@@ -23,6 +23,7 @@ import SiderBar from './SiderBar';
|
||||
import App from '../../App';
|
||||
import FooterBar from './Footer';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import ErrorBoundary from '../common/ErrorBoundary';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
||||
@@ -216,7 +217,9 @@ const PageLayout = () => {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</Content>
|
||||
{!shouldHideFooter && (
|
||||
<Layout.Footer
|
||||
|
||||
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Slider,
|
||||
Typography,
|
||||
Button,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Hash,
|
||||
@@ -241,15 +248,14 @@ const ParameterControl = ({
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
<InputNumber
|
||||
placeholder='MaxTokens'
|
||||
name='max_tokens'
|
||||
required
|
||||
autoComplete='new-password'
|
||||
defaultValue={0}
|
||||
value={inputs.max_tokens}
|
||||
onChange={(value) => onInputChange('max_tokens', value)}
|
||||
className='!rounded-lg'
|
||||
onNumberChange={(value) => onInputChange('max_tokens', value)}
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!parameterEnabled.max_tokens || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,11 +65,15 @@ export const loadConfig = () => {
|
||||
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
|
||||
if (savedConfig) {
|
||||
const parsedConfig = JSON.parse(savedConfig);
|
||||
const parsedMaxTokens = parseInt(parsedConfig?.inputs?.max_tokens, 10);
|
||||
|
||||
const mergedConfig = {
|
||||
inputs: {
|
||||
...DEFAULT_CONFIG.inputs,
|
||||
...parsedConfig.inputs,
|
||||
max_tokens: Number.isNaN(parsedMaxTokens)
|
||||
? parsedConfig?.inputs?.max_tokens
|
||||
: parsedMaxTokens,
|
||||
},
|
||||
parameterEnabled: {
|
||||
...DEFAULT_CONFIG.parameterEnabled,
|
||||
|
||||
@@ -21,9 +21,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ModelPricingCombined from '../../pages/Setting/Ratio/ModelPricingCombined';
|
||||
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
|
||||
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings';
|
||||
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor';
|
||||
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
|
||||
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
|
||||
|
||||
@@ -95,18 +94,14 @@ const RatioSetting = () => {
|
||||
|
||||
return (
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 模型倍率设置以及价格编辑器 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs type='card' defaultActiveKey='visual'>
|
||||
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
|
||||
<ModelRatioSettings options={inputs} refresh={onRefresh} />
|
||||
<Tabs type='card' defaultActiveKey='pricing'>
|
||||
<Tabs.TabPane tab={t('模型定价设置')} itemKey='pricing'>
|
||||
<ModelPricingCombined options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
|
||||
<GroupRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
|
||||
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
|
||||
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
|
||||
@@ -88,7 +88,7 @@ const renderStatus = (text, record, t) => {
|
||||
};
|
||||
|
||||
// Render group column
|
||||
const renderGroupColumn = (text, record, t) => {
|
||||
const renderGroupColumn = (text, record, t, groupRatios = {}) => {
|
||||
if (text === 'auto') {
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -104,7 +104,17 @@ const renderGroupColumn = (text, record, t) => {
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return renderGroup(text);
|
||||
const ratio = groupRatios[text];
|
||||
return (
|
||||
<span className='flex items-center gap-1'>
|
||||
{renderGroup(text)}
|
||||
{ratio !== undefined && (
|
||||
<Tag size='small' color='green' shape='circle'>
|
||||
{ratio}x
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Render token key column with show/hide and copy functionality
|
||||
@@ -469,6 +479,7 @@ export const getTokensColumns = ({
|
||||
setEditingToken,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
groupRatios = {},
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -490,7 +501,7 @@ export const getTokensColumns = ({
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
key: 'group',
|
||||
render: (text, record) => renderGroupColumn(text, record, t),
|
||||
render: (text, record) => renderGroupColumn(text, record, t, groupRatios),
|
||||
},
|
||||
{
|
||||
title: t('密钥'),
|
||||
|
||||
@@ -49,6 +49,7 @@ const TokensTable = (tokensData) => {
|
||||
setEditingToken,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
groupRatios,
|
||||
t,
|
||||
} = tokensData;
|
||||
|
||||
@@ -67,6 +68,7 @@ const TokensTable = (tokensData) => {
|
||||
setEditingToken,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
groupRatios,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
@@ -81,6 +83,7 @@ const TokensTable = (tokensData) => {
|
||||
setEditingToken,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
groupRatios,
|
||||
]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
|
||||
@@ -366,6 +366,14 @@ const EditTokenModal = (props) => {
|
||||
placeholder={t('令牌分组,默认为用户的分组')}
|
||||
optionList={groups}
|
||||
renderOptionItem={renderGroupOption}
|
||||
filter={(input, option) => {
|
||||
const q = input.toLowerCase();
|
||||
return (
|
||||
option.value?.toLowerCase().includes(q) ||
|
||||
(typeof option.label === 'string' &&
|
||||
option.label.toLowerCase().includes(q))
|
||||
);
|
||||
}}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
renderModelPriceSimple,
|
||||
} from '../../../helpers';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { Route, Sparkles } from 'lucide-react';
|
||||
import { CircleAlert, Route, Sparkles } from 'lucide-react';
|
||||
|
||||
const colors = [
|
||||
'amber',
|
||||
@@ -141,12 +141,58 @@ function renderType(type, t) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderIsStream(bool, t) {
|
||||
function buildStreamStatusTooltip(ss, t) {
|
||||
if (!ss) return null;
|
||||
const lines = [
|
||||
t('流状态') + ':' + t('异常'),
|
||||
(ss.end_reason || 'unknown'),
|
||||
];
|
||||
if (ss.error_count > 0) {
|
||||
lines.push(`${t('软错误')}: ${ss.error_count}`);
|
||||
}
|
||||
if (ss.end_error) {
|
||||
lines.push(ss.end_error);
|
||||
}
|
||||
return (
|
||||
<div style={{ lineHeight: 1.6, display: 'flex', flexDirection: 'column' }}>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIsStream(bool, t, streamStatus) {
|
||||
const isError = streamStatus && streamStatus.status !== 'ok';
|
||||
|
||||
if (bool) {
|
||||
return (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('流')}
|
||||
</Tag>
|
||||
<span style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('流')}
|
||||
</Tag>
|
||||
{isError && (
|
||||
<Tooltip content={buildStreamStatusTooltip(streamStatus, t)}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -4,
|
||||
top: -4,
|
||||
lineHeight: 1,
|
||||
color: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<CircleAlert
|
||||
size={14}
|
||||
strokeWidth={2.5}
|
||||
color='currentColor'
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
@@ -694,7 +740,7 @@ export const getLogsColumns = ({
|
||||
<Space>
|
||||
{renderUseTime(text, t)}
|
||||
{renderFirstUseTime(other?.frt, t)}
|
||||
{renderIsStream(record.is_stream, t)}
|
||||
{renderIsStream(record.is_stream, t, other?.stream_status)}
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
|
||||
Vendored
+12
-1
@@ -150,7 +150,18 @@ export const buildApiPayload = (
|
||||
const value = inputs[param];
|
||||
const hasValue = value !== undefined && value !== null;
|
||||
|
||||
if (enabled && hasValue) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (param === 'max_tokens') {
|
||||
if (typeof value === 'number') {
|
||||
payload[param] = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasValue) {
|
||||
payload[param] = value;
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+55
@@ -387,3 +387,58 @@ export const generateChartTimePoints = (
|
||||
|
||||
return chartTimePoints;
|
||||
};
|
||||
|
||||
// ========== 用户维度数据处理 ==========
|
||||
export const processUserData = (data, dataExportDefaultTime, limit = 10) => {
|
||||
const userQuotaTotal = new Map();
|
||||
data.forEach((item) => {
|
||||
const prev = userQuotaTotal.get(item.username) || 0;
|
||||
userQuotaTotal.set(item.username, prev + item.quota);
|
||||
});
|
||||
|
||||
const sorted = Array.from(userQuotaTotal.entries()).sort(
|
||||
(a, b) => b[1] - a[1],
|
||||
);
|
||||
const topUsers = sorted.slice(0, limit).map(([u]) => u);
|
||||
const topUserSet = new Set(topUsers);
|
||||
|
||||
const rankingData = sorted.slice(0, limit).map(([username, quota]) => ({
|
||||
User: username,
|
||||
Quota: quota,
|
||||
}));
|
||||
|
||||
const showYear = isDataCrossYear(data.map((item) => item.created_at));
|
||||
|
||||
const timeUserMap = new Map();
|
||||
const allTimePoints = new Set();
|
||||
|
||||
data.forEach((item) => {
|
||||
const timeKey = timestamp2string1(
|
||||
item.created_at,
|
||||
dataExportDefaultTime,
|
||||
showYear,
|
||||
);
|
||||
allTimePoints.add(timeKey);
|
||||
const user = topUserSet.has(item.username) ? item.username : null;
|
||||
if (!user) return;
|
||||
const key = `${timeKey}-${user}`;
|
||||
const prev = timeUserMap.get(key) || { quota: 0 };
|
||||
timeUserMap.set(key, { quota: prev.quota + item.quota });
|
||||
});
|
||||
|
||||
const sortedTimePoints = Array.from(allTimePoints).sort();
|
||||
const trendData = [];
|
||||
sortedTimePoints.forEach((time) => {
|
||||
topUsers.forEach((user) => {
|
||||
const key = `${time}-${user}`;
|
||||
const val = timeUserMap.get(key);
|
||||
trendData.push({
|
||||
Time: time,
|
||||
User: user,
|
||||
Quota: val?.quota || 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { rankingData, trendData, topUsers };
|
||||
};
|
||||
|
||||
Vendored
+12
@@ -1620,6 +1620,18 @@ function renderPriceSimpleCore({
|
||||
return result;
|
||||
}
|
||||
|
||||
export function renderTaskBillingProcess(other, content) {
|
||||
if (other?.task_id != null) {
|
||||
return renderBillingArticle(
|
||||
[content].filter(Boolean),
|
||||
{ showReferenceNote: false },
|
||||
);
|
||||
}
|
||||
return renderBillingArticle([
|
||||
buildBillingText('任务预扣费(将在任务完成后按实际token重算)'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function renderModelPrice(
|
||||
inputTokens,
|
||||
completionTokens,
|
||||
|
||||
Vendored
+14
@@ -33,6 +33,20 @@ export async function fetchTokenKey(tokenId) {
|
||||
return data.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取多个令牌的真实 key
|
||||
* @param {number[]} tokenIds
|
||||
* @returns {Promise<Record<number, string>>} 返回 {id: key} map,key 不带 sk- 前缀
|
||||
*/
|
||||
export async function fetchTokenKeysBatch(tokenIds) {
|
||||
const response = await API.post('/api/token/batch/keys', { ids: tokenIds });
|
||||
const { success, data, message } = response.data || {};
|
||||
if (!success || !data?.keys) {
|
||||
throw new Error(message || 'Failed to fetch token keys');
|
||||
}
|
||||
return data.keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的 token keys
|
||||
* @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组
|
||||
|
||||
+125
-6
@@ -34,8 +34,14 @@ import {
|
||||
updateChartSpec,
|
||||
updateMapValue,
|
||||
initializeMaps,
|
||||
processUserData,
|
||||
} from '../../helpers/dashboard';
|
||||
|
||||
const USER_COLORS = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
||||
];
|
||||
|
||||
export const useDashboardCharts = (
|
||||
dataExportDefaultTime,
|
||||
setTrendData,
|
||||
@@ -179,7 +185,6 @@ export const useDashboardCharts = (
|
||||
},
|
||||
});
|
||||
|
||||
// 模型消耗趋势折线图
|
||||
const [spec_model_line, setSpecModelLine] = useState({
|
||||
type: 'line',
|
||||
data: [
|
||||
@@ -197,7 +202,7 @@ export const useDashboardCharts = (
|
||||
},
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('模型消耗趋势'),
|
||||
text: t('调用趋势'),
|
||||
subtext: '',
|
||||
},
|
||||
tooltip: {
|
||||
@@ -215,7 +220,6 @@ export const useDashboardCharts = (
|
||||
},
|
||||
});
|
||||
|
||||
// 模型调用次数排行柱状图
|
||||
const [spec_rank_bar, setSpecRankBar] = useState({
|
||||
type: 'bar',
|
||||
data: [
|
||||
@@ -259,6 +263,76 @@ 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: '',
|
||||
},
|
||||
area: { style: { fillOpacity: 0.15 } },
|
||||
line: { style: { lineWidth: 2 } },
|
||||
point: { visible: false },
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [{
|
||||
key: (datum) => datum['User'],
|
||||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||||
}],
|
||||
},
|
||||
},
|
||||
color: { type: 'ordinal', range: USER_COLORS },
|
||||
});
|
||||
|
||||
// ========== 数据处理函数 ==========
|
||||
const generateModelColors = useCallback((uniqueModels, modelColors) => {
|
||||
const newModelColors = {};
|
||||
@@ -426,6 +500,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) => a.rawQuota - b.rawQuota);
|
||||
|
||||
const totalUserQuota = rankingData.reduce((s, i) => s + i.Quota, 0);
|
||||
|
||||
setSpecUserRank((prev) => ({
|
||||
...prev,
|
||||
data: [{ id: 'userRankData', values: userRankValues }],
|
||||
title: {
|
||||
...prev.title,
|
||||
subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
|
||||
},
|
||||
}));
|
||||
|
||||
const userTrendValues = userTrend.map((item) => ({
|
||||
Time: item.Time,
|
||||
User: item.User,
|
||||
rawQuota: item.Quota,
|
||||
Usage: item.Quota ? getQuotaWithUnit(item.Quota, 4) : 0,
|
||||
}));
|
||||
|
||||
setSpecUserTrend((prev) => ({
|
||||
...prev,
|
||||
data: [{ id: 'userTrendData', values: userTrendValues }],
|
||||
title: {
|
||||
...prev.title,
|
||||
subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[dataExportDefaultTime, t],
|
||||
);
|
||||
|
||||
// ========== 初始化图表主题 ==========
|
||||
useEffect(() => {
|
||||
initVChartSemiTheme({
|
||||
@@ -434,14 +553,14 @@ export const useDashboardCharts = (
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 图表规格
|
||||
spec_pie,
|
||||
spec_line,
|
||||
spec_model_line,
|
||||
spec_rank_bar,
|
||||
|
||||
// 函数
|
||||
spec_user_rank,
|
||||
spec_user_trend,
|
||||
updateChartData,
|
||||
updateUserChartData,
|
||||
generateModelColors,
|
||||
};
|
||||
};
|
||||
|
||||
+22
@@ -213,6 +213,27 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
}
|
||||
}, [activeUptimeTab]);
|
||||
|
||||
const loadUserQuotaData = useCallback(async () => {
|
||||
if (!isAdminUser) return [];
|
||||
try {
|
||||
const { start_timestamp, end_timestamp } = inputs;
|
||||
const localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
const localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
const url = `/api/data/users?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
return data || [];
|
||||
} else {
|
||||
showError(message);
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return [];
|
||||
}
|
||||
}, [inputs, isAdminUser]);
|
||||
|
||||
const getUserData = useCallback(async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
@@ -311,6 +332,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
showSearchModal,
|
||||
handleCloseModal,
|
||||
loadQuotaData,
|
||||
loadUserQuotaData,
|
||||
loadUptimeData,
|
||||
getUserData,
|
||||
refresh,
|
||||
|
||||
+8
-1
@@ -167,7 +167,14 @@ export const usePlaygroundState = () => {
|
||||
// 配置导入/重置
|
||||
const handleConfigImport = useCallback((importedConfig) => {
|
||||
if (importedConfig.inputs) {
|
||||
setInputs((prev) => ({ ...prev, ...importedConfig.inputs }));
|
||||
const parsedMaxTokens = parseInt(importedConfig.inputs.max_tokens, 10);
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
...importedConfig.inputs,
|
||||
max_tokens: Number.isNaN(parsedMaxTokens)
|
||||
? importedConfig.inputs.max_tokens
|
||||
: parsedMaxTokens,
|
||||
}));
|
||||
}
|
||||
if (importedConfig.parameterEnabled) {
|
||||
setParameterEnabled((prev) => ({
|
||||
|
||||
+23
-6
@@ -31,6 +31,7 @@ import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import {
|
||||
fetchTokenKey as fetchTokenKeyById,
|
||||
fetchTokenKeysBatch,
|
||||
getServerAddress,
|
||||
encodeChannelConnectionString,
|
||||
} from '../../helpers/token';
|
||||
@@ -41,6 +42,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
// Basic state
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [groupRatios, setGroupRatios] = useState({});
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [tokenCount, setTokenCount] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
@@ -408,14 +410,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const keys = await Promise.all(
|
||||
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
|
||||
);
|
||||
const ids = selectedKeys.map((token) => token.id);
|
||||
const keysMap = await fetchTokenKeysBatch(ids);
|
||||
|
||||
setResolvedTokenKeys((prev) => ({ ...prev, ...keysMap }));
|
||||
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
const fullKey = keys[i];
|
||||
for (const token of selectedKeys) {
|
||||
const fullKey = keysMap[token.id];
|
||||
if (!fullKey) continue;
|
||||
if (copyType === 'name+key') {
|
||||
content += `${selectedKeys[i].name} sk-${fullKey}\n`;
|
||||
content += `${token.name} sk-${fullKey}\n`;
|
||||
} else {
|
||||
content += `sk-${fullKey}\n`;
|
||||
}
|
||||
@@ -433,6 +438,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
API.get('/api/user/self/groups')
|
||||
.then((res) => {
|
||||
if (res.data.success && res.data.data) {
|
||||
const ratios = {};
|
||||
for (const [name, info] of Object.entries(res.data.data)) {
|
||||
ratios[name] = info.ratio;
|
||||
}
|
||||
setGroupRatios(ratios);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [pageSize]);
|
||||
|
||||
return {
|
||||
@@ -443,6 +459,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
|
||||
tokenCount,
|
||||
pageSize,
|
||||
searching,
|
||||
groupRatios,
|
||||
|
||||
// Selection state
|
||||
selectedKeys,
|
||||
|
||||
+5
-1
@@ -36,6 +36,7 @@ import {
|
||||
renderAudioModelPrice,
|
||||
renderClaudeModelPrice,
|
||||
renderModelPrice,
|
||||
renderTaskBillingProcess,
|
||||
} from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
@@ -497,7 +498,10 @@ export const useLogsData = () => {
|
||||
|
||||
let content = '';
|
||||
if (!isViolationFeeLog) {
|
||||
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(
|
||||
other?.text_input,
|
||||
other?.text_output,
|
||||
|
||||
Vendored
+439
-203
File diff suppressed because it is too large
Load Diff
Vendored
+451
-198
File diff suppressed because it is too large
Load Diff
Vendored
+444
-191
File diff suppressed because it is too large
Load Diff
Vendored
+448
-195
File diff suppressed because it is too large
Load Diff
Vendored
+437
-186
File diff suppressed because it is too large
Load Diff
Vendored
+7
-1
@@ -2314,6 +2314,10 @@
|
||||
"调用次数": "调用次数",
|
||||
"调用次数分布": "调用次数分布",
|
||||
"调用次数排行": "调用次数排行",
|
||||
"调用趋势": "调用趋势",
|
||||
"模型排行": "模型排行",
|
||||
"用户消耗排行": "用户消耗排行",
|
||||
"用户消耗趋势": "用户消耗趋势",
|
||||
"调试信息": "调试信息",
|
||||
"谨慎": "谨慎",
|
||||
"警告": "警告",
|
||||
@@ -2980,6 +2984,8 @@
|
||||
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
|
||||
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
|
||||
"连接信息已填入": "连接信息已填入",
|
||||
"无法读取剪贴板": "无法读取剪贴板"
|
||||
"无法读取剪贴板": "无法读取剪贴板",
|
||||
"页面渲染出错,请刷新页面重试": "页面渲染出错,请刷新页面重试",
|
||||
"刷新页面": "刷新页面"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+659
-328
File diff suppressed because it is too large
Load Diff
@@ -103,6 +103,7 @@ const RULES_JSON_PLACEHOLDER = `[
|
||||
},
|
||||
"skip_retry_on_failure": false,
|
||||
"include_using_group": true,
|
||||
"include_model_name": false,
|
||||
"include_rule_name": true
|
||||
}
|
||||
]`;
|
||||
@@ -191,6 +192,36 @@ const parseOptionalObjectJson = (jsonString, label) => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildChannelAffinityRulePayload = ({
|
||||
values,
|
||||
isEdit,
|
||||
editingRuleId,
|
||||
rulesLength,
|
||||
modelRegex,
|
||||
pathRegex,
|
||||
keySources,
|
||||
userAgentInclude,
|
||||
paramOverrideTemplate,
|
||||
}) => ({
|
||||
id: isEdit ? editingRuleId : rulesLength,
|
||||
name: (values?.name || '').trim(),
|
||||
model_regex: modelRegex,
|
||||
path_regex: pathRegex,
|
||||
key_sources: keySources,
|
||||
value_regex: (values?.value_regex || '').trim(),
|
||||
ttl_seconds: Number(values?.ttl_seconds || 0),
|
||||
include_using_group: !!values?.include_using_group,
|
||||
include_model_name: !!values?.include_model_name,
|
||||
include_rule_name: !!values?.include_rule_name,
|
||||
skip_retry_on_failure: !!values?.skip_retry_on_failure,
|
||||
...(userAgentInclude.length > 0
|
||||
? { user_agent_include: userAgentInclude }
|
||||
: {}),
|
||||
...(paramOverrideTemplate
|
||||
? { param_override_template: paramOverrideTemplate }
|
||||
: {}),
|
||||
});
|
||||
|
||||
export default function SettingsChannelAffinity(props) {
|
||||
const { t } = useTranslation();
|
||||
const { Text } = Typography;
|
||||
@@ -246,6 +277,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
ttl_seconds: Number(r.ttl_seconds || 0),
|
||||
skip_retry_on_failure: !!r.skip_retry_on_failure,
|
||||
include_using_group: r.include_using_group ?? true,
|
||||
include_model_name: !!r.include_model_name,
|
||||
include_rule_name: r.include_rule_name ?? true,
|
||||
param_override_template_json: r.param_override_template
|
||||
? stringifyPretty(r.param_override_template)
|
||||
@@ -454,14 +486,12 @@ export default function SettingsChannelAffinity(props) {
|
||||
const templates = [
|
||||
CHANNEL_AFFINITY_RULE_TEMPLATES.codexCli,
|
||||
CHANNEL_AFFINITY_RULE_TEMPLATES.claudeCli,
|
||||
].map(
|
||||
(tpl) => {
|
||||
const baseTemplate = cloneChannelAffinityTemplate(tpl);
|
||||
const name = makeUniqueName(existingNames, tpl.name);
|
||||
existingNames.add(name);
|
||||
return { ...baseTemplate, name };
|
||||
},
|
||||
);
|
||||
].map((tpl) => {
|
||||
const baseTemplate = cloneChannelAffinityTemplate(tpl);
|
||||
const name = makeUniqueName(existingNames, tpl.name);
|
||||
existingNames.add(name);
|
||||
return { ...baseTemplate, name };
|
||||
});
|
||||
|
||||
const next = [...(rules || []), ...templates].map((r, idx) => ({
|
||||
...(r || {}),
|
||||
@@ -581,8 +611,9 @@ export default function SettingsChannelAffinity(props) {
|
||||
title: t('作用域'),
|
||||
render: (_, record) => {
|
||||
const tags = [];
|
||||
if (record?.include_using_group) tags.push('分组');
|
||||
if (record?.include_rule_name) tags.push('规则');
|
||||
if (record?.include_using_group) tags.push(t('分组'));
|
||||
if (record?.include_model_name) tags.push(t('模型'));
|
||||
if (record?.include_rule_name) tags.push(t('规则'));
|
||||
if (tags.length === 0) return '-';
|
||||
return tags.map((x) => (
|
||||
<Tag key={x} style={{ marginRight: 4 }}>
|
||||
@@ -650,6 +681,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
ttl_seconds: 0,
|
||||
skip_retry_on_failure: false,
|
||||
include_using_group: true,
|
||||
include_model_name: false,
|
||||
include_rule_name: true,
|
||||
};
|
||||
setEditingRule(nextRule);
|
||||
@@ -712,26 +744,17 @@ export default function SettingsChannelAffinity(props) {
|
||||
return showError(t(paramTemplateValidation.message));
|
||||
}
|
||||
|
||||
const rulePayload = {
|
||||
id: isEdit ? editingRule.id : rules.length,
|
||||
name: (values.name || '').trim(),
|
||||
model_regex: modelRegex,
|
||||
path_regex: normalizeStringList(values.path_regex_text),
|
||||
key_sources: keySourcesValidation.value,
|
||||
value_regex: (values.value_regex || '').trim(),
|
||||
ttl_seconds: Number(values.ttl_seconds || 0),
|
||||
include_using_group: !!values.include_using_group,
|
||||
include_rule_name: !!values.include_rule_name,
|
||||
...(values.skip_retry_on_failure
|
||||
? { skip_retry_on_failure: true }
|
||||
: {}),
|
||||
...(userAgentInclude.length > 0
|
||||
? { user_agent_include: userAgentInclude }
|
||||
: {}),
|
||||
...(paramTemplateValidation.value
|
||||
? { param_override_template: paramTemplateValidation.value }
|
||||
: {}),
|
||||
};
|
||||
const rulePayload = buildChannelAffinityRulePayload({
|
||||
values,
|
||||
isEdit,
|
||||
editingRuleId: editingRule?.id,
|
||||
rulesLength: rules.length,
|
||||
modelRegex,
|
||||
pathRegex: normalizeStringList(values.path_regex_text),
|
||||
keySources: keySourcesValidation.value,
|
||||
userAgentInclude,
|
||||
paramOverrideTemplate: paramTemplateValidation.value,
|
||||
});
|
||||
|
||||
if (!rulePayload.name) return showError(t('名称不能为空'));
|
||||
|
||||
@@ -1251,7 +1274,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Switch
|
||||
field='include_using_group'
|
||||
label={t('作用域:包含分组')}
|
||||
@@ -1262,7 +1285,16 @@ export default function SettingsChannelAffinity(props) {
|
||||
)}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Switch
|
||||
field='include_model_name'
|
||||
label={t('作用域:包含模型名称')}
|
||||
/>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('开启后,模型名称会参与 cache key(不同模型隔离)。')}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Switch
|
||||
field='include_rule_name'
|
||||
label={t('作用域:包含规则名称')}
|
||||
|
||||
@@ -17,8 +17,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Collapsible,
|
||||
Form,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Row,
|
||||
SideSheet,
|
||||
Spin,
|
||||
Switch,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
@@ -28,10 +43,37 @@ import {
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import GroupTable from './components/GroupTable';
|
||||
import AutoGroupList from './components/AutoGroupList';
|
||||
import GroupGroupRatioRules from './components/GroupGroupRatioRules';
|
||||
import GroupSpecialUsableRules from './components/GroupSpecialUsableRules';
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
const OPTION_KEYS = [
|
||||
'GroupRatio',
|
||||
'UserUsableGroups',
|
||||
'GroupGroupRatio',
|
||||
'group_ratio_setting.group_special_usable_group',
|
||||
'AutoGroups',
|
||||
'DefaultUseAutoGroup',
|
||||
];
|
||||
|
||||
function parseJSONSafe(str, fallback) {
|
||||
if (!str || !str.trim()) return fallback;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default function GroupRatioSettings(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editMode, setEditMode] = useState('visual');
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
|
||||
const [inputs, setInputs] = useState({
|
||||
GroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
@@ -42,80 +84,189 @@ export default function GroupRatioSettings(props) {
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const dataVersionRef = useRef(0);
|
||||
|
||||
const groupNames = useMemo(() => {
|
||||
const ratioMap = parseJSONSafe(inputs.GroupRatio, {});
|
||||
return Object.keys(ratioMap);
|
||||
}, [inputs.GroupRatio]);
|
||||
|
||||
async function onSubmit() {
|
||||
if (editMode === 'manual') {
|
||||
try {
|
||||
await refForm.current.validate();
|
||||
} 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 {
|
||||
await refForm.current
|
||||
.validate()
|
||||
.then(() => {
|
||||
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);
|
||||
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('请检查输入'));
|
||||
});
|
||||
const res = await Promise.all(requestQueue);
|
||||
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) {
|
||||
showError(t('请检查输入'));
|
||||
console.error(error);
|
||||
console.error('Unexpected error:', error);
|
||||
showError(t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
if (OPTION_KEYS.includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
dataVersionRef.current += 1;
|
||||
if (refForm.current) {
|
||||
refForm.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
const handleGroupTableChange = useCallback(
|
||||
({ GroupRatio, UserUsableGroups }) => {
|
||||
setInputs((prev) => ({ ...prev, GroupRatio, UserUsableGroups }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAutoGroupsChange = useCallback((value) => {
|
||||
setInputs((prev) => ({ ...prev, AutoGroups: value }));
|
||||
}, []);
|
||||
|
||||
const handleGroupGroupRatioChange = useCallback((value) => {
|
||||
setInputs((prev) => ({ ...prev, GroupGroupRatio: value }));
|
||||
}, []);
|
||||
|
||||
const handleSpecialUsableChange = useCallback((value) => {
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'group_ratio_setting.group_special_usable_group': value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const dv = dataVersionRef.current;
|
||||
|
||||
const renderVisualMode = () => (
|
||||
<Form key='form-visual' values={inputs} style={{ marginBottom: 15 }}>
|
||||
<Form.Section text={t('分组管理')}>
|
||||
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
|
||||
{t('倍率用于计费乘数,勾选「用户可选」后用户可在创建令牌时选择该分组')}
|
||||
</Text>
|
||||
<GroupTable
|
||||
key={`gt_${dv}`}
|
||||
groupRatio={inputs.GroupRatio}
|
||||
userUsableGroups={inputs.UserUsableGroups}
|
||||
onChange={handleGroupTableChange}
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section text={t('自动分组')}>
|
||||
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
|
||||
{t('令牌分组设为 auto 时,按以下顺序依次尝试选择可用分组,排在前面的优先级更高')}
|
||||
</Text>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Slot label={t('默认使用auto分组')}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={!!inputs.DefaultUseAutoGroup}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
DefaultUseAutoGroup: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Text type='tertiary' size='small' style={{ marginTop: 4 }}>
|
||||
{t('开启后创建令牌默认选择auto分组,初始令牌也将设为auto')}
|
||||
</Text>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
</Row>
|
||||
<AutoGroupList
|
||||
key={`ag_${dv}`}
|
||||
value={inputs.AutoGroups}
|
||||
groupNames={groupNames}
|
||||
onChange={handleAutoGroupsChange}
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section text={t('分组特殊倍率')}>
|
||||
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
|
||||
{t('当某个分组的用户使用另一个分组的令牌时,可设置特殊倍率覆盖基础倍率。例如:vip 分组的用户使用 default 分组时倍率为 0.5')}
|
||||
</Text>
|
||||
<GroupGroupRatioRules
|
||||
key={`ggr_${dv}`}
|
||||
value={inputs.GroupGroupRatio}
|
||||
groupNames={groupNames}
|
||||
onChange={handleGroupGroupRatioChange}
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
<Form.Section text={t('分组特殊可用分组')}>
|
||||
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
|
||||
{t('为特定用户分组配置可用分组的增减规则。「添加」为该分组新增可用分组,「移除」移除默认可用分组,「追加」直接追加分组')}
|
||||
</Text>
|
||||
<GroupSpecialUsableRules
|
||||
key={`gsu_${dv}`}
|
||||
value={inputs['group_ratio_setting.group_special_usable_group']}
|
||||
groupNames={groupNames}
|
||||
onChange={handleSpecialUsableChange}
|
||||
/>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode === 'manual' && refForm.current) {
|
||||
refForm.current.setValues(inputs);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const renderManualMode = () => (
|
||||
<Form
|
||||
key='form-manual'
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('分组JSON设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
@@ -134,7 +285,9 @@ export default function GroupRatioSettings(props) {
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) => setInputs({ ...inputs, GroupRatio: value })}
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({ ...prev, GroupRatio: value }))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -142,7 +295,9 @@ export default function GroupRatioSettings(props) {
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('用户可选分组')}
|
||||
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为分组名称,值为分组描述',
|
||||
)}
|
||||
extraText={t(
|
||||
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
|
||||
)}
|
||||
@@ -157,7 +312,7 @@ export default function GroupRatioSettings(props) {
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, UserUsableGroups: value })
|
||||
setInputs((prev) => ({ ...prev, UserUsableGroups: value }))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
@@ -181,7 +336,7 @@ export default function GroupRatioSettings(props) {
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, GroupGroupRatio: value })
|
||||
setInputs((prev) => ({ ...prev, GroupGroupRatio: value }))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
@@ -205,10 +360,10 @@ export default function GroupRatioSettings(props) {
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'group_ratio_setting.group_special_usable_group': value,
|
||||
})
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
@@ -225,29 +380,23 @@ export default function GroupRatioSettings(props) {
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return true; // Allow empty values
|
||||
}
|
||||
|
||||
// First check if it's valid JSON
|
||||
if (!value || value.trim() === '') return true;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
|
||||
// Check if it's an array
|
||||
if (!Array.isArray(parsed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if every element is a string
|
||||
if (!Array.isArray(parsed)) return false;
|
||||
return parsed.every((item) => typeof item === 'string');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
|
||||
message: t(
|
||||
'必须是有效的 JSON 字符串数组,例如:["g1","g2"]',
|
||||
),
|
||||
},
|
||||
]}
|
||||
onChange={(value) => setInputs({ ...inputs, AutoGroups: value })}
|
||||
onChange={(value) =>
|
||||
setInputs((prev) => ({ ...prev, AutoGroups: value }))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -259,13 +408,351 @@ export default function GroupRatioSettings(props) {
|
||||
)}
|
||||
field={'DefaultUseAutoGroup'}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, DefaultUseAutoGroup: value })
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
DefaultUseAutoGroup: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
<Button onClick={onSubmit}>{t('保存分组相关设置')}</Button>
|
||||
</Form.Section>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Radio, RadioGroup } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ModelPricingEditor from './components/ModelPricingEditor';
|
||||
import ModelRatioSettings from './ModelRatioSettings';
|
||||
|
||||
export default function ModelPricingCombined({ options, refresh }) {
|
||||
const { t } = useTranslation();
|
||||
const [editMode, setEditMode] = useState('visual');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginTop: 12, marginBottom: 16 }}>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
size='small'
|
||||
value={editMode}
|
||||
onChange={(e) => setEditMode(e.target.value)}
|
||||
>
|
||||
<Radio value='visual'>{t('可视化编辑')}</Radio>
|
||||
<Radio value='manual'>{t('手动编辑')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{editMode === 'visual' ? (
|
||||
<ModelPricingEditor options={options} refresh={refresh} />
|
||||
) : (
|
||||
<ModelRatioSettings options={options} refresh={refresh} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconChevronUp,
|
||||
IconChevronDown,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
let _idCounter = 0;
|
||||
const uid = () => `ag_${++_idCounter}`;
|
||||
|
||||
function parseAutoGroups(str) {
|
||||
if (!str || !str.trim()) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(str);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter((item) => typeof item === 'string')
|
||||
.map((name) => ({ _id: uid(), name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function serializeAutoGroups(items) {
|
||||
const names = items.map((i) => i.name).filter(Boolean);
|
||||
return names.length === 0 ? '' : JSON.stringify(names);
|
||||
}
|
||||
|
||||
export default function AutoGroupList({ value, groupNames = [], onChange }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [items, setItems] = useState(() => parseAutoGroups(value));
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newItems) => {
|
||||
setItems(newItems);
|
||||
onChange?.(serializeAutoGroups(newItems));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() => groupNames.map((n) => ({ value: n, label: n })),
|
||||
[groupNames],
|
||||
);
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
emitChange([...items, { _id: uid(), name: '' }]);
|
||||
}, [items, emitChange]);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(id) => {
|
||||
emitChange(items.filter((i) => i._id !== id));
|
||||
},
|
||||
[items, emitChange],
|
||||
);
|
||||
|
||||
const updateItem = useCallback(
|
||||
(id, name) => {
|
||||
emitChange(items.map((i) => (i._id === id ? { ...i, name } : i)));
|
||||
},
|
||||
[items, emitChange],
|
||||
);
|
||||
|
||||
const moveUp = useCallback(
|
||||
(index) => {
|
||||
if (index <= 0) return;
|
||||
const next = [...items];
|
||||
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
||||
emitChange(next);
|
||||
},
|
||||
[items, emitChange],
|
||||
);
|
||||
|
||||
const moveDown = useCallback(
|
||||
(index) => {
|
||||
if (index >= items.length - 1) return;
|
||||
const next = [...items];
|
||||
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
||||
emitChange(next);
|
||||
},
|
||||
[items, emitChange],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<Text type='tertiary' className='block text-center py-4'>
|
||||
{t('暂无自动分组,点击下方按钮添加')}
|
||||
</Text>
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addItem}>
|
||||
{t('添加分组')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='space-y-2'>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item._id}
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<Tag size='small' color='blue' className='shrink-0'>
|
||||
{index + 1}
|
||||
</Tag>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={item.name || undefined}
|
||||
placeholder={t('选择分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => updateItem(item._id, v)}
|
||||
style={{ flex: 1 }}
|
||||
allowCreate
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button
|
||||
icon={<IconChevronUp />}
|
||||
theme='borderless'
|
||||
size='small'
|
||||
disabled={index === 0}
|
||||
onClick={() => moveUp(index)}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconChevronDown />}
|
||||
theme='borderless'
|
||||
size='small'
|
||||
disabled={index === items.length - 1}
|
||||
onClick={() => moveDown(index)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认移除?')}
|
||||
onConfirm={() => removeItem(item._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addItem}>
|
||||
{t('添加分组')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
InputNumber,
|
||||
Select,
|
||||
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 = () => `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);
|
||||
}
|
||||
|
||||
export default function GroupGroupRatioRules({
|
||||
value,
|
||||
groupNames = [],
|
||||
onChange,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newRules) => {
|
||||
setRules(newRules);
|
||||
onChange?.(serializeGroupGroupRatio(newRules));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id, field, val) => {
|
||||
const next = rules.map((r) =>
|
||||
r._id === id ? { ...r, [field]: val } : r,
|
||||
);
|
||||
emitChange(next);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addRule = useCallback(() => {
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: '', usingGroup: '', ratio: 1 },
|
||||
]);
|
||||
}, [rules, emitChange]);
|
||||
|
||||
const removeRule = useCallback(
|
||||
(id) => {
|
||||
emitChange(rules.filter((r) => r._id !== id));
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() => groupNames.map((n) => ({ value: n, label: n })),
|
||||
[groupNames],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('用户分组'),
|
||||
dataIndex: 'userGroup',
|
||||
key: 'userGroup',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={record.userGroup || undefined}
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => updateRule(record._id, 'userGroup', v)}
|
||||
style={{ width: '100%' }}
|
||||
allowCreate
|
||||
position='bottomLeft'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('使用分组'),
|
||||
dataIndex: 'usingGroup',
|
||||
key: 'usingGroup',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={record.usingGroup || undefined}
|
||||
placeholder={t('选择使用分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => updateRule(record._id, 'usingGroup', v)}
|
||||
style={{ width: '100%' }}
|
||||
allowCreate
|
||||
position='bottomLeft'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('倍率'),
|
||||
dataIndex: 'ratio',
|
||||
key: 'ratio',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<InputNumber
|
||||
size='small'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={record.ratio}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateRule(record._id, 'ratio', v ?? 0)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 50,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title={t('确认删除该规则?')}
|
||||
onConfirm={() => removeRule(record._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, groupOptions, updateRule, removeRule],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={rules}
|
||||
rowKey='_id'
|
||||
hidePagination
|
||||
size='small'
|
||||
empty={
|
||||
<Text type='tertiary'>
|
||||
{t('暂无规则,点击下方按钮添加')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addRule}>
|
||||
{t('添加规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
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 = () => `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] = {};
|
||||
const key = toRawKey(op, targetGroup);
|
||||
result[userGroup][key] = 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: '追加' },
|
||||
};
|
||||
|
||||
export default function GroupSpecialUsableRules({
|
||||
value,
|
||||
groupNames = [],
|
||||
onChange,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newRules) => {
|
||||
setRules(newRules);
|
||||
onChange?.(serializeGroupSpecialUsable(newRules));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id, field, val) => {
|
||||
const next = 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;
|
||||
});
|
||||
emitChange(next);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addRule = useCallback(() => {
|
||||
emitChange([
|
||||
...rules,
|
||||
{
|
||||
_id: uid(),
|
||||
userGroup: '',
|
||||
op: OP_APPEND,
|
||||
targetGroup: '',
|
||||
description: '',
|
||||
},
|
||||
]);
|
||||
}, [rules, emitChange]);
|
||||
|
||||
const removeRule = useCallback(
|
||||
(id) => {
|
||||
emitChange(rules.filter((r) => r._id !== id));
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
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 columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('用户分组'),
|
||||
dataIndex: 'userGroup',
|
||||
key: 'userGroup',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={record.userGroup || undefined}
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => updateRule(record._id, 'userGroup', v)}
|
||||
style={{ width: '100%' }}
|
||||
allowCreate
|
||||
position='bottomLeft'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
dataIndex: 'op',
|
||||
key: 'op',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<Select
|
||||
size='small'
|
||||
value={record.op}
|
||||
optionList={opOptions}
|
||||
onChange={(v) => updateRule(record._id, 'op', v)}
|
||||
style={{ width: '100%' }}
|
||||
renderSelectedItem={(optionNode) => {
|
||||
const tagInfo = OP_TAG_MAP[optionNode.value] || {};
|
||||
return (
|
||||
<Tag size='small' color={tagInfo.color}>
|
||||
{optionNode.label}
|
||||
</Tag>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('目标分组'),
|
||||
dataIndex: 'targetGroup',
|
||||
key: 'targetGroup',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Input
|
||||
size='small'
|
||||
value={record.targetGroup}
|
||||
placeholder={t('分组名称')}
|
||||
onChange={(v) => updateRule(record._id, 'targetGroup', v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
render: (_, record) =>
|
||||
record.op === OP_REMOVE ? (
|
||||
<Text type='tertiary' size='small'>-</Text>
|
||||
) : (
|
||||
<Input
|
||||
size='small'
|
||||
value={record.description}
|
||||
placeholder={t('分组描述')}
|
||||
onChange={(v) => updateRule(record._id, 'description', v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 50,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title={t('确认删除该规则?')}
|
||||
onConfirm={() => removeRule(record._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, groupOptions, opOptions, updateRule, removeRule],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={rules}
|
||||
rowKey='_id'
|
||||
hidePagination
|
||||
size='small'
|
||||
empty={
|
||||
<Text type='tertiary'>
|
||||
{t('暂无规则,点击下方按钮添加')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addRule}>
|
||||
{t('添加规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CardTable from '../../../../components/common/ui/CardTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
let _idCounter = 0;
|
||||
const uid = () => `gr_${++_idCounter}`;
|
||||
|
||||
function parseJSON(str, fallback) {
|
||||
if (!str || !str.trim()) return fallback;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRows(groupRatioStr, userUsableGroupsStr) {
|
||||
const ratioMap = parseJSON(groupRatioStr, {});
|
||||
const usableMap = parseJSON(userUsableGroupsStr, {});
|
||||
|
||||
const allNames = new Set([
|
||||
...Object.keys(ratioMap),
|
||||
...Object.keys(usableMap),
|
||||
]);
|
||||
|
||||
return Array.from(allNames).map((name) => ({
|
||||
_id: uid(),
|
||||
name,
|
||||
ratio: ratioMap[name] ?? 1,
|
||||
selectable: name in usableMap,
|
||||
description: usableMap[name] ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function serializeGroupTable(rows) {
|
||||
const groupRatio = {};
|
||||
const userUsableGroups = {};
|
||||
|
||||
rows.forEach((row) => {
|
||||
if (!row.name) return;
|
||||
groupRatio[row.name] = row.ratio;
|
||||
if (row.selectable) {
|
||||
userUsableGroups[row.name] = row.description;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
GroupRatio: JSON.stringify(groupRatio, null, 2),
|
||||
UserUsableGroups: JSON.stringify(userUsableGroups, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
export default function GroupTable({
|
||||
groupRatio,
|
||||
userUsableGroups,
|
||||
onChange,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [rows, setRows] = useState(() =>
|
||||
buildRows(groupRatio, userUsableGroups),
|
||||
);
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newRows) => {
|
||||
setRows(newRows);
|
||||
onChange?.(serializeGroupTable(newRows));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateRow = useCallback(
|
||||
(id, field, value) => {
|
||||
const next = rows.map((r) =>
|
||||
r._id === id ? { ...r, [field]: value } : r,
|
||||
);
|
||||
emitChange(next);
|
||||
},
|
||||
[rows, emitChange],
|
||||
);
|
||||
|
||||
const addRow = useCallback(() => {
|
||||
const existingNames = new Set(rows.map((r) => r.name));
|
||||
let counter = 1;
|
||||
let newName = `group_${counter}`;
|
||||
while (existingNames.has(newName)) {
|
||||
counter++;
|
||||
newName = `group_${counter}`;
|
||||
}
|
||||
emitChange([
|
||||
...rows,
|
||||
{
|
||||
_id: uid(),
|
||||
name: newName,
|
||||
ratio: 1,
|
||||
selectable: true,
|
||||
description: '',
|
||||
},
|
||||
]);
|
||||
}, [rows, emitChange]);
|
||||
|
||||
const removeRow = useCallback(
|
||||
(id) => {
|
||||
emitChange(rows.filter((r) => r._id !== id));
|
||||
},
|
||||
[rows, emitChange],
|
||||
);
|
||||
|
||||
const groupNames = useMemo(() => rows.map((r) => r.name), [rows]);
|
||||
|
||||
const duplicateNames = useMemo(() => {
|
||||
const counts = {};
|
||||
groupNames.forEach((n) => {
|
||||
counts[n] = (counts[n] || 0) + 1;
|
||||
});
|
||||
return new Set(Object.keys(counts).filter((k) => counts[k] > 1));
|
||||
}, [groupNames]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('分组名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Input
|
||||
size='small'
|
||||
value={record.name}
|
||||
status={duplicateNames.has(record.name) ? 'warning' : undefined}
|
||||
onChange={(v) => updateRow(record._id, 'name', v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('倍率'),
|
||||
dataIndex: 'ratio',
|
||||
key: 'ratio',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<InputNumber
|
||||
size='small'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={record.ratio}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateRow(record._id, 'ratio', v ?? 0)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('用户可选'),
|
||||
dataIndex: 'selectable',
|
||||
key: 'selectable',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Checkbox
|
||||
checked={record.selectable}
|
||||
onChange={(e) =>
|
||||
updateRow(record._id, 'selectable', e.target.checked)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
render: (_, record) =>
|
||||
record.selectable ? (
|
||||
<Input
|
||||
size='small'
|
||||
value={record.description}
|
||||
placeholder={t('分组描述')}
|
||||
onChange={(v) => updateRow(record._id, 'description', v)}
|
||||
/>
|
||||
) : (
|
||||
<Text type='tertiary' size='small'>
|
||||
-
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 50,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title={t('确认删除该分组?')}
|
||||
onConfirm={() => removeRow(record._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, duplicateNames, updateRow, removeRow],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey='_id'
|
||||
hidePagination
|
||||
size='small'
|
||||
empty={
|
||||
<Text type='tertiary'>{t('暂无分组,点击下方按钮添加')}</Text>
|
||||
}
|
||||
/>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addRow}>
|
||||
{t('添加分组')}
|
||||
</Button>
|
||||
</div>
|
||||
{duplicateNames.size > 0 && (
|
||||
<Text type='warning' size='small' className='mt-2 block'>
|
||||
{t('存在重复的分组名称:')}{Array.from(duplicateNames).join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user