Parcourir la source

Merge branch 'Calcium-Ion:main' into feat/modeledit

TAKO il y a 1 an
Parent
commit
2eddb93432
67 fichiers modifiés avec 4862 ajouts et 1938 suppressions
  1. 3 0
      BT.md
  2. 30 22
      README.md
  3. 3 1
      common/constants.go
  4. 15 7
      common/email.go
  5. 1 0
      common/model-ratio.go
  6. 2 2
      constant/env.go
  7. 6 0
      constant/finish_reason.go
  8. 155 18
      controller/channel.go
  9. 3 4
      controller/topup.go
  10. 21 6
      docker-compose.yml
  11. 16 18
      go.mod
  12. 35 29
      go.sum
  13. 1 1
      main.go
  14. 1 0
      middleware/auth.go
  15. 38 0
      middleware/gzip.go
  16. 26 6
      model/ability.go
  17. 160 3
      model/channel.go
  18. 6 5
      model/usedata.go
  19. 1 0
      relay/channel/ai360/constants.go
  20. 1 0
      relay/channel/aws/constants.go
  21. 2 2
      relay/channel/baidu/relay-baidu.go
  22. 71 0
      relay/channel/deepseek/adaptor.go
  23. 7 0
      relay/channel/deepseek/constants.go
  24. 4 3
      relay/channel/gemini/constant.go
  25. 1 0
      relay/channel/gemini/dto.go
  26. 20 7
      relay/channel/gemini/relay-gemini.go
  27. 22 5
      relay/channel/openai/adaptor.go
  28. 5 6
      relay/channel/openai/relay-openai.go
  29. 2 2
      relay/channel/palm/relay-palm.go
  30. 2 2
      relay/channel/tencent/relay-tencent.go
  31. 14 1
      relay/channel/vertex/adaptor.go
  32. 5 5
      relay/channel/vertex/constants.go
  33. 4 4
      relay/channel/xunfei/relay-xunfei.go
  34. 2 2
      relay/channel/zhipu/relay-zhipu.go
  35. 3 0
      relay/common/relay_info.go
  36. 0 2
      relay/common/relay_utils.go
  37. 3 0
      relay/constant/api_type.go
  38. 8 1
      relay/relay-audio.go
  39. 10 11
      relay/relay-text.go
  40. 3 0
      relay/relay_adaptor.go
  41. 3 0
      router/api-router.go
  42. 1 0
      router/relay-router.go
  43. 2 2
      service/quota.go
  44. 1 1
      web/package.json
  45. 1967 735
      web/pnpm-lock.yaml
  46. 1 1
      web/src/App.js
  47. 681 398
      web/src/components/ChannelsTable.js
  48. 65 42
      web/src/components/HeaderBar.js
  49. 7 10
      web/src/components/LogsTable.js
  50. 40 0
      web/src/components/PageLayout.js
  51. 11 29
      web/src/components/PersonalSetting.js
  52. 35 1
      web/src/components/RegisterForm.js
  53. 18 31
      web/src/components/SiderBar.js
  54. 21 0
      web/src/components/custom/TextInput.js
  55. 21 0
      web/src/components/custom/TextNumberInput.js
  56. 9 8
      web/src/constants/channel.constants.js
  57. 80 0
      web/src/context/Style/index.js
  58. 4 2
      web/src/helpers/api.js
  59. 75 3
      web/src/helpers/render.js
  60. 22 0
      web/src/index.css
  61. 5 21
      web/src/index.js
  62. 229 187
      web/src/pages/Channel/EditChannel.js
  63. 366 0
      web/src/pages/Channel/EditTagModal.js
  64. 315 205
      web/src/pages/Detail/index.js
  65. 2 0
      web/src/pages/Home/index.js
  66. 162 85
      web/src/pages/Playground/Playground.js
  67. 7 2
      web/src/pages/Redemption/EditRedemption.js

+ 3 - 0
BT.md

@@ -0,0 +1,3 @@
+密钥为环境变量SESSION_SECRET
+
+![8285bba413e770fe9620f1bf9b40d44e](https://github.com/user-attachments/assets/7a6fc03e-c457-45e4-b8f9-184508fc26b0)

+ 30 - 22
README.md

@@ -16,15 +16,6 @@
 > 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持。
 > 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
 
-> [!TIP]
-> 最新版Docker镜像:`calciumion/new-api:latest`  
-> 默认账号root 密码123456  
-> 更新指令:
-> ```
-> docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR
-> ```
-
-
 ## 主要变更
 此分叉版本的主要变更如下:
 
@@ -68,35 +59,51 @@
 
 ## 比原版One API多出的配置
 - `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`。
-- `STREAMING_TIMEOUT`:设置流式一次回复的超时时间,默认为 30 秒。
+- `STREAMING_TIMEOUT`:设置流式一次回复的超时时间,默认为 60 秒。
 - `DIFY_DEBUG`:设置 Dify 渠道是否输出工作流和节点信息到客户端,默认为 `true`。
 - `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,请求上游返回流模式usage,默认为 `true`,建议开启,不影响客户端传入stream_options参数返回结果。
-- `GET_MEDIA_TOKEN`:是统计图片token,默认为 `true`,关闭后将不再在本地计算图片token,可能会导致和上游计费不同,此项覆盖 `GET_MEDIA_TOKEN_NOT_STREAM` 选项作用。
+- `GET_MEDIA_TOKEN`:是统计图片token,默认为 `true`,关闭后将不再在本地计算图片token,可能会导致和上游计费不同,此项覆盖 `GET_MEDIA_TOKEN_NOT_STREAM` 选项作用。
 - `GET_MEDIA_TOKEN_NOT_STREAM`:是否在非流(`stream=false`)情况下统计图片token,默认为 `true`。
 - `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认为 `true`,关闭后将不会更新任务进度。
 - `GEMINI_MODEL_MAP`:Gemini模型指定版本(v1/v1beta),使用“模型:版本”指定,","分隔,例如:-e GEMINI_MODEL_MAP="gemini-1.5-pro-latest:v1beta,gemini-1.5-pro-001:v1beta",为空则使用默认配置(v1beta)
 - `COHERE_SAFETY_SETTING`:Cohere模型[安全设置](https://docs.cohere.com/docs/safety-modes#overview),可选值为 `NONE`, `CONTEXTUAL`,`STRICT`,默认为 `NONE`。
 ## 部署
+> [!TIP]
+> 最新版Docker镜像:`calciumion/new-api:latest`  
+> 默认账号root 密码123456  
+> 更新指令:
+> ```
+> docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR
+> ```
+
 ### 部署要求
 - 本地数据库(默认):SQLite(Docker 部署默认使用 SQLite,必须挂载 `/data` 目录到宿主机)
 - 远程数据库:MySQL 版本 >= 5.7.8,PgSQL 版本 >= 9.6
+
+### 使用宝塔面板Docker功能部署
+安装宝塔面板 (**9.2.0版本**及以上),前往 [宝塔面板](https://www.bt.cn/new/download.html) 官网,选择正式版的脚本下载安装  
+安装后登录宝塔面板,在菜单栏中点击 Docker ,首次进入会提示安装 Docker 服务,点击立即安装,按提示完成安装  
+安装完成后在应用商店中找到 **New-API** ,点击安装,配置基本选项 即可完成安装  
+[图文教程](BT.md)
+
 ### 基于 Docker 进行部署
+### 使用 Docker Compose 部署(推荐)
 ```shell
-# 使用 SQLite 的部署命令:
-docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
-# 使用 MySQL 的部署命令,在上面的基础上添加 `-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi"`,请自行修改数据库连接参数。
-# 例如:
-docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
+# 下载项目
+git clone https://github.com/Calcium-Ion/new-api.git
+cd new-api
+# 按需编辑 docker-compose.yml
+# 启动
+docker-compose up -d
 ```
-### 使用宝塔面板Docker功能部署
+
+### 直接使用 Docker 镜像
 ```shell
 # 使用 SQLite 的部署命令:
-docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /www/wwwroot/new-api:/data calciumion/new-api:latest
+docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
 # 使用 MySQL 的部署命令,在上面的基础上添加 `-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi"`,请自行修改数据库连接参数。
 # 例如:
-# 注意:数据库要开启远程访问,并且只允许服务器IP访问
-docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(宝塔的服务器地址:宝塔数据库端口)/宝塔数据库名称" -e TZ=Asia/Shanghai -v /www/wwwroot/new-api:/data calciumion/new-api:latest
-# 注意:数据库要开启远程访问,并且只允许服务器IP访问
+docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
 ```
 
 ## 渠道重试
@@ -128,6 +135,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
 ![796df8d287b7b7bd7853b2497e7df511](https://github.com/user-attachments/assets/255b5e97-2d3a-4434-b4fa-e922ad88ff5a)
 
 ![image](https://github.com/Calcium-Ion/new-api/assets/61247483/ad0e7aae-0203-471c-9716-2d83768927d4)
+![image](https://github.com/user-attachments/assets/29f81de5-33fc-4fc5-a5ff-f9b54b653c7c)
 
 ![image](https://github.com/Calcium-Ion/new-api/assets/61247483/3ca0b282-00ff-4c96-bf9d-e29ef615c605)
 夜间模式  
@@ -135,7 +143,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
 ![image](https://github.com/Calcium-Ion/new-api/assets/61247483/af9a07ee-5101-4b3d-8bd9-ae21a4fd7e9e)
 
 ## 交流群
-<img src="https://github.com/Calcium-Ion/new-api/assets/61247483/de536a8a-0161-47a7-a0a2-66ef6de81266" width="200">
+<img src="https://github.com/user-attachments/assets/9ca0bc82-e057-4230-a28d-9f198fa022e3" width="200">
 
 ## 相关项目
 - [One API](https://github.com/songquanpeng/one-api):原版项目

+ 3 - 1
common/constants.go

@@ -229,6 +229,7 @@ const (
 	ChannelTypeSiliconFlow    = 40
 	ChannelTypeVertexAi       = 41
 	ChannelTypeMistral        = 42
+	ChannelTypeDeepSeek       = 43
 
 	ChannelTypeDummy // this one is only for count, do not add any channel after this
 
@@ -254,7 +255,7 @@ var ChannelBaseURLs = []string{
 	"https://open.bigmodel.cn",            // 16
 	"https://dashscope.aliyuncs.com",      // 17
 	"",                                    // 18
-	"https://ai.360.cn",                   // 19
+	"https://api.360.cn",                  // 19
 	"https://openrouter.ai/api",           // 20
 	"https://api.aiproxy.io",              // 21
 	"https://fastgpt.run/api/openapi",     // 22
@@ -278,4 +279,5 @@ var ChannelBaseURLs = []string{
 	"https://api.siliconflow.cn",                //40
 	"",                                          //41
 	"https://api.mistral.ai",                    //42
+	"https://api.deepseek.com",                  //43
 }

+ 15 - 7
common/email.go

@@ -9,15 +9,23 @@ import (
 	"time"
 )
 
-func generateMessageID() string {
-	domain := strings.Split(SMTPAccount, "@")[1]
-	return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain)
+func generateMessageID() (string, error) {
+	split := strings.Split(SMTPFrom, "@")
+	if len(split) < 2 {
+		return "", fmt.Errorf("invalid SMTP account")
+	}
+	domain := strings.Split(SMTPFrom, "@")[1]
+	return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
 }
 
 func SendEmail(subject string, receiver string, content string) error {
 	if SMTPFrom == "" { // for compatibility
 		SMTPFrom = SMTPAccount
 	}
+	id, err2 := generateMessageID()
+	if err2 != nil {
+		return err2
+	}
 	if SMTPServer == "" && SMTPAccount == "" {
 		return fmt.Errorf("SMTP 服务器未配置")
 	}
@@ -28,7 +36,7 @@ func SendEmail(subject string, receiver string, content string) error {
 		"Date: %s\r\n"+
 		"Message-ID: %s\r\n"+ // 添加 Message-ID 头
 		"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
-		receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), generateMessageID(), content))
+		receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
 	auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
 	addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
 	to := strings.Split(receiver, ";")
@@ -71,11 +79,11 @@ func SendEmail(subject string, receiver string, content string) error {
 		if err != nil {
 			return err
 		}
-	} else if isOutlookServer(SMTPAccount) {
+	} else if isOutlookServer(SMTPAccount) || SMTPServer == "smtp.azurecomm.net" {
 		auth = LoginAuth(SMTPAccount, SMTPToken)
-		err = smtp.SendMail(addr, auth, SMTPAccount, to, mail)
+		err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
 	} else {
-		err = smtp.SendMail(addr, auth, SMTPAccount, to, mail)
+		err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
 	}
 	return err
 }

+ 1 - 0
common/model-ratio.go

@@ -150,6 +150,7 @@ var defaultModelRatio = map[string]float64{
 	"360gpt-turbo":                   0.0858, // ¥0.0012 / 1k tokens
 	"360gpt-turbo-responsibility-8k": 0.8572, // ¥0.012 / 1k tokens
 	"360gpt-pro":                     0.8572, // ¥0.012 / 1k tokens
+	"360gpt2-pro":                    0.8572, // ¥0.012 / 1k tokens
 	"embedding-bert-512-v1":          0.0715, // ¥0.001 / 1k tokens
 	"embedding_s1_v1":                0.0715, // ¥0.001 / 1k tokens
 	"semantic_similarity_s1_v1":      0.0715, // ¥0.001 / 1k tokens

+ 2 - 2
constant/env.go

@@ -7,7 +7,7 @@ import (
 	"strings"
 )
 
-var StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 30)
+var StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 60)
 var DifyDebug = common.GetEnvOrDefaultBool("DIFY_DEBUG", true)
 
 // ForceStreamOption 覆盖请求参数,强制返回usage信息
@@ -20,7 +20,7 @@ var GetMediaTokenNotStream = common.GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STR
 var UpdateTask = common.GetEnvOrDefaultBool("UPDATE_TASK", true)
 
 var GeminiModelMap = map[string]string{
-	"gemini-1.0-pro":     "v1",
+	"gemini-1.0-pro": "v1",
 }
 
 func InitEnv() {

+ 6 - 0
constant/finish_reason.go

@@ -0,0 +1,6 @@
+package constant
+
+var (
+	FinishReasonStop      = "stop"
+	FinishReasonToolCalls = "tool_calls"
+)

+ 155 - 18
controller/channel.go

@@ -3,12 +3,13 @@ package controller
 import (
 	"encoding/json"
 	"fmt"
-	"github.com/gin-gonic/gin"
 	"net/http"
 	"one-api/common"
 	"one-api/model"
 	"strconv"
 	"strings"
+
+	"github.com/gin-gonic/gin"
 )
 
 type OpenAIModel struct {
@@ -48,19 +49,41 @@ func GetAllChannels(c *gin.Context) {
 	if pageSize < 0 {
 		pageSize = common.ItemsPerPage
 	}
+	channelData := make([]*model.Channel, 0)
 	idSort, _ := strconv.ParseBool(c.Query("id_sort"))
-	channels, err := model.GetAllChannels(p*pageSize, pageSize, false, idSort)
-	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": err.Error(),
-		})
-		return
+	enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
+	if enableTagMode {
+		tags, err := model.GetPaginatedTags(p*pageSize, pageSize)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+		for _, tag := range tags {
+			if tag != nil && *tag != "" {
+				tagChannel, err := model.GetChannelsByTag(*tag, idSort)
+				if err == nil {
+					channelData = append(channelData, tagChannel...)
+				}
+			}
+		}
+	} else {
+		channels, err := model.GetAllChannels(p*pageSize, pageSize, false, idSort)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+		channelData = channels
 	}
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
-		"data":    channels,
+		"data":    channelData,
 	})
 	return
 }
@@ -144,19 +167,41 @@ func SearchChannels(c *gin.Context) {
 	keyword := c.Query("keyword")
 	group := c.Query("group")
 	modelKeyword := c.Query("model")
-	//idSort, _ := strconv.ParseBool(c.Query("id_sort"))
-	channels, err := model.SearchChannels(keyword, group, modelKeyword)
-	if err != nil {
-		c.JSON(http.StatusOK, gin.H{
-			"success": false,
-			"message": err.Error(),
-		})
-		return
+	idSort, _ := strconv.ParseBool(c.Query("id_sort"))
+	enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
+	channelData := make([]*model.Channel, 0)
+	if enableTagMode {
+		tags, err := model.SearchTags(keyword, group, modelKeyword, idSort)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+		for _, tag := range tags {
+			if tag != nil && *tag != "" {
+				tagChannel, err := model.GetChannelsByTag(*tag, idSort)
+				if err == nil {
+					channelData = append(channelData, tagChannel...)
+				}
+			}
+		}
+	} else {
+		channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+		channelData = channels
 	}
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
-		"data":    channels,
+		"data":    channelData,
 	})
 	return
 }
@@ -279,6 +324,98 @@ func DeleteDisabledChannel(c *gin.Context) {
 	return
 }
 
+type ChannelTag struct {
+	Tag          string  `json:"tag"`
+	NewTag       *string `json:"new_tag"`
+	Priority     *int64  `json:"priority"`
+	Weight       *uint   `json:"weight"`
+	ModelMapping *string `json:"model_mapping"`
+	Models       *string `json:"models"`
+	Groups       *string `json:"groups"`
+}
+
+func DisableTagChannels(c *gin.Context) {
+	channelTag := ChannelTag{}
+	err := c.ShouldBindJSON(&channelTag)
+	if err != nil || channelTag.Tag == "" {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+	err = model.DisableChannelByTag(channelTag.Tag)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+	})
+	return
+}
+
+func EnableTagChannels(c *gin.Context) {
+	channelTag := ChannelTag{}
+	err := c.ShouldBindJSON(&channelTag)
+	if err != nil || channelTag.Tag == "" {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+	err = model.EnableChannelByTag(channelTag.Tag)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+	})
+	return
+}
+
+func EditTagChannels(c *gin.Context) {
+	channelTag := ChannelTag{}
+	err := c.ShouldBindJSON(&channelTag)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+	if channelTag.Tag == "" {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "tag不能为空",
+		})
+		return
+	}
+	err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+	})
+	return
+}
+
 type ChannelBatch struct {
 	Ids []int `json:"ids"`
 }

+ 3 - 4
controller/topup.go

@@ -85,14 +85,13 @@ func RequestEpay(c *gin.Context) {
 		c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
 		return
 	}
-
-	var payType epay.PurchaseType
+	payType := "wxpay"
 	if req.PaymentMethod == "zfb" {
-		payType = epay.Alipay
+		payType = "alipay"
 	}
 	if req.PaymentMethod == "wx" {
 		req.PaymentMethod = "wxpay"
-		payType = epay.WechatPay
+		payType = "wxpay"
 	}
 	callBackAddress := service.GetCallbackAddress()
 	returnUrl, _ := url.Parse(constant.ServerAddress + "/log")

+ 21 - 6
docker-compose.yml

@@ -3,7 +3,6 @@ version: '3.4'
 services:
   new-api:
     image: calciumion/new-api:latest
-    # build: .
     container_name: new-api
     restart: always
     command: --log-dir /app/logs
@@ -13,16 +12,17 @@ services:
       - ./data:/data
       - ./logs:/app/logs
     environment:
-      - SQL_DSN=root:123456@tcp(host.docker.internal:3306)/new-api  # 修改此行,或注释掉以使用 SQLite 作为数据库
+      - SQL_DSN=root:123456@tcp(mysql:3306)/new-api  # Point to the mysql service
       - REDIS_CONN_STRING=redis://redis
-      - SESSION_SECRET=random_string  # 修改为随机字符串
       - TZ=Asia/Shanghai
-#      - NODE_TYPE=slave  # 多机部署时从节点取消注释该行
-#      - SYNC_FREQUENCY=60  # 需要定期从数据库加载数据时取消注释该行
-#      - FRONTEND_BASE_URL=https://openai.justsong.cn  # 多机部署时从节点取消注释该行
+    #      - SESSION_SECRET=random_string  # 多机部署时设置,必须修改这个随机字符串!!!!!!!
+    #      - NODE_TYPE=slave  # Uncomment for slave node in multi-node deployment
+    #      - SYNC_FREQUENCY=60  # Uncomment if regular database syncing is needed
+    #      - FRONTEND_BASE_URL=https://openai.justsong.cn  # Uncomment for multi-node deployment with front-end URL
 
     depends_on:
       - redis
+      - mysql
     healthcheck:
       test: [ "CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $2}'" ]
       interval: 30s
@@ -33,3 +33,18 @@ services:
     image: redis:latest
     container_name: redis
     restart: always
+
+  mysql:
+    image: mysql:8.2
+    container_name: mysql
+    restart: always
+    environment:
+      MYSQL_ROOT_PASSWORD: 123456  # Ensure this matches the password in SQL_DSN
+      MYSQL_DATABASE: new-api
+    volumes:
+      - mysql_data:/var/lib/mysql
+    # ports:
+    #   - "3306:3306"  # If you want to access MySQL from outside Docker, uncomment
+
+volumes:
+  mysql_data:

+ 16 - 18
go.mod

@@ -1,24 +1,22 @@
 module one-api
 
 // +heroku goVersion go1.18
-go 1.21
-
-toolchain go1.22.4
+go 1.23.4
 
 require (
-	github.com/Calcium-Ion/go-epay v0.0.2
+	github.com/Calcium-Ion/go-epay v0.0.4
+	github.com/andybalholm/brotli v1.1.1
 	github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
 	github.com/aws/aws-sdk-go-v2 v1.26.1
 	github.com/aws/aws-sdk-go-v2/credentials v1.17.11
 	github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4
 	github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b
-	github.com/bytedance/sonic v1.12.4
-	github.com/gin-contrib/cors v1.4.0
+	github.com/gin-contrib/cors v1.7.2
 	github.com/gin-contrib/gzip v0.0.6
 	github.com/gin-contrib/sessions v0.0.5
 	github.com/gin-contrib/static v0.0.1
 	github.com/gin-gonic/gin v1.9.1
-	github.com/go-playground/validator/v10 v10.19.0
+	github.com/go-playground/validator/v10 v10.20.0
 	github.com/go-redis/redis/v8 v8.11.5
 	github.com/golang-jwt/jwt v3.2.2+incompatible
 	github.com/google/uuid v1.6.0
@@ -29,8 +27,8 @@ require (
 	github.com/pkoukk/tiktoken-go v0.1.7
 	github.com/samber/lo v1.39.0
 	github.com/shirou/gopsutil v3.21.11+incompatible
-	golang.org/x/crypto v0.26.0
-	golang.org/x/image v0.15.0
+	golang.org/x/crypto v0.27.0
+	golang.org/x/image v0.23.0
 	gorm.io/driver/mysql v1.4.3
 	gorm.io/driver/postgres v1.5.2
 	gorm.io/driver/sqlite v1.4.3
@@ -43,7 +41,8 @@ require (
 	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
 	github.com/aws/smithy-go v1.20.2 // indirect
-	github.com/bytedance/sonic/loader v0.2.1 // indirect
+	github.com/bytedance/sonic v1.11.6 // indirect
+	github.com/bytedance/sonic/loader v0.1.1 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cloudwego/base64x v0.1.4 // indirect
 	github.com/cloudwego/iasm v0.2.0 // indirect
@@ -61,9 +60,9 @@ require (
 	github.com/gorilla/securecookie v1.1.1 // indirect
 	github.com/gorilla/sessions v1.2.1 // indirect
 	github.com/jackc/pgpassfile v1.0.0 // indirect
-	github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
-	github.com/jackc/pgx/v5 v5.5.1 // indirect
-	github.com/jackc/puddle/v2 v2.2.1 // indirect
+	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+	github.com/jackc/pgx/v5 v5.7.1 // indirect
+	github.com/jackc/puddle/v2 v2.2.2 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
@@ -74,19 +73,18 @@ require (
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
-	github.com/pelletier/go-toml/v2 v2.0.8 // indirect
-	github.com/stretchr/testify v1.9.0 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.1 // indirect
 	github.com/tklauser/go-sysconf v0.3.12 // indirect
 	github.com/tklauser/numcpus v0.6.1 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
-	github.com/ugorji/go/codec v1.2.11 // indirect
+	github.com/ugorji/go/codec v1.2.12 // indirect
 	github.com/yusufpapurcu/wmi v1.2.3 // indirect
 	golang.org/x/arch v0.12.0 // indirect
 	golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
 	golang.org/x/net v0.28.0 // indirect
-	golang.org/x/sync v0.8.0 // indirect
+	golang.org/x/sync v0.10.0 // indirect
 	golang.org/x/sys v0.27.0 // indirect
-	golang.org/x/text v0.17.0 // indirect
+	golang.org/x/text v0.21.0 // indirect
 	google.golang.org/protobuf v1.34.2 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )

+ 35 - 29
go.sum

@@ -1,5 +1,7 @@
-github.com/Calcium-Ion/go-epay v0.0.2 h1:3knFBuaBFpHzsGeGQU/QxUqZSHh5s0+jGo0P62pJzWc=
-github.com/Calcium-Ion/go-epay v0.0.2/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
+github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
+github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
+github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
+github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
 github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs=
 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=
@@ -20,11 +22,10 @@ github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
 github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
 github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0=
 github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q=
-github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k=
-github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
 github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
-github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
-github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@@ -43,8 +44,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
 github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
-github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
-github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
+github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
+github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
 github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
 github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
 github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
@@ -72,8 +73,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
 github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
 github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
-github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
-github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
 github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
 github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
 github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
@@ -101,12 +102,12 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
 github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
-github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
-github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI=
-github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
-github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
-github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
+github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
 github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
 github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
 github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -157,8 +158,8 @@ github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042
 github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
 github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
 github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
-github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
-github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
+github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
+github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -176,6 +177,7 @@ github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMT
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -183,7 +185,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
@@ -196,25 +198,28 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
 github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
 github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
 github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
-github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
-github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
 github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
 golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
 golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
 golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
-golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
+golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
+golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
 golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
 golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
-golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
-golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
+golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
+golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
 golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
-golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -231,8 +236,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
-golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
@@ -265,3 +270,4 @@ gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
 gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU=
 gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
 nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

+ 1 - 1
main.go

@@ -33,7 +33,7 @@ var indexPage []byte
 func main() {
 	err := godotenv.Load(".env")
 	if err != nil {
-		common.SysLog("Can't load .env file")
+		common.SysError("failed to load .env file: " + err.Error())
 	}
 
 	common.SetupLogger()

+ 1 - 0
middleware/auth.go

@@ -212,6 +212,7 @@ func TokenAuth() func(c *gin.Context) {
 		}
 		c.Set("id", token.UserId)
 		c.Set("token_id", token.Id)
+		c.Set("token_key", token.Key)
 		c.Set("token_name", token.Name)
 		c.Set("token_unlimited_quota", token.UnlimitedQuota)
 		if !token.UnlimitedQuota {

+ 38 - 0
middleware/gzip.go

@@ -0,0 +1,38 @@
+package middleware
+
+import (
+	"compress/gzip"
+	"github.com/andybalholm/brotli"
+	"github.com/gin-gonic/gin"
+	"io"
+	"net/http"
+)
+
+func DecompressRequestMiddleware() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		if c.Request.Body == nil || c.Request.Method == http.MethodGet {
+			c.Next()
+			return
+		}
+		switch c.GetHeader("Content-Encoding") {
+		case "gzip":
+			gzipReader, err := gzip.NewReader(c.Request.Body)
+			if err != nil {
+				c.AbortWithStatus(http.StatusBadRequest)
+				return
+			}
+			defer gzipReader.Close()
+
+			// Replace the request body with the decompressed data
+			c.Request.Body = io.NopCloser(gzipReader)
+			c.Request.Header.Del("Content-Encoding")
+		case "br":
+			reader := brotli.NewReader(c.Request.Body)
+			c.Request.Body = io.NopCloser(reader)
+			c.Request.Header.Del("Content-Encoding")
+		}
+
+		// Continue processing the request
+		c.Next()
+	}
+}

+ 26 - 6
model/ability.go

@@ -10,12 +10,13 @@ import (
 )
 
 type Ability struct {
-	Group     string `json:"group" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
-	Model     string `json:"model" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
-	ChannelId int    `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"`
-	Enabled   bool   `json:"enabled"`
-	Priority  *int64 `json:"priority" gorm:"bigint;default:0;index"`
-	Weight    uint   `json:"weight" gorm:"default:0;index"`
+	Group     string  `json:"group" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
+	Model     string  `json:"model" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
+	ChannelId int     `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"`
+	Enabled   bool    `json:"enabled"`
+	Priority  *int64  `json:"priority" gorm:"bigint;default:0;index"`
+	Weight    uint    `json:"weight" gorm:"default:0;index"`
+	Tag       *string `json:"tag" gorm:"index"`
 }
 
 func GetGroupModels(group string) []string {
@@ -149,6 +150,7 @@ func (channel *Channel) AddAbilities() error {
 				Enabled:   channel.Status == common.ChannelStatusEnabled,
 				Priority:  channel.Priority,
 				Weight:    uint(channel.GetWeight()),
+				Tag:       channel.Tag,
 			}
 			abilities = append(abilities, ability)
 		}
@@ -190,6 +192,24 @@ func UpdateAbilityStatus(channelId int, status bool) error {
 	return DB.Model(&Ability{}).Where("channel_id = ?", channelId).Select("enabled").Update("enabled", status).Error
 }
 
+func UpdateAbilityStatusByTag(tag string, status bool) error {
+	return DB.Model(&Ability{}).Where("tag = ?", tag).Select("enabled").Update("enabled", status).Error
+}
+
+func UpdateAbilityByTag(tag string, newTag *string, priority *int64, weight *uint) error {
+	ability := Ability{}
+	if newTag != nil {
+		ability.Tag = newTag
+	}
+	if priority != nil {
+		ability.Priority = priority
+	}
+	if weight != nil {
+		ability.Weight = *weight
+	}
+	return DB.Model(&Ability{}).Where("tag = ?", tag).Updates(ability).Error
+}
+
 func FixAbility() (int, error) {
 	var channelIds []int
 	count := 0

+ 160 - 3
model/channel.go

@@ -2,9 +2,10 @@ package model
 
 import (
 	"encoding/json"
-	"gorm.io/gorm"
 	"one-api/common"
 	"strings"
+
+	"gorm.io/gorm"
 )
 
 type Channel struct {
@@ -32,6 +33,7 @@ type Channel struct {
 	Priority          *int64  `json:"priority" gorm:"bigint;default:0"`
 	AutoBan           *int    `json:"auto_ban" gorm:"default:1"`
 	OtherInfo         string  `json:"other_info"`
+	Tag               *string `json:"tag" gorm:"index"`
 }
 
 func (channel *Channel) GetModels() []string {
@@ -61,6 +63,17 @@ func (channel *Channel) SetOtherInfo(otherInfo map[string]interface{}) {
 	channel.OtherInfo = string(otherInfoBytes)
 }
 
+func (channel *Channel) GetTag() string {
+	if channel.Tag == nil {
+		return ""
+	}
+	return *channel.Tag
+}
+
+func (channel *Channel) SetTag(tag string) {
+	channel.Tag = &tag
+}
+
 func (channel *Channel) GetAutoBan() bool {
 	if channel.AutoBan == nil {
 		return false
@@ -87,7 +100,17 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan
 	return channels, err
 }
 
-func SearchChannels(keyword string, group string, model string) ([]*Channel, error) {
+func GetChannelsByTag(tag string, idSort bool) ([]*Channel, error) {
+	var channels []*Channel
+	order := "priority desc"
+	if idSort {
+		order = "id desc"
+	}
+	err := DB.Where("tag = ?", tag).Order(order).Find(&channels).Error
+	return channels, err
+}
+
+func SearchChannels(keyword string, group string, model string, idSort bool) ([]*Channel, error) {
 	var channels []*Channel
 	keyCol := "`key`"
 	groupCol := "`group`"
@@ -100,6 +123,11 @@ func SearchChannels(keyword string, group string, model string) ([]*Channel, err
 		modelsCol = `"models"`
 	}
 
+	order := "priority desc"
+	if idSort {
+		order = "id desc"
+	}
+
 	// 构造基础查询
 	baseQuery := DB.Model(&Channel{}).Omit(keyCol)
 
@@ -122,7 +150,7 @@ func SearchChannels(keyword string, group string, model string) ([]*Channel, err
 	}
 
 	// 执行查询
-	err := baseQuery.Where(whereClause, args...).Order("priority desc").Find(&channels).Error
+	err := baseQuery.Where(whereClause, args...).Order(order).Find(&channels).Error
 	if err != nil {
 		return nil, err
 	}
@@ -288,6 +316,74 @@ func UpdateChannelStatusById(id int, status int, reason string) {
 
 }
 
+func EnableChannelByTag(tag string) error {
+	err := DB.Model(&Channel{}).Where("tag = ?", tag).Update("status", common.ChannelStatusEnabled).Error
+	if err != nil {
+		return err
+	}
+	err = UpdateAbilityStatusByTag(tag, true)
+	return err
+}
+
+func DisableChannelByTag(tag string) error {
+	err := DB.Model(&Channel{}).Where("tag = ?", tag).Update("status", common.ChannelStatusManuallyDisabled).Error
+	if err != nil {
+		return err
+	}
+	err = UpdateAbilityStatusByTag(tag, false)
+	return err
+}
+
+func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint) error {
+	updateData := Channel{}
+	shouldReCreateAbilities := false
+	updatedTag := tag
+	// 如果 newTag 不为空且不等于 tag,则更新 tag
+	if newTag != nil && *newTag != tag {
+		updateData.Tag = newTag
+		updatedTag = *newTag
+	}
+	if modelMapping != nil && *modelMapping != "" {
+		updateData.ModelMapping = modelMapping
+	}
+	if models != nil && *models != "" {
+		shouldReCreateAbilities = true
+		updateData.Models = *models
+	}
+	if group != nil && *group != "" {
+		shouldReCreateAbilities = true
+		updateData.Group = *group
+	}
+	if priority != nil {
+		updateData.Priority = priority
+	}
+	if weight != nil {
+		updateData.Weight = weight
+	}
+
+	err := DB.Model(&Channel{}).Where("tag = ?", tag).Updates(updateData).Error
+	if err != nil {
+		return err
+	}
+	if shouldReCreateAbilities {
+		channels, err := GetChannelsByTag(updatedTag, false)
+		if err == nil {
+			for _, channel := range channels {
+				err = channel.UpdateAbilities()
+				if err != nil {
+					common.SysError("failed to update abilities: " + err.Error())
+				}
+			}
+		}
+	} else {
+		err := UpdateAbilityByTag(tag, newTag, priority, weight)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func UpdateChannelUsedQuota(id int, quota int) {
 	if common.BatchUpdateEnabled {
 		addNewRecord(BatchUpdateTypeChannelUsedQuota, id, quota)
@@ -312,3 +408,64 @@ func DeleteDisabledChannel() (int64, error) {
 	result := DB.Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled).Delete(&Channel{})
 	return result.RowsAffected, result.Error
 }
+
+func GetPaginatedTags(offset int, limit int) ([]*string, error) {
+	var tags []*string
+	err := DB.Model(&Channel{}).Select("DISTINCT tag").Where("tag != ''").Offset(offset).Limit(limit).Find(&tags).Error
+	return tags, err
+}
+
+func SearchTags(keyword string, group string, model string, idSort bool) ([]*string, error) {
+	var tags []*string
+	keyCol := "`key`"
+	groupCol := "`group`"
+	modelsCol := "`models`"
+
+	// 如果是 PostgreSQL,使用双引号
+	if common.UsingPostgreSQL {
+		keyCol = `"key"`
+		groupCol = `"group"`
+		modelsCol = `"models"`
+	}
+
+	order := "priority desc"
+	if idSort {
+		order = "id desc"
+	}
+
+	// 构造基础查询
+	baseQuery := DB.Model(&Channel{}).Omit(keyCol)
+
+	// 构造WHERE子句
+	var whereClause string
+	var args []interface{}
+	if group != "" && group != "null" {
+		var groupCondition string
+		if common.UsingMySQL {
+			groupCondition = `CONCAT(',', ` + groupCol + `, ',') LIKE ?`
+		} else {
+			// sqlite, PostgreSQL
+			groupCondition = `(',' || ` + groupCol + ` || ',') LIKE ?`
+		}
+		whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
+		args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+model+"%", "%,"+group+",%")
+	} else {
+		whereClause = "(id = ? OR name LIKE ? OR " + keyCol + " = ?) AND " + modelsCol + " LIKE ?"
+		args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+model+"%")
+	}
+
+	subQuery := baseQuery.Where(whereClause, args...).
+		Select("tag").
+		Where("tag != ''").
+		Order(order)
+
+	err := DB.Table("(?) as sub", subQuery).
+		Select("DISTINCT tag").
+		Find(&tags).Error
+
+	if err != nil {
+		return nil, err
+	}
+
+	return tags, nil
+}

+ 6 - 5
model/usedata.go

@@ -85,7 +85,7 @@ func SaveQuotaDataCache() {
 			//quotaDataDB.Count += quotaData.Count
 			//quotaDataDB.Quota += quotaData.Quota
 			//DB.Table("quota_data").Save(quotaDataDB)
-			increaseQuotaData(quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.Count, quotaData.Quota, quotaData.CreatedAt)
+			increaseQuotaData(quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.Count, quotaData.Quota, quotaData.CreatedAt, quotaData.TokenUsed)
 		} else {
 			DB.Table("quota_data").Create(quotaData)
 		}
@@ -94,11 +94,12 @@ func SaveQuotaDataCache() {
 	common.SysLog(fmt.Sprintf("保存数据看板数据成功,共保存%d条数据", size))
 }
 
-func increaseQuotaData(userId int, username string, modelName string, count int, quota int, createdAt int64) {
+func increaseQuotaData(userId int, username string, modelName string, count int, quota int, createdAt int64, tokenUsed int) {
 	err := DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?",
 		userId, username, modelName, createdAt).Updates(map[string]interface{}{
-		"count": gorm.Expr("count + ?", count),
-		"quota": gorm.Expr("quota + ?", quota),
+		"count":      gorm.Expr("count + ?", count),
+		"quota":      gorm.Expr("quota + ?", quota),
+		"token_used": gorm.Expr("token_used + ?", tokenUsed),
 	}).Error
 	if err != nil {
 		common.SysLog(fmt.Sprintf("increaseQuotaData error: %s", err))
@@ -127,6 +128,6 @@ func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaDat
 	// 从quota_data表中查询数据
 	// only select model_name, sum(count) as count, sum(quota) as quota, model_name, created_at from quota_data group by model_name, created_at;
 	//err = DB.Table("quota_data").Where("created_at >= ? and created_at <= ?", startTime, endTime).Find(&quotaDatas).Error
-	err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find(&quotaDatas).Error
+	err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find(&quotaDatas).Error
 	return quotaDatas, err
 }

+ 1 - 0
relay/channel/ai360/constants.go

@@ -4,6 +4,7 @@ var ModelList = []string{
 	"360gpt-turbo",
 	"360gpt-turbo-responsibility-8k",
 	"360gpt-pro",
+	"360gpt2-pro",
 	"360GPT_S2_V9",
 	"embedding-bert-512-v1",
 	"embedding_s1_v1",

+ 1 - 0
relay/channel/aws/constants.go

@@ -9,6 +9,7 @@ var awsModelIDMap = map[string]string{
 	"claude-3-haiku-20240307":    "anthropic.claude-3-haiku-20240307-v1:0",
 	"claude-3-5-sonnet-20240620": "anthropic.claude-3-5-sonnet-20240620-v1:0",
 	"claude-3-5-sonnet-20241022": "anthropic.claude-3-5-sonnet-20241022-v2:0",
+	"claude-3-5-haiku-20241022": "anthropic.claude-3-5-haiku-20241022-v1:0",
 }
 
 var ChannelName = "aws"

+ 2 - 2
relay/channel/baidu/relay-baidu.go

@@ -9,8 +9,8 @@ import (
 	"io"
 	"net/http"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/dto"
-	relaycommon "one-api/relay/common"
 	"one-api/service"
 	"strings"
 	"sync"
@@ -75,7 +75,7 @@ func streamResponseBaidu2OpenAI(baiduResponse *BaiduChatStreamResponse) *dto.Cha
 	var choice dto.ChatCompletionsStreamResponseChoice
 	choice.Delta.SetContentString(baiduResponse.Result)
 	if baiduResponse.IsEnd {
-		choice.FinishReason = &relaycommon.StopFinishReason
+		choice.FinishReason = &constant.FinishReasonStop
 	}
 	response := dto.ChatCompletionsStreamResponse{
 		Id:      baiduResponse.Id,

+ 71 - 0
relay/channel/deepseek/adaptor.go

@@ -0,0 +1,71 @@
+package deepseek
+
+import (
+	"errors"
+	"fmt"
+	"github.com/gin-gonic/gin"
+	"io"
+	"net/http"
+	"one-api/dto"
+	"one-api/relay/channel"
+	"one-api/relay/channel/openai"
+	relaycommon "one-api/relay/common"
+)
+
+type Adaptor struct {
+}
+
+func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
+	//TODO implement me
+	return nil, errors.New("not implemented")
+}
+
+func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
+	//TODO implement me
+	return nil, errors.New("not implemented")
+}
+
+func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
+}
+
+func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
+	return fmt.Sprintf("%s/chat/completions", info.BaseUrl), nil
+}
+
+func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
+	channel.SetupApiRequestHeader(info, c, req)
+	req.Set("Authorization", "Bearer "+info.ApiKey)
+	return nil
+}
+
+func (a *Adaptor) ConvertRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
+	if request == nil {
+		return nil, errors.New("request is nil")
+	}
+	return request, nil
+}
+
+func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
+	return nil, nil
+}
+
+func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
+	return channel.DoApiRequest(a, c, info, requestBody)
+}
+
+func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
+	if info.IsStream {
+		err, usage = openai.OaiStreamHandler(c, resp, info)
+	} else {
+		err, usage = openai.OpenaiHandler(c, resp, info.PromptTokens, info.UpstreamModelName)
+	}
+	return
+}
+
+func (a *Adaptor) GetModelList() []string {
+	return ModelList
+}
+
+func (a *Adaptor) GetChannelName() string {
+	return ChannelName
+}

+ 7 - 0
relay/channel/deepseek/constants.go

@@ -0,0 +1,7 @@
+package deepseek
+
+var ModelList = []string{
+	"deepseek-chat", "deepseek-coder",
+}
+
+var ChannelName = "deepseek"

+ 4 - 3
relay/channel/gemini/constant.go

@@ -5,9 +5,10 @@ const (
 )
 
 var ModelList = []string{
-	"gemini-1.0-pro-latest", "gemini-1.0-pro-001", "gemini-1.5-pro-latest", "gemini-1.5-flash-latest", "gemini-ultra",
-	"gemini-1.0-pro-vision-latest", "gemini-1.0-pro-vision-001", "gemini-1.5-pro-exp-0827", "gemini-1.5-flash-exp-0827",
-	"gemini-exp-1114",
+	"gemini-1.5-pro-latest", "gemini-1.5-flash-latest", "gemini-ultra",
+	"gemini-1.5-pro-exp-0827", "gemini-1.5-flash-exp-0827",
+	"gemini-exp-1114", "gemini-exp-1206",
+	"gemini-2.0-flash-exp",
 }
 
 var ChannelName = "google gemini"

+ 1 - 0
relay/channel/gemini/dto.go

@@ -34,6 +34,7 @@ type GeminiChatSafetySettings struct {
 }
 
 type GeminiChatTools struct {
+	GoogleSearch         any `json:"googleSearch,omitempty"`
 	FunctionDeclarations any `json:"functionDeclarations,omitempty"`
 }
 

+ 20 - 7
relay/channel/gemini/relay-gemini.go

@@ -8,6 +8,7 @@ import (
 	"io"
 	"net/http"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/dto"
 	relaycommon "one-api/relay/common"
 	"one-api/service"
@@ -44,13 +45,25 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest) *GeminiChatReques
 	}
 	if textRequest.Tools != nil {
 		functions := make([]dto.FunctionCall, 0, len(textRequest.Tools))
+		googleSearch := false
 		for _, tool := range textRequest.Tools {
+			if tool.Function.Name == "googleSearch" {
+				googleSearch = true
+				continue
+			}
 			functions = append(functions, tool.Function)
 		}
-		geminiRequest.Tools = []GeminiChatTools{
-			{
-				FunctionDeclarations: functions,
-			},
+		if len(functions) > 0 {
+			geminiRequest.Tools = []GeminiChatTools{
+				{
+					FunctionDeclarations: functions,
+				},
+			}
+		}
+		if googleSearch {
+			geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTools{
+				GoogleSearch: make(map[string]string),
+			})
 		}
 	} else if textRequest.Functions != nil {
 		geminiRequest.Tools = []GeminiChatTools{
@@ -133,7 +146,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest) *GeminiChatReques
 			shouldAddDummyModelMessage = false
 		}
 	}
-
 	return &geminiRequest
 }
 
@@ -186,10 +198,11 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp
 				Role:    "assistant",
 				Content: content,
 			},
-			FinishReason: relaycommon.StopFinishReason,
+			FinishReason: constant.FinishReasonStop,
 		}
 		if len(candidate.Content.Parts) > 0 {
 			if candidate.Content.Parts[0].FunctionCall != nil {
+				choice.FinishReason = constant.FinishReasonToolCalls
 				choice.Message.ToolCalls = getToolCalls(&candidate)
 			} else {
 				choice.Message.SetStringContent(candidate.Content.Parts[0].Text)
@@ -262,7 +275,7 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom
 		}
 	}
 
-	response := service.GenerateStopResponse(id, createAt, info.UpstreamModelName, relaycommon.StopFinishReason)
+	response := service.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop)
 	service.ObjectData(c, response)
 
 	usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens

+ 22 - 5
relay/channel/openai/adaptor.go

@@ -32,11 +32,15 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
 
 func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 	if info.RelayMode == constant.RelayModeRealtime {
-		// trim https
-		baseUrl := strings.TrimPrefix(info.BaseUrl, "https://")
-		baseUrl = strings.TrimPrefix(baseUrl, "http://")
-		baseUrl = "wss://" + baseUrl
-		info.BaseUrl = baseUrl
+		if strings.HasPrefix(info.BaseUrl, "https://") {
+			baseUrl := strings.TrimPrefix(info.BaseUrl, "https://")
+			baseUrl = "wss://" + baseUrl
+			info.BaseUrl = baseUrl
+		} else if strings.HasPrefix(info.BaseUrl, "http://") {
+			baseUrl := strings.TrimPrefix(info.BaseUrl, "http://")
+			baseUrl = "ws://" + baseUrl
+			info.BaseUrl = baseUrl
+		}
 	}
 	switch info.ChannelType {
 	case common.ChannelTypeAzure:
@@ -132,6 +136,19 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
 
 		writer.WriteField("model", request.Model)
 
+		// 获取所有表单字段
+		formData := c.Request.PostForm
+
+		// 遍历表单字段并打印输出
+		for key, values := range formData {
+			if key == "model" {
+				continue
+			}
+			for _, value := range values {
+				writer.WriteField(key, value)
+			}
+		}
+
 		// 添加文件字段
 		file, header, err := c.Request.FormFile("file")
 		if err != nil {

+ 5 - 6
relay/channel/openai/relay-openai.go

@@ -98,6 +98,11 @@ func OaiStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
 				shouldSendLastResp = false
 			}
 		}
+		for _, choice := range lastStreamResponse.Choices {
+			if choice.FinishReason != nil {
+				shouldSendLastResp = true
+			}
+		}
 	}
 	if shouldSendLastResp {
 		service.StringData(c, lastStreamData)
@@ -279,7 +284,6 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
 }
 
 func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
-	var audioResp dto.AudioResponse
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
 		return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
@@ -288,11 +292,6 @@ func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
 	if err != nil {
 		return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
 	}
-	err = json.Unmarshal(responseBody, &audioResp)
-	if err != nil {
-		return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
-	}
-
 	// Reset response body
 	resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
 	// We shouldn't set the header before we parse the response body, because the parse part may fail.

+ 2 - 2
relay/channel/palm/relay-palm.go

@@ -7,8 +7,8 @@ import (
 	"io"
 	"net/http"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/dto"
-	relaycommon "one-api/relay/common"
 	"one-api/service"
 )
 
@@ -63,7 +63,7 @@ func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *dto.ChatCompleti
 	if len(palmResponse.Candidates) > 0 {
 		choice.Delta.SetContentString(palmResponse.Candidates[0].Content)
 	}
-	choice.FinishReason = &relaycommon.StopFinishReason
+	choice.FinishReason = &constant.FinishReasonStop
 	var response dto.ChatCompletionsStreamResponse
 	response.Object = "chat.completion.chunk"
 	response.Model = "palm2"

+ 2 - 2
relay/channel/tencent/relay-tencent.go

@@ -12,8 +12,8 @@ import (
 	"io"
 	"net/http"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/dto"
-	relaycommon "one-api/relay/common"
 	"one-api/service"
 	"strconv"
 	"strings"
@@ -81,7 +81,7 @@ func streamResponseTencent2OpenAI(TencentResponse *TencentChatResponse) *dto.Cha
 		var choice dto.ChatCompletionsStreamResponseChoice
 		choice.Delta.SetContentString(TencentResponse.Choices[0].Delta.Content)
 		if TencentResponse.Choices[0].FinishReason == "stop" {
-			choice.FinishReason = &relaycommon.StopFinishReason
+			choice.FinishReason = &constant.FinishReasonStop
 		}
 		response.Choices = append(response.Choices, choice)
 	}

+ 14 - 1
relay/channel/vertex/adaptor.go

@@ -176,7 +176,20 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
 }
 
 func (a *Adaptor) GetModelList() []string {
-	return ModelList
+	var modelList []string
+	for i, s := range ModelList {
+		modelList = append(modelList, s)
+		ModelList[i] = s
+	}
+	for i, s := range claude.ModelList {
+		modelList = append(modelList, s)
+		claude.ModelList[i] = s
+	}
+	for i, s := range gemini.ModelList {
+		modelList = append(modelList, s)
+		gemini.ModelList[i] = s
+	}
+	return modelList
 }
 
 func (a *Adaptor) GetChannelName() string {

+ 5 - 5
relay/channel/vertex/constants.go

@@ -1,13 +1,13 @@
 package vertex
 
 var ModelList = []string{
-	"claude-3-sonnet-20240229",
-	"claude-3-opus-20240229",
-	"claude-3-haiku-20240307",
-	"claude-3-5-sonnet-20240620",
+	//"claude-3-sonnet-20240229",
+	//"claude-3-opus-20240229",
+	//"claude-3-haiku-20240307",
+	//"claude-3-5-sonnet-20240620",
 
 	//"gemini-1.5-pro-latest", "gemini-1.5-flash-latest",
-	"gemini-1.5-pro-001", "gemini-1.5-flash-001", "gemini-pro", "gemini-pro-vision",
+	//"gemini-1.5-pro-001", "gemini-1.5-flash-001", "gemini-pro", "gemini-pro-vision",
 
 	"meta/llama3-405b-instruct-maas",
 }

+ 4 - 4
relay/channel/xunfei/relay-xunfei.go

@@ -12,8 +12,8 @@ import (
 	"net/http"
 	"net/url"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/dto"
-	relaycommon "one-api/relay/common"
 	"one-api/service"
 	"strings"
 	"time"
@@ -67,7 +67,7 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse
 			Role:    "assistant",
 			Content: content,
 		},
-		FinishReason: relaycommon.StopFinishReason,
+		FinishReason: constant.FinishReasonStop,
 	}
 	fullTextResponse := dto.OpenAITextResponse{
 		Object:  "chat.completion",
@@ -89,7 +89,7 @@ func streamResponseXunfei2OpenAI(xunfeiResponse *XunfeiChatResponse) *dto.ChatCo
 	var choice dto.ChatCompletionsStreamResponseChoice
 	choice.Delta.SetContentString(xunfeiResponse.Payload.Choices.Text[0].Content)
 	if xunfeiResponse.Payload.Choices.Status == 2 {
-		choice.FinishReason = &relaycommon.StopFinishReason
+		choice.FinishReason = &constant.FinishReasonStop
 	}
 	response := dto.ChatCompletionsStreamResponse{
 		Object:  "chat.completion.chunk",
@@ -245,7 +245,7 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
 func apiVersion2domain(apiVersion string) string {
 	switch apiVersion {
 	case "v1.1":
-		return "general"
+		return "lite"
 	case "v2.1":
 		return "generalv2"
 	case "v3.1":

+ 2 - 2
relay/channel/zhipu/relay-zhipu.go

@@ -8,8 +8,8 @@ import (
 	"io"
 	"net/http"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/dto"
-	relaycommon "one-api/relay/common"
 	"one-api/service"
 	"strings"
 	"sync"
@@ -139,7 +139,7 @@ func streamResponseZhipu2OpenAI(zhipuResponse string) *dto.ChatCompletionsStream
 func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*dto.ChatCompletionsStreamResponse, *dto.Usage) {
 	var choice dto.ChatCompletionsStreamResponseChoice
 	choice.Delta.SetContentString("")
-	choice.FinishReason = &relaycommon.StopFinishReason
+	choice.FinishReason = &constant.FinishReasonStop
 	response := dto.ChatCompletionsStreamResponse{
 		Id:      zhipuResponse.RequestId,
 		Object:  "chat.completion.chunk",

+ 3 - 0
relay/common/relay_info.go

@@ -14,6 +14,7 @@ type RelayInfo struct {
 	ChannelType          int
 	ChannelId            int
 	TokenId              int
+	TokenKey             string
 	UserId               int
 	Group                string
 	TokenUnlimited       bool
@@ -58,6 +59,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
 	channelId := c.GetInt("channel_id")
 
 	tokenId := c.GetInt("token_id")
+	tokenKey := c.GetString("token_key")
 	userId := c.GetInt("id")
 	group := c.GetString("group")
 	tokenUnlimited := c.GetBool("token_unlimited_quota")
@@ -73,6 +75,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
 		ChannelType:       channelType,
 		ChannelId:         channelId,
 		TokenId:           tokenId,
+		TokenKey:          tokenKey,
 		UserId:            userId,
 		Group:             group,
 		TokenUnlimited:    tokenUnlimited,

+ 0 - 2
relay/common/relay_utils.go

@@ -10,8 +10,6 @@ import (
 	"strings"
 )
 
-var StopFinishReason = "stop"
-
 func GetFullRequestURL(baseURL string, requestURL string, channelType int) string {
 	fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
 

+ 3 - 0
relay/constant/api_type.go

@@ -26,6 +26,7 @@ const (
 	APITypeSiliconFlow
 	APITypeVertexAi
 	APITypeMistral
+	APITypeDeepSeek
 
 	APITypeDummy // this one is only for count, do not add any channel after this
 )
@@ -75,6 +76,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
 		apiType = APITypeVertexAi
 	case common.ChannelTypeMistral:
 		apiType = APITypeMistral
+	case common.ChannelTypeDeepSeek:
+		apiType = APITypeDeepSeek
 	}
 	if apiType == -1 {
 		return APITypeOpenAI, false

+ 8 - 1
relay/relay-audio.go

@@ -33,12 +33,19 @@ func getAndValidAudioRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.
 			}
 		}
 	default:
+		err = c.Request.ParseForm()
+		if err != nil {
+			return nil, err
+		}
+		formData := c.Request.PostForm
 		if audioRequest.Model == "" {
-			audioRequest.Model = c.PostForm("model")
+			audioRequest.Model = formData.Get("model")
 		}
+
 		if audioRequest.Model == "" {
 			return nil, errors.New("model is required")
 		}
+		audioRequest.ResponseFormat = formData.Get("response_format")
 		if audioRequest.ResponseFormat == "" {
 			audioRequest.ResponseFormat = "json"
 		}

+ 10 - 11
relay/relay-text.go

@@ -2,11 +2,9 @@ package relay
 
 import (
 	"bytes"
-	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/bytedance/sonic"
 	"io"
 	"math"
 	"net/http"
@@ -36,7 +34,7 @@ func getAndValidateTextRequest(c *gin.Context, relayInfo *relaycommon.RelayInfo)
 		textRequest.Model = c.Param("model")
 	}
 
-	if textRequest.MaxTokens < 0 || textRequest.MaxTokens > math.MaxInt32/2 {
+	if textRequest.MaxTokens > math.MaxInt32/2 {
 		return nil, errors.New("max_tokens is invalid")
 	}
 	if textRequest.Model == "" {
@@ -48,12 +46,12 @@ func getAndValidateTextRequest(c *gin.Context, relayInfo *relaycommon.RelayInfo)
 			return nil, errors.New("field prompt is required")
 		}
 	case relayconstant.RelayModeChatCompletions:
-		if textRequest.Messages == nil || len(textRequest.Messages) == 0 {
+		if len(textRequest.Messages) == 0 {
 			return nil, errors.New("field messages is required")
 		}
 	case relayconstant.RelayModeEmbeddings:
 	case relayconstant.RelayModeModerations:
-		if textRequest.Input == "" || textRequest.Input == nil {
+		if textRequest.Input == nil || textRequest.Input == "" {
 			return nil, errors.New("field input is required")
 		}
 	case relayconstant.RelayModeEdits:
@@ -180,7 +178,7 @@ func TextHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
 	if err != nil {
 		return service.OpenAIErrorWrapperLocal(err, "convert_request_failed", http.StatusInternalServerError)
 	}
-	jsonData, err := sonic.Marshal(convertedRequest)
+	jsonData, err := json.Marshal(convertedRequest)
 	if err != nil {
 		return service.OpenAIErrorWrapperLocal(err, "json_marshal_failed", http.StatusInternalServerError)
 	}
@@ -264,7 +262,7 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
 		return 0, 0, service.OpenAIErrorWrapperLocal(errors.New("user quota is not enough"), "insufficient_user_quota", http.StatusForbidden)
 	}
 	if userQuota-preConsumedQuota < 0 {
-		return 0, 0, service.OpenAIErrorWrapperLocal(errors.New(fmt.Sprintf("chat pre-consumed quota failed, user quota: %d, need quota: %d", userQuota, preConsumedQuota)), "insufficient_user_quota", http.StatusBadRequest)
+		return 0, 0, service.OpenAIErrorWrapperLocal(fmt.Errorf("chat pre-consumed quota failed, user quota: %d, need quota: %d", userQuota, preConsumedQuota), "insufficient_user_quota", http.StatusBadRequest)
 	}
 	err = model.CacheDecreaseUserQuota(relayInfo.UserId, preConsumedQuota)
 	if err != nil {
@@ -298,13 +296,14 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
 
 func returnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, userQuota int, preConsumedQuota int) {
 	if preConsumedQuota != 0 {
-		go func(ctx context.Context) {
-			// return pre-consumed quota
-			err := model.PostConsumeTokenQuota(relayInfo, userQuota, -preConsumedQuota, 0, false)
+		go func() {
+			relayInfoCopy := *relayInfo
+
+			err := model.PostConsumeTokenQuota(&relayInfoCopy, userQuota, -preConsumedQuota, 0, false)
 			if err != nil {
 				common.SysError("error return pre-consumed quota: " + err.Error())
 			}
-		}(c)
+		}()
 	}
 }
 

+ 3 - 0
relay/relay_adaptor.go

@@ -9,6 +9,7 @@ import (
 	"one-api/relay/channel/claude"
 	"one-api/relay/channel/cloudflare"
 	"one-api/relay/channel/cohere"
+	"one-api/relay/channel/deepseek"
 	"one-api/relay/channel/dify"
 	"one-api/relay/channel/gemini"
 	"one-api/relay/channel/jina"
@@ -71,6 +72,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
 		return &vertex.Adaptor{}
 	case constant.APITypeMistral:
 		return &mistral.Adaptor{}
+	case constant.APITypeDeepSeek:
+		return &deepseek.Adaptor{}
 	}
 	return nil
 }

+ 3 - 0
router/api-router.go

@@ -91,6 +91,9 @@ func SetApiRouter(router *gin.Engine) {
 			channelRoute.POST("/", controller.AddChannel)
 			channelRoute.PUT("/", controller.UpdateChannel)
 			channelRoute.DELETE("/disabled", controller.DeleteDisabledChannel)
+			channelRoute.POST("/tag/disabled", controller.DisableTagChannels)
+			channelRoute.POST("/tag/enabled", controller.EnableTagChannels)
+			channelRoute.PUT("/tag", controller.EditTagChannels)
 			channelRoute.DELETE("/:id", controller.DeleteChannel)
 			channelRoute.POST("/batch", controller.DeleteChannelBatch)
 			channelRoute.POST("/fix", controller.FixChannelsAbilities)

+ 1 - 0
router/relay-router.go

@@ -9,6 +9,7 @@ import (
 
 func SetRelayRouter(router *gin.Engine) {
 	router.Use(middleware.CORS())
+	router.Use(middleware.DecompressRequestMiddleware())
 	// https://platform.openai.com/docs/api-reference/introduction
 	modelsRouter := router.Group("/v1/models")
 	modelsRouter.Use(middleware.TokenAuth())

+ 2 - 2
service/quota.go

@@ -22,7 +22,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
 		return err
 	}
 
-	token, err := model.CacheGetTokenByKey(strings.TrimLeft(relayInfo.ApiKey, "sk-"))
+	token, err := model.CacheGetTokenByKey(strings.TrimLeft(relayInfo.TokenKey, "sk-"))
 	if err != nil {
 		return err
 	}
@@ -53,7 +53,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
 		return errors.New(fmt.Sprintf("用户额度不足,剩余额度为 %d", userQuota))
 	}
 
-	if token.RemainQuota < quota {
+	if !token.UnlimitedQuota && token.RemainQuota < quota {
 		return errors.New(fmt.Sprintf("令牌额度不足,剩余额度为 %d", token.RemainQuota))
 	}
 

+ 1 - 1
web/package.json

@@ -5,7 +5,7 @@
   "type": "module",
   "dependencies": {
     "@douyinfe/semi-icons": "^2.63.1",
-    "@douyinfe/semi-ui": "^2.63.1",
+    "@douyinfe/semi-ui": "^2.69.1",
     "@visactor/react-vchart": "~1.8.8",
     "@visactor/vchart": "~1.8.8",
     "@visactor/vchart-semi-theme": "~1.8.8",

Fichier diff supprimé car celui-ci est trop grand
+ 1967 - 735
web/pnpm-lock.yaml


+ 1 - 1
web/src/App.js

@@ -24,7 +24,7 @@ import { Layout } from '@douyinfe/semi-ui';
 import Midjourney from './pages/Midjourney';
 import Pricing from './pages/Pricing/index.js';
 import Task from "./pages/Task/index.js";
-import Playground from './components/Playground.js';
+import Playground from './pages/Playground/Playground.js';
 import OAuth2Callback from "./components/OAuth2Callback.js";
 
 const Home = lazy(() => import('./pages/Home'));

+ 681 - 398
web/src/components/ChannelsTable.js

@@ -7,20 +7,21 @@ import {
   showInfo,
   showSuccess,
   showWarning,
-  timestamp2string,
+  timestamp2string
 } from '../helpers';
 
 import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
 import {
+  getQuotaPerUnit,
   renderGroup,
   renderNumberWithPoint,
-  renderQuota,
+  renderQuota, renderQuotaWithPrompt
 } from '../helpers/render';
 import {
   Button, Divider,
   Dropdown,
-  Form,
-  InputNumber,
+  Form, Input,
+  InputNumber, Modal,
   Popconfirm,
   Space,
   SplitButtonGroup,
@@ -28,11 +29,13 @@ import {
   Table,
   Tag,
   Tooltip,
-  Typography,
+  Typography
 } from '@douyinfe/semi-ui';
 import EditChannel from '../pages/Channel/EditChannel';
-import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
+import { IconList, IconTreeTriangleDown } from '@douyinfe/semi-icons';
 import { loadChannelModels } from './utils.js';
+import EditTagModal from '../pages/Channel/EditTagModal.js';
+import TextNumberInput from './custom/TextNumberInput.js';
 
 function renderTimestamp(timestamp) {
   return <>{timestamp2string(timestamp)}</>;
@@ -49,12 +52,26 @@ function renderType(type) {
     type2label[0] = { value: 0, text: '未知类型', color: 'grey' };
   }
   return (
-    <Tag size='large' color={type2label[type]?.color}>
+    <Tag size="large" color={type2label[type]?.color}>
       {type2label[type]?.text}
     </Tag>
   );
 }
 
+function renderTagType(type) {
+  return (
+    <Tag
+      color='light-blue'
+      prefixIcon={<IconList />}
+      size='large'
+      shape='circle'
+      type='light'
+    >
+      标签聚合
+    </Tag>
+  );
+}
+
 const ChannelsTable = () => {
   const columns = [
     // {
@@ -64,11 +81,11 @@ const ChannelsTable = () => {
     // },
     {
       title: 'ID',
-      dataIndex: 'id',
+      dataIndex: 'id'
     },
     {
       title: '名称',
-      dataIndex: 'name',
+      dataIndex: 'name'
     },
     {
       title: '分组',
@@ -77,20 +94,24 @@ const ChannelsTable = () => {
         return (
           <div>
             <Space spacing={2}>
-              {text.split(',').map((item, index) => {
+              {text?.split(',').map((item, index) => {
                 return renderGroup(item);
               })}
             </Space>
           </div>
         );
-      },
+      }
     },
     {
       title: '类型',
       dataIndex: 'type',
       render: (text, record, index) => {
-        return <div>{renderType(text)}</div>;
-      },
+        if (record.children === undefined) {
+          return <>{renderType(text)}</>;
+        } else {
+          return <>{renderTagType(0)}</>;
+        }
+      }
     },
     {
       title: '状态',
@@ -98,7 +119,7 @@ const ChannelsTable = () => {
       render: (text, record, index) => {
         if (text === 3) {
           if (record.other_info === '') {
-            record.other_info = '{}'
+            record.other_info = '{}';
           }
           let otherInfo = JSON.parse(record.other_info);
           let reason = otherInfo['status_reason'];
@@ -113,181 +134,285 @@ const ChannelsTable = () => {
         } else {
           return renderStatus(text);
         }
-      },
+      }
     },
     {
       title: '响应时间',
       dataIndex: 'response_time',
       render: (text, record, index) => {
         return <div>{renderResponseTime(text)}</div>;
-      },
+      }
     },
     {
       title: '已用/剩余',
       dataIndex: 'expired_time',
       render: (text, record, index) => {
-        return (
-          <div>
-            <Space spacing={1}>
-              <Tooltip content={'已用额度'}>
-                <Tag color='white' type='ghost' size='large'>
-                  {renderQuota(record.used_quota)}
-                </Tag>
-              </Tooltip>
-              <Tooltip content={'剩余额度' + record.balance + ',点击更新'}>
-                <Tag
-                  color='white'
-                  type='ghost'
-                  size='large'
-                  onClick={() => {
-                    updateChannelBalance(record);
-                  }}
-                >
-                  ${renderNumberWithPoint(record.balance)}
-                </Tag>
-              </Tooltip>
-            </Space>
-          </div>
-        );
-      },
+        if (record.children === undefined) {
+          return (
+            <div>
+              <Space spacing={1}>
+                <Tooltip content={'已用额度'}>
+                  <Tag color="white" type="ghost" size="large">
+                    {renderQuota(record.used_quota)}
+                  </Tag>
+                </Tooltip>
+                <Tooltip content={'剩余额度' + record.balance + ',点击更新'}>
+                  <Tag
+                    color="white"
+                    type="ghost"
+                    size="large"
+                    onClick={() => {
+                      updateChannelBalance(record);
+                    }}
+                  >
+                    ${renderNumberWithPoint(record.balance)}
+                  </Tag>
+                </Tooltip>
+              </Space>
+            </div>
+          );
+        } else {
+          return <Tooltip content={'已用额度'}>
+            <Tag color="white" type="ghost" size="large">
+              {renderQuota(record.used_quota)}
+            </Tag>
+          </Tooltip>;
+        }
+      }
     },
     {
       title: '优先级',
       dataIndex: 'priority',
       render: (text, record, index) => {
-        return (
-          <div>
+        if (record.children === undefined) {
+          return (
+            <div>
+              <InputNumber
+                style={{ width: 70 }}
+                name="priority"
+                onBlur={(e) => {
+                  manageChannel(record.id, 'priority', record, e.target.value);
+                }}
+                keepFocus={true}
+                innerButtons
+                defaultValue={record.priority}
+                min={-999}
+              />
+            </div>
+          );
+        } else {
+          return <>
             <InputNumber
               style={{ width: 70 }}
-              name='priority'
+              name="priority"
+              keepFocus={true}
               onBlur={(e) => {
-                manageChannel(record.id, 'priority', record, e.target.value);
+                Modal.warning({
+                  title: '修改子渠道优先级',
+                  content: '确定要修改所有子渠道优先级为 ' + e.target.value + ' 吗?',
+                  onOk: () => {
+                    if (e.target.value === '') {
+                      return;
+                    }
+                    submitTagEdit('priority', {
+                      tag: record.key,
+                      priority: e.target.value
+                    })
+                  },
+                })
               }}
-              keepFocus={true}
               innerButtons
               defaultValue={record.priority}
               min={-999}
             />
-          </div>
-        );
-      },
+          </>;
+        }
+      }
     },
     {
       title: '权重',
       dataIndex: 'weight',
       render: (text, record, index) => {
-        return (
-          <div>
+        if (record.children === undefined) {
+          return (
+            <div>
+              <InputNumber
+                style={{ width: 70 }}
+                name="weight"
+                onBlur={(e) => {
+                  manageChannel(record.id, 'weight', record, e.target.value);
+                }}
+                keepFocus={true}
+                innerButtons
+                defaultValue={record.weight}
+                min={0}
+              />
+            </div>
+          );
+        } else {
+          return (
             <InputNumber
               style={{ width: 70 }}
-              name='weight'
+              name="weight"
+              keepFocus={true}
               onBlur={(e) => {
-                manageChannel(record.id, 'weight', record, e.target.value);
+                Modal.warning({
+                  title: '修改子渠道权重',
+                  content: '确定要修改所有子渠道权重为 ' + e.target.value + ' 吗?',
+                  onOk: () => {
+                    if (e.target.value === '') {
+                      return;
+                    }
+                    submitTagEdit('weight', {
+                      tag: record.key,
+                      weight: e.target.value
+                    })
+                  },
+                })
               }}
-              keepFocus={true}
               innerButtons
               defaultValue={record.weight}
-              min={0}
+              min={-999}
             />
-          </div>
-        );
-      },
+          );
+        }
+      }
     },
     {
       title: '',
       dataIndex: 'operate',
-      render: (text, record, index) => (
-        <div>
-          <SplitButtonGroup
-            style={{ marginRight: 1 }}
-            aria-label='测试操作项目组'
-          >
-            <Button
-              theme='light'
-              onClick={() => {
-                testChannel(record, '');
-              }}
-            >
-              测试
-            </Button>
-            <Dropdown
-              trigger='click'
-              position='bottomRight'
-              menu={record.test_models}
-            >
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          return (
+            <div>
+              <SplitButtonGroup
+                style={{ marginRight: 1 }}
+                aria-label="测试单个渠道操作项目组"
+              >
+                <Button
+                  theme="light"
+                  onClick={() => {
+                    testChannel(record, '');
+                  }}
+                >
+                  测试
+                </Button>
+                <Dropdown
+                  trigger="click"
+                  position="bottomRight"
+                  menu={record.test_models}
+                >
+                  <Button
+                    style={{ padding: '8px 4px' }}
+                    type="primary"
+                    icon={<IconTreeTriangleDown />}
+                  ></Button>
+                </Dropdown>
+              </SplitButtonGroup>
+              {/*<Button theme='light' type='primary' style={{marginRight: 1}} onClick={()=>testChannel(record)}>测试</Button>*/}
+              <Popconfirm
+                title="确定是否要删除此渠道?"
+                content="此修改将不可逆"
+                okType={'danger'}
+                position={'left'}
+                onConfirm={() => {
+                  manageChannel(record.id, 'delete', record).then(() => {
+                    removeRecord(record);
+                  });
+                }}
+              >
+                <Button theme="light" type="danger" style={{ marginRight: 1 }}>
+                  删除
+                </Button>
+              </Popconfirm>
+              {record.status === 1 ? (
+                <Button
+                  theme="light"
+                  type="warning"
+                  style={{ marginRight: 1 }}
+                  onClick={async () => {
+                    manageChannel(record.id, 'disable', record);
+                  }}
+                >
+                  禁用
+                </Button>
+              ) : (
+                <Button
+                  theme="light"
+                  type="secondary"
+                  style={{ marginRight: 1 }}
+                  onClick={async () => {
+                    manageChannel(record.id, 'enable', record);
+                  }}
+                >
+                  启用
+                </Button>
+              )}
               <Button
-                style={{ padding: '8px 4px' }}
-                type='primary'
-                icon={<IconTreeTriangleDown />}
-              ></Button>
-            </Dropdown>
-          </SplitButtonGroup>
-          {/*<Button theme='light' type='primary' style={{marginRight: 1}} onClick={()=>testChannel(record)}>测试</Button>*/}
-          <Popconfirm
-            title='确定是否要删除此渠道?'
-            content='此修改将不可逆'
-            okType={'danger'}
-            position={'left'}
-            onConfirm={() => {
-              manageChannel(record.id, 'delete', record).then(() => {
-                removeRecord(record.id);
-              });
-            }}
-          >
-            <Button theme='light' type='danger' style={{ marginRight: 1 }}>
-              删除
-            </Button>
-          </Popconfirm>
-          {record.status === 1 ? (
-            <Button
-              theme='light'
-              type='warning'
-              style={{ marginRight: 1 }}
-              onClick={async () => {
-                manageChannel(record.id, 'disable', record);
-              }}
-            >
-              禁用
-            </Button>
-          ) : (
-            <Button
-              theme='light'
-              type='secondary'
-              style={{ marginRight: 1 }}
-              onClick={async () => {
-                manageChannel(record.id, 'enable', record);
-              }}
-            >
-              启用
-            </Button>
-          )}
-          <Button
-            theme='light'
-            type='tertiary'
-            style={{ marginRight: 1 }}
-            onClick={() => {
-              setEditingChannel(record);
-              setShowEdit(true);
-            }}
-          >
-            编辑
-          </Button>
-          <Popconfirm
-            title='确定是否要复制此渠道?'
-            content='复制渠道的所有信息'
-            okType={'danger'}
-            position={'left'}
-            onConfirm={async () => {
-              copySelectedChannel(record.id);
-            }}
-          >
-            <Button theme='light' type='primary' style={{ marginRight: 1 }}>
-              复制
-            </Button>
-          </Popconfirm>
-        </div>
-      ),
-    },
+                theme="light"
+                type="tertiary"
+                style={{ marginRight: 1 }}
+                onClick={() => {
+                  setEditingChannel(record);
+                  setShowEdit(true);
+                }}
+              >
+                编辑
+              </Button>
+              <Popconfirm
+                title="确定是否要复制此渠道?"
+                content="复制渠道的所有信息"
+                okType={'danger'}
+                position={'left'}
+                onConfirm={async () => {
+                  copySelectedChannel(record);
+                }}
+              >
+                <Button theme="light" type="primary" style={{ marginRight: 1 }}>
+                  复制
+                </Button>
+              </Popconfirm>
+            </div>
+          );
+        } else {
+          return (
+            <>
+              <Button
+                theme="light"
+                type="secondary"
+                style={{ marginRight: 1 }}
+                onClick={async () => {
+                  manageTag(record.key, 'enable');
+                }}
+              >
+                启用全部
+              </Button>
+              <Button
+                theme="light"
+                type="warning"
+                style={{ marginRight: 1 }}
+                onClick={async () => {
+                  manageTag(record.key, 'disable');
+                }}
+              >
+                禁用全部
+              </Button>
+              <Button
+                theme="light"
+                type="tertiary"
+                style={{ marginRight: 1 }}
+                onClick={() => {
+                  setShowEditTag(true);
+                  setEditingTag(record.key);
+                }}
+              >
+                编辑
+              </Button>
+            </>
+          );
+        }
+      }
+    }
   ];
 
   const [channels, setChannels] = useState([]);
@@ -301,21 +426,37 @@ const ChannelsTable = () => {
   const [updatingBalance, setUpdatingBalance] = useState(false);
   const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
   const [showPrompt, setShowPrompt] = useState(
-    shouldShowPrompt('channel-test'),
+    shouldShowPrompt('channel-test')
   );
   const [channelCount, setChannelCount] = useState(pageSize);
   const [groupOptions, setGroupOptions] = useState([]);
   const [showEdit, setShowEdit] = useState(false);
   const [enableBatchDelete, setEnableBatchDelete] = useState(false);
   const [editingChannel, setEditingChannel] = useState({
-    id: undefined,
+    id: undefined
   });
+  const [showEditTag, setShowEditTag] = useState(false);
+  const [editingTag, setEditingTag] = useState('');
   const [selectedChannels, setSelectedChannels] = useState([]);
+  const [showEditPriority, setShowEditPriority] = useState(false);
+  const [enableTagMode, setEnableTagMode] = useState(false);
 
-  const removeRecord = (id) => {
+
+  const removeRecord = (record) => {
     let newDataSource = [...channels];
-    if (id != null) {
-      let idx = newDataSource.findIndex((data) => data.id === id);
+    if (record.id != null) {
+      let idx = newDataSource.findIndex((data) => {
+        if (data.children !== undefined) {
+          for (let i = 0; i < data.children.length; i++) {
+            if (data.children[i].id === record.id) {
+              data.children.splice(i, 1);
+              return false;
+            }
+          }
+        } else {
+          return data.id === record.id
+        }
+      });
 
       if (idx > -1) {
         newDataSource.splice(idx, 1);
@@ -324,14 +465,10 @@ const ChannelsTable = () => {
     }
   };
 
-  const setChannelFormat = (channels) => {
+  const setChannelFormat = (channels, enableTagMode) => {
+    let channelDates = [];
+    let channelTags = {};
     for (let i = 0; i < channels.length; i++) {
-      // if (channels[i].type === 8) {
-      //   showWarning(
-      //     '检测到您使用了“自定义渠道”类型,请更换为“OpenAI”渠道类型!',
-      //   );
-      //   showWarning('下个版本将不再支持“自定义渠道”类型!');
-      // }
       channels[i].key = '' + channels[i].id;
       let test_models = [];
       channels[i].models.split(',').forEach((item, index) => {
@@ -340,24 +477,87 @@ const ChannelsTable = () => {
           name: item,
           onClick: () => {
             testChannel(channels[i], item);
-          },
+          }
         });
       });
       channels[i].test_models = test_models;
+      if (!enableTagMode) {
+        channelDates.push(channels[i]);
+      } else {
+        let tag = channels[i].tag?channels[i].tag:"";
+        // find from channelTags
+        let tagIndex = channelTags[tag];
+        let tagChannelDates = undefined;
+        if (tagIndex === undefined) {
+          // not found, create a new tag
+          channelTags[tag] = 1;
+          tagChannelDates = {
+            key: tag,
+            id: tag,
+            tag: tag,
+            name: '标签:' + tag,
+            group: '',
+            used_quota: 0,
+            response_time: 0,
+            priority: -1,
+            weight: -1,
+          };
+          tagChannelDates.children = [];
+          channelDates.push(tagChannelDates);
+        } else {
+          // found, add to the tag
+          tagChannelDates = channelDates.find((item) => item.key === tag);
+        }
+        if (tagChannelDates.priority === -1) {
+          tagChannelDates.priority = channels[i].priority;
+        } else {
+          if (tagChannelDates.priority !== channels[i].priority) {
+            tagChannelDates.priority = '';
+          }
+        }
+        if (tagChannelDates.weight === -1) {
+          tagChannelDates.weight = channels[i].weight;
+        } else {
+          if (tagChannelDates.weight !== channels[i].weight) {
+            tagChannelDates.weight = '';
+          }
+        }
+
+        if (tagChannelDates.group === '') {
+          tagChannelDates.group = channels[i].group;
+        } else {
+          let channelGroupsStr = channels[i].group;
+          channelGroupsStr.split(',').forEach((item, index) => {
+            if (tagChannelDates.group.indexOf(item) === -1) {
+              // join
+              tagChannelDates.group += ',' + item;
+            }
+          });
+        }
+
+        tagChannelDates.children.push(channels[i]);
+        if (channels[i].status === 1) {
+          tagChannelDates.status = 1;
+        }
+        tagChannelDates.used_quota += channels[i].used_quota;
+        tagChannelDates.response_time += channels[i].response_time;
+        tagChannelDates.response_time = tagChannelDates.response_time / 2;
+      }
+
     }
     // data.key = '' + data.id
-    setChannels(channels);
-    if (channels.length >= pageSize) {
-      setChannelCount(channels.length + pageSize);
+    setChannels(channelDates);
+    if (channelDates.length >= pageSize) {
+      setChannelCount(channelDates.length + pageSize);
     } else {
-      setChannelCount(channels.length);
+      setChannelCount(channelDates.length);
     }
   };
 
-  const loadChannels = async (startIdx, pageSize, idSort) => {
+  const loadChannels = async (startIdx, pageSize, idSort, enableTagMode) => {
     setLoading(true);
     const res = await API.get(
-      `/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}`,
+      `/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}`
     );
     if (res === undefined) {
       return;
@@ -365,11 +565,11 @@ const ChannelsTable = () => {
     const { success, message, data } = res.data;
     if (success) {
       if (startIdx === 0) {
-        setChannelFormat(data);
+        setChannelFormat(data, enableTagMode);
       } else {
         let newChannels = [...channels];
         newChannels.splice(startIdx * pageSize, data.length, ...data);
-        setChannelFormat(newChannels);
+        setChannelFormat(newChannels, enableTagMode);
       }
     } else {
       showError(message);
@@ -377,11 +577,8 @@ const ChannelsTable = () => {
     setLoading(false);
   };
 
-  const copySelectedChannel = async (id) => {
-    const channelToCopy = channels.find(
-      (channel) => String(channel.id) === String(id),
-    );
-    console.log(channelToCopy);
+  const copySelectedChannel = async (record) => {
+    const channelToCopy = record
     channelToCopy.name += '_复制';
     channelToCopy.created_time = null;
     channelToCopy.balance = 0;
@@ -405,7 +602,7 @@ const ChannelsTable = () => {
   };
 
   const refresh = async () => {
-    await loadChannels(activePage - 1, pageSize, idSort);
+    await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
   };
 
   useEffect(() => {
@@ -415,7 +612,7 @@ const ChannelsTable = () => {
       parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
     setIdSort(localIdSort);
     setPageSize(localPageSize);
-    loadChannels(0, localPageSize, localIdSort)
+    loadChannels(0, localPageSize, localIdSort, enableTagMode)
       .then()
       .catch((reason) => {
         showError(reason);
@@ -472,29 +669,63 @@ const ChannelsTable = () => {
     }
   };
 
+  const manageTag = async (tag, action) => {
+    console.log(tag, action);
+    let res;
+    switch (action) {
+      case 'enable':
+        res = await API.post('/api/channel/tag/enabled', {
+          tag: tag
+        });
+        break;
+      case 'disable':
+        res = await API.post('/api/channel/tag/disabled', {
+          tag: tag
+        });
+        break;
+    }
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('操作成功完成!');
+      let newChannels = [...channels];
+      for (let i = 0; i < newChannels.length; i++) {
+        if (newChannels[i].tag === tag) {
+          let status = action === 'enable' ? 1 : 2;
+          newChannels[i]?.children?.forEach((channel) => {
+            channel.status = status;
+          });
+          newChannels[i].status = status;
+        }
+      }
+      setChannels(newChannels);
+    } else {
+      showError(message);
+    }
+  };
+
   const renderStatus = (status) => {
     switch (status) {
       case 1:
         return (
-          <Tag size='large' color='green'>
+          <Tag size="large" color="green">
             已启用
           </Tag>
         );
       case 2:
         return (
-          <Tag size='large' color='yellow'>
+          <Tag size="large" color="yellow">
             已禁用
           </Tag>
         );
       case 3:
         return (
-          <Tag size='large' color='yellow'>
+          <Tag size="large" color="yellow">
             自动禁用
           </Tag>
         );
       default:
         return (
-          <Tag size='large' color='grey'>
+          <Tag size="large" color="grey">
             未知状态
           </Tag>
         );
@@ -506,51 +737,50 @@ const ChannelsTable = () => {
     time = time.toFixed(2) + ' 秒';
     if (responseTime === 0) {
       return (
-        <Tag size='large' color='grey'>
+        <Tag size="large" color="grey">
           未测试
         </Tag>
       );
     } else if (responseTime <= 1000) {
       return (
-        <Tag size='large' color='green'>
+        <Tag size="large" color="green">
           {time}
         </Tag>
       );
     } else if (responseTime <= 3000) {
       return (
-        <Tag size='large' color='lime'>
+        <Tag size="large" color="lime">
           {time}
         </Tag>
       );
     } else if (responseTime <= 5000) {
       return (
-        <Tag size='large' color='yellow'>
+        <Tag size="large" color="yellow">
           {time}
         </Tag>
       );
     } else {
       return (
-        <Tag size='large' color='red'>
+        <Tag size="large" color="red">
           {time}
         </Tag>
       );
     }
   };
 
-  const searchChannels = async (searchKeyword, searchGroup, searchModel) => {
+  const searchChannels = async (searchKeyword, searchGroup, searchModel, enableTagMode) => {
     if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-      // if keyword is blank, load files instead.
-      await loadChannels(0, pageSize, idSort);
+      await loadChannels(0, pageSize, idSort, enableTagMode);
       setActivePage(1);
       return;
     }
     setSearching(true);
     const res = await API.get(
-      `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}`,
+      `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`
     );
     const { success, message, data } = res.data;
     if (success) {
-      setChannelFormat(data);
+      setChannelFormat(data, enableTagMode);
       setActivePage(1);
     } else {
       showError(message);
@@ -649,14 +879,15 @@ const ChannelsTable = () => {
 
   let pageData = channels.slice(
     (activePage - 1) * pageSize,
-    activePage * pageSize,
+    activePage * pageSize
   );
 
   const handlePageChange = (page) => {
     setActivePage(page);
     if (page === Math.ceil(channels.length / pageSize) + 1) {
       // In this case we have to load more data and then append them.
-      loadChannels(page - 1, pageSize, idSort).then((r) => {});
+      loadChannels(page - 1, pageSize, idSort, enableTagMode).then((r) => {
+      });
     }
   };
 
@@ -664,7 +895,7 @@ const ChannelsTable = () => {
     localStorage.setItem('page-size', size + '');
     setPageSize(size);
     setActivePage(1);
-    loadChannels(0, size, idSort)
+    loadChannels(0, size, idSort, enableTagMode)
       .then()
       .catch((reason) => {
         showError(reason);
@@ -682,14 +913,43 @@ const ChannelsTable = () => {
       setGroupOptions(
         res.data.data.map((group) => ({
           label: group,
-          value: group,
-        })),
+          value: group
+        }))
       );
     } catch (error) {
       showError(error.message);
     }
   };
 
+  const submitTagEdit = async (type, data) => {
+    switch (type) {
+      case 'priority':
+        if (data.priority === undefined || data.priority === '') {
+          showInfo('优先级必须是整数!');
+          return;
+        }
+        data.priority = parseInt(data.priority);
+        break;
+      case 'weight':
+        if (data.weight === undefined || data.weight < 0 || data.weight === '') {
+          showInfo('权重必须是非负整数!');
+          return;
+        }
+        data.weight = parseInt(data.weight);
+        break
+    }
+
+    try {
+      const res = await API.put('/api/channel/tag', data);
+      if (res?.data?.success) {
+        showSuccess('更新成功!');
+        await refresh();
+      }
+    } catch (error) {
+      showError(error);
+    }
+  }
+
   const closeEdit = () => {
     setShowEdit(false);
   };
@@ -698,8 +958,8 @@ const ChannelsTable = () => {
     if (record.status !== 1) {
       return {
         style: {
-          background: 'var(--semi-color-disabled-border)',
-        },
+          background: 'var(--semi-color-disabled-border)'
+        }
       };
     } else {
       return {};
@@ -707,217 +967,240 @@ const ChannelsTable = () => {
   };
 
   return (
-      <>
-        <EditChannel
-            refresh={refresh}
-            visible={showEdit}
-            handleClose={closeEdit}
-            editingChannel={editingChannel}
-        />
-        <Form
-            onSubmit={() => {
-              searchChannels(searchKeyword, searchGroup, searchModel);
-            }}
-            labelPosition='left'
+    <>
+      <EditTagModal
+        visible={showEditTag}
+        tag={editingTag}
+        handleClose={() => setShowEditTag(false)}
+        refresh={refresh}
+      />
+      <EditChannel
+        refresh={refresh}
+        visible={showEdit}
+        handleClose={closeEdit}
+        editingChannel={editingChannel}
+      />
+      <Form
+        onSubmit={() => {
+          searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode);
+        }}
+        labelPosition="left"
+      >
+        <div style={{ display: 'flex' }}>
+          <Space>
+            <Form.Input
+              field="search_keyword"
+              label="搜索渠道关键词"
+              placeholder="ID,名称和密钥 ..."
+              value={searchKeyword}
+              loading={searching}
+              onChange={(v) => {
+                setSearchKeyword(v.trim());
+              }}
+            />
+            <Form.Input
+              field="search_model"
+              label="模型"
+              placeholder="模型关键字"
+              value={searchModel}
+              loading={searching}
+              onChange={(v) => {
+                setSearchModel(v.trim());
+              }}
+            />
+            <Form.Select
+              field="group"
+              label="分组"
+              optionList={[{ label: '选择分组', value: null }, ...groupOptions]}
+              initValue={null}
+              onChange={(v) => {
+                setSearchGroup(v);
+                searchChannels(searchKeyword, v, searchModel, enableTagMode);
+              }}
+            />
+            <Button
+              label="查询"
+              type="primary"
+              htmlType="submit"
+              className="btn-margin-right"
+              style={{ marginRight: 8 }}
+            >
+              查询
+            </Button>
+          </Space>
+        </div>
+      </Form>
+      <Divider style={{ marginBottom: 15 }} />
+      <div
+        style={{
+          display: isMobile() ? '' : 'flex',
+          marginTop: isMobile() ? 0 : -45,
+          zIndex: 999,
+          pointerEvents: 'none'
+        }}
+      >
+        <Space
+          style={{ pointerEvents: 'auto', marginTop: isMobile() ? 0 : 45 }}
         >
-          <div style={{display: 'flex'}}>
-            <Space>
-              <Form.Input
-                  field='search_keyword'
-                  label='搜索渠道关键词'
-                  placeholder='ID,名称和密钥 ...'
-                  value={searchKeyword}
-                  loading={searching}
-                  onChange={(v) => {
-                    setSearchKeyword(v.trim());
-                  }}
-              />
-              <Form.Input
-                  field='search_model'
-                  label='模型'
-                  placeholder='模型关键字'
-                  value={searchModel}
-                  loading={searching}
-                  onChange={(v) => {
-                    setSearchModel(v.trim());
-                  }}
-              />
-              <Form.Select
-                  field='group'
-                  label='分组'
-                  optionList={[{label: '选择分组', value: null}, ...groupOptions]}
-                  initValue={null}
-                  onChange={(v) => {
-                    setSearchGroup(v);
-                    searchChannels(searchKeyword, v, searchModel);
-                  }}
-              />
-              <Button
-                  label='查询'
-                  type='primary'
-                  htmlType='submit'
-                  className='btn-margin-right'
-                  style={{marginRight: 8}}
-              >
-                查询
-              </Button>
-            </Space>
-          </div>
-        </Form>
-        <Divider style={{marginBottom:15}}/>
-        <div
-            style={{
-              display: isMobile() ? '' : 'flex',
-              marginTop: isMobile() ? 0 : -45,
-              zIndex: 999,
-              pointerEvents: 'none',
+          <Typography.Text strong>使用ID排序</Typography.Text>
+          <Switch
+            checked={idSort}
+            label="使用ID排序"
+            uncheckedText="关"
+            aria-label="是否用ID排序"
+            onChange={(v) => {
+              localStorage.setItem('id-sort', v + '');
+              setIdSort(v);
+              loadChannels(0, pageSize, v, enableTagMode)
+                .then()
+                .catch((reason) => {
+                  showError(reason);
+                });
+            }}
+          ></Switch>
+          <Button
+            theme="light"
+            type="primary"
+            style={{ marginRight: 8 }}
+            onClick={() => {
+              setEditingChannel({
+                id: undefined
+              });
+              setShowEdit(true);
             }}
-        >
-          <Space
-              style={{pointerEvents: 'auto', marginTop: isMobile() ? 0 : 45}}
           >
-            <Typography.Text strong>使用ID排序</Typography.Text>
-            <Switch
-                checked={idSort}
-                label='使用ID排序'
-                uncheckedText='关'
-                aria-label='是否用ID排序'
-                onChange={(v) => {
-                  localStorage.setItem('id-sort', v + '');
-                  setIdSort(v);
-                  loadChannels(0, pageSize, v)
-                      .then()
-                      .catch((reason) => {
-                        showError(reason);
-                      });
-                }}
-            ></Switch>
-            <Button
-                theme='light'
-                type='primary'
-                style={{marginRight: 8}}
-                onClick={() => {
-                  setEditingChannel({
-                    id: undefined,
-                  });
-                  setShowEdit(true);
-                }}
-            >
-              添加渠道
+            添加渠道
+          </Button>
+          <Popconfirm
+            title="确定?"
+            okType={'warning'}
+            onConfirm={testAllChannels}
+            position={isMobile() ? 'top' : 'top'}
+          >
+            <Button theme="light" type="warning" style={{ marginRight: 8 }}>
+              测试所有通道
             </Button>
-            <Popconfirm
-                title='确定?'
-                okType={'warning'}
-                onConfirm={testAllChannels}
-                position={isMobile() ? 'top' : 'top'}
-            >
-              <Button theme='light' type='warning' style={{marginRight: 8}}>
-                测试所有通道
-              </Button>
-            </Popconfirm>
-            <Popconfirm
-                title='确定?'
-                okType={'secondary'}
-                onConfirm={updateAllChannelsBalance}
-            >
-              <Button theme='light' type='secondary' style={{marginRight: 8}}>
-                更新所有已启用通道余额
-              </Button>
-            </Popconfirm>
-            <Popconfirm
-                title='确定是否要删除禁用通道?'
-                content='此修改将不可逆'
-                okType={'danger'}
-                onConfirm={deleteAllDisabledChannels}
-            >
-              <Button theme='light' type='danger' style={{marginRight: 8}}>
-                删除禁用通道
-              </Button>
-            </Popconfirm>
+          </Popconfirm>
+          <Popconfirm
+            title="确定?"
+            okType={'secondary'}
+            onConfirm={updateAllChannelsBalance}
+          >
+            <Button theme="light" type="secondary" style={{ marginRight: 8 }}>
+              更新所有已启用通道余额
+            </Button>
+          </Popconfirm>
+          <Popconfirm
+            title="确定是否要删除禁用通道?"
+            content="此修改将不可逆"
+            okType={'danger'}
+            onConfirm={deleteAllDisabledChannels}
+          >
+            <Button theme="light" type="danger" style={{ marginRight: 8 }}>
+              删除禁用通道
+            </Button>
+          </Popconfirm>
 
+          <Button
+            theme="light"
+            type="primary"
+            style={{ marginRight: 8 }}
+            onClick={refresh}
+          >
+            刷新
+          </Button>
+        </Space>
+      </div>
+      <div style={{ marginTop: 20 }}>
+        <Space>
+          <Typography.Text strong>开启批量删除</Typography.Text>
+          <Switch
+            label="开启批量删除"
+            uncheckedText="关"
+            aria-label="是否开启批量删除"
+            onChange={(v) => {
+              setEnableBatchDelete(v);
+            }}
+          ></Switch>
+          <Popconfirm
+            title="确定是否要删除所选通道?"
+            content="此修改将不可逆"
+            okType={'danger'}
+            onConfirm={batchDeleteChannels}
+            disabled={!enableBatchDelete}
+            position={'top'}
+          >
             <Button
-                theme='light'
-                type='primary'
-                style={{marginRight: 8}}
-                onClick={refresh}
+              disabled={!enableBatchDelete}
+              theme="light"
+              type="danger"
+              style={{ marginRight: 8 }}
             >
-              刷新
+              删除所选通道
             </Button>
-          </Space>
-        </div>
-        <div style={{marginTop: 20}}>
-          <Space>
-            <Typography.Text strong>开启批量删除</Typography.Text>
-            <Switch
-                label='开启批量删除'
-                uncheckedText='关'
-                aria-label='是否开启批量删除'
-                onChange={(v) => {
-                  setEnableBatchDelete(v);
-                }}
-            ></Switch>
-            <Popconfirm
-                title='确定是否要删除所选通道?'
-                content='此修改将不可逆'
-                okType={'danger'}
-                onConfirm={batchDeleteChannels}
-                disabled={!enableBatchDelete}
-                position={'top'}
-            >
-              <Button
-                  disabled={!enableBatchDelete}
-                  theme='light'
-                  type='danger'
-                  style={{marginRight: 8}}
-              >
-                删除所选通道
-              </Button>
-            </Popconfirm>
-            <Popconfirm
-                title='确定是否要修复数据库一致性?'
-                content='进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'
-                okType={'warning'}
-                onConfirm={fixChannelsAbilities}
-                position={'top'}
-            >
-              <Button theme='light' type='secondary' style={{marginRight: 8}}>
-                修复数据库一致性
-              </Button>
-            </Popconfirm>
-          </Space>
-        </div>
-
-        <Table
-            className={'channel-table'}
-            style={{marginTop: 15}}
-            columns={columns}
-            dataSource={pageData}
-            pagination={{
-              currentPage: activePage,
-              pageSize: pageSize,
-              total: channelCount,
-              pageSizeOpts: [10, 20, 50, 100],
-              showSizeChanger: true,
-              formatPageText: (page) => '',
-              onPageSizeChange: (size) => {
-                handlePageSizeChange(size).then();
-              },
-              onPageChange: handlePageChange,
+          </Popconfirm>
+          <Popconfirm
+            title="确定是否要修复数据库一致性?"
+            content="进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用"
+            okType={'warning'}
+            onConfirm={fixChannelsAbilities}
+            position={'top'}
+          >
+            <Button theme="light" type="secondary" style={{ marginRight: 8 }}>
+              修复数据库一致性
+            </Button>
+          </Popconfirm>
+        </Space>
+      </div>
+      <div style={{ marginTop: 20 }}>
+      <Space>
+          <Typography.Text strong>标签聚合模式</Typography.Text>
+          <Switch
+            checked={enableTagMode}
+            label="标签聚合模式"
+            uncheckedText="关"
+            aria-label="是否启用标签聚合"
+            onChange={(v) => {
+              setEnableTagMode(v);
+              // 切换模式时重新加载数据
+              loadChannels(0, pageSize, idSort, v);
             }}
-            loading={loading}
-            onRow={handleRow}
-            rowSelection={
-              enableBatchDelete
-                  ? {
-                    onChange: (selectedRowKeys, selectedRows) => {
-                      // console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows);
-                      setSelectedChannels(selectedRows);
-                    },
-                  }
-                  : null
+          />
+        </Space>
+      </div>
+
+
+      <Table
+        className={'channel-table'}
+        style={{ marginTop: 15 }}
+        columns={columns}
+        dataSource={pageData}
+        pagination={{
+          currentPage: activePage,
+          pageSize: pageSize,
+          total: channelCount,
+          pageSizeOpts: [10, 20, 50, 100],
+          showSizeChanger: true,
+          formatPageText: (page) => '',
+          onPageSizeChange: (size) => {
+            handlePageSizeChange(size).then();
+          },
+          onPageChange: handlePageChange
+        }}
+        loading={loading}
+        onRow={handleRow}
+        rowSelection={
+          enableBatchDelete
+            ? {
+              onChange: (selectedRowKeys, selectedRows) => {
+                // console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows);
+                setSelectedChannels(selectedRows);
+              }
             }
-        />
-      </>
+            : null
+        }
+      />
+    </>
   );
 };
 

+ 65 - 42
web/src/components/HeaderBar.js

@@ -9,17 +9,19 @@ import '../index.css';
 import fireworks from 'react-fireworks';
 
 import {
+  IconClose,
   IconHelpCircle,
   IconHome,
-  IconHomeStroked,
-  IconKey,
+  IconHomeStroked, IconIndentLeft,
+  IconKey, IconMenu,
   IconNoteMoneyStroked,
   IconPriceTag,
   IconUser
 } from '@douyinfe/semi-icons';
-import { Avatar, Dropdown, Layout, Nav, Switch } from '@douyinfe/semi-ui';
+import { Avatar, Button, Dropdown, Layout, Nav, Switch } from '@douyinfe/semi-ui';
 import { stringToColor } from '../helpers/render';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
+import { StyleContext } from '../context/Style/index.js';
 
 // HeaderBar Buttons
 let headerButtons = [
@@ -31,21 +33,6 @@ let headerButtons = [
   },
 ];
 
-let buttons = [
-  {
-    text: '首页',
-    itemKey: 'home',
-    to: '/',
-    // icon: <IconHomeStroked />,
-  },
-  // {
-  //   text: 'Playground',
-  //   itemKey: 'playground',
-  //   to: '/playground',
-  //   // icon: <IconNoteMoneyStroked />,
-  // },
-];
-
 if (localStorage.getItem('chat_link')) {
   headerButtons.splice(1, 0, {
     name: '聊天',
@@ -56,9 +43,9 @@ if (localStorage.getItem('chat_link')) {
 
 const HeaderBar = () => {
   const [userState, userDispatch] = useContext(UserContext);
+  const [styleState, styleDispatch] = useContext(StyleContext);
   let navigate = useNavigate();
 
-  const [showSidebar, setShowSidebar] = useState(false);
   const systemName = getSystemName();
   const logo = getLogo();
   const currentDate = new Date();
@@ -69,8 +56,25 @@ const HeaderBar = () => {
       currentDate.getDate() >= 9 &&
       currentDate.getDate() <= 24);
 
+  let buttons = [
+    {
+      text: '首页',
+      itemKey: 'home',
+      to: '/',
+    },
+    {
+      text: '控制台',
+      itemKey: 'detail',
+      to: '/',
+    },
+    {
+      text: '定价',
+      itemKey: 'pricing',
+      to: '/pricing',
+    },
+  ];
+
   async function logout() {
-    setShowSidebar(false);
     await API.get('/api/user/logout');
     showSuccess('注销成功!');
     userDispatch({ type: 'logout' });
@@ -108,36 +112,57 @@ const HeaderBar = () => {
         <div style={{ width: '100%' }}>
           <Nav
             mode={'horizontal'}
-            // bodyStyle={{ height: 100 }}
             renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
               const routerMap = {
                 about: '/about',
                 login: '/login',
                 register: '/register',
+                pricing: '/pricing',
+                detail: '/detail',
                 home: '/',
               };
               return (
-                <Link
-                  style={{ textDecoration: 'none' }}
-                  to={routerMap[props.itemKey]}
-                >
-                  {itemElement}
-                </Link>
+                <div onClick={(e) => {
+                  if (props.itemKey === 'home') {
+                    styleDispatch({ type: 'SET_INNER_PADDING', payload: false });
+                    styleDispatch({ type: 'SET_SIDER', payload: false });
+                  } else {
+                    styleDispatch({ type: 'SET_INNER_PADDING', payload: true });
+                    styleDispatch({ type: 'SET_SIDER', payload: true });
+                  }
+                }}>
+                  <Link
+                    className="header-bar-text"
+                    style={{ textDecoration: 'none' }}
+                    to={routerMap[props.itemKey]}
+                  >
+                    {itemElement}
+                  </Link>
+                </div>
               );
             }}
             selectedKeys={[]}
             // items={headerButtons}
             onSelect={(key) => {}}
-            header={isMobile()?{
+            header={styleState.isMobile?{
               logo: (
-                <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
+                <>
+                  {
+                    !styleState.showSider ?
+                      <Button icon={<IconMenu />} theme="light" aria-label="展开侧边栏" onClick={
+                        () => styleDispatch({ type: 'SET_SIDER', payload: true })
+                      } />:
+                      <Button icon={<IconIndentLeft />} theme="light" aria-label="关闭侧边栏" onClick={
+                        () => styleDispatch({ type: 'SET_SIDER', payload: false })
+                      } />
+                  }
+                </>
               ),
             }:{
               logo: (
                 <img src={logo} alt='logo' />
               ),
               text: systemName,
-
             }}
             items={buttons}
             footer={
@@ -159,17 +184,15 @@ const HeaderBar = () => {
                 )}
                 <Nav.Item itemKey={'about'} icon={<IconHelpCircle />} />
                 <>
-                {!isMobile() && (
-                    <Switch
-                      checkedText='🌞'
-                      size={'large'}
-                      checked={theme === 'dark'}
-                      uncheckedText='🌙'
-                      onChange={(checked) => {
-                        setTheme(checked);
-                      }}
-                    />
-                  )}
+                  <Switch
+                    checkedText='🌞'
+                    size={styleState.isMobile?'default':'large'}
+                    checked={theme === 'dark'}
+                    uncheckedText='🌙'
+                    onChange={(checked) => {
+                      setTheme(checked);
+                    }}
+                  />
                 </>
                 {userState.user ? (
                   <>
@@ -188,7 +211,7 @@ const HeaderBar = () => {
                       >
                         {userState.user.username[0]}
                       </Avatar>
-                      <span>{userState.user.username}</span>
+                      {styleState.isMobile?null:<Text>{userState.user.username}</Text>}
                     </Dropdown>
                   </>
                 ) : (

+ 7 - 10
web/src/components/LogsTable.js

@@ -25,7 +25,7 @@ import {
 import { ITEMS_PER_PAGE } from '../constants';
 import {
   renderAudioModelPrice,
-  renderModelPrice,
+  renderModelPrice, renderModelPriceSimple,
   renderNumber,
   renderQuota,
   stringToColor
@@ -386,14 +386,11 @@ const LogsTable = () => {
           );
         }
 
-        // let content = renderModelPrice(
-        //   record.prompt_tokens,
-        //   record.completion_tokens,
-        //   other.model_ratio,
-        //   other.model_price,
-        //   other.completion_ratio,
-        //   other.group_ratio,
-        // );
+        let content = renderModelPriceSimple(
+          other.model_ratio,
+          other.model_price,
+          other.group_ratio,
+        );
         return (
             <Paragraph
                 ellipsis={{
@@ -401,7 +398,7 @@ const LogsTable = () => {
                 }}
                 style={{ maxWidth: 240 }}
             >
-              调用消费
+              {content}
             </Paragraph>
         );
       },

+ 40 - 0
web/src/components/PageLayout.js

@@ -0,0 +1,40 @@
+import HeaderBar from './HeaderBar.js';
+import { Layout } from '@douyinfe/semi-ui';
+import SiderBar from './SiderBar.js';
+import App from '../App.js';
+import FooterBar from './Footer.js';
+import { ToastContainer } from 'react-toastify';
+import React, { useContext } from 'react';
+import { StyleContext } from '../context/Style/index.js';
+const { Sider, Content, Header, Footer } = Layout;
+
+
+const PageLayout = () => {
+  const [styleState, styleDispatch] = useContext(StyleContext);
+
+  return (
+    <Layout style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
+      <Header>
+        <HeaderBar />
+      </Header>
+      <Layout style={{ flex: 1, overflow: 'hidden' }}>
+        <Sider>
+          {styleState.showSider ? <SiderBar /> : null}
+        </Sider>
+        <Layout>
+          <Content
+            style={{ overflowY: 'auto', padding: styleState.shouldInnerPadding? '24px': '0' }}
+          >
+            <App />
+          </Content>
+          <Layout.Footer>
+            <FooterBar></FooterBar>
+          </Layout.Footer>
+        </Layout>
+      </Layout>
+      <ToastContainer />
+    </Layout>
+  )
+}
+
+export default PageLayout;

+ 11 - 29
web/src/components/PersonalSetting.js

@@ -363,36 +363,18 @@ const PersonalSetting = () => {
                                     </Space>
                                 </>
                             }
-                            footer={
-                                <Descriptions row>
-                                    <Descriptions.Item itemKey='当前余额'>
-                                        {renderQuota(userState?.user?.quota)}
-                                    </Descriptions.Item>
-                                    <Descriptions.Item itemKey='历史消耗'>
-                                        {renderQuota(userState?.user?.used_quota)}
-                                    </Descriptions.Item>
-                                    <Descriptions.Item itemKey='请求次数'>
-                                        {userState.user?.request_count}
-                                    </Descriptions.Item>
-                                </Descriptions>
-                            }
                         >
-                            <Typography.Title heading={6}>可用模型</Typography.Title>
-                            <div style={{marginTop: 10}}>
-                                <Space wrap>
-                                    {models.map((model) => (
-                                        <Tag
-                                            key={model}
-                                            color='cyan'
-                                            onClick={() => {
-                                                copyText(model);
-                                            }}
-                                        >
-                                            {model}
-                                        </Tag>
-                                    ))}
-                                </Space>
-                            </div>
+                            <Descriptions row>
+                                <Descriptions.Item itemKey='当前余额'>
+                                    {renderQuota(userState?.user?.quota)}
+                                </Descriptions.Item>
+                                <Descriptions.Item itemKey='历史消耗'>
+                                    {renderQuota(userState?.user?.used_quota)}
+                                </Descriptions.Item>
+                                <Descriptions.Item itemKey='请求次数'>
+                                    {userState.user?.request_count}
+                                </Descriptions.Item>
+                            </Descriptions>
                         </Card>
                         <Card
                             style={{marginTop: 10}}

+ 35 - 1
web/src/components/RegisterForm.js

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useContext, useEffect, useState } from 'react';
 import { Link, useNavigate } from 'react-router-dom';
 import { API, getLogo, showError, showInfo, showSuccess, updateAPI } from '../helpers';
 import Turnstile from 'react-turnstile';
@@ -11,6 +11,7 @@ import LinuxDoIcon from './LinuxDoIcon.js';
 import WeChatIcon from './WeChatIcon.js';
 import TelegramLoginButton from 'react-telegram-login/src';
 import { setUserData } from '../helpers/data.js';
+import { UserContext } from '../context/User/index.js';
 
 const RegisterForm = () => {
   const [inputs, setInputs] = useState({
@@ -22,6 +23,7 @@ const RegisterForm = () => {
   });
   const { username, password, password2 } = inputs;
   const [showEmailVerification, setShowEmailVerification] = useState(false);
+  const [userState, userDispatch] = useContext(UserContext);
   const [turnstileEnabled, setTurnstileEnabled] = useState(false);
   const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
   const [turnstileToken, setTurnstileToken] = useState('');
@@ -133,6 +135,38 @@ const RegisterForm = () => {
     setLoading(false);
   };
 
+  const onTelegramLoginClicked = async (response) => {
+    const fields = [
+      'id',
+      'first_name',
+      'last_name',
+      'username',
+      'photo_url',
+      'auth_date',
+      'hash',
+      'lang',
+    ];
+    const params = {};
+    fields.forEach((field) => {
+      if (response[field]) {
+        params[field] = response[field];
+      }
+    });
+    const res = await API.get(`/api/oauth/telegram/login`, { params });
+    const { success, message, data } = res.data;
+    if (success) {
+      userDispatch({ type: 'login', payload: data });
+      localStorage.setItem('user', JSON.stringify(data));
+      showSuccess('登录成功!');
+      setUserData(data);
+      updateAPI();
+      navigate('/');
+    } else {
+      showError(message);
+    }
+  };
+
+
   return (
     <div>
       <Layout>

+ 18 - 31
web/src/components/SiderBar.js

@@ -31,14 +31,15 @@ import { Avatar, Dropdown, Layout, Nav, Switch } from '@douyinfe/semi-ui';
 import { setStatusData } from '../helpers/data.js';
 import { stringToColor } from '../helpers/render.js';
 import { useSetTheme, useTheme } from '../context/Theme/index.js';
+import { StyleContext } from '../context/Style/index.js';
 
 // HeaderBar Buttons
 
 const SiderBar = () => {
-  const [userState, userDispatch] = useContext(UserContext);
+  const [styleState, styleDispatch] = useContext(StyleContext);
   const [statusState, statusDispatch] = useContext(StatusContext);
   const defaultIsCollapsed =
-    isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true';
+    localStorage.getItem('default_collapse_sidebar') === 'true';
 
   const [selectedKeys, setSelectedKeys] = useState(['home']);
   const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed);
@@ -72,12 +73,6 @@ const SiderBar = () => {
         to: '/playground',
         icon: <IconCommentStroked />,
       },
-      {
-        text: '模型价格',
-        itemKey: 'pricing',
-        to: '/pricing',
-        icon: <IconPriceTag />,
-      },
       {
         text: '渠道',
         itemKey: 'channel',
@@ -101,6 +96,16 @@ const SiderBar = () => {
         to: '/token',
         icon: <IconKey />,
       },
+      {
+        text: '数据看板',
+        itemKey: 'detail',
+        to: '/detail',
+        icon: <IconCalendarClock />,
+        className:
+          localStorage.getItem('enable_data_export') === 'true'
+            ? 'semi-navigation-item-normal'
+            : 'tableHiddle',
+      },
       {
         text: '兑换码',
         itemKey: 'redemption',
@@ -127,16 +132,6 @@ const SiderBar = () => {
         to: '/log',
         icon: <IconHistogram />,
       },
-      {
-        text: '数据看板',
-        itemKey: 'detail',
-        to: '/detail',
-        icon: <IconCalendarClock />,
-        className:
-          localStorage.getItem('enable_data_export') === 'true'
-            ? 'semi-navigation-item-normal'
-            : 'tableHiddle',
-      },
       {
         text: '绘图',
         itemKey: 'midjourney',
@@ -196,7 +191,6 @@ const SiderBar = () => {
   useEffect(() => {
     loadStatus().then(() => {
       setIsCollapsed(
-        isMobile() ||
           localStorage.getItem('default_collapse_sidebar') === 'true',
       );
     });
@@ -239,7 +233,6 @@ const SiderBar = () => {
       <Nav
         style={{ maxWidth: 220, height: '100%' }}
         defaultIsCollapsed={
-          isMobile() ||
           localStorage.getItem('default_collapse_sidebar') === 'true'
         }
         isCollapsed={isCollapsed}
@@ -280,21 +273,15 @@ const SiderBar = () => {
         }}
         items={headerButtons}
         onSelect={(key) => {
+          if (key.itemKey.toString().startsWith('chat')) {
+            styleDispatch({ type: 'SET_INNER_PADDING', payload: false });
+          } else {
+            styleDispatch({ type: 'SET_INNER_PADDING', payload: true });
+          }
           setSelectedKeys([key.itemKey]);
         }}
         footer={
           <>
-            {isMobile() && (
-              <Switch
-                checkedText='🌞'
-                size={'small'}
-                checked={theme === 'dark'}
-                uncheckedText='🌙'
-                onChange={(checked) => {
-                  setTheme(checked);
-                }}
-              />
-            )}
           </>
         }
       >

+ 21 - 0
web/src/components/custom/TextInput.js

@@ -0,0 +1,21 @@
+import { Input, Typography } from '@douyinfe/semi-ui';
+import React from 'react';
+
+const TextInput = ({ label, name, value, onChange, placeholder, type = 'text' }) => {
+  return (
+    <>
+      <div style={{ marginTop: 10 }}>
+        <Typography.Text strong>{label}</Typography.Text>
+      </div>
+      <Input
+        name={name}
+        placeholder={placeholder}
+        onChange={(value) => onChange(value)}
+        value={value}
+        autoComplete="new-password"
+      />
+    </>
+  );
+}
+
+export default TextInput;

+ 21 - 0
web/src/components/custom/TextNumberInput.js

@@ -0,0 +1,21 @@
+import { Input, InputNumber, Typography } from '@douyinfe/semi-ui';
+import React from 'react';
+
+const TextNumberInput = ({ label, name, value, onChange, placeholder }) => {
+  return (
+    <>
+      <div style={{ marginTop: 10 }}>
+        <Typography.Text strong>{label}</Typography.Text>
+      </div>
+      <InputNumber
+        name={name}
+        placeholder={placeholder}
+        onChange={(value) => onChange(value)}
+        value={value}
+        autoComplete="new-password"
+      />
+    </>
+  );
+}
+
+export default TextNumberInput;

+ 9 - 8
web/src/constants/channel.constants.js

@@ -44,13 +44,6 @@ export const CHANNEL_OPTIONS = [
     color: 'teal',
     label: 'Azure OpenAI'
   },
-  {
-    key: 24,
-    text: 'Google Gemini',
-    value: 24,
-    color: 'orange',
-    label: 'Google Gemini'
-  },
   {
     key: 34,
     text: 'Cohere',
@@ -58,6 +51,8 @@ export const CHANNEL_OPTIONS = [
     color: 'purple',
     label: 'Cohere'
   },
+  { key: 39, text: 'Cloudflare', value: 39, color: 'grey', label: 'Cloudflare' },
+  { key: 43, text: 'DeepSeek', value: 43, color: 'blue', label: 'DeepSeek' },
   {
     key: 15,
     text: '百度文心千帆',
@@ -93,6 +88,13 @@ export const CHANNEL_OPTIONS = [
     color: 'purple',
     label: '智谱 GLM-4V'
   },
+  {
+    key: 24,
+    text: 'Google Gemini',
+    value: 24,
+    color: 'orange',
+    label: 'Google Gemini'
+  },
   {
     key: 11,
     text: 'Google PaLM2',
@@ -100,7 +102,6 @@ export const CHANNEL_OPTIONS = [
     color: 'orange',
     label: 'Google PaLM2'
   },
-  { key: 39, text: 'Cloudflare', value: 39, color: 'grey', label: 'Cloudflare' },
   { key: 25, text: 'Moonshot', value: 25, color: 'green', label: 'Moonshot' },
   { key: 19, text: '360 智脑', value: 19, color: 'blue', label: '360 智脑' },
   { key: 23, text: '腾讯混元', value: 23, color: 'teal', label: '腾讯混元' },

+ 80 - 0
web/src/context/Style/index.js

@@ -0,0 +1,80 @@
+// contexts/User/index.jsx
+
+import React, { useState, useEffect } from 'react';
+import { isMobile } from '../../helpers/index.js';
+
+export const StyleContext = React.createContext({
+  dispatch: () => null,
+});
+
+export const StyleProvider = ({ children }) => {
+  const [state, setState] = useState({
+    isMobile: false,
+    showSider: false,
+    shouldInnerPadding: false,
+  });
+
+  const dispatch = (action) => {
+    if ('type' in action) {
+      switch (action.type) {
+        case 'TOGGLE_SIDER':
+          setState(prev => ({ ...prev, showSider: !prev.showSider }));
+          break;
+        case 'SET_SIDER':
+          setState(prev => ({ ...prev, showSider: action.payload }));
+          break;
+        case 'SET_MOBILE':
+          setState(prev => ({ ...prev, isMobile: action.payload }));
+          break;
+        case 'SET_INNER_PADDING':
+          setState(prev => ({ ...prev, shouldInnerPadding: action.payload }));
+          break;
+        default:
+          setState(prev => ({ ...prev, ...action }));
+      }
+    } else {
+      setState(prev => ({ ...prev, ...action }));
+    }
+  };
+
+  useEffect(() => {
+    const updateIsMobile = () => {
+      dispatch({ type: 'SET_MOBILE', payload: isMobile() });
+    };
+
+    updateIsMobile();
+
+    const updateShowSider = () => {
+      // check pathname
+      const pathname = window.location.pathname;
+      if (pathname === '' || pathname === '/' || pathname.includes('/home') || pathname.includes('/chat')) {
+        dispatch({ type: 'SET_SIDER', payload: false });
+        dispatch({ type: 'SET_INNER_PADDING', payload: false });
+      } else {
+        dispatch({ type: 'SET_SIDER', payload: true });
+        dispatch({ type: 'SET_INNER_PADDING', payload: true });
+      }
+
+      if (isMobile()) {
+        dispatch({ type: 'SET_SIDER', payload: false });
+      }
+    };
+
+    updateShowSider()
+
+
+    // Optionally, add event listeners to handle window resize
+    window.addEventListener('resize', updateIsMobile);
+
+    // Cleanup event listener on component unmount
+    return () => {
+      window.removeEventListener('resize', updateIsMobile);
+    };
+  }, []);
+
+  return (
+    <StyleContext.Provider value={[state, dispatch]}>
+      {children}
+    </StyleContext.Provider>
+  );
+};

+ 4 - 2
web/src/helpers/api.js

@@ -6,7 +6,8 @@ export let API = axios.create({
     ? import.meta.env.VITE_REACT_APP_SERVER_URL
     : '',
   headers: {
-    'New-API-User': getUserIdFromLocalStorage()
+    'New-API-User': getUserIdFromLocalStorage(),
+    'Cache-Control': 'no-store'
   }
 });
 
@@ -16,7 +17,8 @@ export function updateAPI() {
       ? import.meta.env.VITE_REACT_APP_SERVER_URL
       : '',
     headers: {
-      'New-API-User': getUserIdFromLocalStorage()
+      'New-API-User': getUserIdFromLocalStorage(),
+      'Cache-Control': 'no-store'
     }
   });
 }

+ 75 - 3
web/src/helpers/render.js

@@ -67,6 +67,8 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
 }
 
 export function renderNumberWithPoint(num) {
+  if (num === undefined)
+    return '';
   num = num.toFixed(2);
   if (num >= 100000) {
     // Convert number to string to manipulate it
@@ -173,6 +175,19 @@ export function renderModelPrice(
   }
 }
 
+export function renderModelPriceSimple(
+  modelRatio,
+  modelPrice = -1,
+  groupRatio,
+) {
+  // 1 ratio = $0.002 / 1K tokens
+  if (modelPrice !== -1) {
+    return '价格:$' + modelPrice + ' * 分组:' + groupRatio;
+  } else {
+    return '模型: ' + modelRatio + ' * 分组: ' + groupRatio;
+  }
+}
+
 export function renderAudioModelPrice(
   inputTokens,
   completionTokens,
@@ -253,6 +268,44 @@ const colors = [
   'yellow',
 ];
 
+// 基础10色色板 (N ≤ 10)
+const baseColors = [
+  '#1664FF', // 主色
+  '#1AC6FF', 
+  '#FF8A00',
+  '#3CC780',
+  '#7442D4',
+  '#FFC400',
+  '#304D77',
+  '#B48DEB',
+  '#009488',
+  '#FF7DDA'
+];
+
+// 扩展20色色板 (10 < N ≤ 20)
+const extendedColors = [
+  '#1664FF',
+  '#B2CFFF',
+  '#1AC6FF',
+  '#94EFFF',
+  '#FF8A00',
+  '#FFCE7A',
+  '#3CC780',
+  '#B9EDCD',
+  '#7442D4',
+  '#DDC5FA',
+  '#FFC400',
+  '#FAE878',
+  '#304D77',
+  '#8B959E',
+  '#B48DEB',
+  '#EFE3FF',
+  '#009488',
+  '#59BAA8',
+  '#FF7DDA',
+  '#FFCFEE'
+];
+
 export const modelColorMap = {
   'dall-e': 'rgb(147,112,219)', // 深紫色
   // 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
@@ -297,14 +350,33 @@ export const modelColorMap = {
   'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别)
 };
 
+export function modelToColor(modelName) {
+  // 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
+  if (modelColorMap[modelName]) {
+    return modelColorMap[modelName];
+  }
+
+  // 2. 生成一个稳定的数字作为索引
+  let hash = 0;
+  for (let i = 0; i < modelName.length; i++) {
+    hash = ((hash << 5) - hash) + modelName.charCodeAt(i);
+    hash = hash & hash; // Convert to 32-bit integer
+  }
+  hash = Math.abs(hash);
+
+  // 3. 根据模型名称长度选择不同的色板
+  const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
+  
+  // 4. 使用hash值选择颜色
+  const index = hash % colorPalette.length;
+  return colorPalette[index];
+}
+
 export function stringToColor(str) {
   let sum = 0;
-  // 对字符串中的每个字符进行操作
   for (let i = 0; i < str.length; i++) {
-    // 将字符的ASCII值加到sum中
     sum += str.charCodeAt(i);
   }
-  // 使用模运算得到个位数
   let i = sum % colors.length;
   return colors[i];
 }

+ 22 - 0
web/src/index.css

@@ -17,7 +17,25 @@ body {
   flex-direction: column;
 }
 
+#root > section > header > section > div > div > div > div.semi-navigation-header-list-outer > div.semi-navigation-list-wrapper > ul > div > a > li > span{
+  font-weight: 600 !important;
+}
+
+.semi-descriptions-double-small .semi-descriptions-item {
+  padding-right: 30px;
+}
+
+.panel-desc-card {
+  /*min-width: 320px;*/
+}
+
 @media only screen and (max-width: 767px) {
+  #root > section > header > section > div > div > div > div.semi-navigation-header-list-outer > div.semi-navigation-list-wrapper > ul > div > a > li {
+    padding: 0 5px;
+  }
+  #root > section > header > section > div > div > div > div.semi-navigation-footer > div:nth-child(1) > a > li {
+    padding: 0 5px;
+  }
   .semi-table-tbody,
   .semi-table-row,
   .semi-table-row-cell {
@@ -39,6 +57,10 @@ body {
     row-gap: 3px;
     column-gap: 10px;
   }
+
+  .semi-navigation-horizontal .semi-navigation-header {
+    margin-right: 0;
+  }
 }
 
 .semi-table-tbody > .semi-table-row > .semi-table-row-cell {

+ 5 - 21
web/src/index.js

@@ -13,6 +13,8 @@ import { Layout } from '@douyinfe/semi-ui';
 import SiderBar from './components/SiderBar';
 import { ThemeProvider } from './context/Theme';
 import FooterBar from './components/Footer';
+import { StyleProvider } from './context/Style/index.js';
+import PageLayout from './components/PageLayout.js';
 
 // initialization
 
@@ -24,27 +26,9 @@ root.render(
       <UserProvider>
         <BrowserRouter>
           <ThemeProvider>
-            <Layout style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
-              <Header>
-                <HeaderBar />
-              </Header>
-              <Layout style={{ flex: 1, overflow: 'hidden' }}>
-                <Sider>
-                  <SiderBar />
-                </Sider>
-                <Layout>
-                  <Content
-                    style={{ overflowY: 'auto', padding: '24px' }}
-                  >
-                    <App />
-                  </Content>
-                  <Layout.Footer>
-                    <FooterBar></FooterBar>
-                  </Layout.Footer>
-                </Layout>
-              </Layout>
-              <ToastContainer />
-            </Layout>
+            <StyleProvider>
+              <PageLayout/>
+            </StyleProvider>
           </ThemeProvider>
         </BrowserRouter>
       </UserProvider>

+ 229 - 187
web/src/pages/Channel/EditChannel.js

@@ -6,7 +6,7 @@ import {
   showError,
   showInfo,
   showSuccess,
-  verifyJSON,
+  verifyJSON
 } from '../../helpers';
 import { CHANNEL_OPTIONS } from '../../constants';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
@@ -21,28 +21,26 @@ import {
   Select,
   TextArea,
   Checkbox,
-  Banner,
+  Banner
 } from '@douyinfe/semi-ui';
 import { Divider } from 'semantic-ui-react';
 import { getChannelModels, loadChannelModels } from '../../components/utils.js';
 import axios from 'axios';
 
 const MODEL_MAPPING_EXAMPLE = {
-  'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
-  'gpt-4-0314': 'gpt-4',
-  'gpt-4-32k-0314': 'gpt-4-32k',
+  'gpt-3.5-turbo': 'gpt-3.5-turbo-0125'
 };
 
 const STATUS_CODE_MAPPING_EXAMPLE = {
-  400: '500',
+  400: '500'
 };
 
 const REGION_EXAMPLE = {
-  "default": "us-central1",
-  "claude-3-5-sonnet-20240620": "europe-west1"
-}
+  'default': 'us-central1',
+  'claude-3-5-sonnet-20240620': 'europe-west1'
+};
 
-const fetchButtonTips = "1. 新建渠道时,请求通过当前浏览器发出;2. 编辑已有渠道,请求通过后端服务器发出"
+const fetchButtonTips = '1. 新建渠道时,请求通过当前浏览器发出;2. 编辑已有渠道,请求通过后端服务器发出';
 
 function type2secretPrompt(type) {
   // inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
@@ -84,6 +82,9 @@ const EditChannel = (props) => {
     auto_ban: 1,
     test_model: '',
     groups: ['default'],
+    priority: 0,
+    weight: 0,
+    tag: ''
   };
   const [batch, setBatch] = useState(false);
   const [autoBan, setAutoBan] = useState(true);
@@ -108,7 +109,7 @@ const EditChannel = (props) => {
             'mj_blend',
             'mj_upscale',
             'mj_describe',
-            'mj_uploads',
+            'mj_uploads'
           ];
           break;
         case 5:
@@ -128,13 +129,13 @@ const EditChannel = (props) => {
             'mj_high_variation',
             'mj_low_variation',
             'mj_pan',
-            'mj_uploads',
+            'mj_uploads'
           ];
           break;
         case 36:
           localModels = [
             'suno_music',
-            'suno_lyrics',
+            'suno_lyrics'
           ];
           break;
         default:
@@ -171,7 +172,7 @@ const EditChannel = (props) => {
         data.model_mapping = JSON.stringify(
           JSON.parse(data.model_mapping),
           null,
-          2,
+          2
         );
       }
       setInputs(data);
@@ -190,70 +191,69 @@ const EditChannel = (props) => {
 
 
   const fetchUpstreamModelList = async (name) => {
-    if (inputs["type"] !== 1) {
-      showError("仅支持 OpenAI 接口格式")
+    if (inputs['type'] !== 1) {
+      showError('仅支持 OpenAI 接口格式');
       return;
     }
-    setLoading(true)
-    const models = inputs["models"] || []
+    setLoading(true);
+    const models = inputs['models'] || [];
     let err = false;
     if (isEdit) {
-      const res = await API.get("/api/channel/fetch_models/" + channelId)
+      const res = await API.get('/api/channel/fetch_models/' + channelId);
       if (res.data && res.data?.success) {
-        models.push(...res.data.data)
+        models.push(...res.data.data);
       } else {
-        err = true
+        err = true;
       }
     } else {
-      if (!inputs?.["key"]) {
-        showError("请填写密钥")
-        err = true
+      if (!inputs?.['key']) {
+        showError('请填写密钥');
+        err = true;
       } else {
         try {
-          const host = new URL((inputs["base_url"] || "https://api.openai.com"))
+          const host = new URL((inputs['base_url'] || 'https://api.openai.com'));
 
           const url = `https://${host.hostname}/v1/models`;
-          const key = inputs["key"];
+          const key = inputs['key'];
           const res = await axios.get(url, {
             headers: {
               'Authorization': `Bearer ${key}`
             }
-          })
+          });
           if (res.data && res.data?.success) {
-            models.push(...res.data.data.map((model) => model.id))
+            models.push(...res.data.data.map((model) => model.id));
           } else {
-            err = true
+            err = true;
           }
-        }
-        catch (error) {
-          err = true
+        } catch (error) {
+          err = true;
         }
       }
     }
     if (!err) {
       handleInputChange(name, Array.from(new Set(models)));
-      showSuccess("获取模型列表成功");
+      showSuccess('获取模型列表成功');
     } else {
       showError('获取模型列表失败');
     }
     setLoading(false);
-  }
+  };
 
   const fetchModels = async () => {
     try {
       let res = await API.get(`/api/channel/models`);
       let localModelOptions = res.data.data.map((model) => ({
         label: model.id,
-        value: model.id,
+        value: model.id
       }));
       setOriginModelOptions(localModelOptions);
       setFullModels(res.data.data.map((model) => model.id));
       setBasicModels(
         res.data.data
           .filter((model) => {
-            return model.id.startsWith('gpt-3') || model.id.startsWith('text-');
+            return model.id.startsWith('gpt-') || model.id.startsWith('text-');
           })
-          .map((model) => model.id),
+          .map((model) => model.id)
       );
     } catch (error) {
       showError(error.message);
@@ -269,8 +269,8 @@ const EditChannel = (props) => {
       setGroupOptions(
         res.data.data.map((group) => ({
           label: group,
-          value: group,
-        })),
+          value: group
+        }))
       );
     } catch (error) {
       showError(error.message);
@@ -280,10 +280,10 @@ const EditChannel = (props) => {
   useEffect(() => {
     let localModelOptions = [...originModelOptions];
     inputs.models.forEach((model) => {
-      if (!localModelOptions.find((option) => option.key === model)) {
+      if (!localModelOptions.find((option) => option.label === model)) {
         localModelOptions.push({
           label: model,
-          value: model,
+          value: model
         });
       }
     });
@@ -320,7 +320,7 @@ const EditChannel = (props) => {
     if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
       localInputs.base_url = localInputs.base_url.slice(
         0,
-        localInputs.base_url.length - 1,
+        localInputs.base_url.length - 1
       );
     }
     if (localInputs.type === 3 && localInputs.other === '') {
@@ -341,7 +341,7 @@ const EditChannel = (props) => {
     if (isEdit) {
       res = await API.put(`/api/channel/`, {
         ...localInputs,
-        id: parseInt(channelId),
+        id: parseInt(channelId)
       });
     } else {
       res = await API.post(`/api/channel/`, localInputs);
@@ -378,7 +378,7 @@ const EditChannel = (props) => {
           // 添加到下拉选项
           key: model,
           text: model,
-          value: model,
+          value: model
         });
       } else if (model) {
         showError('某些模型已存在!');
@@ -409,11 +409,11 @@ const EditChannel = (props) => {
         footer={
           <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
             <Space>
-              <Button theme='solid' size={'large'} onClick={submit}>
+              <Button theme="solid" size={'large'} onClick={submit}>
                 提交
               </Button>
               <Button
-                theme='solid'
+                theme="solid"
                 size={'large'}
                 type={'tertiary'}
                 onClick={handleCancel}
@@ -432,7 +432,7 @@ const EditChannel = (props) => {
             <Typography.Text strong>类型:</Typography.Text>
           </div>
           <Select
-            name='type'
+            name="type"
             required
             optionList={CHANNEL_OPTIONS}
             value={inputs.type}
@@ -450,8 +450,8 @@ const EditChannel = (props) => {
                       ,因为 One API 会把请求体中的 model
                       参数替换为你的部署名称(模型名称中的点会被剔除),
                       <a
-                        target='_blank'
-                        href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'
+                        target="_blank"
+                        href="https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271"
                       >
                         图片演示
                       </a>
@@ -466,8 +466,8 @@ const EditChannel = (props) => {
                 </Typography.Text>
               </div>
               <Input
-                label='AZURE_OPENAI_ENDPOINT'
-                name='azure_base_url'
+                label="AZURE_OPENAI_ENDPOINT"
+                name="azure_base_url"
                 placeholder={
                   '请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com'
                 }
@@ -475,14 +475,14 @@ const EditChannel = (props) => {
                   handleInputChange('base_url', value);
                 }}
                 value={inputs.base_url}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
               <div style={{ marginTop: 10 }}>
                 <Typography.Text strong>默认 API 版本:</Typography.Text>
               </div>
               <Input
-                label='默认 API 版本'
-                name='azure_other'
+                label="默认 API 版本"
+                name="azure_other"
                 placeholder={
                   '请输入默认 API 版本,例如:2023-06-01-preview,该配置可以被实际的请求查询参数所覆盖'
                 }
@@ -490,7 +490,7 @@ const EditChannel = (props) => {
                   handleInputChange('other', value);
                 }}
                 value={inputs.other}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
             </>
           )}
@@ -512,7 +512,7 @@ const EditChannel = (props) => {
                 </Typography.Text>
               </div>
               <Input
-                name='base_url'
+                name="base_url"
                 placeholder={
                   '请输入完整的URL,例如:https://api.openai.com/v1/chat/completions'
                 }
@@ -520,49 +520,84 @@ const EditChannel = (props) => {
                   handleInputChange('base_url', value);
                 }}
                 value={inputs.base_url}
-                autoComplete='new-password'
+                autoComplete="new-password"
+              />
+            </>
+          )}
+          {inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && inputs.type !== 36 && (
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>代理:</Typography.Text>
+              </div>
+              <Input
+                label="代理"
+                name="base_url"
+                placeholder={'此项可选,用于通过代理站来进行 API 调用'}
+                onChange={(value) => {
+                  handleInputChange('base_url', value);
+                }}
+                value={inputs.base_url}
+                autoComplete="new-password"
+              />
+            </>
+          )}
+          {inputs.type === 22 && (
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>私有部署地址:</Typography.Text>
+              </div>
+              <Input
+                name="base_url"
+                placeholder={
+                  '请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'
+                }
+                onChange={(value) => {
+                  handleInputChange('base_url', value);
+                }}
+                value={inputs.base_url}
+                autoComplete="new-password"
               />
             </>
           )}
           {inputs.type === 36 && (
-              <>
-                <div style={{marginTop: 10}}>
-                  <Typography.Text strong>
-                    注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用
-                  </Typography.Text>
-                </div>
-                <Input
-                    name='base_url'
-                    placeholder={
-                      '请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com '
-                    }
-                    onChange={(value) => {
-                      handleInputChange('base_url', value);
-                    }}
-                    value={inputs.base_url}
-                    autoComplete='new-password'
-                />
-              </>
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>
+                  注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用
+                </Typography.Text>
+              </div>
+              <Input
+                name="base_url"
+                placeholder={
+                  '请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com '
+                }
+                onChange={(value) => {
+                  handleInputChange('base_url', value);
+                }}
+                value={inputs.base_url}
+                autoComplete="new-password"
+              />
+            </>
           )}
-          <div style={{marginTop: 10}}>
+          <div style={{ marginTop: 10 }}>
             <Typography.Text strong>名称:</Typography.Text>
           </div>
           <Input
-              required
-              name='name'
+            required
+            name="name"
             placeholder={'请为渠道命名'}
             onChange={(value) => {
               handleInputChange('name', value);
             }}
             value={inputs.name}
-            autoComplete='new-password'
+            autoComplete="new-password"
           />
           <div style={{ marginTop: 10 }}>
             <Typography.Text strong>分组:</Typography.Text>
           </div>
           <Select
             placeholder={'请选择可以使用该渠道的分组'}
-            name='groups'
+            name="groups"
             required
             multiple
             selection
@@ -572,7 +607,7 @@ const EditChannel = (props) => {
               handleInputChange('groups', value);
             }}
             value={inputs.groups}
-            autoComplete='new-password'
+            autoComplete="new-password"
             optionList={groupOptions}
           />
           {inputs.type === 18 && (
@@ -581,7 +616,7 @@ const EditChannel = (props) => {
                 <Typography.Text strong>模型版本:</Typography.Text>
               </div>
               <Input
-                name='other'
+                name="other"
                 placeholder={
                   '请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'
                 }
@@ -589,7 +624,7 @@ const EditChannel = (props) => {
                   handleInputChange('other', value);
                 }}
                 value={inputs.other}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
             </>
           )}
@@ -599,7 +634,7 @@ const EditChannel = (props) => {
                 <Typography.Text strong>部署地区:</Typography.Text>
               </div>
               <TextArea
-                name='other'
+                name="other"
                 placeholder={
                   '请输入部署地区,例如:us-central1\n支持使用模型映射格式\n' +
                   '{\n' +
@@ -612,18 +647,18 @@ const EditChannel = (props) => {
                   handleInputChange('other', value);
                 }}
                 value={inputs.other}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
               <Typography.Text
                 style={{
                   color: 'rgba(var(--semi-blue-5), 1)',
                   userSelect: 'none',
-                  cursor: 'pointer',
+                  cursor: 'pointer'
                 }}
                 onClick={() => {
                   handleInputChange(
                     'other',
-                    JSON.stringify(REGION_EXAMPLE, null, 2),
+                    JSON.stringify(REGION_EXAMPLE, null, 2)
                   );
                 }}
               >
@@ -637,14 +672,14 @@ const EditChannel = (props) => {
                 <Typography.Text strong>知识库 ID:</Typography.Text>
               </div>
               <Input
-                label='知识库 ID'
-                name='other'
+                label="知识库 ID"
+                name="other"
                 placeholder={'请输入知识库 ID,例如:123456'}
                 onChange={(value) => {
                   handleInputChange('other', value);
                 }}
                 value={inputs.other}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
             </>
           )}
@@ -654,7 +689,7 @@ const EditChannel = (props) => {
                 <Typography.Text strong>Account ID:</Typography.Text>
               </div>
               <Input
-                name='other'
+                name="other"
                 placeholder={
                   '请输入Account ID,例如:d6b5da8hk1awo8nap34ube6gh'
                 }
@@ -662,7 +697,7 @@ const EditChannel = (props) => {
                   handleInputChange('other', value);
                 }}
                 value={inputs.other}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
             </>
           )}
@@ -671,21 +706,23 @@ const EditChannel = (props) => {
           </div>
           <Select
             placeholder={'请选择该渠道所支持的模型'}
-            name='models'
+            name="models"
             required
             multiple
             selection
+            filter
+            searchPosition='dropdown'
             onChange={(value) => {
               handleInputChange('models', value);
             }}
             value={inputs.models}
-            autoComplete='new-password'
+            autoComplete="new-password"
             optionList={modelOptions}
           />
           <div style={{ lineHeight: '40px', marginBottom: '12px' }}>
             <Space>
               <Button
-                type='primary'
+                type="primary"
                 onClick={() => {
                   handleInputChange('models', basicModels);
                 }}
@@ -693,7 +730,7 @@ const EditChannel = (props) => {
                 填入相关模型
               </Button>
               <Button
-                type='secondary'
+                type="secondary"
                 onClick={() => {
                   handleInputChange('models', fullModels);
                 }}
@@ -702,7 +739,7 @@ const EditChannel = (props) => {
               </Button>
               <Tooltip content={fetchButtonTips}>
                 <Button
-                  type='tertiary'
+                  type="tertiary"
                   onClick={() => {
                     fetchUpstreamModelList('models');
                   }}
@@ -711,7 +748,7 @@ const EditChannel = (props) => {
                 </Button>
               </Tooltip>
               <Button
-                type='warning'
+                type="warning"
                 onClick={() => {
                   handleInputChange('models', []);
                 }}
@@ -721,11 +758,11 @@ const EditChannel = (props) => {
             </Space>
             <Input
               addonAfter={
-                <Button type='primary' onClick={addCustomModels}>
+                <Button type="primary" onClick={addCustomModels}>
                   填入
                 </Button>
               }
-              placeholder='输入自定义模型名称'
+              placeholder="输入自定义模型名称"
               value={customModel}
               onChange={(value) => {
                 setCustomModel(value.trim());
@@ -737,24 +774,24 @@ const EditChannel = (props) => {
           </div>
           <TextArea
             placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
-            name='model_mapping'
+            name="model_mapping"
             onChange={(value) => {
               handleInputChange('model_mapping', value);
             }}
             autosize
             value={inputs.model_mapping}
-            autoComplete='new-password'
+            autoComplete="new-password"
           />
           <Typography.Text
             style={{
               color: 'rgba(var(--semi-blue-5), 1)',
               userSelect: 'none',
-              cursor: 'pointer',
+              cursor: 'pointer'
             }}
             onClick={() => {
               handleInputChange(
                 'model_mapping',
-                JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2),
+                JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)
               );
             }}
           >
@@ -765,8 +802,8 @@ const EditChannel = (props) => {
           </div>
           {batch ? (
             <TextArea
-              label='密钥'
-              name='key'
+              label="密钥"
+              name="key"
               required
               placeholder={'请输入密钥,一行一个'}
               onChange={(value) => {
@@ -774,14 +811,14 @@ const EditChannel = (props) => {
               }}
               value={inputs.key}
               style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
-              autoComplete='new-password'
+              autoComplete="new-password"
             />
           ) : (
             <>
               {inputs.type === 41 ? (
                 <TextArea
-                  label='鉴权json'
-                  name='key'
+                  label="鉴权json"
+                  name="key"
                   required
                   placeholder={'{\n' +
                     '  "type": "service_account",\n' +
@@ -801,23 +838,36 @@ const EditChannel = (props) => {
                   }}
                   autosize={{ minRows: 10 }}
                   value={inputs.key}
-                  autoComplete='new-password'
+                  autoComplete="new-password"
                 />
               ) : (
                 <Input
-                  label='密钥'
-                  name='key'
+                  label="密钥"
+                  name="key"
                   required
                   placeholder={type2secretPrompt(inputs.type)}
                   onChange={(value) => {
                     handleInputChange('key', value);
                   }}
                   value={inputs.key}
-                  autoComplete='new-password'
+                  autoComplete="new-password"
                 />
               )
               }
-              </>
+            </>
+          )}
+          {!isEdit && (
+            <div style={{ marginTop: 10, display: 'flex' }}>
+              <Space>
+                <Checkbox
+                  checked={batch}
+                  label="批量创建"
+                  name="batch"
+                  onChange={() => setBatch(!batch)}
+                />
+                <Typography.Text strong>批量创建</Typography.Text>
+              </Space>
+            </div>
           )}
           {inputs.type === 1 && (
             <>
@@ -825,9 +875,9 @@ const EditChannel = (props) => {
                 <Typography.Text strong>组织:</Typography.Text>
               </div>
               <Input
-                label='组织,可选,不填则为默认组织'
-                name='openai_organization'
-                placeholder='请输入组织org-xxx'
+                label="组织,可选,不填则为默认组织"
+                name="openai_organization"
+                placeholder="请输入组织org-xxx"
                 onChange={(value) => {
                   handleInputChange('openai_organization', value);
                 }}
@@ -839,8 +889,8 @@ const EditChannel = (props) => {
             <Typography.Text strong>默认测试模型:</Typography.Text>
           </div>
           <Input
-            name='test_model'
-            placeholder='不填则为模型列表第一个'
+            name="test_model"
+            placeholder="不填则为模型列表第一个"
             onChange={(value) => {
               handleInputChange('test_model', value);
             }}
@@ -849,7 +899,7 @@ const EditChannel = (props) => {
           <div style={{ marginTop: 10, display: 'flex' }}>
             <Space>
               <Checkbox
-                name='auto_ban'
+                name="auto_ban"
                 checked={autoBan}
                 onChange={() => {
                   setAutoBan(!autoBan);
@@ -861,55 +911,6 @@ const EditChannel = (props) => {
               </Typography.Text>
             </Space>
           </div>
-
-          {!isEdit && (
-            <div style={{ marginTop: 10, display: 'flex' }}>
-              <Space>
-                <Checkbox
-                  checked={batch}
-                  label='批量创建'
-                  name='batch'
-                  onChange={() => setBatch(!batch)}
-                />
-                <Typography.Text strong>批量创建</Typography.Text>
-              </Space>
-            </div>
-          )}
-          {inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && inputs.type !== 36 && (
-            <>
-              <div style={{ marginTop: 10 }}>
-                <Typography.Text strong>代理:</Typography.Text>
-              </div>
-              <Input
-                label='代理'
-                name='base_url'
-                placeholder={'此项可选,用于通过代理站来进行 API 调用'}
-                onChange={(value) => {
-                  handleInputChange('base_url', value);
-                }}
-                value={inputs.base_url}
-                autoComplete='new-password'
-              />
-            </>
-          )}
-          {inputs.type === 22 && (
-            <>
-              <div style={{ marginTop: 10 }}>
-                <Typography.Text strong>私有部署地址:</Typography.Text>
-              </div>
-              <Input
-                name='base_url'
-                placeholder={
-                  '请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'
-                }
-                onChange={(value) => {
-                  handleInputChange('base_url', value);
-                }}
-                value={inputs.base_url}
-                autoComplete='new-password'
-              />
-            </>
-          )}
           <div style={{ marginTop: 10 }}>
             <Typography.Text strong>
               状态码复写(仅影响本地判断,不修改返回到上游的状态码):
@@ -917,43 +918,84 @@ const EditChannel = (props) => {
           </div>
           <TextArea
             placeholder={`此项可选,用于复写返回的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:\n${JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2)}`}
-            name='status_code_mapping'
+            name="status_code_mapping"
             onChange={(value) => {
               handleInputChange('status_code_mapping', value);
             }}
             autosize
             value={inputs.status_code_mapping}
-            autoComplete='new-password'
+            autoComplete="new-password"
           />
           <Typography.Text
             style={{
               color: 'rgba(var(--semi-blue-5), 1)',
               userSelect: 'none',
-              cursor: 'pointer',
+              cursor: 'pointer'
             }}
             onClick={() => {
               handleInputChange(
                 'status_code_mapping',
-                JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2),
+                JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2)
               );
             }}
           >
             填入模板
           </Typography.Text>
-          {/*<div style={{ marginTop: 10 }}>*/}
-          {/*  <Typography.Text strong>*/}
-          {/*    最大请求token(0表示不限制):*/}
-          {/*  </Typography.Text>*/}
-          {/*</div>*/}
-          {/*<Input*/}
-          {/*  label='最大请求token'*/}
-          {/*  name='max_input_tokens'*/}
-          {/*  placeholder='默认为0,表示不限制'*/}
-          {/*  onChange={(value) => {*/}
-          {/*    handleInputChange('max_input_tokens', value);*/}
-          {/*  }}*/}
-          {/*  value={inputs.max_input_tokens}*/}
-          {/*/>*/}
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>
+              渠道标签
+            </Typography.Text>
+          </div>
+          <Input
+            label="渠道标签"
+            name="tag"
+            placeholder={'渠道标签'}
+            onChange={(value) => {
+              handleInputChange('tag', value);
+            }}
+            value={inputs.tag}
+            autoComplete="new-password"
+          />
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>
+              渠道优先级
+            </Typography.Text>
+          </div>
+          <Input
+            label="渠道优先级"
+            name="priority"
+            placeholder={'渠道优先级'}
+            onChange={(value) => {
+              const number = parseInt(value);
+              if (isNaN(number)) {
+                handleInputChange('priority', value);
+              } else {
+                handleInputChange('priority', number);
+              }
+            }}
+            value={inputs.priority}
+            autoComplete="new-password"
+          />
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>
+              渠道权重
+            </Typography.Text>
+          </div>
+          <Input
+            label="渠道权重"
+            name="weight"
+            placeholder={'渠道权重'}
+            onChange={(value) => {
+              const number = parseInt(value);
+              if (isNaN(number)) {
+                handleInputChange('weight', value);
+              } else {
+                handleInputChange('weight', number);
+              }
+            }}
+            value={inputs.weight}
+            autoComplete="new-password"
+          />
         </Spin>
       </SideSheet>
     </>

+ 366 - 0
web/src/pages/Channel/EditTagModal.js

@@ -0,0 +1,366 @@
+import React, { useState, useEffect } from 'react';
+import { API, showError, showInfo, showSuccess, showWarning, verifyJSON } from '../../helpers';
+import { SideSheet, Space, Button, Input, Typography, Spin, Modal, Select, Banner, TextArea } from '@douyinfe/semi-ui';
+import TextInput from '../../components/custom/TextInput.js';
+import { getChannelModels } from '../../components/utils.js';
+
+const MODEL_MAPPING_EXAMPLE = {
+  'gpt-3.5-turbo': 'gpt-3.5-turbo-0125'
+};
+
+const EditTagModal = (props) => {
+  const { visible, tag, handleClose, refresh } = props;
+  const [loading, setLoading] = useState(false);
+  const [originModelOptions, setOriginModelOptions] = useState([]);
+  const [modelOptions, setModelOptions] = useState([]);
+  const [groupOptions, setGroupOptions] = useState([]);
+  const [basicModels, setBasicModels] = useState([]);
+  const [fullModels, setFullModels] = useState([]);
+  const [customModel, setCustomModel] = useState('');
+  const originInputs = {
+    tag: '',
+    new_tag: null,
+    model_mapping: null,
+    groups: [],
+    models: [],
+  }
+  const [inputs, setInputs] = useState(originInputs);
+
+  const handleInputChange = (name, value) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+    if (name === 'type') {
+      let localModels = [];
+      switch (value) {
+        case 2:
+          localModels = [
+            'mj_imagine',
+            'mj_variation',
+            'mj_reroll',
+            'mj_blend',
+            'mj_upscale',
+            'mj_describe',
+            'mj_uploads'
+          ];
+          break;
+        case 5:
+          localModels = [
+            'swap_face',
+            'mj_imagine',
+            'mj_variation',
+            'mj_reroll',
+            'mj_blend',
+            'mj_upscale',
+            'mj_describe',
+            'mj_zoom',
+            'mj_shorten',
+            'mj_modal',
+            'mj_inpaint',
+            'mj_custom_zoom',
+            'mj_high_variation',
+            'mj_low_variation',
+            'mj_pan',
+            'mj_uploads'
+          ];
+          break;
+        case 36:
+          localModels = [
+            'suno_music',
+            'suno_lyrics'
+          ];
+          break;
+        default:
+          localModels = getChannelModels(value);
+          break;
+      }
+      if (inputs.models.length === 0) {
+        setInputs((inputs) => ({ ...inputs, models: localModels }));
+      }
+      setBasicModels(localModels);
+    }
+  };
+
+  const fetchModels = async () => {
+    try {
+      let res = await API.get(`/api/channel/models`);
+      let localModelOptions = res.data.data.map((model) => ({
+        label: model.id,
+        value: model.id
+      }));
+      setOriginModelOptions(localModelOptions);
+      setFullModels(res.data.data.map((model) => model.id));
+      setBasicModels(
+        res.data.data
+          .filter((model) => {
+            return model.id.startsWith('gpt-') || model.id.startsWith('text-');
+          })
+          .map((model) => model.id)
+      );
+    } catch (error) {
+      showError(error.message);
+    }
+  };
+
+  const fetchGroups = async () => {
+    try {
+      let res = await API.get(`/api/group/`);
+      if (res === undefined) {
+        return;
+      }
+      setGroupOptions(
+        res.data.data.map((group) => ({
+          label: group,
+          value: group
+        }))
+      );
+    } catch (error) {
+      showError(error.message);
+    }
+  };
+
+
+  const handleSave = async () => {
+    setLoading(true);
+    let data = {
+      tag: tag,
+    }
+    if (inputs.model_mapping !== null && inputs.model_mapping !== '') {
+      if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
+        showInfo('模型映射必须是合法的 JSON 格式!');
+        setLoading(false);
+        return;
+      }
+      data.model_mapping = inputs.model_mapping
+    }
+    if (inputs.groups.length > 0) {
+      data.groups = inputs.groups.join(',');
+    }
+    if (inputs.models.length > 0) {
+      data.models = inputs.models.join(',');
+    }
+    data.new_tag = inputs.new_tag;
+    // check have any change
+    if (data.model_mapping === undefined && data.groups === undefined && data.models === undefined && data.new_tag === undefined) {
+      showWarning('没有任何修改!');
+      setLoading(false);
+      return;
+    }
+    await submit(data);
+    setLoading(false);
+  };
+
+  const submit = async (data) => {
+    try {
+      const res = await API.put('/api/channel/tag', data);
+      if (res?.data?.success) {
+        showSuccess('标签更新成功!');
+        refresh();
+        handleClose();
+      }
+    } catch (error) {
+      showError(error);
+    }
+  }
+
+  useEffect(() => {
+    let localModelOptions = [...originModelOptions];
+    inputs.models.forEach((model) => {
+      if (!localModelOptions.find((option) => option.label === model)) {
+        localModelOptions.push({
+          label: model,
+          value: model
+        });
+      }
+    });
+    setModelOptions(localModelOptions);
+  }, [originModelOptions, inputs.models]);
+
+  useEffect(() => {
+    setInputs({
+      ...originInputs,
+      tag: tag,
+      new_tag: tag,
+    })
+    fetchModels().then();
+    fetchGroups().then();
+  }, [visible]);
+
+  const addCustomModels = () => {
+    if (customModel.trim() === '') return;
+    // 使用逗号分隔字符串,然后去除每个模型名称前后的空格
+    const modelArray = customModel.split(',').map((model) => model.trim());
+
+    let localModels = [...inputs.models];
+    let localModelOptions = [...modelOptions];
+    let hasError = false;
+
+    modelArray.forEach((model) => {
+      // 检查模型是否已存在,且模型名称非空
+      if (model && !localModels.includes(model)) {
+        localModels.push(model); // 添加到模型列表
+        localModelOptions.push({
+          // 添加到下拉选项
+          key: model,
+          text: model,
+          value: model
+        });
+      } else if (model) {
+        showError('某些模型已存在!');
+        hasError = true;
+      }
+    });
+
+    if (hasError) return; // 如果有错误则终止操作
+
+    // 更新状态值
+    setModelOptions(localModelOptions);
+    setCustomModel('');
+    handleInputChange('models', localModels);
+  };
+
+
+  return (
+    <SideSheet
+      title="编辑标签"
+      visible={visible}
+      onCancel={handleClose}
+      footer={
+        <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
+          <Space>
+            <Button onClick={handleClose}>取消</Button>
+            <Button type="primary" onClick={handleSave} loading={loading}>保存</Button>
+          </Space>
+        </div>
+      }
+    >
+      <div style={{ marginTop: 10 }}>
+        <Banner
+          type={'warning'}
+          description={
+            <>
+              所有编辑均为覆盖操作,留空则不更改
+            </>
+          }
+        ></Banner>
+      </div>
+      <Spin spinning={loading}>
+        <TextInput
+          label="标签名,留空则解散标签"
+          name="newTag"
+          value={inputs.new_tag}
+          onChange={(value) => setInputs({ ...inputs, new_tag: value })}
+          placeholder="请输入新标签"
+        />
+        <div style={{ marginTop: 10 }}>
+          <Typography.Text strong>模型,留空则不更改:</Typography.Text>
+        </div>
+        <Select
+          placeholder={'请选择该渠道所支持的模型,留空则不更改'}
+          name="models"
+          required
+          multiple
+          selection
+          filter
+          searchPosition='dropdown'
+          onChange={(value) => {
+            handleInputChange('models', value);
+          }}
+          value={inputs.models}
+          autoComplete="new-password"
+          optionList={modelOptions}
+        />
+        <Input
+          addonAfter={
+            <Button type="primary" onClick={addCustomModels}>
+              填入
+            </Button>
+          }
+          placeholder="输入自定义模型名称"
+          value={customModel}
+          onChange={(value) => {
+            setCustomModel(value.trim());
+          }}
+        />
+        <div style={{ marginTop: 10 }}>
+          <Typography.Text strong>分组,留空则不更改:</Typography.Text>
+        </div>
+        <Select
+          placeholder={'请选择可以使用该渠道的分组,留空则不更改'}
+          name="groups"
+          required
+          multiple
+          selection
+          allowAdditions
+          additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
+          onChange={(value) => {
+            handleInputChange('groups', value);
+          }}
+          value={inputs.groups}
+          autoComplete="new-password"
+          optionList={groupOptions}
+        />
+        <div style={{ marginTop: 10 }}>
+          <Typography.Text strong>模型重定向:</Typography.Text>
+        </div>
+        <TextArea
+          placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改`}
+          name="model_mapping"
+          onChange={(value) => {
+            handleInputChange('model_mapping', value);
+          }}
+          autosize
+          value={inputs.model_mapping}
+          autoComplete="new-password"
+        />
+        <Space>
+          <Typography.Text
+            style={{
+              color: 'rgba(var(--semi-blue-5), 1)',
+              userSelect: 'none',
+              cursor: 'pointer'
+            }}
+            onClick={() => {
+              handleInputChange(
+                'model_mapping',
+                JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)
+              );
+            }}
+          >
+            填入模板
+          </Typography.Text>
+          <Typography.Text
+            style={{
+              color: 'rgba(var(--semi-blue-5), 1)',
+              userSelect: 'none',
+              cursor: 'pointer'
+            }}
+            onClick={() => {
+              handleInputChange(
+                'model_mapping',
+                JSON.stringify({}, null, 2)
+              );
+            }}
+          >
+            清空重定向
+          </Typography.Text>
+          <Typography.Text
+            style={{
+              color: 'rgba(var(--semi-blue-5), 1)',
+              userSelect: 'none',
+              cursor: 'pointer'
+            }}
+            onClick={() => {
+              handleInputChange(
+                'model_mapping',
+                ""
+              );
+            }}
+          >
+            不更改
+          </Typography.Text>
+        </Space>
+      </Spin>
+    </SideSheet>
+  );
+};
+
+export default EditTagModal;

+ 315 - 205
web/src/pages/Detail/index.js

@@ -1,8 +1,8 @@
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useContext, useEffect, useRef, useState } from 'react';
 import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
 
-import { Button, Col, Form, Layout, Row, Spin } from '@douyinfe/semi-ui';
-import VChart from '@visactor/vchart';
+import { Button, Card, Col, Descriptions, Form, Layout, Row, Spin, Tabs } from '@douyinfe/semi-ui';
+import { VChart } from "@visactor/react-vchart";
 import {
   API,
   isAdmin,
@@ -17,11 +17,16 @@ import {
   renderQuota,
   renderQuotaNumberWithDigit,
   stringToColor,
+  modelToColor,
 } from '../../helpers/render';
+import { UserContext } from '../../context/User/index.js';
+import { StyleContext } from '../../context/Style/index.js';
 
 const Detail = (props) => {
   const formRef = useRef();
   let now = new Date();
+  const [userState, userDispatch] = useContext(UserContext);
+  const [styleState, styleDispatch] = useContext(StyleContext);
   const [inputs, setInputs] = useState({
     username: '',
     token_name: '',
@@ -40,32 +45,76 @@ const Detail = (props) => {
     inputs;
   const isAdminUser = isAdmin();
   const initialized = useRef(false);
-  const [modelDataChart, setModelDataChart] = useState(null);
-  const [modelDataPieChart, setModelDataPieChart] = useState(null);
   const [loading, setLoading] = useState(false);
   const [quotaData, setQuotaData] = useState([]);
   const [consumeQuota, setConsumeQuota] = useState(0);
+  const [consumeTokens, setConsumeTokens] = useState(0);
   const [times, setTimes] = useState(0);
   const [dataExportDefaultTime, setDataExportDefaultTime] = useState(
     localStorage.getItem('data_export_default_time') || 'hour',
   );
-
-  const handleInputChange = (value, name) => {
-    if (name === 'data_export_default_time') {
-      setDataExportDefaultTime(value);
-      return;
-    }
-    setInputs((inputs) => ({ ...inputs, [name]: value }));
-  };
-
-  const spec_line = {
-    type: 'bar',
-    data: [
-      {
-        id: 'barData',
-        values: [],
+  const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
+  const [lineData, setLineData] = useState([]);
+  const [spec_pie, setSpecPie] = useState({
+    type: 'pie',
+    data: [{
+      id: 'id0',
+      values: pieData
+    }],
+    outerRadius: 0.8,
+    innerRadius: 0.5,
+    padAngle: 0.6,
+    valueField: 'value',
+    categoryField: 'type',
+    pie: {
+      style: {
+        cornerRadius: 10,
       },
-    ],
+      state: {
+        hover: {
+          outerRadius: 0.85,
+          stroke: '#000',
+          lineWidth: 1,
+        },
+        selected: {
+          outerRadius: 0.85,
+          stroke: '#000',
+          lineWidth: 1,
+        },
+      },
+    },
+    title: {
+      visible: true,
+      text: '模型调用次数占比',
+      subtext: `总计:${renderNumber(times)}`,
+    },
+    legends: {
+      visible: true,
+      orient: 'left',
+    },
+    label: {
+      visible: true,
+    },
+    tooltip: {
+      mark: {
+        content: [
+          {
+            key: (datum) => datum['type'],
+            value: (datum) => renderNumber(datum['value']),
+          },
+        ],
+      },
+    },
+    color: {
+      specified: modelColorMap,
+    },
+  });
+  const [spec_line, setSpecLine] = useState({
+    type: 'bar',
+    data: [{
+      id: 'barData',
+      values: lineData
+    }],
     xField: 'Time',
     yField: 'Usage',
     seriesField: 'Model',
@@ -77,7 +126,7 @@ const Detail = (props) => {
     title: {
       visible: true,
       text: '模型消耗分布',
-      subtext: '0',
+      subtext: `总计:${renderQuota(consumeQuota, 2)}`,
     },
     bar: {
       // The state style of bar
@@ -129,196 +178,197 @@ const Detail = (props) => {
     color: {
       specified: modelColorMap,
     },
-  };
+  });
 
-  const spec_pie = {
-    type: 'pie',
-    data: [
-      {
-        id: 'id0',
-        values: [{ type: 'null', value: '0' }],
-      },
-    ],
-    outerRadius: 0.8,
-    innerRadius: 0.5,
-    padAngle: 0.6,
-    valueField: 'value',
-    categoryField: 'type',
-    pie: {
-      style: {
-        cornerRadius: 10,
-      },
-      state: {
-        hover: {
-          outerRadius: 0.85,
-          stroke: '#000',
-          lineWidth: 1,
-        },
-        selected: {
-          outerRadius: 0.85,
-          stroke: '#000',
-          lineWidth: 1,
-        },
-      },
-    },
-    title: {
-      visible: true,
-      text: '模型调用次数占比',
-    },
-    legends: {
-      visible: true,
-      orient: 'left',
-    },
-    label: {
-      visible: true,
-    },
-    tooltip: {
-      mark: {
-        content: [
-          {
-            key: (datum) => datum['type'],
-            value: (datum) => renderNumber(datum['value']),
-          },
-        ],
-      },
-    },
-    color: {
-      specified: modelColorMap,
-    },
+  // 添加一个新的状态来存储模型-颜色映射
+  const [modelColors, setModelColors] = useState({});
+
+  const handleInputChange = (value, name) => {
+    if (name === 'data_export_default_time') {
+      setDataExportDefaultTime(value);
+      return;
+    }
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
   };
 
-  const loadQuotaData = async (lineChart, pieChart) => {
+  const loadQuotaData = async () => {
     setLoading(true);
-
-    let url = '';
-    let localStartTimestamp = Date.parse(start_timestamp) / 1000;
-    let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-    if (isAdminUser) {
-      url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
-    } else {
-      url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
-    }
-    const res = await API.get(url);
-    const { success, message, data } = res.data;
-    if (success) {
-      setQuotaData(data);
-      if (data.length === 0) {
-        data.push({
-          count: 0,
-          model_name: '无数据',
-          quota: 0,
-          created_at: now.getTime() / 1000,
-        });
+    try {
+      let url = '';
+      let localStartTimestamp = Date.parse(start_timestamp) / 1000;
+      let localEndTimestamp = Date.parse(end_timestamp) / 1000;
+      if (isAdminUser) {
+        url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
+      } else {
+        url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
       }
-      // 根据dataExportDefaultTime重制时间粒度
-      let timeGranularity = 3600;
-      if (dataExportDefaultTime === 'day') {
-        timeGranularity = 86400;
-      } else if (dataExportDefaultTime === 'week') {
-        timeGranularity = 604800;
+      const res = await API.get(url);
+      const { success, message, data } = res.data;
+      if (success) {
+        setQuotaData(data);
+        if (data.length === 0) {
+          data.push({
+            count: 0,
+            model_name: '无数据',
+            quota: 0,
+            created_at: now.getTime() / 1000,
+          });
+        }
+        // 根据dataExportDefaultTime重制时间粒度
+        let timeGranularity = 3600;
+        if (dataExportDefaultTime === 'day') {
+          timeGranularity = 86400;
+        } else if (dataExportDefaultTime === 'week') {
+          timeGranularity = 604800;
+        }
+        // sort created_at
+        data.sort((a, b) => a.created_at - b.created_at);
+        data.forEach((item) => {
+          item['created_at'] =
+            Math.floor(item['created_at'] / timeGranularity) * timeGranularity;
+        });
+        updateChartData(data);
+      } else {
+        showError(message);
       }
-      // sort created_at
-      data.sort((a, b) => a.created_at - b.created_at);
-      data.forEach((item) => {
-        item['created_at'] =
-          Math.floor(item['created_at'] / timeGranularity) * timeGranularity;
-      });
-      updateChart(lineChart, pieChart, data);
-    } else {
-      showError(message);
+    } finally {
+      setLoading(false);
     }
-    setLoading(false);
   };
 
   const refresh = async () => {
-    await loadQuotaData(modelDataChart, modelDataPieChart);
+    await loadQuotaData();
   };
 
   const initChart = async () => {
-    let lineChart = modelDataChart;
-    if (!modelDataChart) {
-      lineChart = new VChart(spec_line, { dom: 'model_data' });
-      setModelDataChart(lineChart);
-      lineChart.renderAsync();
-    }
-    let pieChart = modelDataPieChart;
-    if (!modelDataPieChart) {
-      pieChart = new VChart(spec_pie, { dom: 'model_pie' });
-      setModelDataPieChart(pieChart);
-      pieChart.renderAsync();
-    }
-    console.log('init vchart');
-    await loadQuotaData(lineChart, pieChart);
+    await loadQuotaData();
   };
 
-  const updateChart = (lineChart, pieChart, data) => {
-    if (isAdminUser) {
-      // 将所有用户合并
-    }
-    let pieData = [];
-    let lineData = [];
-    let consumeQuota = 0;
-    let times = 0;
-    for (let i = 0; i < data.length; i++) {
-      const item = data[i];
-      consumeQuota += item.quota;
-      times += item.count;
-      // 合并model_name
-      let pieItem = pieData.find((it) => it.type === item.model_name);
+  const updateChartData = (data) => {
+    let newPieData = [];
+    let newLineData = [];
+    let totalQuota = 0;
+    let totalTimes = 0;
+    let uniqueModels = new Set();
+    let totalTokens = 0;
+
+    // 收集所有唯一的模型名称和时间点
+    let uniqueTimes = new Set();
+    data.forEach(item => {
+      uniqueModels.add(item.model_name);
+      uniqueTimes.add(timestamp2string1(item.created_at, dataExportDefaultTime));
+      totalTokens += item.token_used;
+    });
+    
+    // 处理颜色映射
+    const newModelColors = {};
+    Array.from(uniqueModels).forEach((modelName) => {
+      newModelColors[modelName] = modelColorMap[modelName] || 
+        modelColors[modelName] || 
+        modelToColor(modelName);
+    });
+    setModelColors(newModelColors);
+
+    // 处理饼图数据
+    for (let item of data) {
+      totalQuota += item.quota;
+      totalTimes += item.count;
+      
+      let pieItem = newPieData.find((it) => it.type === item.model_name);
       if (pieItem) {
         pieItem.value += item.count;
       } else {
-        pieData.push({
+        newPieData.push({
           type: item.model_name,
           value: item.count,
         });
       }
-      // 合并created_at和model_name 为 lineData, created_at 数据类型是小时的时间戳
-      // 转换日期格式
-      let createTime = timestamp2string1(
-        item.created_at,
-        dataExportDefaultTime,
-      );
-      let lineItem = lineData.find(
-        (it) => it.Time === createTime && it.Model === item.model_name,
-      );
-      if (lineItem) {
-        lineItem.Usage += parseFloat(getQuotaWithUnit(item.quota));
-      } else {
-        lineData.push({
-          Time: createTime,
-          Model: item.model_name,
-          Usage: parseFloat(getQuotaWithUnit(item.quota)),
-        });
-      }
     }
-    setConsumeQuota(consumeQuota);
-    setTimes(times);
 
-    // sort by count
-    pieData.sort((a, b) => b.value - a.value);
-    spec_pie.title.subtext = `总计:${renderNumber(times)}`;
-    spec_pie.data[0].values = pieData;
+    // 处理柱状图数据
+    let timePoints = Array.from(uniqueTimes);
+    if (timePoints.length < 7) {
+      // 根据时间粒度生成合适的时间点
+      const generateTimePoints = () => {
+        let lastTime = Math.max(...data.map(item => item.created_at));
+        let points = [];
+        let interval = dataExportDefaultTime === 'hour' ? 3600 
+                      : dataExportDefaultTime === 'day' ? 86400 
+                      : 604800;
 
-    spec_line.title.subtext = `总计:${renderQuota(consumeQuota, 2)}`;
-    spec_line.data[0].values = lineData;
-    pieChart.updateSpec(spec_pie);
-    lineChart.updateSpec(spec_line);
+        for (let i = 0; i < 7; i++) {
+          points.push(timestamp2string1(lastTime - (i * interval), dataExportDefaultTime));
+        }
+        return points.reverse();
+      };
 
-    // pieChart.updateData('id0', pieData);
-    // lineChart.updateData('barData', lineData);
-    pieChart.reLayout();
-    lineChart.reLayout();
+      timePoints = generateTimePoints();
+    }
+
+    // 为每个时间点和模型生成数据
+    timePoints.forEach(time => {
+      Array.from(uniqueModels).forEach(model => {
+        let existingData = data.find(item => 
+          timestamp2string1(item.created_at, dataExportDefaultTime) === time && 
+          item.model_name === model
+        );
+
+        newLineData.push({
+          Time: time,
+          Model: model,
+          Usage: existingData ? parseFloat(getQuotaWithUnit(existingData.quota)) : 0
+        });
+      });
+    });
+
+    // 排序
+    newPieData.sort((a, b) => b.value - a.value);
+    newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
+
+    // 更新图表配置和数据
+    setSpecPie(prev => ({
+      ...prev,
+      data: [{ id: 'id0', values: newPieData }],
+      title: {
+        ...prev.title,
+        subtext: `总计:${renderNumber(totalTimes)}`
+      },
+      color: {
+        specified: newModelColors
+      }
+    }));
+
+    setSpecLine(prev => ({
+      ...prev,
+      data: [{ id: 'barData', values: newLineData }],
+      title: {
+        ...prev.title,
+        subtext: `总计:${renderQuota(totalQuota, 2)}`
+      },
+      color: {
+        specified: newModelColors
+      }
+    }));
+    
+    setPieData(newPieData);
+    setLineData(newLineData);
+    setConsumeQuota(totalQuota);
+    setTimes(totalTimes);
+    setConsumeTokens(totalTokens);
+  };
+
+  const getUserData = async () => {
+    let res = await API.get(`/api/user/self`);
+    const {success, message, data} = res.data;
+    if (success) {
+      userDispatch({type: 'login', payload: data});
+    } else {
+      showError(message);
+    }
   };
 
   useEffect(() => {
-    // setDataExportDefaultTime(localStorage.getItem('data_export_default_time'));
-    // if (dataExportDefaultTime === 'day') {
-    //     // 设置开始时间为7天前
-    //     let st = timestamp2string(now.getTime() / 1000 - 86400 * 7)
-    //     inputs.start_timestamp = st;
-    //     formRef.current.formApi.setValue('start_timestamp', st);
-    // }
+    getUserData()
     if (!initialized.current) {
       initVChartSemiTheme({
         isWatchingThemeSwitch: true,
@@ -389,33 +439,93 @@ const Detail = (props) => {
                   />
                 </>
               )}
+              <Button
+                label='查询'
+                type='primary'
+                htmlType='submit'
+                className='btn-margin-right'
+                onClick={refresh}
+                loading={loading}
+                style={{ marginTop: 24 }}
+              >
+                查询
+              </Button>
               <Form.Section>
-                <Button
-                  label='查询'
-                  type='primary'
-                  htmlType='submit'
-                  className='btn-margin-right'
-                  onClick={refresh}
-                  loading={loading}
-                >
-                  查询
-                </Button>
               </Form.Section>
             </>
           </Form>
           <Spin spinning={loading}>
-            <div style={{ height: 500 }}>
-              <div
-                id='model_pie'
-                style={{ width: '100%', minWidth: 100 }}
-              ></div>
-            </div>
-            <div style={{ height: 500 }}>
-              <div
-                id='model_data'
-                style={{ width: '100%', minWidth: 100 }}
-              ></div>
-            </div>
+            <Row gutter={{ xs: 16, sm: 16, md: 16, lg: 24, xl: 24, xxl: 24 }} style={{marginTop: 20}} type="flex" justify="space-between">
+              <Col span={styleState.isMobile?24:8}>
+                <Card className='panel-desc-card'>
+                  <Descriptions row size="small">
+                    <Descriptions.Item itemKey='当前余额'>
+                      {renderQuota(userState?.user?.quota)}
+                    </Descriptions.Item>
+                    <Descriptions.Item itemKey='历史消耗'>
+                      {renderQuota(userState?.user?.used_quota)}
+                    </Descriptions.Item>
+                    <Descriptions.Item itemKey='请求次数'>
+                      {userState.user?.request_count}
+                    </Descriptions.Item>
+                  </Descriptions>
+                </Card>
+              </Col>
+              <Col span={styleState.isMobile?24:8}>
+                <Card>
+                  <Descriptions row size="small">
+                    <Descriptions.Item itemKey='统计额度'>
+                      {renderQuota(consumeQuota)}
+                    </Descriptions.Item>
+                    <Descriptions.Item itemKey='统计Tokens'>
+                      {consumeTokens}
+                    </Descriptions.Item>
+                    <Descriptions.Item itemKey='统计次数'>
+                      {times}
+                    </Descriptions.Item>
+                  </Descriptions>
+                </Card>
+              </Col>
+              <Col span={styleState.isMobile ? 24 : 8}>
+                <Card>
+                  <Descriptions row size='small'>
+                    <Descriptions.Item itemKey='平均RPM'>
+                      {(times /
+                        ((Date.parse(end_timestamp) -
+                          Date.parse(start_timestamp)) /
+                          60000)).toFixed(3)}
+                    </Descriptions.Item>
+                    <Descriptions.Item itemKey='平均TPM'>
+                      {(consumeTokens /
+                        ((Date.parse(end_timestamp) -
+                          Date.parse(start_timestamp)) /
+                          60000)).toFixed(3)}
+                    </Descriptions.Item>
+                  </Descriptions>
+                </Card>
+              </Col>
+            </Row>
+            <Card style={{marginTop: 20}}>
+              <Tabs type="line" defaultActiveKey="1">
+                <Tabs.TabPane tab="消耗分布" itemKey="1">
+                  <div style={{ height: 500 }}>
+                    <VChart
+                      spec={spec_line}
+                      option={{ mode: "desktop-browser" }}
+                    />
+                  </div>
+                </Tabs.TabPane>
+                <Tabs.TabPane tab="调用次数分布" itemKey="2">
+                  <div style={{ height: 500 }}>
+                    <VChart
+                      spec={spec_pie}
+                      option={{ mode: "desktop-browser" }}
+                    />
+                  </div>
+                </Tabs.TabPane>
+
+              </Tabs>
+            </Card>
           </Spin>
         </Layout.Content>
       </Layout>

+ 2 - 0
web/src/pages/Home/index.js

@@ -3,11 +3,13 @@ import { Card, Col, Row } from '@douyinfe/semi-ui';
 import { API, showError, showNotice, timestamp2string } from '../../helpers';
 import { StatusContext } from '../../context/Status';
 import { marked } from 'marked';
+import { StyleContext } from '../../context/Style/index.js';
 
 const Home = () => {
   const [statusState] = useContext(StatusContext);
   const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
   const [homePageContent, setHomePageContent] = useState('');
+  const [styleState, styleDispatch] = useContext(StyleContext);
 
   const displayNotice = async () => {
     const res = await API.get('/api/notice');

+ 162 - 85
web/src/components/Playground.js → web/src/pages/Playground/Playground.js

@@ -1,9 +1,11 @@
 import React, { useCallback, useContext, useEffect, useState } from 'react';
 import { useNavigate, useSearchParams } from 'react-router-dom';
-import { UserContext } from '../context/User';
-import { API, getUserIdFromLocalStorage, showError } from '../helpers';
-import { Card, Chat, Input, Layout, Select, Slider, TextArea, Typography } from '@douyinfe/semi-ui';
+import { UserContext } from '../../context/User/index.js';
+import { API, getUserIdFromLocalStorage, showError } from '../../helpers/index.js';
+import { Card, Chat, Input, Layout, Select, Slider, TextArea, Typography, Button } from '@douyinfe/semi-ui';
 import { SSE } from 'sse';
+import { IconSetting } from '@douyinfe/semi-icons';
+import { StyleContext } from '../../context/Style/index.js';
 
 const defaultMessage = [
   {
@@ -20,6 +22,21 @@ const defaultMessage = [
   }
 ];
 
+const roleInfo = {
+  user:  {
+    name: 'User',
+    avatar: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/docs-icon.png'
+  },
+  assistant: {
+    name: 'Assistant',
+    avatar: 'logo.png'
+  },
+  system: {
+    name: 'System',
+    avatar: 'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/logo.png'
+  }
+}
+
 let id = 4;
 function getId() {
   return `${id++}`
@@ -39,6 +56,8 @@ const Playground = () => {
   const [message, setMessage] = useState(defaultMessage);
   const [models, setModels] = useState([]);
   const [groups, setGroups] = useState([]);
+  const [showSettings, setShowSettings] = useState(true);
+  const [styleState, styleDispatch] = useContext(StyleContext);
 
   const handleInputChange = (name, value) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -84,11 +103,16 @@ const Playground = () => {
       // handleInputChange('group', localGroupOptions[0].value);
 
       if (localGroupOptions.length > 0) {
-        // set default group at first
-        localGroupOptions.unshift({
-          label: '用户分组',
-          value: '',
-        });
+        // set user group at first
+        if (userState.user && userState.user.group) {
+          let userGroup = userState.user.group;
+          // Find and move user's group to the front
+          const userGroupIndex = localGroupOptions.findIndex(g => g.value === userGroup);
+          if (userGroupIndex > -1) {
+            const userGroupOption = localGroupOptions.splice(userGroupIndex, 1)[0];
+            localGroupOptions.unshift(userGroupOption);
+          }
+        }
       } else {
         localGroupOptions = [{
           label: '用户分组',
@@ -242,94 +266,147 @@ const Playground = () => {
     })
   }, []);
 
+  const SettingsToggle = () => {
+    if (!styleState.isMobile) return null;
+    return (
+      <Button
+        icon={<IconSetting />}
+        style={{
+          position: 'absolute',
+          left: showSettings ? -10 : -20,
+          top: '50%',
+          transform: 'translateY(-50%)',
+          zIndex: 1000,
+          width: 40,
+          height: 40,
+          borderRadius: '0 20px 20px 0',
+          padding: 0,
+          boxShadow: '2px 0 8px rgba(0, 0, 0, 0.15)',
+        }}
+        onClick={() => setShowSettings(!showSettings)}
+        theme="solid"
+        type="primary"
+      />
+    );
+  };
+
+  function CustomInputRender(props) {
+    const { detailProps } = props;
+    const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
+
+    return <div style={{margin: '8px 16px', display: 'flex', flexDirection:'row',
+      alignItems: 'flex-end', borderRadius: 16,padding: 10, border: '1px solid var(--semi-color-border)'}}
+                onClick={onClick}
+    >
+      {/*{uploadNode}*/}
+      {inputNode}
+      {sendNode}
+    </div>
+  }
+
+  const renderInputArea = useCallback((props) => {
+    return (<CustomInputRender {...props} />)
+  }, []);
+
   return (
     <Layout style={{height: '100%'}}>
-      <Layout.Sider>
-        <Card style={commonOuterStyle}>
-          <div style={{ marginTop: 10 }}>
-            <Typography.Text strong>分组:</Typography.Text>
-          </div>
-          <Select
-            placeholder={'请选择分组'}
-            name='group'
-            required
-            selection
-            onChange={(value) => {
-              handleInputChange('group', value);
-            }}
-            value={inputs.group}
-            autoComplete='new-password'
-            optionList={groups}
-          />
-          <div style={{ marginTop: 10 }}>
-            <Typography.Text strong>模型:</Typography.Text>
-          </div>
-          <Select
-            placeholder={'请选择模型'}
-            name='model'
-            required
-            selection
-            filter
-            onChange={(value) => {
-              handleInputChange('model', value);
-            }}
-            value={inputs.model}
-            autoComplete='new-password'
-            optionList={models}
-          />
-          <div style={{ marginTop: 10 }}>
-            <Typography.Text strong>Temperature:</Typography.Text>
-          </div>
-          <Slider
-            step={0.1}
-            min={0.1}
-            max={1}
-            value={inputs.temperature}
-            onChange={(value) => {
-              handleInputChange('temperature', value);
-            }}
-          />
-          <div style={{ marginTop: 10 }}>
-            <Typography.Text strong>MaxTokens:</Typography.Text>
-          </div>
-          <Input
-            placeholder='MaxTokens'
-            name='max_tokens'
-            required
-            autoComplete='new-password'
-            defaultValue={0}
-            value={inputs.max_tokens}
-            onChange={(value) => {
-              handleInputChange('max_tokens', value);
-            }}
-          />
+      {(showSettings || !styleState.isMobile) && (
+        <Layout.Sider style={{ display: styleState.isMobile ? 'block' : 'initial' }}>
+          <Card style={commonOuterStyle}>
+            <div style={{ marginTop: 10 }}>
+              <Typography.Text strong>分组:</Typography.Text>
+            </div>
+            <Select
+              placeholder={'请选择分组'}
+              name='group'
+              required
+              selection
+              onChange={(value) => {
+                handleInputChange('group', value);
+              }}
+              value={inputs.group}
+              autoComplete='new-password'
+              optionList={groups.map((group) => ({
+                ...group,
+                label: styleState.isMobile && group.label.length > 16
+                  ? group.label.substring(0, 16) + '...'
+                  : group.label,
+              }))}
+            />
+            <div style={{ marginTop: 10 }}>
+              <Typography.Text strong>模型:</Typography.Text>
+            </div>
+            <Select
+              placeholder={'请选择模型'}
+              name='model'
+              required
+              selection
+              searchPosition='dropdown'
+              filter
+              onChange={(value) => {
+                handleInputChange('model', value);
+              }}
+              value={inputs.model}
+              autoComplete='new-password'
+              optionList={models}
+            />
+            <div style={{ marginTop: 10 }}>
+              <Typography.Text strong>Temperature:</Typography.Text>
+            </div>
+            <Slider
+              step={0.1}
+              min={0.1}
+              max={1}
+              value={inputs.temperature}
+              onChange={(value) => {
+                handleInputChange('temperature', value);
+              }}
+            />
+            <div style={{ marginTop: 10 }}>
+              <Typography.Text strong>MaxTokens:</Typography.Text>
+            </div>
+            <Input
+              placeholder='MaxTokens'
+              name='max_tokens'
+              required
+              autoComplete='new-password'
+              defaultValue={0}
+              value={inputs.max_tokens}
+              onChange={(value) => {
+                handleInputChange('max_tokens', value);
+              }}
+            />
 
-          <div style={{ marginTop: 10 }}>
-            <Typography.Text strong>System:</Typography.Text>
-          </div>
-          <TextArea
-            placeholder='System Prompt'
-            name='system'
-            required
-            autoComplete='new-password'
-            autosize
-            defaultValue={systemPrompt}
-            // value={systemPrompt}
-            onChange={(value) => {
-              setSystemPrompt(value);
-            }}
-          />
+            <div style={{ marginTop: 10 }}>
+              <Typography.Text strong>System:</Typography.Text>
+            </div>
+            <TextArea
+              placeholder='System Prompt'
+              name='system'
+              required
+              autoComplete='new-password'
+              autosize
+              defaultValue={systemPrompt}
+              // value={systemPrompt}
+              onChange={(value) => {
+                setSystemPrompt(value);
+              }}
+            />
 
-        </Card>
-      </Layout.Sider>
+          </Card>
+        </Layout.Sider>
+      )}
       <Layout.Content>
-        <div style={{height: '100%'}}>
+        <div style={{height: '100%', position: 'relative'}}>
+          <SettingsToggle />
           <Chat
             chatBoxRenderConfig={{
               renderChatBoxAction: () => {
                 return <div></div>
               }
             }}
+            renderInputArea={renderInputArea}
+            roleConfig={roleInfo}
             style={commonOuterStyle}
             chats={message}
             onMessageSend={onMessageSend}

+ 7 - 2
web/src/pages/Redemption/EditRedemption.js

@@ -7,7 +7,7 @@ import {
   showError,
   showSuccess,
 } from '../../helpers';
-import { renderQuotaWithPrompt } from '../../helpers/render';
+import { getQuotaPerUnit, renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
 import {
   AutoComplete,
   Button,
@@ -66,11 +66,16 @@ const EditRedemption = (props) => {
   }, [props.editingRedemption.id]);
 
   const submit = async () => {
-    if (!isEdit && inputs.name === '') return;
+    let name = inputs.name;
+    if (!isEdit && inputs.name === '') {
+      // set default name
+      name = '兑换码-' + renderQuota(quota);
+    }
     setLoading(true);
     let localInputs = inputs;
     localInputs.count = parseInt(localInputs.count);
     localInputs.quota = parseInt(localInputs.quota);
+    localInputs.name = name;
     let res;
     if (isEdit) {
       res = await API.put(`/api/redemption/`, {

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff