Просмотр исходного кода

Merge branch 'main' into refactor-settings-operation

QuentinHsu 1 год назад
Родитель
Сommit
698af0786d

+ 2 - 0
common/constants.go

@@ -208,6 +208,8 @@ const (
 	ChannelTypeLingYiWanWu    = 31
 	ChannelTypeAws            = 33
 	ChannelTypeCohere         = 34
+
+	ChannelTypeDummy // this one is only for count, do not add any channel after this
 )
 
 var ChannelBaseURLs = []string{

+ 37 - 2
common/model-ratio.go

@@ -28,6 +28,7 @@ var DefaultModelRatio = map[string]float64{
 	"gpt-4-vision-preview":      5,    // $0.01 / 1K tokens
 	"gpt-4-1106-vision-preview": 5,    // $0.01 / 1K tokens
 	"gpt-4-turbo":               5,    // $0.01 / 1K tokens
+	"gpt-4-turbo-2024-04-09":    5,    // $0.01 / 1K tokens
 	"gpt-3.5-turbo":             0.25, // $0.0015 / 1K tokens
 	//"gpt-3.5-turbo-0301":           0.75, //deprecated
 	"gpt-3.5-turbo-0613":           0.75,
@@ -111,6 +112,8 @@ var DefaultModelRatio = map[string]float64{
 	"command-light-nightly": 0.5,
 	"command-r":             0.25,
 	"command-r-plus	":       1.5,
+	"deepseek-chat":         0.07,
+	"deepseek-coder":        0.07,
 }
 
 var DefaultModelPrice = map[string]float64{
@@ -135,6 +138,12 @@ var DefaultModelPrice = map[string]float64{
 var modelPrice map[string]float64 = nil
 var modelRatio map[string]float64 = nil
 
+var CompletionRatio map[string]float64 = nil
+var DefaultCompletionRatio = map[string]float64{
+	"gpt-4-gizmo-*": 2,
+	"gpt-4-all":     2,
+}
+
 func ModelPrice2JSONString() string {
 	if modelPrice == nil {
 		modelPrice = DefaultModelPrice
@@ -199,6 +208,22 @@ func GetModelRatio(name string) float64 {
 	return ratio
 }
 
+func CompletionRatio2JSONString() string {
+	if CompletionRatio == nil {
+		CompletionRatio = DefaultCompletionRatio
+	}
+	jsonBytes, err := json.Marshal(CompletionRatio)
+	if err != nil {
+		SysError("error marshalling completion ratio: " + err.Error())
+	}
+	return string(jsonBytes)
+}
+
+func UpdateCompletionRatioByJSONString(jsonStr string) error {
+	CompletionRatio = make(map[string]float64)
+	return json.Unmarshal([]byte(jsonStr), &CompletionRatio)
+}
+
 func GetCompletionRatio(name string) float64 {
 	if strings.HasPrefix(name, "gpt-3.5") {
 		if name == "gpt-3.5-turbo" || strings.HasSuffix(name, "0125") {
@@ -211,7 +236,7 @@ func GetCompletionRatio(name string) float64 {
 		}
 		return 4.0 / 3.0
 	}
-	if strings.HasPrefix(name, "gpt-4") {
+	if strings.HasPrefix(name, "gpt-4") && name != "gpt-4-all" && !strings.HasPrefix(name, "gpt-4-gizmo") {
 		if strings.HasPrefix(name, "gpt-4-turbo") || strings.HasSuffix(name, "preview") {
 			return 3
 		}
@@ -240,9 +265,19 @@ func GetCompletionRatio(name string) float64 {
 			return 2
 		}
 	}
+	if strings.HasPrefix(name, "deepseek") {
+		return 2
+	}
 	switch name {
 	case "llama2-70b-4096":
-		return 0.8 / 0.7
+		return 0.8 / 0.64
+	case "llama3-8b-8192":
+		return 2
+	case "llama3-70b-8192":
+		return 0.79 / 0.59
+	}
+	if ratio, ok := CompletionRatio[name]; ok {
+		return ratio
 	}
 	return 1
 }

+ 9 - 0
common/utils.go

@@ -1,6 +1,7 @@
 package common
 
 import (
+	"encoding/json"
 	"fmt"
 	"github.com/google/uuid"
 	"html/template"
@@ -241,3 +242,11 @@ func RandomSleep() {
 	// Sleep for 0-3000 ms
 	time.Sleep(time.Duration(rand.Intn(3000)) * time.Millisecond)
 }
+
+func MapToJsonStr(m map[string]interface{}) string {
+	bytes, err := json.Marshal(m)
+	if err != nil {
+		return ""
+	}
+	return string(bytes)
+}

+ 1 - 1
controller/channel-test.go

@@ -208,7 +208,7 @@ func testAllChannels(notify bool) error {
 			if isChannelEnabled && service.ShouldDisableChannel(openaiErr, -1) && ban {
 				service.DisableChannel(channel.Id, channel.Name, err.Error())
 			}
-			if !isChannelEnabled && service.ShouldEnableChannel(err, openaiErr) {
+			if !isChannelEnabled && service.ShouldEnableChannel(err, openaiErr, channel.Status) {
 				service.EnableChannel(channel.Id, channel.Name)
 			}
 			channel.UpdateResponseTime(milliseconds)

+ 1 - 1
controller/misc.go

@@ -147,7 +147,7 @@ func SendEmailVerification(c *gin.Context) {
 		}
 	}
 	if common.EmailAliasRestrictionEnabled {
-		containsSpecialSymbols := strings.Contains(localPart, "+") || strings.Count(localPart, ".") > 1
+		containsSpecialSymbols := strings.Contains(localPart, "+") || strings.Contains(localPart, ".")
 		if containsSpecialSymbols {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,

+ 44 - 7
controller/model.go

@@ -4,13 +4,15 @@ import (
 	"fmt"
 	"github.com/gin-gonic/gin"
 	"net/http"
+	"one-api/common"
 	"one-api/constant"
 	"one-api/dto"
 	"one-api/model"
 	"one-api/relay"
 	"one-api/relay/channel/ai360"
-	"one-api/relay/channel/moonshot"
 	"one-api/relay/channel/lingyiwanwu"
+	"one-api/relay/channel/moonshot"
+	relaycommon "one-api/relay/common"
 	relayconstant "one-api/relay/constant"
 )
 
@@ -43,8 +45,9 @@ type OpenAIModels struct {
 
 var openAIModels []OpenAIModels
 var openAIModelsMap map[string]OpenAIModels
+var channelId2Models map[int][]string
 
-func init() {
+func getPermission() []OpenAIModelPermission {
 	var permission []OpenAIModelPermission
 	permission = append(permission, OpenAIModelPermission{
 		Id:                 "modelperm-LwHkVFn8AcMItP432fKKDIKJ",
@@ -60,7 +63,12 @@ func init() {
 		Group:              nil,
 		IsBlocking:         false,
 	})
+	return permission
+}
+
+func init() {
 	// https://platform.openai.com/docs/models/model-endpoint-compatibility
+	permission := getPermission()
 	for i := 0; i < relayconstant.APITypeDummy; i++ {
 		if i == relayconstant.APITypeAIProxyLibrary {
 			continue
@@ -85,7 +93,7 @@ func init() {
 			Id:         modelName,
 			Object:     "model",
 			Created:    1626777600,
-			OwnedBy:    "360",
+			OwnedBy:    ai360.ChannelName,
 			Permission: permission,
 			Root:       modelName,
 			Parent:     nil,
@@ -128,6 +136,17 @@ func init() {
 	for _, model := range openAIModels {
 		openAIModelsMap[model.Id] = model
 	}
+	channelId2Models = make(map[int][]string)
+	for i := 1; i <= common.ChannelTypeDummy; i++ {
+		apiType := relayconstant.ChannelType2APIType(i)
+		if apiType == -1 || apiType == relayconstant.APITypeAIProxyLibrary {
+			continue
+		}
+		meta := &relaycommon.RelayInfo{ChannelType: i}
+		adaptor := relay.GetAdaptor(apiType)
+		adaptor.Init(meta, dto.GeneralOpenAIRequest{})
+		channelId2Models[i] = adaptor.GetModelList()
+	}
 }
 
 func ListModels(c *gin.Context) {
@@ -142,21 +161,39 @@ func ListModels(c *gin.Context) {
 	}
 	models := model.GetGroupModels(user.Group)
 	userOpenAiModels := make([]OpenAIModels, 0)
+	permission := getPermission()
 	for _, s := range models {
 		if _, ok := openAIModelsMap[s]; ok {
 			userOpenAiModels = append(userOpenAiModels, openAIModelsMap[s])
+		} else {
+			userOpenAiModels = append(userOpenAiModels, OpenAIModels{
+				Id:         s,
+				Object:     "model",
+				Created:    1626777600,
+				OwnedBy:    "openai",
+				Permission: permission,
+				Root:       s,
+				Parent:     nil,
+			})
 		}
 	}
 	c.JSON(200, gin.H{
-		"object": "list",
-		"data":   userOpenAiModels,
+		"success": true,
+		"data":    userOpenAiModels,
 	})
 }
 
 func ChannelListModels(c *gin.Context) {
 	c.JSON(200, gin.H{
-		"object": "list",
-		"data":   openAIModels,
+		"success": true,
+		"data":    openAIModels,
+	})
+}
+
+func DashboardListModels(c *gin.Context) {
+	c.JSON(200, gin.H{
+		"success": true,
+		"data":    channelId2Models,
 	})
 }
 

+ 4 - 1
model/log.go

@@ -24,6 +24,7 @@ type Log struct {
 	IsStream         bool   `json:"is_stream" gorm:"default:false"`
 	ChannelId        int    `json:"channel" gorm:"index"`
 	TokenId          int    `json:"token_id" gorm:"default:0;index"`
+	Other            string `json:"other"`
 }
 
 const (
@@ -57,12 +58,13 @@ func RecordLog(userId int, logType int, content string) {
 	}
 }
 
-func RecordConsumeLog(ctx context.Context, userId int, channelId int, promptTokens int, completionTokens int, modelName string, tokenName string, quota int, content string, tokenId int, userQuota int, useTimeSeconds int, isStream bool) {
+func RecordConsumeLog(ctx context.Context, userId int, channelId int, promptTokens int, completionTokens int, modelName string, tokenName string, quota int, content string, tokenId int, userQuota int, useTimeSeconds int, isStream bool, other map[string]interface{}) {
 	common.LogInfo(ctx, fmt.Sprintf("record consume log: userId=%d, 用户调用前余额=%d, channelId=%d, promptTokens=%d, completionTokens=%d, modelName=%s, tokenName=%s, quota=%d, content=%s", userId, userQuota, channelId, promptTokens, completionTokens, modelName, tokenName, quota, content))
 	if !common.LogConsumeEnabled {
 		return
 	}
 	username, _ := CacheGetUsername(userId)
+	otherStr := common.MapToJsonStr(other)
 	log := &Log{
 		UserId:           userId,
 		Username:         username,
@@ -78,6 +80,7 @@ func RecordConsumeLog(ctx context.Context, userId int, channelId int, promptToke
 		TokenId:          tokenId,
 		UseTime:          useTimeSeconds,
 		IsStream:         isStream,
+		Other:            otherStr,
 	}
 	err := DB.Create(log).Error
 	if err != nil {

+ 3 - 0
model/option.go

@@ -83,6 +83,7 @@ func InitOptionMap() {
 	common.OptionMap["ModelRatio"] = common.ModelRatio2JSONString()
 	common.OptionMap["ModelPrice"] = common.ModelPrice2JSONString()
 	common.OptionMap["GroupRatio"] = common.GroupRatio2JSONString()
+	common.OptionMap["CompletionRatio"] = common.CompletionRatio2JSONString()
 	common.OptionMap["TopUpLink"] = common.TopUpLink
 	common.OptionMap["ChatLink"] = common.ChatLink
 	common.OptionMap["ChatLink2"] = common.ChatLink2
@@ -290,6 +291,8 @@ func updateOptionMap(key string, value string) (err error) {
 		err = common.UpdateModelRatioByJSONString(value)
 	case "GroupRatio":
 		err = common.UpdateGroupRatioByJSONString(value)
+	case "CompletionRatio":
+		err = common.UpdateCompletionRatioByJSONString(value)
 	case "ModelPrice":
 		err = common.UpdateModelPriceByJSONString(value)
 	case "TopUpLink":

+ 7 - 4
model/user.go

@@ -253,14 +253,17 @@ func (user *User) Edit(updatePassword bool) error {
 		}
 	}
 	newUser := *user
-	DB.First(&user, user.Id)
-	err = DB.Model(user).Updates(map[string]interface{}{
+	updates := map[string]interface{}{
 		"username":     newUser.Username,
-		"password":     newUser.Password,
 		"display_name": newUser.DisplayName,
 		"group":        newUser.Group,
 		"quota":        newUser.Quota,
-	}).Error
+	}
+	if updatePassword {
+		updates["password"] = newUser.Password
+	}
+	DB.First(&user, user.Id)
+	err = DB.Model(user).Updates(updates).Error
 	if err == nil {
 		if common.RedisEnabled {
 			_ = common.RedisSet(fmt.Sprintf("user_group:%d", user.Id), user.Group, time.Duration(UserId2GroupCacheSeconds)*time.Second)

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

@@ -6,3 +6,5 @@ var ModelList = []string{
 	"embedding_s1_v1",
 	"semantic_similarity_s1_v1",
 }
+
+var ChannelName = "ai360"

+ 3 - 1
relay/channel/ollama/constants.go

@@ -1,5 +1,7 @@
 package ollama
 
-var ModelList []string
+var ModelList = []string{
+	"llama3-7b",
+}
 
 var ChannelName = "ollama"

+ 1 - 1
relay/channel/openai/constant.go

@@ -6,7 +6,7 @@ var ModelList = []string{
 	"gpt-3.5-turbo-instruct",
 	"gpt-4", "gpt-4-0314", "gpt-4-0613", "gpt-4-1106-preview", "gpt-4-0125-preview",
 	"gpt-4-32k", "gpt-4-32k-0314", "gpt-4-32k-0613",
-	"gpt-4-turbo-preview",
+	"gpt-4-turbo-preview", "gpt-4-turbo", "gpt-4-turbo-2024-04-09",
 	"gpt-4-vision-preview",
 	"text-embedding-ada-002", "text-embedding-3-small", "text-embedding-3-large",
 	"text-curie-001", "text-babbage-001", "text-ada-001", "text-davinci-002", "text-davinci-003",

+ 11 - 1
relay/constant/api_type.go

@@ -25,8 +25,18 @@ const (
 )
 
 func ChannelType2APIType(channelType int) int {
-	apiType := APITypeOpenAI
+	apiType := -1
 	switch channelType {
+	case common.ChannelTypeOpenAI:
+		apiType = APITypeOpenAI
+	case common.ChannelTypeAzure:
+		apiType = APITypeOpenAI
+	case common.ChannelTypeMoonshot:
+		apiType = APITypeOpenAI
+	case common.ChannelTypeLingYiWanWu:
+		apiType = APITypeOpenAI
+	case common.ChannelType360:
+		apiType = APITypeOpenAI
 	case common.ChannelTypeAnthropic:
 		apiType = APITypeAnthropic
 	case common.ChannelTypeBaidu:

+ 4 - 1
relay/relay-audio.go

@@ -196,7 +196,10 @@ func AudioHelper(c *gin.Context, relayMode int) *dto.OpenAIErrorWithStatusCode {
 			if quota != 0 {
 				tokenName := c.GetString("token_name")
 				logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio)
-				model.RecordConsumeLog(ctx, userId, channelId, promptTokens, 0, audioRequest.Model, tokenName, quota, logContent, tokenId, userQuota, int(useTimeSeconds), false)
+				other := make(map[string]interface{})
+				other["model_ratio"] = modelRatio
+				other["group_ratio"] = groupRatio
+				model.RecordConsumeLog(ctx, userId, channelId, promptTokens, 0, audioRequest.Model, tokenName, quota, logContent, tokenId, userQuota, int(useTimeSeconds), false, other)
 				model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
 				channelId := c.GetInt("channel_id")
 				model.UpdateChannelUsedQuota(channelId, quota)

+ 4 - 1
relay/relay-image.go

@@ -191,7 +191,10 @@ func RelayImageHelper(c *gin.Context, relayMode int) *dto.OpenAIErrorWithStatusC
 				quality = "hd"
 			}
 			logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f, 大小 %s, 品质 %s", modelRatio, groupRatio, imageRequest.Size, quality)
-			model.RecordConsumeLog(ctx, userId, channelId, 0, 0, imageRequest.Model, tokenName, quota, logContent, tokenId, userQuota, int(useTimeSeconds), false)
+			other := make(map[string]interface{})
+			other["model_ratio"] = modelRatio
+			other["group_ratio"] = groupRatio
+			model.RecordConsumeLog(ctx, userId, channelId, 0, 0, imageRequest.Model, tokenName, quota, logContent, tokenId, userQuota, int(useTimeSeconds), false, other)
 			model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
 			channelId := c.GetInt("channel_id")
 			model.UpdateChannelUsedQuota(channelId, quota)

+ 8 - 2
relay/relay-mj.go

@@ -202,7 +202,10 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
 			if quota != 0 {
 				tokenName := c.GetString("token_name")
 				logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, groupRatio, constant.MjActionSwapFace)
-				model.RecordConsumeLog(ctx, userId, channelId, 0, 0, modelName, tokenName, quota, logContent, tokenId, userQuota, 0, false)
+				other := make(map[string]interface{})
+				other["model_price"] = modelPrice
+				other["group_ratio"] = groupRatio
+				model.RecordConsumeLog(ctx, userId, channelId, 0, 0, modelName, tokenName, quota, logContent, tokenId, userQuota, 0, false, other)
 				model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
 				channelId := c.GetInt("channel_id")
 				model.UpdateChannelUsedQuota(channelId, quota)
@@ -498,7 +501,10 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
 			if quota != 0 {
 				tokenName := c.GetString("token_name")
 				logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, groupRatio, midjRequest.Action)
-				model.RecordConsumeLog(ctx, userId, channelId, 0, 0, modelName, tokenName, quota, logContent, tokenId, userQuota, 0, false)
+				other := make(map[string]interface{})
+				other["model_price"] = modelPrice
+				other["group_ratio"] = groupRatio
+				model.RecordConsumeLog(ctx, userId, channelId, 0, 0, modelName, tokenName, quota, logContent, tokenId, userQuota, 0, false, other)
 				model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
 				channelId := c.GetInt("channel_id")
 				model.UpdateChannelUsedQuota(channelId, quota)

+ 6 - 1
relay/relay-text.go

@@ -315,7 +315,12 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, textRe
 		logModel = "gpt-4-gizmo-*"
 		logContent += fmt.Sprintf(",模型 %s", textRequest.Model)
 	}
-	model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel, tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream)
+	other := make(map[string]interface{})
+	other["model_ratio"] = modelRatio
+	other["group_ratio"] = groupRatio
+	other["completion_ratio"] = completionRatio
+	other["model_price"] = modelPrice
+	model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel, tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, other)
 
 	//if quota != 0 {
 	//

+ 1 - 0
router/api-router.go

@@ -14,6 +14,7 @@ func SetApiRouter(router *gin.Engine) {
 	apiRouter.Use(middleware.GlobalAPIRateLimit())
 	{
 		apiRouter.GET("/status", controller.GetStatus)
+		apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
 		apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
 		apiRouter.GET("/notice", controller.GetNotice)
 		apiRouter.GET("/about", controller.GetAbout)

+ 4 - 1
service/channel.go

@@ -63,7 +63,7 @@ func ShouldDisableChannel(err *relaymodel.OpenAIError, statusCode int) bool {
 	return false
 }
 
-func ShouldEnableChannel(err error, openAIErr *relaymodel.OpenAIError) bool {
+func ShouldEnableChannel(err error, openAIErr *relaymodel.OpenAIError, status int) bool {
 	if !common.AutomaticEnableChannelEnabled {
 		return false
 	}
@@ -73,5 +73,8 @@ func ShouldEnableChannel(err error, openAIErr *relaymodel.OpenAIError) bool {
 	if openAIErr != nil {
 		return false
 	}
+	if status != common.ChannelStatusAutoDisabled {
+		return false
+	}
 	return true
 }

+ 17 - 13
web/src/components/ChannelsTable.js

@@ -31,6 +31,7 @@ import {
 } from '@douyinfe/semi-ui';
 import EditChannel from '../pages/Channel/EditChannel';
 import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
+import { loadChannelModels } from './utils.js';
 
 function renderTimestamp(timestamp) {
   return <>{timestamp2string(timestamp)}</>;
@@ -354,27 +355,29 @@ const ChannelsTable = () => {
   };
 
   const copySelectedChannel = async (id) => {
-    const channelToCopy = channels.find(channel => String(channel.id) === String(id));
-    console.log(channelToCopy)
+    const channelToCopy = channels.find(
+      (channel) => String(channel.id) === String(id),
+    );
+    console.log(channelToCopy);
     channelToCopy.name += '_复制';
     channelToCopy.created_time = null;
     channelToCopy.balance = 0;
     channelToCopy.used_quota = 0;
     if (!channelToCopy) {
-        showError("渠道未找到,请刷新页面后重试。");
-        return;
+      showError('渠道未找到,请刷新页面后重试。');
+      return;
     }
     try {
-        const newChannel = {...channelToCopy, id: undefined};
-        const response = await API.post('/api/channel/', newChannel);
-        if (response.data.success) {
-            showSuccess("渠道复制成功");
-            await refresh();
-        } else {
-            showError(response.data.message);
-        }
+      const newChannel = { ...channelToCopy, id: undefined };
+      const response = await API.post('/api/channel/', newChannel);
+      if (response.data.success) {
+        showSuccess('渠道复制成功');
+        await refresh();
+      } else {
+        showError(response.data.message);
+      }
     } catch (error) {
-        showError("渠道复制失败: " + error.message);
+      showError('渠道复制失败: ' + error.message);
     }
   };
 
@@ -395,6 +398,7 @@ const ChannelsTable = () => {
         showError(reason);
       });
     fetchGroups().then();
+    loadChannelModels().then();
   }, []);
 
   const manageChannel = async (id, action, record, value) => {

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

@@ -19,9 +19,15 @@ import {
   Spin,
   Table,
   Tag,
+  Tooltip,
 } from '@douyinfe/semi-ui';
 import { ITEMS_PER_PAGE } from '../constants';
-import { renderNumber, renderQuota, stringToColor } from '../helpers/render';
+import {
+  renderModelPrice,
+  renderNumber,
+  renderQuota,
+  stringToColor,
+} from '../helpers/render';
 import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
 
 const { Header } = Layout;
@@ -292,16 +298,44 @@ const LogsTable = () => {
       title: '详情',
       dataIndex: 'content',
       render: (text, record, index) => {
+        if (record.other === '') {
+          return (
+            <Paragraph
+              ellipsis={{
+                rows: 2,
+                showTooltip: {
+                  type: 'popover',
+                  opts: { style: { width: 240 } },
+                },
+              }}
+              style={{ maxWidth: 240 }}
+            >
+              {text}
+            </Paragraph>
+          );
+        }
+        let other = JSON.parse(record.other);
+        let content = renderModelPrice(
+          other.model_ratio,
+          other.model_price,
+          other.completion_ratio,
+          other.group_ratio,
+        );
         return (
-          <Paragraph
-            ellipsis={{
-              rows: 2,
-              showTooltip: { type: 'popover', opts: { style: { width: 240 } } },
-            }}
-            style={{ maxWidth: 240 }}
-          >
-            {text}
-          </Paragraph>
+          <Tooltip content={content}>
+            <Paragraph
+              ellipsis={{
+                rows: 2,
+                showTooltip: {
+                  type: 'popover',
+                  opts: { style: { width: 240 } },
+                },
+              }}
+              style={{ maxWidth: 240 }}
+            >
+              {text}
+            </Paragraph>
+          </Tooltip>
         );
       },
     },

+ 34 - 0
web/src/components/MjLogsTable.js

@@ -236,6 +236,31 @@ const renderTimestamp = (timestampInSeconds) => {
 
   return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
 };
+// 修改renderDuration函数以包含颜色逻辑
+function renderDuration(submit_time, finishTime) {
+  // 确保startTime和finishTime都是有效的时间戳
+  if (!submit_time || !finishTime) return 'N/A'; 
+
+  // 将时间戳转换为Date对象
+  const start = new Date(submit_time);
+  const finish = new Date(finishTime);
+
+  // 计算时间差(毫秒)
+  const durationMs = finish - start;
+
+  // 将时间差转换为秒,并保留一位小数
+  const durationSec = (durationMs / 1000).toFixed(1);
+
+  // 设置颜色:大于60秒则为红色,小于等于60秒则为绿色
+  const color = durationSec > 60 ? 'red' : 'green';
+
+  // 返回带有样式的颜色标签
+  return (
+    <Tag color={color} size="large">
+      {durationSec} 秒
+    </Tag>
+  );
+}
 
 const LogsTable = () => {
   const [isModalOpen, setIsModalOpen] = useState(false);
@@ -248,6 +273,15 @@ const LogsTable = () => {
         return <div>{renderTimestamp(text / 1000)}</div>;
       },
     },
+    {
+      title: '花费时间',
+      dataIndex: 'finish_time', // 以finish_time作为dataIndex
+      key: 'finish_time',
+      render: (finish, record) => {
+        // 假设record.start_time是存在的,并且finish是完成时间的时间戳
+        return renderDuration(record.submit_time, finish);
+      },
+    },
     {
       title: '渠道',
       dataIndex: 'channel_id',

+ 23 - 0
web/src/components/OperationSetting.js

@@ -29,6 +29,7 @@ const OperationSetting = () => {
     PreConsumedQuota: 0,
     StreamCacheQueueLength: 0,
     ModelRatio: '',
+    CompletionRatio: '',
     ModelPrice: '',
     GroupRatio: '',
     TopUpLink: '',
@@ -69,6 +70,7 @@ const OperationSetting = () => {
         if (
           item.key === 'ModelRatio' ||
           item.key === 'GroupRatio' ||
+          item.key === 'CompletionRatio' ||
           item.key === 'ModelPrice'
         ) {
           item.value = JSON.stringify(JSON.parse(item.value), null, 2);
@@ -166,6 +168,13 @@ const OperationSetting = () => {
           }
           await updateOption('ModelRatio', inputs.ModelRatio);
         }
+        if (originInputs['CompletionRatio'] !== inputs.CompletionRatio) {
+          if (!verifyJSON(inputs.CompletionRatio)) {
+            showError('模型补全倍率不是合法的 JSON 字符串');
+            return;
+          }
+          await updateOption('CompletionRatio', inputs.CompletionRatio);
+        }
         if (originInputs['GroupRatio'] !== inputs.GroupRatio) {
           if (!verifyJSON(inputs.GroupRatio)) {
             showError('分组倍率不是合法的 JSON 字符串');
@@ -304,6 +313,20 @@ const OperationSetting = () => {
                 placeholder='为一个 JSON 文本,键为模型名称,值为倍率'
               />
             </Form.Group>
+            <Form.Group widths='equal'>
+              <Form.TextArea
+                label='模型补全倍率(仅对自定义模型有效)'
+                name='CompletionRatio'
+                onChange={handleInputChange}
+                style={{
+                  minHeight: 250,
+                  fontFamily: 'JetBrains Mono, Consolas',
+                }}
+                autoComplete='new-password'
+                value={inputs.CompletionRatio}
+                placeholder='为一个 JSON 文本,键为分组名称,值为倍率'
+              />
+            </Form.Group>
             <Form.Group widths='equal'>
               <Form.TextArea
                 label='分组倍率'

+ 29 - 0
web/src/components/utils.js

@@ -18,3 +18,32 @@ export async function onGitHubOAuthClicked(github_client_id) {
     `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
   );
 }
+
+let channelModels = undefined;
+export async function loadChannelModels() {
+  const res = await API.get('/api/models');
+  const { success, data } = res.data;
+  if (!success) {
+    return;
+  }
+  channelModels = data;
+  localStorage.setItem('channel_models', JSON.stringify(data));
+}
+
+export function getChannelModels(type) {
+  if (channelModels !== undefined && type in channelModels) {
+    if (!channelModels[type]) {
+      return [];
+    }
+    return channelModels[type];
+  }
+  let models = localStorage.getItem('channel_models');
+  if (!models) {
+    return [];
+  }
+  channelModels = JSON.parse(models);
+  if (type in channelModels) {
+    return channelModels[type];
+  }
+  return [];
+}

+ 2 - 2
web/src/constants/channel.constants.js

@@ -86,13 +86,13 @@ export const CHANNEL_OPTIONS = [
     label: '智谱 ChatGLM',
   },
   {
-    key: 16,
+    key: 26,
     text: '智谱 GLM-4V',
     value: 26,
     color: 'purple',
     label: '智谱 GLM-4V',
   },
-  { key: 16, text: 'Moonshot', value: 25, color: 'green', label: 'Moonshot' },
+  { 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: '腾讯混元' },
   { key: 31, text: '零一万物', value: 31, color: 'green', label: '零一万物' },

+ 26 - 0
web/src/helpers/render.js

@@ -135,6 +135,32 @@ export function renderQuota(quota, digits = 2) {
   return renderNumber(quota);
 }
 
+export function renderModelPrice(
+  modelRatio,
+  modelPrice = -1,
+  completionRatio,
+  groupRatio,
+) {
+  // 1 ratio = $0.002 / 1K tokens
+  if (modelPrice !== -1) {
+    return '模型价格:$' + modelPrice * groupRatio;
+  } else {
+    if (completionRatio === undefined) {
+      completionRatio = 0;
+    }
+    let inputRatioPrice = modelRatio * 0.002 * groupRatio;
+    let completionRatioPrice =
+      modelRatio * completionRatio * 0.002 * groupRatio;
+    return (
+      '输入:$' +
+      inputRatioPrice.toFixed(3) +
+      '/1K tokens,补全:$' +
+      completionRatioPrice.toFixed(3) +
+      '/1K tokens'
+    );
+  }
+}
+
 export function renderQuotaWithPrompt(quota, digits) {
   let displayInCurrency = localStorage.getItem('display_in_currency');
   displayInCurrency = displayInCurrency === 'true';

+ 14 - 91
web/src/pages/Channel/EditChannel.js

@@ -23,6 +23,7 @@ import {
   Banner,
 } from '@douyinfe/semi-ui';
 import { Divider } from 'semantic-ui-react';
+import { getChannelModels, loadChannelModels } from '../../components/utils.js';
 
 const MODEL_MAPPING_EXAMPLE = {
   'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
@@ -87,97 +88,9 @@ const EditChannel = (props) => {
   const [customModel, setCustomModel] = useState('');
   const handleInputChange = (name, value) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
-    if (name === 'type' && inputs.models.length === 0) {
+    if (name === 'type') {
       let localModels = [];
       switch (value) {
-        case 33:
-        case 14:
-          localModels = [
-            'claude-instant-1.2',
-            'claude-2',
-            'claude-2.0',
-            'claude-2.1',
-            'claude-3-opus-20240229',
-            'claude-3-sonnet-20240229',
-            'claude-3-haiku-20240307',
-          ];
-          break;
-        case 11:
-          localModels = ['PaLM-2'];
-          break;
-        case 15:
-          localModels = [
-            'ERNIE-Bot',
-            'ERNIE-Bot-turbo',
-            'ERNIE-Bot-4',
-            'Embedding-V1',
-          ];
-          break;
-        case 17:
-          localModels = [
-            'qwen-turbo',
-            'qwen-plus',
-            'qwen-max',
-            'qwen-max-longcontext',
-            'text-embedding-v1',
-          ];
-          break;
-        case 16:
-          localModels = ['chatglm_pro', 'chatglm_std', 'chatglm_lite'];
-          break;
-        case 18:
-          localModels = [
-            'SparkDesk',
-            'SparkDesk-v1.1',
-            'SparkDesk-v2.1',
-            'SparkDesk-v3.1',
-            'SparkDesk-v3.5',
-          ];
-          break;
-        case 19:
-          localModels = [
-            '360GPT_S2_V9',
-            'embedding-bert-512-v1',
-            'embedding_s1_v1',
-            'semantic_similarity_s1_v1',
-          ];
-          break;
-        case 23:
-          localModels = ['hunyuan'];
-          break;
-        case 24:
-          localModels = [
-            'gemini-1.0-pro-001',
-            'gemini-1.0-pro-vision-001',
-            'gemini-1.5-pro',
-            'gemini-1.5-pro-latest',
-            'gemini-pro',
-            'gemini-pro-vision',
-          ];
-          break;
-        case 34:
-          localModels = [
-            'command-r',
-            'command-r-plus',
-            'command-light',
-            'command-light-nightly',
-            'command',
-            'command-nightly',
-          ];
-          break;
-        case 25:
-          localModels = [
-            'moonshot-v1-8k',
-            'moonshot-v1-32k',
-            'moonshot-v1-128k',
-          ];
-          break;
-        case 26:
-          localModels = ['glm-4', 'glm-4v', 'glm-3-turbo'];
-          break;
-        case 31:
-          localModels = ['yi-34b-chat-0205', 'yi-34b-chat-200k', 'yi-vl-plus'];
-          break;
         case 2:
           localModels = [
             'mj_imagine',
@@ -207,8 +120,14 @@ const EditChannel = (props) => {
             'mj_pan',
           ];
           break;
+        default:
+          localModels = getChannelModels(value);
+          break;
       }
-      setInputs((inputs) => ({ ...inputs, models: localModels }));
+      if (inputs.models.length === 0) {
+        setInputs((inputs) => ({ ...inputs, models: localModels }));
+      }
+      setBasicModels(localModels);
     }
     //setAutoBan
   };
@@ -244,6 +163,7 @@ const EditChannel = (props) => {
       } else {
         setAutoBan(true);
       }
+      setBasicModels(getChannelModels(data.type));
       // console.log(data);
     } else {
       showError(message);
@@ -312,6 +232,9 @@ const EditChannel = (props) => {
       loadChannel().then(() => {});
     } else {
       setInputs(originInputs);
+      let localModels = getChannelModels(inputs.type);
+      setBasicModels(localModels);
+      setInputs((inputs) => ({ ...inputs, models: localModels }));
     }
   }, [props.editingChannel.id]);
 
@@ -596,7 +519,7 @@ const EditChannel = (props) => {
                   handleInputChange('models', basicModels);
                 }}
               >
-                填入基础模型
+                填入相关模型
               </Button>
               <Button
                 type='secondary'

+ 49 - 9
web/src/pages/User/EditUser.js

@@ -1,12 +1,13 @@
 import React, { useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { API, isMobile, showError, showSuccess } from '../../helpers';
-import { renderQuotaWithPrompt } from '../../helpers/render';
+import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import {
   Button,
   Divider,
   Input,
+  Modal,
   Select,
   SideSheet,
   Space,
@@ -17,6 +18,8 @@ import {
 const EditUser = (props) => {
   const userId = props.editingUser.id;
   const [loading, setLoading] = useState(true);
+  const [addQuotaModalOpen, setIsModalOpen] = useState(false);
+  const [addQuotaLocal, setAddQuotaLocal] = useState('');
   const [inputs, setInputs] = useState({
     username: '',
     display_name: '',
@@ -107,6 +110,16 @@ const EditUser = (props) => {
     setLoading(false);
   };
 
+  const addLocalQuota = () => {
+    let newQuota = parseInt(quota) + parseInt(addQuotaLocal);
+    setInputs((inputs) => ({ ...inputs, quota: newQuota }));
+  };
+
+  const openAddQuotaModal = () => {
+    setAddQuotaLocal('0');
+    setIsModalOpen(true);
+  };
+
   return (
     <>
       <SideSheet
@@ -192,14 +205,17 @@ const EditUser = (props) => {
               <div style={{ marginTop: 20 }}>
                 <Typography.Text>{`剩余额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
               </div>
-              <Input
-                name='quota'
-                placeholder={'请输入新的剩余额度'}
-                onChange={(value) => handleInputChange('quota', value)}
-                value={quota}
-                type={'number'}
-                autoComplete='new-password'
-              />
+              <Space>
+                <Input
+                  name='quota'
+                  placeholder={'请输入新的剩余额度'}
+                  onChange={(value) => handleInputChange('quota', value)}
+                  value={quota}
+                  type={'number'}
+                  autoComplete='new-password'
+                />
+                <Button onClick={openAddQuotaModal}>添加额度</Button>
+              </Space>
             </>
           )}
           <Divider style={{ marginTop: 20 }}>以下信息不可修改</Divider>
@@ -245,6 +261,30 @@ const EditUser = (props) => {
           />
         </Spin>
       </SideSheet>
+      <Modal
+        centered={true}
+        visible={addQuotaModalOpen}
+        onOk={() => {
+          addLocalQuota();
+          setIsModalOpen(false);
+        }}
+        onCancel={() => setIsModalOpen(false)}
+        closable={null}
+      >
+        <div style={{ marginTop: 20 }}>
+          <Typography.Text>{`新额度${renderQuota(quota)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(quota + parseInt(addQuotaLocal))}`}</Typography.Text>
+        </div>
+        <Input
+          name='addQuotaLocal'
+          placeholder={'需要添加的额度(支持负数)'}
+          onChange={(value) => {
+            setAddQuotaLocal(value);
+          }}
+          value={addQuotaLocal}
+          type={'number'}
+          autoComplete='new-password'
+        />
+      </Modal>
     </>
   );
 };